FN-6887: add footer terminal launcher

Move terminal access from the header into a footer launcher and resizable bottom panel.

- Add a TerminalLauncher footer control with dock, resize, collapse, and pop-out affordances.
- Extend TerminalModal to support bottom-panel layout, drag resizing, and persistent panel state.
- Remove terminal/script controls from header overflow while preserving compact Mailbox and tool ordering.
- Update executor footer spacing, dashboard docs, changeset, and focused terminal/header tests.

Files changed:
 .changeset/fn-6887-terminal-footer-panel.md        |   5 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/App.tsx                     |   6 +-
 .../app/__tests__/tablet-header-controls.test.tsx  |  95 +---
 .../dashboard/app/components/ExecutorStatusBar.css |   8 +
 .../dashboard/app/components/ExecutorStatusBar.tsx |  27 +-
 packages/dashboard/app/components/Header.css       |  27 -
 packages/dashboard/app/components/Header.tsx       | 549 +--------------------
 .../dashboard/app/components/TerminalLauncher.css  | 180 +++++++
 .../dashboard/app/components/TerminalLauncher.tsx  | 306 ++++++++++++
 .../dashboard/app/components/TerminalModal.css     | 117 +++++
 .../dashboard/app/components/TerminalModal.tsx     | 360 +++++++++++++-
 .../__tests__/ExecutorStatusBar.test.tsx           |  52 +-
 .../app/components/__tests__/Header.test.tsx       | 304 ++----------
 .../components/__tests__/TerminalLauncher.test.tsx |  57 +++
 .../components/__tests__/TerminalModal.test.tsx    | 111 +++++
 16 files changed, 1282 insertions(+), 924 deletions(-)

Fusion-Task-Id: FN-6887

Fusion-Task-Lineage: 755f28b1-1ba5-43f8-b13c-264e31507658
This commit is contained in:
gsxdsm
2026-06-21 22:43:34 -07:00
parent 8f4897d3aa
commit 7034b55288
16 changed files with 1288 additions and 930 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Move the dashboard terminal launcher to the footer executor status bar and add docked plus floating resizable terminal modes on desktop/tablet while preserving mobile fullscreen terminal behavior.

View File

@@ -371,6 +371,8 @@ Features:
- Multiple terminal tabs
- PTY-backed shell sessions
- On desktop and tablet, the terminal opens from the footer executor status bar as a bottom-docked panel with a draggable top resize handle; use **Pop out** to switch to a draggable, freely resizable floating terminal, then **Dock** to return it to the footer panel.
- Mobile keeps the terminal as a full-screen modal with the existing keyboard-aware layout instead of the docked or floating desktop/tablet modes.
- Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT
- Ctrl/Cmd+V pastes clipboard text into the active terminal session
- The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, and Up/Down/Left/Right arrow buttons that send standard ANSI cursor sequences for keyboard-less shell history and line editing

View File

@@ -1974,9 +1974,6 @@ function AppInner() {
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onOpenScripts={openScriptsWithNav}
onRunScript={runScriptWithNav}
onToggleTerminal={toggleTerminalWithNav}
onOpenFiles={openFilesWithNav}
filesOpen={modalManager.filesOpen}
todosEnabled={todosEnabled}
@@ -2192,6 +2189,9 @@ function AppInner() {
onOpenProjectDirectory={handleOpenProjectDirectory}
keyboardOpen={footerKeyboardOpen}
hideWhenKeyboardOpen={mobileKeyboardOpen}
onToggleTerminal={toggleTerminalWithNav}
onOpenScripts={openScriptsWithNav}
onRunScript={runScriptWithNav}
/>
)}
<MobileNavBar

View File

