FN-7684: move terminal shortcuts/zoom controls into footer on tablet widths

On tablet-width viewports (769-1024px) the terminal header was overcrowded, so shortcut/zoom/preference controls now move into the bottom status-bar footer instead, mirroring the existing FN-7560 mobile layout while true desktop keeps the header controls.

- Add an isTabletTerminal detection flag (769-1024px, non-mobile) alongside the existing mobile flag
- Render the shared terminalActionControls fragment in the .terminal-status-bar footer for tablet widths, keeping desktop pin/pop-out toggles available there too
- True desktop (>1024px) continues to render font size / clear / shortcuts / preferences controls in the header
- Update TerminalModal.css with footer layout styles for the tablet action controls
- Update TerminalModal tests to cover the new tablet breakpoint rendering
- Update docs/dashboard-guide.md to describe the tablet footer control location
- Add a changeset (@runfusion/fusion: patch) documenting the fix

Files changed:
 .changeset/fn-7684-tablet-terminal-footer.md       |   7 ++
 docs/dashboard-guide.md                            |   6 +-
 .../dashboard/app/components/TerminalModal.css     |  34 ++++++
 .../dashboard/app/components/TerminalModal.tsx     | 103 ++++++++++++-----
 .../components/__tests__/TerminalModal.test.tsx    | 126 ++++++++++++++++++---
 5 files changed, 231 insertions(+), 45 deletions(-)

Fusion-Task-Id: FN-7684

Fusion-Task-Lineage: 627c40af-a152-451a-a045-65ad8babea9e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 09:56:32 -07:00
parent a832b7979f
commit 83e77431e2
5 changed files with 231 additions and 45 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: On tablet widths, move terminal shortcuts/zoom controls into the bottom footer so they no longer overlap header icons.
category: fix
dev: Adds an isTabletTerminal flag (769–1024px, non-mobile) that renders the shared terminalActionControls fragment in the .terminal-status-bar footer (as FN-7560 did for mobile) instead of the header; true desktop (>1024px) keeps the header layout. Tablet footer keeps the desktop pin/pop-out toggles.

View File

@@ -611,12 +611,12 @@ On Windows, the embedded terminal starts a supported shell inside Fusion, such a
Use the terminal on desktop/tablet:
1. Select the **Terminal** button in the footer executor status bar.
Expected outcome: the terminal opens as a bottom-docked overlay panel with the active shell session, header controls for font size / clear / shortcuts / preferences, and a draggable top resize handle.
2. Select **Pin terminal (push content)** from the terminal header.
Expected outcome: the terminal opens as a bottom-docked overlay panel with the active shell session and a draggable top resize handle. On true desktop (wider than the tablet tier) the font size / clear / shortcuts / preferences controls render in the terminal header; on tablet widths (769-1024px) those same controls render in a bottom action-control footer bar instead, so the narrower tablet header does not overflow (the tab strip, workspace picker, and close button stay in the header on both).
2. Select **Pin terminal (push content)** — from the terminal header on desktop, or the bottom action-control footer on tablet.
Expected outcome: the terminal moves into a persisted below-application panel that reserves space instead of covering the board, chat, or right sidebar. Select **Unpin terminal (overlay content)** to return to the overlay docked panel.
3. Drag the top edge of the docked or pinned panel.
Expected outcome: the panel height changes within its viewport-safe bounds and persists per project, with pinned mode clamped shorter so the application remains usable.
4. Select **Pop out** from the terminal header.
4. Select **Pop out** — from the terminal header on desktop, or the bottom action-control footer on tablet.
Expected outcome: the terminal switches to a floating window that can be dragged and freely resized; size, position, and display mode are saved per project.
5. Select **Dock** in the floating terminal.
Expected outcome: the terminal returns to the bottom docked overlay panel using the saved docked height.

View File

