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:
5
.changeset/fn-6887-terminal-footer-panel.md
Normal file
5
.changeset/fn-6887-terminal-footer-panel.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
180
packages/dashboard/app/components/TerminalLauncher.css
Normal file
180
packages/dashboard/app/components/TerminalLauncher.css
Normal 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;
|
||||
}
|
||||
}
|
||||
306
packages/dashboard/app/components/TerminalLauncher.tsx
Normal file
306
packages/dashboard/app/components/TerminalLauncher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user