diff --git a/.changeset/fn-7684-tablet-terminal-footer.md b/.changeset/fn-7684-tablet-terminal-footer.md new file mode 100644 index 0000000000..e2e07a9e1b --- /dev/null +++ b/.changeset/fn-7684-tablet-terminal-footer.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0dfee583e0..259511d428 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 19e6901dad..00b07af170 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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; + } +} diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index afd41657a7..dd2b73dc9c 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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(() => readTerminalFloatSize(projectId)); const [floatingPosition, setFloatingPosition] = useState(() => 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 ? ( - + ) : ( + /* Actions — labels hidden on mobile via .terminal-action-label */ +
+ {terminalActionControls} + +
+ )} )} @@ -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) && (
{terminalActionControls}
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 803dd9ead4..e709862610 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -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(); + + 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 });