@@ -1790,3 +1790,37 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
display: none;
}
}
/*
FNXC:TerminalFooter 2026-07-08-15:00:
FN-7684: extend the FN-7560 mobile footer relocation to the project's canonical
tablet tier (769-1024px, `--tablet-breakpoint: 1024px`, matching the JS
`isTabletTerminal` flag). A tablet keeps the desktop display modes (docked/
floating/pinned-below, unlike mobile's fullscreen shell) but its header still
overflows if it renders the full `.terminal-actions` cluster — the help text
and zoom controls collide with the pin/pop-out/close icons. TerminalModal.tsx
stops rendering `.terminal-actions` in the header at <=1024px and instead
renders the SAME shared `terminalActionControls` fragment here, mirroring every
mobile footer declaration (not just a display:flex override) so the tablet
footer keeps the same min-width: 0 + overflow-x: auto horizontal-scroll safety
net as mobile (FN-7550/FN-7560) plus the FN-7621 touch-action carve-out.
Unlike mobile, the tablet footer has more room, so it intentionally does NOT
reapply the mobile-only `.terminal-shortcuts--header, .terminal-connection-status
{ display: none }` hide above — both stay visible on tablet.
*/
@media (min-width: 769px) and (max-width: 1024px) {
.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;
touch-action: pan-x pan-y;
}
}

View File

