diff --git a/.changeset/fn-7502-terminal-below-layout.md b/.changeset/fn-7502-terminal-below-layout.md new file mode 100644 index 0000000000..6c7aa6e95f --- /dev/null +++ b/.changeset/fn-7502-terminal-below-layout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a pinned below-application layout option for the dashboard terminal. +category: feature +dev: Terminal display mode now supports persisted docked, floating, and below layouts, with header controls replacing the footer shell. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c78808f8d3..e9e3fd5335 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -527,14 +527,16 @@ On Windows, the embedded terminal starts a supported shell inside Fusion, such a Use the terminal on desktop/tablet: 1. Select the **Terminal** button in the footer executor status bar. - Expected outcome: the terminal opens as a bottom-docked panel with the active shell session and a draggable top resize handle. -2. Drag the top edge of the docked panel. - Expected outcome: the panel height changes within its viewport-safe bounds and persists per project. -3. Select **Pop out** from the terminal header. + Expected outcome: the terminal opens as a bottom-docked overlay panel with the active shell session, header controls for font size / clear / shortcuts / preferences, and a draggable top resize handle. +2. Select **Pin terminal (push content)** from the terminal header. + Expected outcome: the terminal moves into a persisted below-application panel that reserves space instead of covering the board, chat, or right sidebar. Select **Unpin terminal (overlay content)** to return to the overlay docked panel. +3. Drag the top edge of the docked or pinned panel. + Expected outcome: the panel height changes within its viewport-safe bounds and persists per project, with pinned mode clamped shorter so the application remains usable. +4. Select **Pop out** from the terminal header. Expected outcome: the terminal switches to a floating window that can be dragged and freely resized; size, position, and display mode are saved per project. -4. Select **Dock** in the floating terminal. - Expected outcome: the terminal returns to the bottom docked panel using the saved docked height. -5. Select the scripts chevron beside the footer **Terminal** button. +5. Select **Dock** in the floating terminal. + Expected outcome: the terminal returns to the bottom docked overlay panel using the saved docked height. +6. Select the scripts chevron beside the footer **Terminal** button. Expected outcome: the quick scripts menu opens without toggling the terminal; choosing a script runs it in the terminal, and the menu footer opens script management. Use the terminal on mobile: diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 67f40a1e10..1c5f81388f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -12,6 +12,7 @@ import { AppModals } from "./components/AppModals"; import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; +import { TerminalModal } from "./components/TerminalModal"; import { type CliActionId } from "./components/SessionNotificationBanner"; import { isOnboardingCompleted, @@ -937,15 +938,19 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeGitHubImport }); }, [modalManager, pushNav]); + const closeTerminalWithNav = useCallback(() => { + removeNav(modalManager.closeTerminal); + modalManager.closeTerminal(); + }, [modalManager, removeNav]); + const toggleTerminalWithNav = useCallback(() => { if (!modalManager.terminalOpen) { modalManager.toggleTerminal(); pushNav({ type: "modal", close: modalManager.closeTerminal }); } else { - removeNav(modalManager.closeTerminal); - modalManager.toggleTerminal(); + closeTerminalWithNav(); } - }, [modalManager, pushNav, removeNav]); + }, [closeTerminalWithNav, modalManager, pushNav]); const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { modalManager.openFiles(workspace, initialFile); @@ -1475,6 +1480,7 @@ function AppInner() { } /> +
{sidebarActive && ( {rightDock.dock}
+ {currentProject && ( + + )} +
{rightDock.modal} {executorFooterVisible && currentProject && ( { - removeNav(modalManager.closeTerminal); - modalManager.closeTerminal(); - }, [modalManager.closeTerminal, removeNav]); - const closeScriptsWithNav = useCallback(() => { removeNav(modalManager.closeScripts); modalManager.closeScripts(); @@ -408,14 +402,6 @@ export function AppModals({ /> - - isTerminalMobileViewport()); const isDockedMode = !isMobileTerminal && displayMode === "docked"; const isFloatingMode = !isMobileTerminal && displayMode === "floating"; + const isBelowMode = !isMobileTerminal && displayMode === "below"; // FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet). const [floatingZ, setFloatingZ] = useState(() => nextFloatingZ()); const bringFloatingToFront = useCallback(() => { @@ -604,6 +628,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const setDisplayMode = useCallback((mode: TerminalDisplayMode) => { setDisplayModeState(writeTerminalDisplayMode(mode, projectId)); + window.dispatchEvent(new CustomEvent("fusion:terminal-display-mode-change", { detail: { projectId, mode } })); }, [projectId]); const persistFloatingSize = useCallback((size: TerminalFloatSize) => { @@ -626,7 +651,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners. */ const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent) => { - if (!isDockedMode) return; + if (!isDockedMode && !isBelowMode) return; event.preventDefault(); const captureTarget = event.currentTarget; const pointerId = event.pointerId; @@ -641,7 +666,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) return; - latestHeight = clampTerminalDockedHeight(startHeight + (startY - moveEvent.clientY)); + const nextHeight = isBelowMode ? startHeight + (moveEvent.clientY - startY) : startHeight + (startY - moveEvent.clientY); + latestHeight = isBelowMode ? clampTerminalBelowHeight(nextHeight) : clampTerminalDockedHeight(nextHeight); if (frame) return; frame = requestAnimationFrame(() => { frame = 0; @@ -656,7 +682,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG }; function handlePointerUp() { if (frame) cancelAnimationFrame(frame); - setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId)); + setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId, isBelowMode ? "below" : "docked")); document.body.style.userSelect = previousUserSelect; detachListeners(); dragTeardownRef.current = null; @@ -673,7 +699,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG captureTarget.addEventListener("pointermove", handlePointerMove); captureTarget.addEventListener("pointerup", handlePointerUp); captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [dockedHeight, isDockedMode, projectId]); + }, [dockedHeight, isBelowMode, isDockedMode, projectId]); /* FNXC:Terminal 2026-06-22-19:50: @@ -2007,6 +2033,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG setDisplayMode(displayMode === "floating" ? "docked" : "floating"); }, [displayMode, setDisplayMode]); + const handleToggleBelowMode = useCallback(() => { + setDisplayMode(displayMode === "below" ? "docked" : "below"); + if (displayMode !== "below") { + setDockedHeight((current) => clampTerminalBelowHeight(current || TERMINAL_BELOW_DEFAULT_HEIGHT)); + } + }, [displayMode, setDisplayMode]); + const handlePreferenceFontSizeChange = useCallback( (value: string) => { const parsed = Number.parseInt(value, 10); @@ -2112,7 +2145,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const isLoading = !isReady || (!activeTab && !bootstrapError); // FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it. const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`; - const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}`; + const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}`; const modalStyle = { ...(keyboardOverlap > 0 ? { @@ -2126,6 +2159,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG } : {}), ...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}), + ...(isBelowMode ? { "--terminal-below-height": `${clampTerminalBelowHeight(dockedHeight || TERMINAL_BELOW_DEFAULT_HEIGHT)}px` } : {}), ...(isFloatingMode ? { "--terminal-float-x": `${floatingPosition.x}px`, @@ -2138,36 +2172,24 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG : {}), } as CSSProperties; - // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the terminal shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly when all panels live at the document root. Docked/floating/mobile are all position:fixed, so portaling does not change their placement. - return createPortal( + const terminalPanel = (
0 ? { "--overlay-padding-top": "0px" } : {}), - } as CSSProperties} + ref={modalRef} + className={modalClassName} + data-testid="terminal-modal" + style={modalStyle} + onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined} + onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined} + role={isBelowMode ? "region" : undefined} + aria-label={isBelowMode ? t("terminal.belowRegion", "Pinned terminal") : undefined} > -
- {isDockedMode && ( + {(isDockedMode || isBelowMode) && (
)} @@ -2415,6 +2437,51 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG )} {/* + FNXC:TerminalHeader 2026-07-04-19:16: + Footer controls live in the terminal header so docked, floating, pinned-below, and mobile layouts keep terminal actions reachable without spending a separate footer row. The pin button mirrors the right sidebar contract: aria-pressed means the persistent below-application push layout is active. + */} + + + {fontSize}{TERMINAL_KEY_LABELS.pxUnit} + + + + + + + {connectionStatus === "connected" && t("terminal.statusConnected", "Connected")} + {connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")} + {connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")} + {connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")} + + {exitCode !== null && {t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}} + {t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")} + {!isMobileTerminal && ( + + )} + {/* FNXC:Terminal 2026-06-23-00:15: Clear / Shortcuts / Preferences moved OUT of the header actions and DOWN into the bottom status bar (footer) next to the text-size control, so the header keeps only contextual reconnect/restart, the icon-only pop-out toggle, and close. The pop-out/dock toggle is now ICON-ONLY (no visible "Pop out"/"Dock" text); the icon flips and the title/aria-label still announce the toggle target for accessibility. @@ -2709,82 +2776,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
)} - {/* - FNXC:Terminal 2026-06-23-00:15: - Footer is laid out left-to-right as a flex row: the text-size control sits at the LEFT, followed by the relocated Clear / Shortcuts / Preferences action buttons (a grouped cluster). The connection-status text and zoom-hint copy stay on the right and collapse first on narrow widths. The whole control cluster wraps/scrolls when the footer is too narrow so docked/floating/mobile layouts never clip the buttons. - */} -
- - - - {fontSize}{TERMINAL_KEY_LABELS.pxUnit} - - - - {/* FNXC:Terminal 2026-06-23-00:15: Clear / Shortcuts / Preferences relocated here from the header actions; same handlers, testids, and labels preserved. */} - - - - - - - {connectionStatus === "connected" && t("terminal.statusConnected", "Connected")} - {connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")} - {connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")} - {connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")} - - {exitCode !== null && ( - - {t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })} - - )} - - {t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")} - -
+
+ ); + + if (isBelowMode) { + return ( +
+ {terminalPanel}
+ ); + } + + // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so overlay terminal modes share the ONE root stacking context with other floating modals. Below mode intentionally skips the portal so it can reserve in-flow application space. + return createPortal( +
0 ? { "--overlay-padding-top": "0px" } : {}), + } as CSSProperties} + > + {terminalPanel}
, document.body, ); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index faf7978c34..e0163caed6 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -490,7 +490,8 @@ describe("TerminalModal", () => { expect(menuRule).toContain("max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));"); expect(menuRule).toContain("overflow-y: auto;"); expect(menuRule).toContain("overscroll-behavior: contain;"); - expect(actionsRule).toContain("flex: 0 0 auto;"); + expect(actionsRule).toContain("flex: 1 1 auto;"); + expect(actionsRule).toContain("overflow-x: auto;"); expect(mobileHeaderRule).toContain("flex-wrap: wrap;"); expect(mobileHeaderRule).toContain("overflow: hidden;"); expect(mobileTerminalTabsRule).toContain("display: none;"); @@ -551,6 +552,93 @@ describe("TerminalModal", () => { }); }); + it("defaults missing and invalid display-mode storage to overlay docked mode", async () => { + const missingProjectId = "missing-display-mode-test"; + window.localStorage.removeItem(`fusion:terminal-display-mode-${missingProjectId}`); + + const { unmount } = render(); + expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked"); + expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument(); + expect(screen.queryByTestId("terminal-below-host")).toBeNull(); + unmount(); + + const invalidProjectId = "invalid-display-mode-test"; + window.localStorage.setItem(`fusion:terminal-display-mode-${invalidProjectId}`, "sideways"); + render(); + + expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked"); + expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument(); + expect(screen.queryByTestId("terminal-below-host")).toBeNull(); + }); + + it("pins and persists the terminal below the application with right-dock-style labels", async () => { + const projectId = "below-pin-test"; + render(); + + const pin = await screen.findByTestId("terminal-pin-toggle"); + expect(pin).toHaveAttribute("aria-label", "Pin terminal (push content)"); + expect(pin).toHaveAttribute("title", "Pin terminal (push content)"); + expect(pin).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(pin); + + await waitFor(() => { + expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("below"); + expect(screen.getByTestId("terminal-below-host")).toBeInTheDocument(); + expect(screen.queryByTestId("terminal-modal-overlay")).toBeNull(); + expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--below"); + }); + expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-label", "Unpin terminal (overlay content)"); + expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByTestId("terminal-pin-toggle")); + await waitFor(() => { + expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("docked"); + expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument(); + expect(screen.queryByTestId("terminal-below-host")).toBeNull(); + }); + }); + + it("keeps floating and mobile modes out of the below-layout shell", async () => { + const floatingProjectId = "floating-no-below-shell"; + window.localStorage.setItem(`fusion:terminal-display-mode-${floatingProjectId}`, "floating"); + const { unmount } = render(); + expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--floating"); + expect(screen.queryByTestId("terminal-below-host")).toBeNull(); + unmount(); + + 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 { + window.localStorage.setItem("fusion:terminal-display-mode-mobile-no-below-shell", "below"); + render(); + expect(await screen.findByTestId("terminal-modal")).not.toHaveClass("terminal-modal--below"); + expect(screen.queryByTestId("terminal-below-host")).toBeNull(); + expect(screen.queryByTestId("terminal-pin-toggle")).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("encodes below-terminal in-flow layout without fixed overlay geometry", () => { + const hostRule = terminalModalCss.match(/\.terminal-below-host\s*\{([^}]*)\}/)?.[1] ?? ""; + const belowRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--below\s*\{([^}]*)\}/)?.[1] ?? ""; + const footerShellRule = terminalModalCss.match(/\.terminal-status-bar\s*\{/); + + expect(hostRule).toContain("display: flex;"); + expect(belowRule).toContain("position: relative;"); + expect(belowRule).not.toContain("position: fixed;"); + expect(belowRule).toContain("height: var(--terminal-below-height);"); + expect(footerShellRule).toBeNull(); + }); + 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"); @@ -3147,14 +3235,14 @@ describe("TerminalModal — mobile layout contract", () => { }); }); - it("status-bar shows connection state text alongside tabs row", async () => { + it("header actions show connection state without a footer status-bar shell", async () => { render(); await waitFor(() => { - const statusBar = screen.getByTestId("terminal-status-bar"); - expect(statusBar).toBeTruthy(); - // Should contain connection status text - const connectionStatus = statusBar.querySelector(".terminal-connection-status"); + expect(screen.queryByTestId("terminal-status-bar")).toBeNull(); + expect(screen.queryByTestId("terminal-footer-actions")).toBeNull(); + const actions = screen.getByTestId("terminal-actions"); + const connectionStatus = actions.querySelector(".terminal-connection-status"); expect(connectionStatus?.textContent).toBe("Disconnected"); }); });