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:
7
.changeset/fn-7560-mobile-terminal-footer.md
Normal file
7
.changeset/fn-7560-mobile-terminal-footer.md
Normal 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.
|
||||
@@ -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%");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user