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%");
|
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/);
|
const ruleBody = findRuleBody(/\.terminal-actions/);
|
||||||
|
expect(ruleBody).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
expect(ruleBody).toContain("order: 3");
|
it("gives the mobile footer action cluster the horizontal-scroll flex-scroll pattern (FN-7560)", () => {
|
||||||
expect(ruleBody).toContain("flex: 0 0 auto");
|
const ruleBody = findRuleBody(/\.terminal-status-bar/);
|
||||||
expect(ruleBody).toContain("border-top: none");
|
|
||||||
|
expect(ruleBody).toContain("min-width: 0");
|
||||||
|
expect(ruleBody).toContain("overflow-x: auto");
|
||||||
expect(ruleBody).not.toContain("flex: 1 1 100%");
|
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;
|
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 {
|
.terminal-title {
|
||||||
display: none;
|
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 */
|
/* Hide text labels on action buttons to save space */
|
||||||
.terminal-action-label {
|
.terminal-action-label {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -1694,9 +1690,31 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-actions {
|
/*
|
||||||
flex-wrap: wrap;
|
FNXC:TerminalFooter 2026-07-04-20:00:
|
||||||
justify-content: flex-end;
|
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,
|
.terminal-shortcuts--header,
|
||||||
|
|||||||
@@ -2194,6 +2194,96 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
: {}),
|
: {}),
|
||||||
} as CSSProperties;
|
} 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 = (
|
const terminalPanel = (
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
@@ -2428,98 +2518,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status indicator */}
|
{/*
|
||||||
<div className="terminal-title" data-testid="terminal-title">
|
FNXC:TerminalFooter 2026-07-04-20:00:
|
||||||
<TerminalIcon size={16} />
|
On desktop/floating/pinned-below the header keeps its FN-7502 shape:
|
||||||
{getStatusIndicator()}
|
status title + `.terminal-actions` (rendering the shared
|
||||||
</div>
|
`terminalActionControls` fragment) + close. On mobile (isMobileTerminal)
|
||||||
|
the header renders ONLY the close button here — no `.terminal-actions`
|
||||||
{/* Actions — labels hidden on mobile via .terminal-action-label */}
|
shell — because the mobile tab dropdown already occupies the header and
|
||||||
<div className="terminal-actions" data-testid="terminal-actions">
|
the action controls move into the `.terminal-status-bar` footer below
|
||||||
{connectionStatus === "disconnected" && activeTab && (
|
(FN-7560) so they don't crowd the dropdown/close. Both sites render the
|
||||||
<button
|
SAME fragment, never a duplicated copy, so handlers cannot drift.
|
||||||
className="terminal-reconnect-btn"
|
*/}
|
||||||
onClick={reconnect}
|
{isMobileTerminal ? (
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="terminal-close"
|
className="terminal-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -2528,7 +2538,28 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
@@ -2798,6 +2829,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -680,13 +680,26 @@ describe("TerminalModal", () => {
|
|||||||
it("encodes below-terminal in-flow layout without fixed overlay geometry", () => {
|
it("encodes below-terminal in-flow layout without fixed overlay geometry", () => {
|
||||||
const hostRule = terminalModalCss.match(/\.terminal-below-host\s*\{([^}]*)\}/)?.[1] ?? "";
|
const hostRule = terminalModalCss.match(/\.terminal-below-host\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||||
const belowRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--below\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(hostRule).toContain("display: flex;");
|
||||||
expect(belowRule).toContain("position: relative;");
|
expect(belowRule).toContain("position: relative;");
|
||||||
expect(belowRule).not.toContain("position: fixed;");
|
expect(belowRule).not.toContain("position: fixed;");
|
||||||
expect(belowRule).toContain("height: var(--terminal-below-height);");
|
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 () => {
|
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");
|
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 () => {
|
it("is hidden by default and toggles from header action", async () => {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
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} />);
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -3313,6 +3343,112 @@ describe("TerminalModal — mobile layout contract", () => {
|
|||||||
const connectionStatus = actions.querySelector(".terminal-connection-status");
|
const connectionStatus = actions.querySelector(".terminal-connection-status");
|
||||||
expect(connectionStatus?.textContent).toBe("Disconnected");
|
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 () => {
|
it("delivers buffered terminal output to xterm when subscriptions are established after websocket messages", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user