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 () => {