@@ -357,6 +357,24 @@ function isTerminalMobileViewport(): boolean {
return window.innerWidth <= 768 || (hasTouchScreen && (getTerminalViewportWidth(true) <= 768 || getTerminalViewportHeight(true) <= 480));
}
/*
FNXC:TerminalFooter 2026-07-08-15:00:
FN-7684: on a tablet-width viewport (769-1024px, the project's canonical tablet
tier — `--tablet-breakpoint: 1024px`) the terminal is NOT mobile but its header
still overflows if it renders the full desktop `.terminal-actions` cluster (the
zoom/font-size controls, Shortcuts/Preferences toggles, connection status, and
help text collide with the pin/pop-out/close icons — see attachment 1863.png).
This flag is checked ONLY when `isTerminalMobileViewport()` is false, so mobile
always wins the mobile fullscreen shell; it gates relocating the shared action-
control fragment into the `.terminal-status-bar` footer on tablet too (FN-7560
established the footer for mobile).
*/
function isTerminalTabletViewport(): boolean {
if (typeof window === "undefined") return false;
if (isTerminalMobileViewport()) return false;
return window.innerWidth >= 769 && window.innerWidth <= 1024;
}
function isMacPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
@@ -548,6 +566,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const [floatingSize, setFloatingSize] = useState<TerminalFloatSize>(() => readTerminalFloatSize(projectId));
const [floatingPosition, setFloatingPosition] = useState<TerminalFloatPosition>(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId));
const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport());
// FNXC:TerminalFooter 2026-07-08-15:00: FN-7684 tablet tier (769-1024px, non-mobile) — keeps the desktop display modes (docked/floating/pinned-below), only the action-control render location changes.
const [isTabletTerminal, setIsTabletTerminal] = useState(() => isTerminalTabletViewport());
const isDockedMode = !isMobileTerminal && displayMode === "docked";
const isFloatingMode = !isMobileTerminal && displayMode === "floating";
const isBelowMode = !isMobileTerminal && displayMode === "below";
@@ -618,7 +638,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
FNXC:Terminal 2026-06-21-22:58:
Viewport changes must force the terminal back onto the mobile fullscreen path at <=768px or touch-primary short landscape, then restore the stored desktop/tablet docked/floating mode when the viewport expands.
*/
const updateViewportMode = () => setIsMobileTerminal(isTerminalMobileViewport());
const updateViewportMode = () => {
setIsMobileTerminal(isTerminalMobileViewport());
// FNXC:TerminalFooter 2026-07-08-15:00: FN-7684 — keep isTabletTerminal in sync from the SAME resize/visualViewport listeners as isMobileTerminal rather than adding a second competing listener.
setIsTabletTerminal(isTerminalTabletViewport());
};
updateViewportMode();
window.addEventListener("resize", updateViewportMode);
window.visualViewport?.addEventListener("resize", updateViewportMode);
@@ -2297,14 +2321,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
} as CSSProperties;
/*
FNXC:TerminalFooter 2026-07-04-20:00:
FNXC:TerminalFooter 2026-07-08-15: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.
per breakpoint: inside the header `.terminal-actions` ONLY on true desktop (>1024px,
!isMobileTerminal && !isTabletTerminal), or inside a dedicated bottom
`.terminal-status-bar` footer on BOTH mobile (<=768px, FN-7560) and tablet
(769-1024px, FN-7684) so the narrower header does not crowd the tab strip/dropdown,
workspace picker, and close button. Same fragment, one location per breakpoint —
never duplicate these handlers elsewhere.
*/
const terminalActionControls = (
<>
@@ -2621,15 +2647,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
)}
{/*
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`
FNXC:TerminalFooter 2026-07-08-15:00:
The header renders `.terminal-actions` (the shared `terminalActionControls`
fragment) ONLY on true desktop (!isMobileTerminal && !isTabletTerminal,
>1024px) — the FN-7502 shape. On mobile (isMobileTerminal) the header
renders ONLY the corner-pinned close button — 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.
the action controls move into the `.terminal-status-bar` footer (FN-7560).
On tablet (isTabletTerminal, 769-1024px, FN-7684) the header keeps the
desktop tab strip + title + workspace picker but ALSO drops
`.terminal-actions` — it still overflows at that width — in favor of a
plain close button, with the same action controls relocating into the
footer alongside mobile. Both footer/header render sites use the SAME
fragment, never a duplicated copy, so handlers cannot drift.
FNXC:TerminalHeader 2026-07-04-20:45:
FN-7565: on mobile, being a direct child of `.terminal-header` (not
@@ -2641,7 +2671,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
expect for an app-sheet close control. The `terminal-close--corner`
class (CSS: highest `order` + `margin-inline-start: auto`) fixes this
so the X renders last in flex order and hugs the right edge regardless
of how wide the tab dropdown / workspace picker grow.
of how wide the tab dropdown / workspace picker grow. Tablet keeps the
desktop `.terminal-tabs` (not the mobile dropdown) ahead of title/close
in normal DOM order, so its plain close button needs no order override.
*/}
{isMobileTerminal ? (
<button
@@ -2660,9 +2692,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{getStatusIndicator()}
</div>
{/* Actions — labels hidden on mobile via .terminal-action-label */}
<div className="terminal-actions" data-testid="terminal-actions">
{terminalActionControls}
{isTabletTerminal ? (
<button
className="terminal-close"
onClick={onClose}
@@ -2671,7 +2701,20 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
>
<X size={20} />
</button>
</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>
@@ -2944,16 +2987,20 @@ 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.
FNXC:TerminalFooter 2026-07-08-15:00:
Mobile + tablet footer bar (FN-7560 mobile, FN-7684 tablet) restoring
the pre-FN-7502 footer ergonomics on both narrower tiers: the same
`terminalActionControls` fragment used by the true-desktop header
renders here instead so font-size/Clear/Shortcuts/Preferences/
connection-status/exit-code stay reachable without crowding the
header's tab strip/dropdown, workspace picker, 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. Tablet keeps the desktop display modes (docked/
floating/pinned-below), so this footer also carries the !isMobileTerminal-
gated pin/pop-out toggles the fragment already renders for tablet.
*/}
{isMobileTerminal && (
{(isMobileTerminal || isTabletTerminal) && (
<div className="terminal-status-bar" data-testid="terminal-footer-actions">
{terminalActionControls}
</div>

View File

@@ -807,20 +807,26 @@ describe("TerminalModal", () => {
expect(belowRule).not.toContain("position: fixed;");
expect(belowRule).toContain("height: var(--terminal-below-height);");
// 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*\{/);
// FN-7560/FN-7684: the `.terminal-status-bar` footer is a MOBILE + TABLET-ONLY
// affordance (isMobileTerminal || isTabletTerminal, both of which exclude
// below mode's true-desktop display) — it must exist only scoped inside the
// mobile `@media (max-width: 768px)` block or the tablet
// `@media (min-width: 769px) and (max-width: 1024px)` block, never as a
// global/unscoped rule that could leak a footer shell into true-desktop
// (>1024px) docked/floating/pinned-below. Strip every mobile AND tablet
// media-query block out of the stylesheet and confirm no `.terminal-status-bar`
// rule remains outside them.
const cssWithoutMobileAndTabletMediaBlocks = terminalModalCss
.replace(/@media \(max-width: 768px\) \{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "")
.replace(/@media \(min-width: 769px\) and \(max-width: 1024px\) \{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "");
expect(cssWithoutMobileAndTabletMediaBlocks).not.toMatch(/\.terminal-status-bar\s*\{/);
const mobileFooterRule =
terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-status-bar\s*\{/);
expect(mobileFooterRule).not.toBeNull();
const tabletFooterRule = terminalModalCss.match(
/@media \(min-width: 769px\) and \(max-width: 1024px\) \{[\s\S]*?\.terminal-status-bar\s*\{/,
);
expect(tabletFooterRule).not.toBeNull();
});
it("exposes floating drag and resize handles and refits after floating resize", async () => {
@@ -1497,6 +1503,30 @@ describe("TerminalModal", () => {
expect(footerRule).toContain("min-width: 0;");
});
it("gives the tablet footer bar the same horizontal-scroll pattern (FN-7684)", () => {
// FN-7684: the tablet-tier action-control footer mirrors the FN-7560
// mobile footer's min-width: 0 + overflow-x: auto flex-scroll pattern.
const tabletFooterRule =
terminalModalCss.match(
/@media \(min-width: 769px\) and \(max-width: 1024px\) \{[\s\S]*?\.terminal-status-bar\s*\{([^}]*)\}/,
)?.[1] ?? "";
expect(tabletFooterRule).toContain("overflow-x: auto;");
expect(tabletFooterRule).toContain("min-width: 0;");
expect(tabletFooterRule).toContain("touch-action: pan-x pan-y;");
// Unlike mobile, the tablet-scoped mobile `display: none` hide for the
// help text and connection status must NOT apply at the tablet tier.
const mobileHideBlock = terminalModalCss.match(
/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-shortcuts--header,\s*\n\s*\.terminal-connection-status \{[\s\S]*?\}\s*\n\}/,
);
expect(mobileHideBlock).not.toBeNull();
const tabletBlock = terminalModalCss.match(
/@media \(min-width: 769px\) and \(max-width: 1024px\) \{([\s\S]*?)\n\}/,
)?.[1] ?? "";
expect(tabletBlock).not.toMatch(/\.terminal-shortcuts--header/);
expect(tabletBlock).not.toMatch(/\.terminal-connection-status/);
});
describe("real-CSS mobile cascade (FN-7621 recurrence #3)", () => {
// FN-7621: the FN-7550/FN-7560 tests above are leaf-rule string matches —
// they proved the declarations exist, but never proved the panel actually
@@ -3553,9 +3583,10 @@ describe("TerminalModal — mobile layout contract", () => {
});
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.
// FN-7560/FN-7684: explicitly TRUE-desktop-width (>1024px) — the footer
// only exists on the mobile (isMobileTerminal) and tablet (isTabletTerminal)
// paths; true 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 });
@@ -3619,6 +3650,73 @@ describe("TerminalModal — mobile layout contract", () => {
}
});
it("renders terminal action controls in a tablet footer, not the header, and keeps pin/pop-out toggles (FN-7684)", async () => {
// FN-7684: tablet width (769-1024px) must relocate the same shared
// terminalActionControls fragment into the footer, exactly as FN-7560
// did for mobile — but unlike mobile, tablet keeps the desktop pin/
// pop-out toggles and the desktop tab strip / workspace picker.
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
fireEvent(window, new Event("resize"));
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");
const pinToggle = screen.getByTestId("terminal-pin-toggle");
const popoutToggle = screen.getByTestId("terminal-popout-toggle");
// Controls live inside the footer region, including the desktop-only
// pin/pop-out toggles (tablet keeps docked/floating/pinned-below modes)...
expect(footer.contains(clearBtn)).toBe(true);
expect(footer.contains(shortcutToggle)).toBe(true);
expect(footer.contains(preferencesToggle)).toBe(true);
expect(footer.contains(fontSizeValue)).toBe(true);
expect(footer.contains(pinToggle)).toBe(true);
expect(footer.contains(popoutToggle)).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);
expect(header?.contains(pinToggle)).toBe(false);
expect(header?.contains(popoutToggle)).toBe(false);
// No empty .terminal-actions shell renders in the tablet header.
expect(header?.querySelector(".terminal-actions")).toBeNull();
// The close button, the desktop tab strip (not the mobile dropdown),
// and the workspace picker remain in the header.
const closeBtn = screen.getByTestId("terminal-close-btn");
expect(header?.contains(closeBtn)).toBe(true);
expect(footer.contains(closeBtn)).toBe(false);
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
const tabs = screen.queryByTestId("terminal-tabs");
expect(tabs).toBeTruthy();
expect(header?.contains(tabs)).toBe(true);
// The workspace picker only renders when workspaces exist (default mock
// has none) — assert it stays in the header when present.
const workspacePicker = screen.queryByTestId("terminal-workspace-picker");
if (workspacePicker) {
expect(header?.contains(workspacePicker)).toBe(true);
}
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
fireEvent(window, new Event("resize"));
}
});
it("pins the mobile close button to the top-right corner of the header, not buried in .terminal-actions (FN-7565)", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });