FN-7560: move mobile terminal controls to footer with scrollable shortcuts

Relocates the mobile (<=768px) terminal action cluster from the header into a new bottom footer bar and ensures the shortcut key row can scroll horizontally without clipping.

- Add terminal-footer-actions bar for mobile viewports, keeping desktop/floating/pinned-below header layout (FN-7502) unchanged
- Apply min-width:0 and overflow-x:auto to the footer actions cluster so it no longer crowds the header
- Preserve the FN-7550 shortcut-panel horizontal scroll fix
- Update TerminalModal and mobile header-row tests to cover the new footer layout
- Add changeset documenting the fix

Files changed:
 .changeset/fn-7560-mobile-terminal-footer.md       |   7 +
 .../__tests__/terminal-mobile-header-row.test.ts   |  17 +-
 .../dashboard/app/components/TerminalModal.css     |  46 ++--
 .../dashboard/app/components/TerminalModal.tsx     | 233 +++++++++++++--------
 .../components/__tests__/TerminalModal.test.tsx    | 142 ++++++++++++-
 5 files changed, 331 insertions(+), 114 deletions(-)

Fusion-Task-Id: FN-7560

Fusion-Task-Lineage: 98ffeb22-ec33-419e-987a-18ec98af7aa3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 22:26:50 -07:00
parent 2ed06f9f14
commit 6d364fc407
5 changed files with 331 additions and 114 deletions

View File

@@ -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.

View File

@@ -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%");
});

View File

@@ -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,

View File

@@ -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 && (
<button
className="terminal-reconnect-btn"
onClick={reconnect}
title={t("terminal.reconnect", "Reconnect")}
data-testid="terminal-reconnect-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">{t("terminal.reconnect", "Reconnect")}</span>
</button>
)}
{exitCode !== null && (
<button
className="terminal-restart-btn"
onClick={handleRestart}
title={t("terminal.newSession", "New Session")}
data-testid="terminal-restart-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">{t("terminal.newSession", "New Session")}</span>
</button>
)}
<span className="terminal-font-size-controls terminal-font-size-controls--header">
<button type="button" className="terminal-font-size-btn" onClick={handleDecreaseFontSize} data-testid="terminal-font-size-decrease" aria-label={t("terminal.decreaseFontSize", "Decrease terminal font size")}>
<Minus size={14} />
</button>
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">{fontSize}{TERMINAL_KEY_LABELS.pxUnit}</span>
<button type="button" className="terminal-font-size-btn" onClick={handleIncreaseFontSize} data-testid="terminal-font-size-increase" aria-label={t("terminal.increaseFontSize", "Increase terminal font size")}>
<Plus size={14} />
</button>
</span>
<button className="terminal-clear-btn" onClick={handleClear} data-testid="terminal-clear-btn" title={t("terminal.clearTerminal", "Clear terminal")}>
<Trash2 size={14} />
<span className="terminal-action-label">{t("terminal.clear", "Clear")}</span>
</button>
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowShortcuts((current) => !current)} data-testid="terminal-shortcut-toggle" title={t("terminal.shortcuts", "Shortcuts")} aria-pressed={showShortcuts}>
<Keyboard size={14} />
<span className="terminal-action-label">{t("terminal.shortcuts", "Shortcuts")}</span>
</button>
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowPreferences((current) => !current)} data-testid="terminal-preferences-toggle" title={t("terminal.preferences", "Preferences")} aria-pressed={showPreferences}>
<Settings size={14} />
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
</button>
<span className={`terminal-connection-status ${connectionStatus}`}>
{connectionStatus === "connected" && t("terminal.statusConnected", "Connected")}
{connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")}
{connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")}
{connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")}
</span>
{exitCode !== null && <span className="terminal-exit-code" data-testid="terminal-exit-code">{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}</span>}
<span className="terminal-shortcuts terminal-shortcuts--header">{t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")}</span>
{!isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleBelowMode}
data-testid="terminal-pin-toggle"
title={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-label={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-pressed={isBelowMode}
>
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
{!isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleDisplayMode}
data-testid="terminal-popout-toggle"
title={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-label={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} />}
</button>
)}
</>
);
const terminalPanel = (
<div
ref={modalRef}
@@ -2428,98 +2518,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
)}
{/* Status indicator */}
<div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} />
{getStatusIndicator()}
</div>
{/* Actions — labels hidden on mobile via .terminal-action-label */}
<div className="terminal-actions" data-testid="terminal-actions">
{connectionStatus === "disconnected" && activeTab && (
<button
className="terminal-reconnect-btn"
onClick={reconnect}
title={t("terminal.reconnect", "Reconnect")}
data-testid="terminal-reconnect-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">{t("terminal.reconnect", "Reconnect")}</span>
</button>
)}
{exitCode !== null && (
<button
className="terminal-restart-btn"
onClick={handleRestart}
title={t("terminal.newSession", "New Session")}
data-testid="terminal-restart-btn"
>
<RefreshCw size={14} />
<span className="terminal-action-label">{t("terminal.newSession", "New Session")}</span>
</button>
)}
{/*
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.
*/}
<span className="terminal-font-size-controls terminal-font-size-controls--header">
<button type="button" className="terminal-font-size-btn" onClick={handleDecreaseFontSize} data-testid="terminal-font-size-decrease" aria-label={t("terminal.decreaseFontSize", "Decrease terminal font size")}>
<Minus size={14} />
</button>
<span className="terminal-font-size-value" data-testid="terminal-font-size-value">{fontSize}{TERMINAL_KEY_LABELS.pxUnit}</span>
<button type="button" className="terminal-font-size-btn" onClick={handleIncreaseFontSize} data-testid="terminal-font-size-increase" aria-label={t("terminal.increaseFontSize", "Increase terminal font size")}>
<Plus size={14} />
</button>
</span>
<button className="terminal-clear-btn" onClick={handleClear} data-testid="terminal-clear-btn" title={t("terminal.clearTerminal", "Clear terminal")}>
<Trash2 size={14} />
<span className="terminal-action-label">{t("terminal.clear", "Clear")}</span>
</button>
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowShortcuts((current) => !current)} data-testid="terminal-shortcut-toggle" title={t("terminal.shortcuts", "Shortcuts")} aria-pressed={showShortcuts}>
<Keyboard size={14} />
<span className="terminal-action-label">{t("terminal.shortcuts", "Shortcuts")}</span>
</button>
<button className="terminal-clear-btn terminal-clear-btn--shortcut" onClick={() => setShowPreferences((current) => !current)} data-testid="terminal-preferences-toggle" title={t("terminal.preferences", "Preferences")} aria-pressed={showPreferences}>
<Settings size={14} />
<span className="terminal-action-label">{t("terminal.preferences", "Preferences")}</span>
</button>
<span className={`terminal-connection-status ${connectionStatus}`}>
{connectionStatus === "connected" && t("terminal.statusConnected", "Connected")}
{connectionStatus === "connecting" && t("terminal.statusConnecting", "Connecting...")}
{connectionStatus === "reconnecting" && t("terminal.statusReconnecting", "Reconnecting...")}
{connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")}
</span>
{exitCode !== null && <span className="terminal-exit-code" data-testid="terminal-exit-code">{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}</span>}
<span className="terminal-shortcuts terminal-shortcuts--header">{t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")}</span>
{!isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleBelowMode}
data-testid="terminal-pin-toggle"
title={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-label={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-pressed={isBelowMode}
>
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
{/*
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 && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleDisplayMode}
data-testid="terminal-popout-toggle"
title={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-label={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} />}
</button>
)}
{/*
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 ? (
<button
className="terminal-close"
onClick={onClose}
@@ -2528,7 +2538,28 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
>
<X size={20} />
</button>
</div>
) : (
<>
{/* Status indicator */}
<div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} />
{getStatusIndicator()}
</div>
{/* Actions — labels hidden on mobile via .terminal-action-label */}
<div className="terminal-actions" data-testid="terminal-actions">
{terminalActionControls}
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
</div>
</>
)}
</div>
{/* Error message */}
@@ -2798,6 +2829,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
)}
{/*
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 && (
<div className="terminal-status-bar" data-testid="terminal-footer-actions">
{terminalActionControls}
</div>
)}
</div>
);

View File

@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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 () => {