diff --git a/.changeset/fn-7560-mobile-terminal-footer.md b/.changeset/fn-7560-mobile-terminal-footer.md new file mode 100644 index 0000000000..94b0877e65 --- /dev/null +++ b/.changeset/fn-7560-mobile-terminal-footer.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Move the mobile terminal controls into a bottom footer so they no longer crowd the header, and keep the shortcut key bar horizontally scrollable. +category: fix +dev: On the ≤768px terminal, the `.terminal-actions` cluster now renders in a `terminal-footer-actions` bar (with `min-width:0; overflow-x:auto`) instead of the header; desktop/floating/pinned-below keep the FN-7502 header layout. Preserves the FN-7550 shortcut-panel scroll fix. diff --git a/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts b/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts index 279bf72bed..fccb136c2c 100644 --- a/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts +++ b/packages/dashboard/app/__tests__/terminal-mobile-header-row.test.ts @@ -33,12 +33,21 @@ describe("terminal mobile header row CSS contract", () => { expect(mobileSelectorRule).not.toContain("min-width: 100%"); }); - it("keeps the action cluster reachable without a second-row divider", () => { + it("renders no .terminal-actions shell in the mobile header (FN-7560: actions moved to footer)", () => { + // FN-7560: on mobile the action-control cluster no longer lives in the + // header (`.terminal-actions`) at all — it moved to a bottom + // `.terminal-status-bar` footer so it doesn't crowd the tab dropdown and + // close button. The mobile media query must not define a `.terminal-actions` + // override any more. const ruleBody = findRuleBody(/\.terminal-actions/); + expect(ruleBody).toBe(""); + }); - expect(ruleBody).toContain("order: 3"); - expect(ruleBody).toContain("flex: 0 0 auto"); - expect(ruleBody).toContain("border-top: none"); + it("gives the mobile footer action cluster the horizontal-scroll flex-scroll pattern (FN-7560)", () => { + const ruleBody = findRuleBody(/\.terminal-status-bar/); + + expect(ruleBody).toContain("min-width: 0"); + expect(ruleBody).toContain("overflow-x: auto"); expect(ruleBody).not.toContain("flex: 1 1 100%"); }); diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 454d2df5ff..14cfeed9bd 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -1507,21 +1507,17 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo max-width: 150px; } - /* Hide the redundant title/status indicator on mobile — .terminal-status-bar shows connection state */ + /* + FNXC:TerminalFooter 2026-07-04-20:00: + On mobile the header no longer renders .terminal-title or .terminal-actions at + all (FN-7560) — the header keeps only the tab dropdown and close button, and + the action controls (incl. connection state) render in the .terminal-status-bar + footer at the bottom of the panel instead, so they don't crowd the dropdown. + */ .terminal-title { display: none; } - /* Actions stay reachable at the end of the header row and wrap as a unit after the selector/picker when space is exhausted. */ - .terminal-actions { - order: 3; - flex: 0 0 auto; - justify-content: flex-end; - border-top: none; - padding: 0 var(--space-xs) 0 0; - min-height: 36px; - } - /* Hide text labels on action buttons to save space */ .terminal-action-label { display: none; @@ -1694,9 +1690,31 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo display: none; } - .terminal-actions { - flex-wrap: wrap; - justify-content: flex-end; + /* + FNXC:TerminalFooter 2026-07-04-20:00: + FN-7560: on mobile the terminal action controls (font-size, Clear, Shortcuts, + Preferences, connection status, exit code) render in this bottom footer bar + instead of the header, restoring pre-FN-7502 footer ergonomics so the narrow + header only has to fit the tab dropdown and close button. Reuses the same + min-width: 0 + overflow-x: auto flex-scroll pattern as .terminal-actions and + .terminal-shortcut-panel (FN-7550) so a crowded footer scrolls horizontally + instead of clipping/wrapping. padding-bottom composes with the iOS safe-area + inset so the bar clears the home indicator; it stays in-flow at the bottom of + the panel's flex column, so the .terminal-modal[style*="--keyboard-overlap"] + height clamp shrinks .terminal-container (flex: 1) rather than the footer. + */ + .terminal-status-bar { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: var(--space-xs); + border-top: 1px solid var(--border); + padding: var(--space-xs); + padding-bottom: max(var(--space-xs), env(safe-area-inset-bottom, 0)); + min-height: 36px; + min-width: 0; + overflow-x: auto; + scrollbar-width: thin; } .terminal-shortcuts--header, diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index e6d3fb98b4..cf9f432a38 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -2194,6 +2194,96 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG : {}), } as CSSProperties; + /* + FNXC:TerminalFooter 2026-07-04-20:00: + Single source of truth for the terminal action-control cluster (reconnect/restart, + font-size, Clear, Shortcuts toggle, Preferences toggle, connection status, exit code, + help text, and the desktop-only pin/pop-out toggles). Rendered in exactly ONE place + per breakpoint: inside the header `.terminal-actions` on desktop/floating/pinned-below + (FN-7502), or inside a dedicated bottom `.terminal-status-bar` footer on mobile (FN-7560) + so the narrow mobile header does not crowd the tab dropdown and close button. Do not + duplicate these handlers elsewhere — always render this fragment. + */ + const terminalActionControls = ( + <> + {connectionStatus === "disconnected" && activeTab && ( + + )} + {exitCode !== null && ( + + )} + + + {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 && ( + + )} + {!isMobileTerminal && ( + + )} + + ); + const terminalPanel = (
)} - {/* Status indicator */} -
- - {getStatusIndicator()} -
- - {/* Actions — labels hidden on mobile via .terminal-action-label */} -
- {connectionStatus === "disconnected" && activeTab && ( - - )} - {exitCode !== null && ( - - )} - {/* - 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. - */} - {!isMobileTerminal && ( - - )} + {/* + FNXC:TerminalFooter 2026-07-04-20:00: + On desktop/floating/pinned-below the header keeps its FN-7502 shape: + status title + `.terminal-actions` (rendering the shared + `terminalActionControls` fragment) + close. On mobile (isMobileTerminal) + the header renders ONLY the close button here — no `.terminal-actions` + shell — because the mobile tab dropdown already occupies the header and + the action controls move into the `.terminal-status-bar` footer below + (FN-7560) so they don't crowd the dropdown/close. Both sites render the + SAME fragment, never a duplicated copy, so handlers cannot drift. + */} + {isMobileTerminal ? ( -
+ ) : ( + <> + {/* Status indicator */} +
+ + {getStatusIndicator()} +
+ + {/* Actions — labels hidden on mobile via .terminal-action-label */} +
+ {terminalActionControls} + +
+ + )}
{/* Error message */} @@ -2798,6 +2829,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG )} + {/* + FNXC:TerminalFooter 2026-07-04-20:00: + Mobile-only footer bar (FN-7560) restoring the pre-FN-7502 footer + ergonomics: the same `terminalActionControls` fragment used by the + desktop header renders here instead so font-size/Clear/Shortcuts/ + Preferences/connection-status/exit-code stay reachable without + crowding the mobile header's tab dropdown and close button. Scrolls + horizontally (min-width: 0 + overflow-x: auto) if controls exceed the + viewport width, matching the .terminal-shortcut-panel (FN-7550) pattern. + */} + {isMobileTerminal && ( +
+ {terminalActionControls} +
+ )} + ); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 321091101e..a2a3b80f12 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -680,13 +680,26 @@ describe("TerminalModal", () => { 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(); + + // FN-7560: the `.terminal-status-bar` footer is a MOBILE-ONLY affordance + // (isMobileTerminal, which itself excludes below mode) — it must exist only + // scoped inside a `@media (max-width: 768px)` block, never as a global/ + // unscoped rule that could leak a footer shell into desktop/floating/ + // pinned-below. Strip every mobile media-query block out of the + // stylesheet and confirm no `.terminal-status-bar` rule remains outside it. + const cssWithoutMobileMediaBlocks = terminalModalCss.replace( + /@media \(max-width: 768px\) \{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, + "", + ); + expect(cssWithoutMobileMediaBlocks).not.toMatch(/\.terminal-status-bar\s*\{/); + const mobileFooterRule = + terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-status-bar\s*\{/); + expect(mobileFooterRule).not.toBeNull(); }); it("exposes floating drag and resize handles and refits after floating resize", async () => { @@ -1353,6 +1366,16 @@ describe("TerminalModal", () => { expect(mobilePanelRule).not.toContain("min-width"); }); + it("gives the mobile footer bar the same horizontal-scroll pattern (FN-7560)", () => { + // FN-7560: the mobile action-control footer must reuse the min-width: 0 + + // overflow-x: auto flex-scroll pattern so a crowded footer scrolls + // horizontally instead of clipping/wrapping. + const footerRule = + terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-status-bar\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(footerRule).toContain("overflow-x: auto;"); + expect(footerRule).toContain("min-width: 0;"); + }); + it("is hidden by default and toggles from header action", async () => { render(); @@ -3303,7 +3326,14 @@ describe("TerminalModal — mobile layout contract", () => { }); }); - it("header actions show connection state without a footer status-bar shell", async () => { + it("header actions show connection state without a footer status-bar shell (desktop, FN-7502)", async () => { + // FN-7560: explicitly desktop-width — the footer only exists on the mobile + // (isMobileTerminal) path; desktop/floating/pinned-below keep the FN-7502 + // header-actions contract with NO footer shell rendered. + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true }); + + try { render(); await waitFor(() => { @@ -3313,6 +3343,112 @@ describe("TerminalModal — mobile layout contract", () => { const connectionStatus = actions.querySelector(".terminal-connection-status"); expect(connectionStatus?.textContent).toBe("Disconnected"); }); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } + }); + + it("renders terminal action controls in a mobile footer, not the header (FN-7560)", async () => { + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + + try { + render(); + + await waitFor(() => { + const footer = screen.getByTestId("terminal-footer-actions"); + expect(footer.className).toContain("terminal-status-bar"); + + const clearBtn = screen.getByTestId("terminal-clear-btn"); + const shortcutToggle = screen.getByTestId("terminal-shortcut-toggle"); + const preferencesToggle = screen.getByTestId("terminal-preferences-toggle"); + const fontSizeValue = screen.getByTestId("terminal-font-size-value"); + + // Controls live inside the footer region... + expect(footer.contains(clearBtn)).toBe(true); + expect(footer.contains(shortcutToggle)).toBe(true); + expect(footer.contains(preferencesToggle)).toBe(true); + expect(footer.contains(fontSizeValue)).toBe(true); + + // ...and NOT inside the header. + const header = document.querySelector(".terminal-header"); + expect(header).toBeTruthy(); + expect(header?.contains(clearBtn)).toBe(false); + expect(header?.contains(shortcutToggle)).toBe(false); + expect(header?.contains(preferencesToggle)).toBe(false); + expect(header?.contains(fontSizeValue)).toBe(false); + + // No empty .terminal-actions shell renders in the mobile header. + expect(header?.querySelector(".terminal-actions")).toBeNull(); + + // The close button and mobile tab dropdown remain in the header. + const closeBtn = screen.getByTestId("terminal-close-btn"); + expect(header?.contains(closeBtn)).toBe(true); + expect(footer.contains(closeBtn)).toBe(false); + const mobileTabs = screen.getByTestId("terminal-mobile-tabs"); + expect(header?.contains(mobileTabs)).toBe(true); + }); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } + }); + + it("shows the reconnect control in the mobile footer when disconnected (FN-7560)", async () => { + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + + try { + mockUseTerminal.mockReturnValue( + createMockTerminalState({ connectionStatus: "disconnected" }), + ); + + render(); + + await waitFor(() => { + const footer = screen.getByTestId("terminal-footer-actions"); + const reconnectBtn = screen.getByTestId("terminal-reconnect-btn"); + expect(footer.contains(reconnectBtn)).toBe(true); + }); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } + }); + + it("shows the restart control and exit code in the mobile footer after the terminal exits (FN-7560)", async () => { + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + + let exitCallback: ((code: number) => void) | null = null; + const customOnExit = vi.fn((cb: (code: number) => void) => { + exitCallback = cb; + return vi.fn(); + }); + + try { + mockUseTerminal.mockReturnValue( + createMockTerminalState({ connectionStatus: "connected", onExit: customOnExit }), + ); + + render(); + + await waitFor(() => { + expect(mockTerminalInstance.open).toHaveBeenCalled(); + }); + + act(() => { + exitCallback?.(0); + }); + + await waitFor(() => { + const footer = screen.getByTestId("terminal-footer-actions"); + const restartBtn = screen.getByTestId("terminal-restart-btn"); + const exitCodeEl = screen.getByTestId("terminal-exit-code"); + expect(footer.contains(restartBtn)).toBe(true); + expect(footer.contains(exitCodeEl)).toBe(true); + }); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } }); it("delivers buffered terminal output to xterm when subscriptions are established after websocket messages", async () => {