@@ -226,20 +226,12 @@ describe("tablet header controls", () => {
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("overflow menu contains terminal group on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
});
it("overflow menu contains terminal submenu scripts when expanded on tablet", async () => {
it("overflow menu omits terminal launcher and scripts on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
it("overflow menu contains automation on tablet", () => {
@@ -288,14 +280,6 @@ describe("tablet header controls", () => {
expect(onOpenSettings).toHaveBeenCalled();
});
it("calls onToggleTerminal from terminal primary button on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenUsage from overflow menu on tablet", () => {
const onOpenUsage = vi.fn();
renderTabletHeader({ onOpenUsage });
@@ -328,19 +312,6 @@ describe("tablet header controls", () => {
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes terminal submenu on Escape without closing overflow menu on tablet", async () => {
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
// Submenu closes but overflow menu stays open
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
expect(screen.getByRole("menu")).toBeDefined();
});
// ── Search on tablet ───────────────────────────────────────────
it("renders search toggle button on tablet board view (not mobile search trigger)", () => {
@@ -465,9 +436,11 @@ describe("tablet header controls", () => {
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
});
it("renders terminal inline on desktop", () => {
it("does not render terminal inline on desktop", () => {
renderDesktopHeader({ onToggleTerminal: noop });
expect(screen.getByTitle("Open Terminal")).toBeDefined();
expect(screen.queryByTitle("Open Terminal")).toBeNull();
expect(screen.queryByTestId("terminal-toggle-btn")).toBeNull();
expect(screen.queryByTestId("scripts-btn")).toBeNull();
});
it("does not render overflow menu trigger on desktop", () => {
@@ -490,54 +463,16 @@ describe("tablet header controls", () => {
});
});
// ── Split-action Terminal button regression tests ─────────────
// ── Terminal launcher relocation regression tests ─────────────
describe("split-action terminal button on tablet", () => {
it("primary terminal button opens terminal directly on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal });
describe("terminal launcher relocation on tablet", () => {
it("keeps terminal launcher affordances out of the tablet header overflow", () => {
renderTabletHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
// Menu should close after action
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
});
it("chevron toggle opens submenu without calling onToggleTerminal on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal, onOpenScripts: noop });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(onToggleTerminal).not.toHaveBeenCalled();
// Menu should still be open
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
});
it("renders script entries in submenu when scripts are fetched", async () => {
mockFetchScripts.mockResolvedValue({ lint: "pnpm lint", build: "pnpm build" });
const onRunScript = vi.fn();
renderTabletHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-lint")).toBeDefined();
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
});
it("clicking a script entry calls onRunScript and closes overflow on tablet", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderTabletHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
// Menu should close
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
expect(screen.queryByTestId("overflow-script-item-build")).toBeNull();
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
});

View File

@@ -46,6 +46,14 @@
color: var(--text-dim);
}
/*
FNXC:Terminal 2026-06-21-22:13:
FN-6887 makes the footer status bar the canonical desktop/tablet terminal launcher surface. Mobile continues to use MobileNavBar's More sheet, so this segment is hidden at the mobile breakpoint.
*/
.executor-status-bar__segment--terminal-launcher {
flex-shrink: 0;
}
.executor-status-bar__segment--stuck {
color: var(--color-error);
}

View File

@@ -15,6 +15,8 @@ import { LoadingSpinner } from "./LoadingSpinner";
import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControlMenu";
import { TerminalLauncher } from "./TerminalLauncher";
import { useViewportMode } from "../hooks/useViewportMode";
interface ExecutorStatusBarProps {
/** Task list (shared with the board to keep counts in sync) */
@@ -43,6 +45,12 @@ interface ExecutorStatusBarProps {
/** iOS-only hide guard to prevent footer drifting over content while
* visualViewport settles during keyboard transitions. */
hideWhenKeyboardOpen?: boolean;
/** Opens or closes the terminal surface from the desktop/tablet footer launcher. */
onToggleTerminal?: () => void;
/** Opens the scripts management modal from the footer launcher dropdown. */
onOpenScripts?: () => void;
/** Runs a configured script in the terminal from the footer launcher dropdown. */
onRunScript?: (name: string, command: string) => void;
}
/**
@@ -92,8 +100,10 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) {
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript }: ExecutorStatusBarProps) {
const { t } = useTranslation("app");
const viewportMode = useViewportMode();
const showTerminalLauncher = viewportMode !== "mobile" && Boolean(onToggleTerminal);
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
const engineControlMenuRef = useRef<EngineControlMenuHandle>(null);
@@ -289,6 +299,21 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
{/* Spacer */}
<div className="executor-status-bar__spacer" />
{showTerminalLauncher && (
<>
<div className="executor-status-bar__segment executor-status-bar__segment--terminal-launcher" data-testid="executor-terminal-launcher-segment">
<TerminalLauncher
projectId={projectId}
onToggleTerminal={onToggleTerminal}
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
variant="footer"
/>
</div>
<span className="executor-status-bar__divider" aria-hidden="true" />
</>
)}
{/* Last activity */}
<div className="executor-status-bar__segment executor-status-bar__segment--time">
<Clock size={12} className="executor-status-bar__icon" aria-hidden="true" />

View File

@@ -361,33 +361,6 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
}
}
/* Split-button for terminal + scripts controls */
.terminal-split-btn {
position: relative;
display: flex;
align-items: center;
border-radius: var(--radius-md);
}
.terminal-split-btn__main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.terminal-split-btn__chevron {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 28px;
padding: 0;
}
.terminal-split-btn__divider {
width: 1px;
height: 16px;
background: var(--border);
flex-shrink: 0;
}
/* Header badge for active sessions */
.btn-icon--has-indicator {
position: relative;

View File

@@ -1,13 +1,12 @@
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, Play, LayoutGrid, List, Terminal, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight } from "lucide-react";
import { Settings, LayoutGrid, List, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, Grid3X3, Mail, MessageSquare, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight, ChevronDown, ChevronRight } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { NodeHealthDot } from "./NodeHealthDot";
import { PluginSlot } from "./PluginSlot";
@@ -49,11 +48,6 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
);
}
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface HeaderProps {
onOpenSettings?: () => void;
@@ -73,9 +67,6 @@ export interface HeaderProps {
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenWorkflowEditor?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
onToggleTerminal?: () => void;
/** Opens the top-level workspace-aware file browser modal. */
onOpenFiles?: () => void;
filesOpen?: boolean;
@@ -136,9 +127,6 @@ export function Header({
onOpenSchedules,
onOpenGitManager,
onOpenWorkflowEditor,
onOpenScripts,
onRunScript,
onToggleTerminal,
onOpenFiles,
todosEnabled,
view = "board",
@@ -192,30 +180,17 @@ export function Header({
// Track when user has explicitly closed the search (used for toggle visibility)
const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
const [isViewOverflowOpen, setIsViewOverflowOpen] = useState(false);
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const [highlightedScriptIndex, setHighlightedScriptIndex] = useState(-1);
const [scriptsDropdownPosition, setScriptsDropdownPosition] = useState<DropdownPosition | null>(null);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
const overflowMenuRef = useRef<HTMLDivElement>(null);
const mobileSearchRef = useRef<HTMLDivElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
const nodeSelectorRef = useRef<HTMLDivElement>(null);
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
const viewOverflowRef = useRef<HTMLDivElement>(null);
const viewOverflowTriggerRef = useRef<HTMLButtonElement>(null);
const scriptsSplitButtonRef = useRef<HTMLDivElement>(null);
const scriptsChevronButtonRef = useRef<HTMLButtonElement>(null);
const scriptsMenuRef = useRef<HTMLDivElement>(null);
const scriptsOpenRef = useRef(false);
// Get remote nodes only (exclude local node type)
const remoteNodes = useMemo(() =>
@@ -224,19 +199,6 @@ export function Header({
);
const showNodeSelector = remoteNodes.length > 0;
// Script entries sorted alphabetically for desktop scripts dropdown
const scriptEntries = useMemo(() => {
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
}, [scripts]);
const showScriptsFooter = scriptEntries.length > 0;
const totalScriptItems = scriptEntries.length + (showScriptsFooter ? 1 : 0);
// Script entries sorted alphabetically for overflow submenu
const overflowScriptEntries = useMemo(() => {
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
}, [overflowScripts]);
const hasViewOverflowItems = useMemo(() => {
return !!(
onChangeView ||
@@ -253,283 +215,6 @@ export function Header({
);
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, isTablet, pluginDashboardViews]);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
if (vv && vv.width > 0 && vv.height > 0) {
return {
width: vv.width,
height: vv.height,
offsetTop: vv.offsetTop,
offsetLeft: vv.offsetLeft,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
offsetTop: 0,
offsetLeft: 0,
};
}, []);
const updateScriptsDropdownPosition = useCallback(() => {
const trigger = scriptsChevronButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = scriptsMenuRef.current;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const horizontalPadding = 16;
const verticalPadding = 16;
const gap = 6;
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, 260);
const width = Math.min(
measuredWidth,
Math.max(viewportWidth - horizontalPadding * 2, 160),
);
const measuredHeight = menu?.offsetHeight || 280;
const constrainedHeight = Math.min(
measuredHeight,
Math.max(viewportHeight - verticalPadding * 2, 160),
);
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerRight = rect.right - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerRight - width, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - constrainedHeight,
);
setScriptsDropdownPosition({ top, left, width });
}, [getEffectiveViewport]);
const handleRunQuickScript = useCallback(
(name: string, command: string) => {
onRunScript?.(name, command);
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
},
[onRunScript],
);
const handleManageScripts = useCallback(() => {
onOpenScripts?.();
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}, [onOpenScripts]);
const handleScriptsDropdownKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (totalScriptItems > 0) {
setHighlightedScriptIndex((prev) => (prev < totalScriptItems - 1 ? prev + 1 : 0));
}
break;
case "ArrowUp":
e.preventDefault();
if (totalScriptItems > 0) {
setHighlightedScriptIndex((prev) => (prev > 0 ? prev - 1 : totalScriptItems - 1));
}
break;
case "Enter":
e.preventDefault();
if (highlightedScriptIndex >= 0) {
if (highlightedScriptIndex < scriptEntries.length) {
const [name, command] = scriptEntries[highlightedScriptIndex];
handleRunQuickScript(name, command);
} else if (showScriptsFooter && highlightedScriptIndex === scriptEntries.length) {
handleManageScripts();
}
}
break;
case "Home":
e.preventDefault();
if (totalScriptItems > 0) {
setHighlightedScriptIndex(0);
}
break;
case "End":
e.preventDefault();
if (totalScriptItems > 0) {
setHighlightedScriptIndex(totalScriptItems - 1);
}
break;
}
},
[handleManageScripts, handleRunQuickScript, highlightedScriptIndex, scriptEntries, showScriptsFooter, totalScriptItems],
);
// Keep ref in sync with state
useEffect(() => {
terminalSubmenuOpenRef.current = isTerminalSubmenuOpen;
}, [isTerminalSubmenuOpen]);
useEffect(() => {
scriptsOpenRef.current = isScriptsOpen;
}, [isScriptsOpen]);
// Fetch scripts when terminal submenu opens in compact mode
useEffect(() => {
if (!isTerminalSubmenuOpen || !isCompact) return;
let cancelled = false;
setOverflowScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) {
setOverflowScripts(data);
}
})
.catch(() => {
if (!cancelled) {
setOverflowScripts({});
}
})
.finally(() => {
if (!cancelled) {
setOverflowScriptsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isTerminalSubmenuOpen, isCompact, projectId]);
// Fetch scripts when desktop scripts dropdown opens
useEffect(() => {
if (!isScriptsOpen || isCompact) return;
let cancelled = false;
setScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) {
setScripts(data);
}
})
.catch(() => {
if (!cancelled) {
setScripts({});
}
})
.finally(() => {
if (!cancelled) {
setScriptsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isScriptsOpen, isCompact, projectId]);
// Close desktop scripts dropdown on outside click
useEffect(() => {
if (!isScriptsOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
scriptsSplitButtonRef.current &&
!scriptsSplitButtonRef.current.contains(e.target as Node)
) {
setIsScriptsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isScriptsOpen]);
// Close desktop scripts dropdown on Escape
useEffect(() => {
if (!isScriptsOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsScriptsOpen(false);
scriptsChevronButtonRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isScriptsOpen]);
// Reset highlight and focus menu when dropdown opens
useEffect(() => {
if (isScriptsOpen) {
setHighlightedScriptIndex(-1);
const timeoutId = window.setTimeout(() => scriptsMenuRef.current?.focus(), 0);
return () => window.clearTimeout(timeoutId);
}
setScriptsDropdownPosition(null);
}, [isScriptsOpen]);
// Position scripts dropdown when opening and content changes
useEffect(() => {
if (!isScriptsOpen) return;
const rafId = requestAnimationFrame(() => {
updateScriptsDropdownPosition();
});
return () => cancelAnimationFrame(rafId);
}, [isScriptsOpen, scriptsLoading, scriptEntries.length, showScriptsFooter, updateScriptsDropdownPosition]);
// Keep scripts dropdown anchored on viewport changes
useEffect(() => {
if (!isScriptsOpen) return;
const handleReposition = () => updateScriptsDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isScriptsOpen, updateScriptsDropdownPosition]);
useEffect(() => {
if (isCompact) {
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}
}, [isCompact]);
// Keep mobile search open if there's an active search query
const shouldShowMobileSearch = isMobileSearchOpen || searchQuery.length > 0;
@@ -588,15 +273,6 @@ export function Header({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsViewOverflowOpen(false);
if (terminalSubmenuOpenRef.current) {
setIsTerminalSubmenuOpen(false);
return;
}
if (scriptsOpenRef.current) {
setIsScriptsOpen(false);
scriptsChevronButtonRef.current?.focus();
return;
}
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
setIsNodeSelectorOpen(false);
@@ -666,7 +342,6 @@ export function Header({
const handleOverflowAction = useCallback((callback?: () => void) => {
if (callback) callback();
setIsOverflowMenuOpen(false);
setIsTerminalSubmenuOpen(false);
}, []);
const handleMobileSearchClose = useCallback(() => {
@@ -1045,7 +720,7 @@ export function Header({
)}
<button
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
onClick={() => onChangeView("mailbox")}
onClick={() => (onOpenMailbox ? onOpenMailbox() : onChangeView("mailbox"))}
title={t("header.mailboxView", "Mailbox view")}
aria-label={t("header.mailboxView", "Mailbox view")}
aria-pressed={view === "mailbox"}
@@ -1290,118 +965,6 @@ export function Header({
FN-6886 removes the header Lightbulb affordances because Planning Mode is now a primary left-sidebar destination after Command Center and a single canonical MobileNavBar More item on compact breakpoints.
*/}
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<div className="terminal-split-btn" ref={scriptsSplitButtonRef}>
<button
className="btn-icon btn-icon--terminal terminal-split-btn__main"
onClick={onToggleTerminal}
title={t("header.openTerminal", "Open Terminal")}
data-testid="terminal-toggle-btn"
>
<Terminal size={16} />
</button>
{onOpenScripts && onRunScript && (
<>
<span className="terminal-split-btn__divider" />
<button
ref={scriptsChevronButtonRef}
className={`btn-icon terminal-split-btn__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
onClick={() => setIsScriptsOpen((prev) => !prev)}
title={t("header.scripts", "Scripts")}
aria-haspopup="listbox"
aria-expanded={isScriptsOpen}
aria-label={t("header.quickScripts", "Quick scripts")}
data-testid="scripts-btn"
>
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
</button>
{isScriptsOpen && (
<div
ref={scriptsMenuRef}
tabIndex={-1}
className="quick-scripts-dropdown__menu"
role="listbox"
aria-label={t("header.scripts", "Scripts")}
onKeyDown={handleScriptsDropdownKeyDown}
data-testid="quick-scripts-dropdown"
style={
scriptsDropdownPosition
? {
position: "fixed",
top: `${scriptsDropdownPosition.top}px`,
left: `${scriptsDropdownPosition.left}px`,
width: `${scriptsDropdownPosition.width}px`,
right: "auto",
}
: undefined
}
>
{scriptsLoading ? (
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
<Loader2 size={16} className="animate-spin" />
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
</div>
) : scriptEntries.length === 0 ? (
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
<div className="quick-scripts-dropdown__empty-icon">
<Terminal size={16} />
</div>
<p>{t("header.noScriptsConfigured", "No scripts configured")}</p>
<button
className="quick-scripts-dropdown__empty-action btn"
onClick={handleManageScripts}
>
{t("header.addFirstScript", "Add your first script")}
</button>
</div>
) : (
<>
<div className="quick-scripts-dropdown__list">
{scriptEntries.map(([name, command], index) => (
<button
key={name}
className={`quick-scripts-dropdown__item ${
highlightedScriptIndex === index ? "highlighted" : ""
}`}
onClick={() => handleRunQuickScript(name, command)}
role="option"
aria-selected={highlightedScriptIndex === index}
data-testid={`quick-script-item-${name}`}
>
<Play size={14} className="quick-scripts-dropdown__item-icon" />
<div className="quick-scripts-dropdown__item-info">
<span className="quick-scripts-dropdown__item-name">{name}</span>
<span className="quick-scripts-dropdown__item-command" title={command}>
{command.length > 50 ? `${command.slice(0, 50)}...` : command}
</span>
</div>
</button>
))}
</div>
<div className="quick-scripts-dropdown__footer">
<button
className={`quick-scripts-dropdown__manage ${
showScriptsFooter && highlightedScriptIndex === scriptEntries.length ? "highlighted" : ""
}`}
onClick={handleManageScripts}
data-testid="quick-scripts-manage"
>
<Settings size={14} />
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
</button>
</div>
</>
)}
</div>
)}
</>
)}
</div>
)}
{/* Workflows - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowEditor && (
<button
@@ -1498,98 +1061,17 @@ export function Header({
<span>{t("header.importFromGitHub", "Import from GitHub")}</span>
</button>
)}
<div
className="mobile-overflow-group"
data-testid="overflow-terminal-group"
>
<div className="mobile-overflow-split-row">
<button
className="mobile-overflow-item mobile-overflow-split-primary"
onClick={() => handleOverflowAction(onToggleTerminal)}
role="menuitem"
data-testid="overflow-terminal-primary-btn"
>
<Terminal size={16} />
<span>{t("header.terminal", "Terminal")}</span>
</button>
<button
className="mobile-overflow-split-toggle"
onClick={() => setIsTerminalSubmenuOpen((prev) => !prev)}
role="menuitem"
aria-expanded={isTerminalSubmenuOpen}
aria-haspopup="menu"
aria-label={t("header.showScripts", "Show scripts")}
data-testid="overflow-terminal-submenu-toggle"
>
<ChevronRight
size={14}
className={`mobile-overflow-chevron${isTerminalSubmenuOpen ? " mobile-overflow-chevron--open" : ""}`}
/>
</button>
</div>
{isTerminalSubmenuOpen && (
<div className="mobile-overflow-submenu" role="menu" aria-label={t("header.scriptsSubmenu", "Scripts submenu")}>
{overflowScriptsLoading ? (
<div className="mobile-overflow-submenu-loading" data-testid="overflow-scripts-loading">
<Loader2 size={14} className="animate-spin" />
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
</div>
) : overflowScriptEntries.length > 0 ? (
<>
{overflowScriptEntries.map(([name, command]) => (
<button
key={name}
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => {
if (onRunScript) onRunScript(name, command);
setIsOverflowMenuOpen(false);
setIsTerminalSubmenuOpen(false);
}}
role="menuitem"
data-testid={`overflow-script-item-${name}`}
>
<Play size={14} />
<span>{name}</span>
</button>
))}
{onOpenScripts && (
<button
className="mobile-overflow-item mobile-overflow-subitem mobile-overflow-subitem--manage"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-manage"
>
<FileCode size={14} />
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
</button>
)}
</>
) : (
onOpenScripts && (
<button
className="mobile-overflow-item mobile-overflow-subitem"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-manage"
>
<FileCode size={14} />
<span>{t("header.noScriptsAddOne", "No scripts — add one…")}</span>
</button>
)
)}
</div>
)}
</div>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSchedules)}
role="menuitem"
data-testid="overflow-schedules-btn"
>
<Clock size={16} />
<span>{t("header.automation", "Automation")}</span>
</button>
{/* Activity Log - in overflow on mobile */}
{onOpenSchedules && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSchedules)}
role="menuitem"
data-testid="overflow-schedules-btn"
>
<Clock size={16} />
<span>{t("header.automation", "Automation")}</span>
</button>
)}
{onOpenActivityLog && (
<button
className="mobile-overflow-item"
@@ -1601,7 +1083,6 @@ export function Header({
<span>{t("header.viewActivityLog", "View Activity Log")}</span>
</button>
)}
{/* Mailbox - in overflow on mobile */}
{onOpenMailbox && (
<button
className="mobile-overflow-item"
@@ -1616,7 +1097,6 @@ export function Header({
)}
</button>
)}
{/* Usage - in overflow on mobile */}
{onOpenUsage && (
<button
className="mobile-overflow-item"
@@ -1630,7 +1110,6 @@ export function Header({
<span>{t("header.viewUsage", "View Usage")}</span>
</button>
)}
{/* Workflows - in overflow on mobile */}
{onOpenWorkflowEditor && (
<button
className="mobile-overflow-item"

View File

@@ -0,0 +1,180 @@
:root {
--terminal-launcher-menu-min-width: calc(var(--space-xl) * 5);
--terminal-launcher-menu-width: calc(var(--space-xl) * 8.125);
--terminal-launcher-menu-height: calc(var(--space-xl) * 8.75);
}
.terminal-launcher {
position: relative;
display: inline-flex;
align-items: center;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.terminal-launcher--footer {
background: transparent;
border-color: transparent;
}
.terminal-launcher__main {
min-height: var(--control-height-sm);
gap: var(--space-xs);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
color: var(--text-muted);
}
.terminal-launcher__main:hover,
.terminal-launcher__chevron:hover {
color: var(--text);
}
.terminal-launcher__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.terminal-launcher__chevron {
min-height: var(--control-height-sm);
width: var(--control-height-sm);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
color: var(--text-muted);
}
.terminal-launcher__divider {
width: var(--border-width, 1px);
height: var(--space-md);
background: var(--border);
}
.quick-scripts-dropdown__trigger-chevron {
color: currentColor;
transition: transform var(--transition-fast);
}
.quick-scripts-dropdown__trigger-chevron.rotate {
transform: rotate(180deg);
}
.quick-scripts-dropdown__menu {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
min-width: var(--terminal-launcher-menu-width);
max-height: min(70vh, calc(var(--space-xl) * 11.25));
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: var(--z-popover);
padding: var(--space-sm);
}
.quick-scripts-dropdown__loading,
.quick-scripts-dropdown__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-lg);
color: var(--text-muted);
text-align: center;
}
.quick-scripts-dropdown__empty-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.quick-scripts-dropdown__empty p {
margin: 0;
color: var(--text);
font-size: var(--font-size-sm);
}
.quick-scripts-dropdown__empty-action {
min-height: var(--control-height-sm);
}
.quick-scripts-dropdown__list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.quick-scripts-dropdown__item,
.quick-scripts-dropdown__manage {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.quick-scripts-dropdown__item:hover,
.quick-scripts-dropdown__item.highlighted,
.quick-scripts-dropdown__manage:hover,
.quick-scripts-dropdown__manage.highlighted {
background: var(--card);
}
.quick-scripts-dropdown__item-icon,
.quick-scripts-dropdown__manage svg {
flex-shrink: 0;
color: var(--text-muted);
}
.quick-scripts-dropdown__item-info {
display: flex;
flex-direction: column;
gap: var(--space-xxs);
min-width: 0;
}
.quick-scripts-dropdown__item-name {
color: var(--text);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.quick-scripts-dropdown__item-command {
color: var(--text-muted);
font-size: var(--font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-scripts-dropdown__footer {
padding-top: var(--space-xs);
margin-top: var(--space-xs);
border-top: 1px solid var(--border);
}
.quick-scripts-dropdown__manage {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.quick-scripts-dropdown__manage span {
flex: 1;
}
@media (max-width: 768px) {
.terminal-launcher {
display: none;
}
}

View File

@@ -0,0 +1,306 @@
import "./TerminalLauncher.css";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Loader2, Play, Settings, Terminal } from "lucide-react";
import { fetchScripts } from "../api";
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface TerminalLauncherProps {
projectId?: string;
onToggleTerminal?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
variant?: "header" | "footer";
compact?: boolean;
}
/*
FNXC:Terminal 2026-06-21-22:05:
FN-6887 extracts the terminal launcher (icon, Terminal label, and scripts dropdown) so desktop/tablet can render the single canonical launcher in the footer status bar instead of the Header toolbar.
*/
export function TerminalLauncher({
projectId,
onToggleTerminal,
onOpenScripts,
onRunScript,
variant = "footer",
compact = false,
}: TerminalLauncherProps) {
const { t } = useTranslation("app");
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const [highlightedScriptIndex, setHighlightedScriptIndex] = useState(-1);
const [scriptsDropdownPosition, setScriptsDropdownPosition] = useState<DropdownPosition | null>(null);
const splitButtonRef = useRef<HTMLDivElement>(null);
const chevronButtonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const scriptEntries = useMemo(() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)), [scripts]);
const showScriptsFooter = scriptEntries.length > 0;
const totalScriptItems = scriptEntries.length + (showScriptsFooter ? 1 : 0);
const scriptsEnabled = Boolean(onOpenScripts && onRunScript);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
if (vv && vv.width > 0 && vv.height > 0) {
return { width: vv.width, height: vv.height, offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft };
}
return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0 };
}, []);
const updateScriptsDropdownPosition = useCallback(() => {
const trigger = chevronButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = menuRef.current;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const rootStyle = getComputedStyle(document.documentElement);
const horizontalPadding = Number.parseFloat(rootStyle.getPropertyValue("--space-md")) || 16;
const verticalPadding = horizontalPadding;
const gap = Number.parseFloat(rootStyle.getPropertyValue("--space-xs")) || 6;
const minWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-min-width")) || 160;
const preferredWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-width")) || 260;
const preferredHeight = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-height")) || 280;
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, preferredWidth);
const width = Math.min(measuredWidth, Math.max(viewportWidth - horizontalPadding * 2, minWidth));
const measuredHeight = menu?.offsetHeight || preferredHeight;
const constrainedHeight = Math.min(measuredHeight, Math.max(viewportHeight - verticalPadding * 2, minWidth));
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerRight = rect.right - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerRight - width, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - constrainedHeight);
setScriptsDropdownPosition({ top, left, width });
}, [getEffectiveViewport]);
const handleRunQuickScript = useCallback((name: string, command: string) => {
onRunScript?.(name, command);
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}, [onRunScript]);
const handleManageScripts = useCallback(() => {
onOpenScripts?.();
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}, [onOpenScripts]);
const handleScriptsDropdownKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex((prev) => (prev < totalScriptItems - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex((prev) => (prev > 0 ? prev - 1 : totalScriptItems - 1));
break;
case "Enter":
e.preventDefault();
if (highlightedScriptIndex >= 0) {
if (highlightedScriptIndex < scriptEntries.length) {
const [name, command] = scriptEntries[highlightedScriptIndex];
handleRunQuickScript(name, command);
} else if (showScriptsFooter && highlightedScriptIndex === scriptEntries.length) {
handleManageScripts();
}
}
break;
case "Home":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex(0);
break;
case "End":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex(totalScriptItems - 1);
break;
}
}, [handleManageScripts, handleRunQuickScript, highlightedScriptIndex, scriptEntries, showScriptsFooter, totalScriptItems]);
useEffect(() => {
if (!isScriptsOpen || !scriptsEnabled) return;
let cancelled = false;
setScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) setScripts(data);
})
.catch(() => {
if (!cancelled) setScripts({});
})
.finally(() => {
if (!cancelled) setScriptsLoading(false);
});
return () => {
cancelled = true;
};
}, [isScriptsOpen, projectId, scriptsEnabled]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (splitButtonRef.current && !splitButtonRef.current.contains(e.target as Node)) {
setIsScriptsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isScriptsOpen]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsScriptsOpen(false);
chevronButtonRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isScriptsOpen]);
useEffect(() => {
if (isScriptsOpen) {
setHighlightedScriptIndex(-1);
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
return () => window.clearTimeout(timeoutId);
}
setScriptsDropdownPosition(null);
}, [isScriptsOpen]);
useEffect(() => {
if (!isScriptsOpen) return;
const rafId = requestAnimationFrame(() => updateScriptsDropdownPosition());
return () => cancelAnimationFrame(rafId);
}, [isScriptsOpen, scriptsLoading, scriptEntries.length, showScriptsFooter, updateScriptsDropdownPosition]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleReposition = () => updateScriptsDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isScriptsOpen, updateScriptsDropdownPosition]);
return (
<div className={`terminal-launcher terminal-launcher--${variant}${compact ? " terminal-launcher--compact" : ""}`} ref={splitButtonRef}>
<button
className="btn terminal-launcher__main"
onClick={onToggleTerminal}
title={t("header.openTerminal", "Open Terminal")}
data-testid="terminal-toggle-btn"
type="button"
>
<Terminal size={16} />
{!compact && <span className="terminal-launcher__label">{t("header.terminal", "Terminal")}</span>}
</button>
{scriptsEnabled && (
<>
<span className="terminal-launcher__divider" />
<button
ref={chevronButtonRef}
className={`btn-icon terminal-launcher__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
onClick={() => setIsScriptsOpen((prev) => !prev)}
title={t("header.scripts", "Scripts")}
aria-haspopup="listbox"
aria-expanded={isScriptsOpen}
aria-label={t("header.quickScripts", "Quick scripts")}
data-testid="scripts-btn"
type="button"
>
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
</button>
{isScriptsOpen && (
<div
ref={menuRef}
tabIndex={-1}
className="quick-scripts-dropdown__menu"
role="listbox"
aria-label={t("header.scripts", "Scripts")}
onKeyDown={handleScriptsDropdownKeyDown}
data-testid="quick-scripts-dropdown"
style={scriptsDropdownPosition ? { position: "fixed", top: `${scriptsDropdownPosition.top}px`, left: `${scriptsDropdownPosition.left}px`, width: `${scriptsDropdownPosition.width}px`, right: "auto" } : undefined}
>
{scriptsLoading ? (
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
<Loader2 size={16} className="animate-spin" />
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
</div>
) : scriptEntries.length === 0 ? (
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
<div className="quick-scripts-dropdown__empty-icon"><Terminal size={16} /></div>
<p>{t("header.noScriptsConfigured", "No scripts configured")}</p>
<button className="quick-scripts-dropdown__empty-action btn" onClick={handleManageScripts} type="button">
{t("header.addFirstScript", "Add your first script")}
</button>
</div>
) : (
<>
<div className="quick-scripts-dropdown__list">
{scriptEntries.map(([name, command], index) => (
<button
key={name}
className={`quick-scripts-dropdown__item ${highlightedScriptIndex === index ? "highlighted" : ""}`}
onClick={() => handleRunQuickScript(name, command)}
role="option"
aria-selected={highlightedScriptIndex === index}
data-testid={`quick-script-item-${name}`}
type="button"
>
<Play size={14} className="quick-scripts-dropdown__item-icon" />
<div className="quick-scripts-dropdown__item-info">
<span className="quick-scripts-dropdown__item-name">{name}</span>
<span className="quick-scripts-dropdown__item-command" title={command}>{command.length > 50 ? `${command.slice(0, 50)}...` : command}</span>
</div>
</button>
))}
</div>
<div className="quick-scripts-dropdown__footer">
<button
className={`quick-scripts-dropdown__manage ${showScriptsFooter && highlightedScriptIndex === scriptEntries.length ? "highlighted" : ""}`}
onClick={handleManageScripts}
data-testid="quick-scripts-manage"
type="button"
>
<Settings size={14} />
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
</button>
</div>
</>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -23,6 +23,19 @@ FN-6811 recurrence #6 tightened ownership of this scoped symbols face: every ter
padding-bottom: 0;
}
/*
FNXC:Terminal 2026-06-21-22:32:
FN-6887 turns desktop/tablet terminal into a bottom-docked panel above the footer. Keep mobile on the fullscreen modal path through the max-width media override below.
*/
.terminal-modal-overlay--docked,
.terminal-modal-overlay--floating {
align-items: stretch;
justify-content: flex-end;
padding: 0;
background: transparent;
pointer-events: none;
}
.modal.terminal-modal {
/* Initial dimensions are applied only when no persisted size has been
restored — see :not([style*=...]) selectors below. */
@@ -44,6 +57,110 @@ FN-6811 recurrence #6 tightened ownership of this scoped symbols face: every ter
height: min(85vh, calc(100dvh - 40px));
}
.modal.terminal-modal.terminal-modal--docked {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--icb-bottom-offset, 0px) + var(--executor-footer-height, calc(var(--space-xl) + var(--space-md))));
width: 100vw;
min-width: 0;
height: var(--terminal-docked-height);
min-height: calc(var(--space-xl) * 10);
max-width: none;
max-height: calc(100dvh - (var(--space-xl) * 4));
resize: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
pointer-events: auto;
box-shadow: var(--shadow-xl);
}
.terminal-docked-resize-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--space-sm);
cursor: ns-resize;
z-index: 1;
}
.terminal-docked-resize-handle::before {
content: "";
position: absolute;
left: 50%;
top: calc(var(--space-xs) / 2);
width: calc(var(--space-xl) * 2);
height: calc(var(--space-xs) / 2);
transform: translateX(-50%);
border-radius: var(--radius-full);
background: var(--border);
}
.modal.terminal-modal.terminal-modal--floating {
position: fixed;
left: var(--terminal-float-x);
top: var(--terminal-float-y);
width: var(--terminal-float-width);
height: var(--terminal-float-height);
min-width: calc(var(--space-xl) * 20);
min-height: calc(var(--space-xl) * 13.333);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
box-shadow: var(--shadow-xl);
}
.terminal-header--draggable {
cursor: grab;
user-select: none;
}
.terminal-header--draggable:active {
cursor: grabbing;
}
.terminal-floating-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.terminal-floating-resize-handle--n,
.terminal-floating-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.terminal-floating-resize-handle--n { top: 0; }
.terminal-floating-resize-handle--s { bottom: 0; }
.terminal-floating-resize-handle--e,
.terminal-floating-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.terminal-floating-resize-handle--e { right: 0; }
.terminal-floating-resize-handle--w { left: 0; }
.terminal-floating-resize-handle--ne,
.terminal-floating-resize-handle--nw,
.terminal-floating-resize-handle--se,
.terminal-floating-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.terminal-floating-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.terminal-floating-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.terminal-header {
display: flex;
align-items: center;

View File

@@ -20,10 +20,11 @@ import {
Plus,
Keyboard,
Settings,
Maximize2,
Minimize2,
} from "lucide-react";
import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { getPathBasename } from "../utils/pathDisplay";
import {
DEFAULT_TERMINAL_PREFERENCES,
@@ -49,6 +50,123 @@ const XTERM_INIT_TIMEOUT_MS = 10000;
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
type TerminalDisplayMode = "docked" | "floating";
const TERMINAL_DOCKED_DEFAULT_HEIGHT = 360;
const TERMINAL_DOCKED_MIN_HEIGHT = 240;
const TERMINAL_DOCKED_VIEWPORT_MARGIN = 96;
const TERMINAL_FLOAT_DEFAULT_WIDTH = 960;
const TERMINAL_FLOAT_DEFAULT_HEIGHT = 560;
const TERMINAL_FLOAT_MIN_WIDTH = 480;
const TERMINAL_FLOAT_MIN_HEIGHT = 320;
const TERMINAL_FLOAT_VIEWPORT_PADDING = 16;
type TerminalResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const TERMINAL_RESIZE_DIRECTIONS: TerminalResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
interface TerminalFloatSize {
width: number;
height: number;
}
interface TerminalFloatPosition {
x: number;
y: number;
}
function readTerminalDisplayMode(projectId?: string): TerminalDisplayMode {
if (typeof window === "undefined") return "docked";
const value = window.localStorage.getItem(`fusion:terminal-display-mode-${projectId ?? "default"}`);
return value === "floating" ? "floating" : "docked";
}
function writeTerminalDisplayMode(mode: TerminalDisplayMode, projectId?: string): TerminalDisplayMode {
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId ?? "default"}`, mode);
}
return mode;
}
function readTerminalDockedHeight(projectId?: string): number {
if (typeof window === "undefined") return TERMINAL_DOCKED_DEFAULT_HEIGHT;
const parsed = Number.parseInt(window.localStorage.getItem(`fusion:terminal-docked-height-${projectId ?? "default"}`) ?? "", 10);
return Number.isFinite(parsed) ? parsed : TERMINAL_DOCKED_DEFAULT_HEIGHT;
}
function clampTerminalDockedHeight(height: number): number {
if (typeof window === "undefined") return Math.max(TERMINAL_DOCKED_MIN_HEIGHT, height);
const maxHeight = Math.max(TERMINAL_DOCKED_MIN_HEIGHT, window.innerHeight - TERMINAL_DOCKED_VIEWPORT_MARGIN);
return Math.min(Math.max(height, TERMINAL_DOCKED_MIN_HEIGHT), maxHeight);
}
function writeTerminalDockedHeight(height: number, projectId?: string): number {
const clamped = clampTerminalDockedHeight(height);
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-docked-height-${projectId ?? "default"}`, String(Math.round(clamped)));
}
return clamped;
}
function clampTerminalFloatSize(size: TerminalFloatSize): TerminalFloatSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, TERMINAL_FLOAT_MIN_WIDTH), Math.max(TERMINAL_FLOAT_MIN_WIDTH, window.innerWidth - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, TERMINAL_FLOAT_MIN_HEIGHT), Math.max(TERMINAL_FLOAT_MIN_HEIGHT, window.innerHeight - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)),
};
}
function clampTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize): TerminalFloatPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerHeight - size.height - TERMINAL_FLOAT_VIEWPORT_PADDING)),
};
}
function readTerminalFloatSize(projectId?: string): TerminalFloatSize {
if (typeof window === "undefined") return { width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(`fusion:terminal-modal-size-${projectId ?? "default"}`) ?? window.localStorage.getItem("fusion:terminal-modal-size");
if (raw) {
const parsed = JSON.parse(raw) as Partial<TerminalFloatSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") return clampTerminalFloatSize({ width: parsed.width, height: parsed.height });
}
} catch {
// ignore corrupted size
}
return clampTerminalFloatSize({ width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT });
}
function writeTerminalFloatSize(size: TerminalFloatSize, projectId?: string): TerminalFloatSize {
const clamped = clampTerminalFloatSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-modal-size-${projectId ?? "default"}`, JSON.stringify(clamped));
}
return clamped;
}
function readTerminalFloatPosition(size: TerminalFloatSize, projectId?: string): TerminalFloatPosition {
if (typeof window === "undefined") return { x: TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(`fusion:terminal-float-pos-${projectId ?? "default"}`);
if (raw) {
const parsed = JSON.parse(raw) as Partial<TerminalFloatPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") return clampTerminalFloatPosition({ x: parsed.x, y: parsed.y }, size);
}
} catch {
// ignore corrupted position
}
return clampTerminalFloatPosition({ x: window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING }, size);
}
function writeTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize, projectId?: string): TerminalFloatPosition {
const clamped = clampTerminalFloatPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(`fusion:terminal-float-pos-${projectId ?? "default"}`, JSON.stringify(clamped));
}
return clamped;
}
const TERMINAL_KEY_LABELS = {
ctrl: "Ctrl",
alt: "Alt",
@@ -169,6 +287,12 @@ function isMobileDevice(): boolean {
return hasTouchScreen && isNarrow;
}
function isTerminalMobileViewport(): boolean {
if (typeof window === "undefined") return false;
const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0;
return window.innerWidth <= 768 || (hasTouchScreen && window.innerHeight <= 480);
}
function isMacPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
@@ -274,11 +398,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const [showPreferences, setShowPreferences] = useState(false);
const [stickyModifier, setStickyModifier] = useState<null | "ctrl" | "alt">(null);
const [pendingInitialCommandGeneration, setPendingInitialCommandGeneration] = useState(0);
const [displayMode, setDisplayModeState] = useState<TerminalDisplayMode>(() => readTerminalDisplayMode(projectId));
const [dockedHeight, setDockedHeight] = useState(() => readTerminalDockedHeight(projectId));
const [floatingSize, setFloatingSize] = useState<TerminalFloatSize>(() => readTerminalFloatSize(projectId));
const [floatingPosition, setFloatingPosition] = useState<TerminalFloatPosition>(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId));
const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport());
const isDockedMode = !isMobileTerminal && displayMode === "docked";
const isFloatingMode = !isMobileTerminal && displayMode === "floating";
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const overlayMouseDownRef = useRef(false);
useModalResizePersist(modalRef, isOpen, "fusion:terminal-modal-size");
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const hasInitialCommandRun = useRef<string | false>(false);
@@ -311,6 +441,141 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
terminalPreferencesRef.current = terminalPreferences;
resolvedFontFamilyRef.current = resolvedFontFamily;
useEffect(() => {
setDisplayModeState(readTerminalDisplayMode(projectId));
setDockedHeight(readTerminalDockedHeight(projectId));
const nextSize = readTerminalFloatSize(projectId);
setFloatingSize(nextSize);
setFloatingPosition(readTerminalFloatPosition(nextSize, projectId));
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
/*
FNXC:Terminal 2026-06-21-22:58:
Viewport changes must force the terminal back onto the mobile fullscreen path at <=768px or touch-primary short landscape, then restore the stored desktop/tablet docked/floating mode when the viewport expands.
*/
const updateViewportMode = () => setIsMobileTerminal(isTerminalMobileViewport());
updateViewportMode();
window.addEventListener("resize", updateViewportMode);
window.visualViewport?.addEventListener("resize", updateViewportMode);
return () => {
window.removeEventListener("resize", updateViewportMode);
window.visualViewport?.removeEventListener("resize", updateViewportMode);
};
}, [isOpen]);
const setDisplayMode = useCallback((mode: TerminalDisplayMode) => {
setDisplayModeState(writeTerminalDisplayMode(mode, projectId));
}, [projectId]);
const persistDockedHeight = useCallback((height: number) => {
setDockedHeight(writeTerminalDockedHeight(height, projectId));
}, [projectId]);
const persistFloatingSize = useCallback((size: TerminalFloatSize) => {
setFloatingSize(writeTerminalFloatSize(size, projectId));
}, [projectId]);
const persistFloatingPosition = useCallback((position: TerminalFloatPosition, size = floatingSize) => {
setFloatingPosition(writeTerminalFloatPosition(position, size, projectId));
}, [floatingSize, projectId]);
/*
FNXC:Terminal 2026-06-21-22:26:
FN-6887 requires desktop/tablet terminal opens to default to a project-scoped docked bottom panel. Persist `fusion:terminal-display-mode-${projectId}` and `fusion:terminal-docked-height-${projectId}` so each project restores its preferred panel mode and height without affecting mobile fullscreen behavior.
FNXC:Terminal 2026-06-21-22:45:
The pop-out terminal mode uses project-scoped `fusion:terminal-modal-size-${projectId}` and `fusion:terminal-float-pos-${projectId}` keys so floating windows restore independently per project while avoiding the old bottom-right native resize grip conflict.
*/
const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDockedMode) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const startY = event.clientY;
const startHeight = dockedHeight;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
persistDockedHeight(startHeight + (startY - moveEvent.clientY));
};
const handlePointerUp = () => {
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [dockedHeight, isDockedMode, persistDockedHeight]);
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isFloatingMode || (event.target as HTMLElement).closest("button")) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = floatingPosition;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
persistFloatingPosition({ x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY });
};
const handlePointerUp = () => {
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, isFloatingMode, persistFloatingPosition]);
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: TerminalResizeDirection) => {
if (!isFloatingMode) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = floatingSize;
const startPosition = floatingPosition;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const rawSize = {
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
};
const nextSize = clampTerminalFloatSize(rawSize);
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
persistFloatingSize(nextSize);
persistFloatingPosition(nextPosition, nextSize);
};
const handlePointerUp = () => {
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition, persistFloatingSize]);
/**
* Fit xterm and publish cols/rows for a specific terminal session.
*
@@ -423,6 +688,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
};
}, [fitAndResizeForSession, isOpen]);
/*
FNXC:Terminal 2026-06-21-22:07:
Docked and floating terminal resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode, docked height, or floating size changes to keep rows/cols synchronized.
*/
useEffect(() => {
if (!isOpen) return;
const sessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined;
const frame = requestAnimationFrame(() => fitAndResizeForSession(sessionId));
return () => cancelAnimationFrame(frame);
}, [displayMode, dockedHeight, fitAndResizeForSession, floatingSize, isOpen]);
// Refit xterm whenever the user drags the modal's CSS resize grip.
// The window/visualViewport listeners only fire on viewport changes; native
// `resize: both` does NOT emit window resize, so we observe the modal node
@@ -1252,6 +1528,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
setFontSize((current) => clampTerminalFontSize(current - 1));
}, [setFontSize]);
const handleToggleDisplayMode = useCallback(() => {
setDisplayMode(displayMode === "floating" ? "docked" : "floating");
}, [displayMode, setDisplayMode]);
const handlePreferenceFontSizeChange = useCallback(
(value: string) => {
const parsed = Number.parseInt(value, 10);
@@ -1355,10 +1635,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Once a tab exists we keep the xterm container visible while UI init runs,
// avoiding a retry-loop spinner flash after bootstrap recovery.
const isLoading = !isReady || (!activeTab && !bootstrapError);
const overlayClassName = `modal-overlay open${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
const modalClassName = `modal terminal-modal${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}`;
const modalStyle = {
...(keyboardOverlap > 0
? {
"--keyboard-overlap": `${keyboardOverlap}px`,
// On mobile with keyboard open, constrain to visualViewport height
// so the modal (including status bar) fits entirely above the keyboard.
// This is more reliable than 100dvh which behaves differently
// across Chrome Android vs iOS Safari.
"--vv-height": viewportHeight ? `${viewportHeight}px` : undefined,
}
: {}),
...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}),
...(isFloatingMode
? {
"--terminal-float-x": `${floatingPosition.x}px`,
"--terminal-float-y": `${floatingPosition.y}px`,
"--terminal-float-width": `${floatingSize.width}px`,
"--terminal-float-height": `${floatingSize.height}px`,
}
: {}),
} as CSSProperties;
return (
<div
className="modal-overlay open"
className={overlayClassName}
onMouseDown={handleOverlayMouseDown}
onMouseUp={handleOverlayMouseUp}
role="dialog"
@@ -1368,30 +1671,39 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
keyboardOverlap > 0
? {
"--overlay-padding-top": "0px",
} as React.CSSProperties
} as CSSProperties
: undefined
}
>
<div
ref={modalRef}
className="modal terminal-modal"
className={modalClassName}
data-testid="terminal-modal"
style={
keyboardOverlap > 0
? {
"--keyboard-overlap": `${keyboardOverlap}px`,
// On mobile with keyboard open, constrain to visualViewport height
// so the modal (including status bar) fits entirely above the keyboard.
// This is more reliable than 100dvh which behaves differently
// across Chrome Android vs iOS Safari.
"--vv-height": viewportHeight ? `${viewportHeight}px` : undefined,
} as React.CSSProperties
: undefined
}
style={modalStyle}
>
{isDockedMode && (
<div
className="terminal-docked-resize-handle"
data-testid="terminal-docked-resize-handle"
role="separator"
aria-orientation="horizontal"
aria-label={t("terminal.resizeDockedPanel", "Resize terminal panel")}
onPointerDown={handleDockedResizePointerDown}
/>
)}
{isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`terminal-floating-resize-handle terminal-floating-resize-handle--${direction}`}
data-testid={`terminal-floating-resize-${direction}`}
role="separator"
aria-label={t("terminal.resizeFloatingPanel", "Resize terminal window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
{/* Header — on mobile (≤768px) keep tabs and actions on one row;
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className="terminal-header">
<div className={`terminal-header${isFloatingMode ? " terminal-header--draggable" : ""}`} onPointerDown={handleFloatingDragPointerDown}>
{/* Tab Bar */}
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
@@ -1486,6 +1798,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
<Settings size={14} />
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
</button>
{!isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut"
onClick={handleToggleDisplayMode}
data-testid="terminal-popout-toggle"
title={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-pressed={displayMode === "floating"}
>
{displayMode === "floating" ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
<span className="terminal-action-label">{displayMode === "floating" ? t("terminal.dock", "Dock") : t("terminal.popOut", "Pop out")}</span>
</button>
)}
<button
className="terminal-close"
onClick={onClose}

View File

@@ -1,8 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ExecutorStatusBar } from "../ExecutorStatusBar";
const viewportModeMock = vi.hoisted(() => ({ value: "desktop" as "desktop" | "tablet" | "mobile" }));
const mockFetchScripts = vi.hoisted(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => viewportModeMock.value,
}));
vi.mock("../../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
// Mock the useExecutorStats hook
vi.mock("../../hooks/useExecutorStats", () => ({
useExecutorStats: vi.fn(),
@@ -65,6 +76,8 @@ describe("ExecutorStatusBar", () => {
beforeEach(() => {
vi.clearAllMocks();
viewportModeMock.value = "desktop";
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
@@ -167,6 +180,41 @@ describe("ExecutorStatusBar", () => {
expect(statusBar).toHaveTextContent("3");
});
it("renders the terminal launcher in the footer on desktop and opens terminal from the preserved toggle test id", async () => {
const user = userEvent.setup();
const onToggleTerminal = vi.fn();
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={onToggleTerminal} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
await user.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("scripts-btn")).toBeInTheDocument();
expect(await screen.findByTestId("quick-scripts-dropdown")).toBeInTheDocument();
await waitFor(() => expect(mockFetchScripts).toHaveBeenCalledWith(undefined));
});
it("renders the terminal launcher in the footer on tablet", () => {
viewportModeMock.value = "tablet";
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={vi.fn()} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
expect(screen.getByTestId("executor-terminal-launcher-segment")).toBeInTheDocument();
expect(screen.getByTestId("terminal-toggle-btn")).toBeInTheDocument();
});
it("omits the terminal launcher from the footer on mobile", () => {
viewportModeMock.value = "mobile";
render(<ExecutorStatusBar tasks={emptyTasks} onToggleTerminal={vi.fn()} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
expect(screen.queryByTestId("executor-terminal-launcher-segment")).toBeNull();
expect(screen.queryByTestId("terminal-toggle-btn")).toBeNull();
expect(screen.queryByTestId("scripts-btn")).toBeNull();
});
it("does not show stuck tasks segment when count is 0", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
@@ -295,7 +343,7 @@ describe("ExecutorStatusBar", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const statusBar = screen.getByLabelText("Executor status");
expect(statusBar).toHaveTextContent("Loading...");
expect(statusBar).toHaveClass("executor-status-bar--loading");
});

View File

@@ -495,109 +495,24 @@ describe("Header", () => {
});
});
describe("terminal split button", () => {
it("renders terminal main button and scripts chevron on desktop", () => {
describe("terminal launcher relocation", () => {
it("does not render the terminal launcher or scripts chevron in the desktop header", () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
expect(screen.getByTitle("Open Terminal")).toBeDefined();
expect(screen.getByTestId("scripts-btn")).toBeDefined();
});
it("does not render terminal button inline on mobile", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
expect(screen.queryByTitle("Open Terminal")).toBeNull();
expect(screen.queryByTestId("terminal-toggle-btn")).toBeNull();
expect(screen.queryByTestId("scripts-btn")).toBeNull();
expect(screen.queryByTestId("terminal-split-btn")).toBeNull();
});
it("clicking main button calls onToggleTerminal without opening scripts dropdown", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("clicking scripts chevron opens dropdown without calling onToggleTerminal", async () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(onToggleTerminal).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
});
it("fetches scripts and runs selected script from dropdown", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript, projectId: "proj-1" }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("quick-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
it("shows loading state while scripts are fetching", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
});
it("shows empty state when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
});
});
it("shows manage scripts footer when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ test: "pnpm test" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
});
});
it("supports keyboard navigation in scripts dropdown", async () => {
mockFetchScripts.mockResolvedValue({ alpha: "echo alpha", beta: "echo beta" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript }, "desktop");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
});
const menu = screen.getByTestId("quick-scripts-dropdown");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(onRunScript).toHaveBeenCalledWith("alpha", "echo alpha");
fireEvent.click(screen.getByTestId("scripts-btn"));
await waitFor(() => {
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
});
fireEvent.keyDown(screen.getByTestId("quick-scripts-dropdown"), { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
});
});
it("is always enabled regardless of task state", () => {
renderHeader({ onToggleTerminal: noop }, "desktop");
const btn = screen.getByTitle("Open Terminal");
expect(btn.hasAttribute("disabled")).toBe(false);
it("does not render terminal launcher affordances in the mobile header overflow", () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.queryByTitle("Open Terminal")).toBeNull();
expect(screen.queryByTestId("terminal-toggle-btn")).toBeNull();
expect(screen.queryByTestId("scripts-btn")).toBeNull();
expect(screen.queryByTestId("terminal-split-btn")).toBeNull();
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
});
});
@@ -820,174 +735,14 @@ describe("Header", () => {
expect(screen.queryByTitle("More header actions")).toBeNull();
});
it("shows terminal group in overflow menu on mobile", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
it("does not render terminal or scripts affordances in mobile header overflow", () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, onRunScript: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
expect(screen.getByTestId("overflow-terminal-submenu-toggle")).toBeDefined();
});
it("shows terminal submenu items when terminal group is expanded on mobile", async () => {
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("shows scripts manage in terminal submenu on mobile when onOpenScripts is provided", async () => {
renderHeader({ onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show scripts manage in terminal submenu when onOpenScripts is undefined", () => {
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
it("calls onToggleTerminal from primary terminal button on mobile", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenScripts from terminal submenu manage on mobile", async () => {
const onOpenScripts = vi.fn();
renderHeader({ onOpenScripts }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-scripts-manage"));
expect(onOpenScripts).toHaveBeenCalled();
});
it("primary terminal button opens terminal directly without expanding submenu", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click primary button — should open terminal and NOT expand submenu
fireEvent.click(screen.getByTestId("overflow-terminal-primary-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
// Overflow menu should close after action
expect(screen.queryByRole("menu")).toBeNull();
});
it("chevron toggle expands submenu without opening terminal", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
// Click chevron — should expand submenu but NOT call onToggleTerminal
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(onToggleTerminal).not.toHaveBeenCalled();
// Overflow menu should still be open (check by primary button still being visible)
expect(screen.getByTestId("overflow-terminal-primary-btn")).toBeDefined();
});
it("renders one script item per fetched script in submenu", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build", test: "pnpm test" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-script-item-test")).toBeDefined();
});
});
it("clicking a script entry calls onRunScript and closes overflow", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
const onRunScript = vi.fn();
renderHeader({ onToggleTerminal: noop, onRunScript, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
// Overflow menu should close after running script
expect(screen.queryByRole("menu")).toBeNull();
});
it("does not render old overflow-scripts-btn item", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// The old generic scripts button should not exist
expect(screen.queryByTestId("overflow-terminal-primary-btn")).toBeNull();
expect(screen.queryByTestId("overflow-terminal-submenu-toggle")).toBeNull();
expect(screen.queryByTestId("overflow-scripts-btn")).toBeNull();
// The old terminal submenu "Open Terminal" button should not exist
expect(screen.queryByTestId("overflow-terminal-btn")).toBeNull();
});
it("shows loading state while fetching scripts", () => {
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
expect(screen.getByTestId("overflow-scripts-loading")).toBeDefined();
});
it("shows manage scripts link when no scripts are configured", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
});
it("does not show manage scripts link when onOpenScripts is undefined", async () => {
mockFetchScripts.mockResolvedValue({});
renderHeader({ onToggleTerminal: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
});
it("handles missing onRunScript gracefully", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
});
// Clicking script without onRunScript should not throw
expect(() => {
fireEvent.click(screen.getByTestId("overflow-script-item-build"));
}).not.toThrow();
// Overflow menu should still close
expect(screen.queryByRole("menu")).toBeNull();
});
it("shows Manage Scripts after script entries when scripts exist", async () => {
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
renderHeader({ onToggleTerminal: noop, onRunScript: noop, onOpenScripts: noop, projectId: "test-project" }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-submenu-toggle"));
await waitFor(() => {
expect(screen.getByTestId("overflow-script-item-build")).toBeDefined();
expect(screen.getByTestId("overflow-scripts-manage")).toBeDefined();
});
expect(screen.queryByTestId("overflow-scripts-manage")).toBeNull();
});
it("shows GitHub import in overflow menu on mobile", () => {
@@ -996,6 +751,39 @@ describe("Header", () => {
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("keeps Mailbox in the compact overflow with unread and approval badges", () => {
const onOpenMailbox = vi.fn();
renderHeader({ onOpenMailbox, mailboxUnreadCount: 3, mailboxPendingApprovalCount: 2 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
const mailboxButton = screen.getByTestId("overflow-mailbox-btn");
expect(mailboxButton).toHaveTextContent("Mailbox (3)");
expect(screen.getByTestId("overflow-mailbox-approval-badge")).toHaveTextContent("2");
fireEvent.click(mailboxButton);
expect(onOpenMailbox).toHaveBeenCalledTimes(1);
});
it("keeps compact overflow tool ordering from before terminal moved to the footer launcher", () => {
renderHeader({ onOpenGitManager: noop, onOpenSchedules: noop, onOpenActivityLog: noop, onOpenMailbox: noop, onOpenUsage: noop, onOpenWorkflowEditor: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
const menu = screen.getByRole("menu", { name: "Additional header actions" });
const orderedItems = [
"overflow-git-btn",
"overflow-schedules-btn",
"overflow-activity-log-btn",
"overflow-mailbox-btn",
"overflow-usage-btn",
"overflow-workflow-steps-btn",
].map((testId) => screen.getByTestId(testId));
expect(menu).toContainElement(orderedItems[0]);
for (let index = 1; index < orderedItems.length; index += 1) {
expect(orderedItems[index - 1].compareDocumentPosition(orderedItems[index]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
});
it("omits planning from the header overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TerminalLauncher } from "../TerminalLauncher";
const mockFetchScripts = vi.fn();
vi.mock("../../api", () => ({
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));
describe("TerminalLauncher", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchScripts.mockResolvedValue({ build: "pnpm build" });
});
it("renders the terminal button and toggles terminal", () => {
const onToggleTerminal = vi.fn();
render(<TerminalLauncher projectId="proj-1" onToggleTerminal={onToggleTerminal} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
fireEvent.click(screen.getByTestId("terminal-toggle-btn"));
expect(onToggleTerminal).toHaveBeenCalledTimes(1);
expect(screen.getByText("Terminal")).toBeInTheDocument();
});
it("opens scripts dropdown from chevron without toggling terminal", async () => {
const onToggleTerminal = vi.fn();
render(<TerminalLauncher projectId="proj-1" onToggleTerminal={onToggleTerminal} onOpenScripts={vi.fn()} onRunScript={vi.fn()} />);
fireEvent.click(screen.getByTestId("scripts-btn"));
expect(onToggleTerminal).not.toHaveBeenCalled();
expect(await screen.findByTestId("quick-scripts-dropdown")).toBeInTheDocument();
await waitFor(() => expect(mockFetchScripts).toHaveBeenCalledWith("proj-1"));
});
it("runs a quick script", async () => {
const onRunScript = vi.fn();
render(<TerminalLauncher projectId="proj-1" onToggleTerminal={vi.fn()} onOpenScripts={vi.fn()} onRunScript={onRunScript} />);
fireEvent.click(screen.getByTestId("scripts-btn"));
fireEvent.click(await screen.findByTestId("quick-script-item-build"));
expect(onRunScript).toHaveBeenCalledWith("build", "pnpm build");
});
it("opens manage scripts from the dropdown footer", async () => {
const onOpenScripts = vi.fn();
render(<TerminalLauncher projectId="proj-1" onToggleTerminal={vi.fn()} onOpenScripts={onOpenScripts} onRunScript={vi.fn()} />);
fireEvent.click(screen.getByTestId("scripts-btn"));
fireEvent.click(await screen.findByTestId("quick-scripts-manage"));
expect(onOpenScripts).toHaveBeenCalledTimes(1);
});
});

View File

@@ -249,6 +249,117 @@ describe("TerminalModal", () => {
expect(container.firstChild).toBeNull();
});
it("renders desktop terminal as a docked bottom panel and refits after top-handle resize", async () => {
const projectId = "docked-resize-test";
window.localStorage.removeItem(`fusion:terminal-docked-height-${projectId}`);
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
const modal = await screen.findByTestId("terminal-modal");
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(modal).toHaveClass("terminal-modal--docked");
expect(modal).not.toHaveClass("terminal-modal--floating");
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
handle.setPointerCapture = vi.fn();
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
fireEvent.pointerMove(document, { clientY: 420 });
fireEvent.pointerUp(document, { pointerId: 1 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-docked-height-${projectId}`)).toBe("440");
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
});
});
it("toggles between docked and floating terminal modes with the pop-out control", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="popout-toggle-test" />);
const modal = await screen.findByTestId("terminal-modal");
expect(modal).toHaveClass("terminal-modal--docked");
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
await waitFor(() => {
expect(modal).toHaveClass("terminal-modal--floating");
expect(modal).not.toHaveClass("terminal-modal--docked");
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
await waitFor(() => {
expect(modal).toHaveClass("terminal-modal--docked");
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
});
});
it("exposes floating drag and resize handles and refits after floating resize", async () => {
const projectId = "floating-resize-test";
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating");
window.localStorage.removeItem(`fusion:terminal-modal-size-${projectId}`);
window.localStorage.removeItem(`fusion:terminal-float-pos-${projectId}`);
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
const modal = await screen.findByTestId("terminal-modal");
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
expect(modal).toHaveClass("terminal-modal--floating");
expect(screen.getByTestId("terminal-floating-resize-n")).toBeInTheDocument();
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
resizeHandle.setPointerCapture = vi.fn();
fireEvent.pointerDown(resizeHandle, { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { clientX: 140, clientY: 130 });
fireEvent.pointerUp(document, { pointerId: 1 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 }));
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
});
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
header.setPointerCapture = vi.fn();
fireEvent.pointerDown(header, { pointerId: 2, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { clientX: 125, clientY: 135 });
fireEvent.pointerUp(document, { pointerId: 2 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();
});
});
it("keeps mobile terminal on the full-screen modal path without docked or floating controls", async () => {
const previousInnerWidth = window.innerWidth;
const previousOntouchstart = window.ontouchstart;
Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="mobile-fullscreen-test" />);
const modal = await screen.findByTestId("terminal-modal");
expect(modal).not.toHaveClass("terminal-modal--docked");
expect(modal).not.toHaveClass("terminal-modal--floating");
expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-floating-resize-se")).toBeNull();
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
if (previousOntouchstart === undefined) {
delete (window as any).ontouchstart;
} else {
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
}
}
});
it("shows loading state while sessions are not ready", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,