diff --git a/.changeset/fn-6887-terminal-footer-panel.md b/.changeset/fn-6887-terminal-footer-panel.md new file mode 100644 index 0000000000..4eaf532e6e --- /dev/null +++ b/.changeset/fn-6887-terminal-footer-panel.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 50d5b04412..4c79c4d878 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8506d9977a..9b470313c2 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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} /> )} { 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(); }); }); diff --git a/packages/dashboard/app/components/ExecutorStatusBar.css b/packages/dashboard/app/components/ExecutorStatusBar.css index de24a545a4..41510e9331 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.css +++ b/packages/dashboard/app/components/ExecutorStatusBar.css @@ -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); } diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index 0bd7f6f19e..5f6185e453 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -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(null); @@ -289,6 +299,21 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH {/* Spacer */}
+ {showTerminalLauncher && ( + <> +
+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 893ce20971..757b72a8fd 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 9870fbd1f1..504e4f451f 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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; + 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; + 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); const [pendingInitialCommandGeneration, setPendingInitialCommandGeneration] = useState(0); + const [displayMode, setDisplayModeState] = useState(() => readTerminalDisplayMode(projectId)); + const [dockedHeight, setDockedHeight] = useState(() => readTerminalDockedHeight(projectId)); + const [floatingSize, setFloatingSize] = useState(() => readTerminalFloatSize(projectId)); + const [floatingPosition, setFloatingPosition] = useState(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId)); + const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport()); + const isDockedMode = !isMobileTerminal && displayMode === "docked"; + const isFloatingMode = !isMobileTerminal && displayMode === "floating"; const terminalRef = useRef(null); const modalRef = useRef(null); const overlayMouseDownRef = useRef(false); - useModalResizePersist(modalRef, isOpen, "fusion:terminal-modal-size"); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const hasInitialCommandRun = useRef(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) => { + 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) => { + 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, 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 (
0 ? { "--overlay-padding-top": "0px", - } as React.CSSProperties + } as CSSProperties : undefined } >
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 && ( +
+ )} + {isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => ( +
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) */} -
+
{/* Tab Bar */}
{tabs.map((tab) => ( @@ -1486,6 +1798,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG {t("terminal.preferences", "Preferences")} + {!isMobileTerminal && ( + + )}