FN-7829: move terminal action controls into footer and add tab-strip overflow collapse

Relocates the shared terminal action-control cluster into the bottom status-bar footer at every breakpoint and replaces the fixed tablet-viewport tab collapse with a container-width overflow check.

- Move font-size, Clear, Shortcuts, Preferences, connection status, pin, and pop-out controls out of the desktop header into the `.terminal-status-bar` footer at all widths (desktop/tablet/mobile/floating/docked/pinned/embedded), replacing the prior viewport-tier-based split.
- Replace `isTerminalTabletViewport()` with `evaluateTabsOverflow()`, a container-size (ResizeObserver-driven) check with hysteresis that swaps the `.terminal-tabs` strip for the existing mobile-style `.terminal-mobile-tabs` dropdown whenever the tab strip doesn't fit, independent of viewport breakpoint.
- Update TerminalModal.css to match the new footer-first layout and drop now-unused tablet-tier header rules.
- Update dashboard-guide.md terminal usage steps to describe the new always-footer control location and the width-based (not tablet-only) tab dropdown fallback.
- Expand TerminalModal.test.tsx coverage for the overflow-driven tab collapse/expand behavior and footer control placement.
- Add changeset fn-7829-terminal-shortcuts-footer.md (patch, feature) documenting the footer/tab-dropdown change; resolve an add/add conflict on the pre-existing artifacts-doc-editing-and-comment-fix.md changeset by keeping fusion/fn-7829's summary wording.

Files changed:
 .../artifacts-doc-editing-and-comment-fix.md       |   2 +-
 .changeset/fn-7829-terminal-shortcuts-footer.md    |   7 +
 docs/dashboard-guide.md                            |   9 +-
 .../dashboard/app/components/TerminalModal.css     | 121 +++-----
 .../dashboard/app/components/TerminalModal.tsx     | 336 +++++++++++----------
 .../components/__tests__/TerminalModal.test.tsx    | 185 ++++++++----
 6 files changed, 351 insertions(+), 309 deletions(-)

Fusion-Task-Id: FN-7829

Fusion-Task-Lineage: 443c6e69-8946-4307-8a1a-f4f90681a054

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 20:06:53 -07:00
parent 4edd8cc293
commit 6b506f2c97
6 changed files with 351 additions and 309 deletions

View File

@@ -2,6 +2,6 @@
"@runfusion/fusion": minor
---
summary: Edit task documents and project files in Artifacts, with Markdown previews and working add-comment controls.
summary: Edit task documents and project files in the Artifacts view; markdown by default; fix the Add comment button.
category: feature
dev: DocumentsView embeds the shared CodeMirror FileEditor for task-document (PUT /tasks/:id/documents/:key) and project-file (project workspace file API) edits. The Add comment no-op was a CSS bundle-order regression — `.btn:active` out-ordered the equal-specificity trigger rule; the `:active` rules now use `.btn.selection-comment-trigger` (0,3,0) with a test asserting the prefix.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Move terminal shortcuts into the footer and collapse crowded terminal tabs into a dropdown.
category: feature
dev: The shared terminalActionControls fragment now always renders in the .terminal-status-bar footer (never the header .terminal-actions); a ResizeObserver-driven container-overflow check swaps the .terminal-tabs strip for the existing .terminal-mobile-tabs <select> dropdown when tabs don't fit, distinct from the viewport-based isMobileTerminal/isTabletTerminal flags.

View File

@@ -636,15 +636,16 @@ Task Detail has two terminal-adjacent tabs when both are applicable: **Session**
On Windows, the embedded terminal starts a supported shell inside Fusion, such as Command Prompt (`cmd.exe`) or Windows PowerShell. Windows Terminal (`wt.exe`) is an external terminal host and is not required or launched for the embedded panel, so Fusion should not show native Windows Terminal help/version popups while starting a terminal. If embedded terminal startup fails, Fusion shows an inline error with **Retry** instead of a blocking native dialog; install or repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` only if you want to use Windows Terminal outside Fusion.
<!-- FNXC:TerminalFooter 2026-07-11-20:45: FN-7829 moved all terminal action controls (font size, Clear, Shortcuts, Preferences, status, pin, and pop-out) into the bottom terminal footer at every width, and desktop/tablet tabs now fall back to the mobile-style selector when the tab strip cannot fit its container. -->
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 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 opens as a bottom-docked overlay panel with the active shell session and a draggable top resize handle. The font size / clear / shortcuts / preferences controls, connection status, pin, and pop-out controls render in the terminal's bottom action-control footer at every desktop/tablet width, so the header only has to carry the tab affordance, title/status, workspace picker, and close button.
2. Select **Pin terminal (push content)** from the bottom action-control footer.
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 on desktop, or the bottom action-control footer on tablet.
4. Select **Pop out** from the bottom action-control footer.
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.
@@ -656,7 +657,7 @@ Use the terminal on mobile:
1. Open the bottom navigation **More** sheet and select **Terminal**.
Expected outcome: the terminal opens as a full-screen, keyboard-aware modal rather than the desktop/tablet docked or floating surface.
2. Use the **Terminal tab** selector to switch between terminal tabs, or use the adjacent **+** action to open another Project Root terminal.
Expected outcome: every terminal tab appears in the dropdown, switching preserves the active session, and the desktop horizontal tab strip is not shown on mobile.
Expected outcome: every terminal tab appears in the dropdown, switching preserves the active session, and the desktop horizontal tab strip is not shown on mobile. The same selector also appears on desktop/tablet when a narrow docked or floating terminal does not have enough room to show the whole tab strip.
3. When multiple tabs are open, use **Close current tab** beside the selector, then close the modal when finished.
Expected outcome: mobile can close the active terminal tab without exposing a cramped horizontal tab strip, and terminal sessions reconnect/recover normally without desktop dock state affecting the mobile layout.

View File

@@ -458,6 +458,56 @@ The floating-mode header is the move grip. `touch-action: none` is required so a
opacity: var(--opacity-disabled, 0.5);
}
.terminal-tab-region {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
}
.terminal-tab-region .terminal-tabs,
.terminal-tab-region .terminal-mobile-tabs {
flex: 1 1 auto;
min-width: 0;
}
.terminal-tab-region .terminal-mobile-tabs {
display: flex;
}
.terminal-tabs--measuring {
position: absolute;
inset: 0;
width: 100%;
visibility: hidden;
pointer-events: none;
overflow: hidden;
scrollbar-width: none;
}
.terminal-tabs--measuring::-webkit-scrollbar {
display: none;
}
/*
FNXC:TerminalFooter 2026-07-11-20:36:
FN-7829 makes the terminal status footer unconditional for mobile, tablet, desktop, and embedded terminals because the header no longer renders `.terminal-actions`. Keep the FN-7550/FN-7621 min-width, horizontal-scroll, nowrap, and touch-action safeguards here so the single shared action cluster remains reachable without clipping at every breakpoint.
*/
.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: calc(var(--space-xl) + var(--space-sm));
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
touch-action: pan-x pan-y;
}
/*
FNXC:TerminalWorkspaces 2026-06-29-00:00:
Terminal worktree selection mirrors the file-browser workspace model while staying header-sized for docked, floating, and mobile terminals. Render the picker only when task workspace entries exist so failures or empty worktree lists never leave an inert button shell next to the always-fast + terminal affordance.
@@ -1757,83 +1807,12 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
display: none;
}
/*
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;
/*
FNXC:Terminal 2026-07-06-19:45:
FN-7621: this footer shares .terminal-shortcut-panel's FN-7550 min-width/
overflow-x pattern but never had ITS OWN touch-action opt-in, so real
mobile swipes on it were also defeated by the ancestor-chain touch-action
intersection from styles.css's mobile lockdown (see the .modal.terminal-modal
and .modal-overlay.terminal-modal-overlay carve-outs above/elsewhere in
this file). Give it the same pan-x pan-y opt-in as .terminal-shortcut-panel.
*/
touch-action: pan-x pan-y;
}
.terminal-shortcuts--header,
.terminal-connection-status {
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;
}
}
/*
FNXC:Terminal 2026-07-10-00:00:
FN-7813 embeds the shared terminal inside Task Detail. The host must fill the parent tab panel, keep scroll/resize inside the terminal surface, and avoid overlay/floating dimensions so Task Detail owns layout at every breakpoint.

View File

@@ -357,22 +357,21 @@ function isTerminalMobileViewport(): boolean {
return window.innerWidth <= 768 || (hasTouchScreen && (getTerminalViewportWidth(true) <= 768 || getTerminalViewportHeight(true) <= 480));
}
interface TabsOverflowMeasurement {
scrollWidth: number;
clientWidth: number;
currentlyOverflowing?: boolean;
}
const TERMINAL_TABS_OVERFLOW_HYSTERESIS = 1;
/*
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).
FNXC:TerminalTabs 2026-07-11-20:28:
FN-7829 treats terminal tab collapse as a container-width decision, not a viewport breakpoint. Collapse only after content exceeds the available tab region by a small hysteresis gap, and expand as soon as the strip fits again so narrow floated/docked desktop panels can use the mobile-style dropdown without changing mobile behavior.
*/
function isTerminalTabletViewport(): boolean {
if (typeof window === "undefined") return false;
if (isTerminalMobileViewport()) return false;
return window.innerWidth >= 769 && window.innerWidth <= 1024;
export function evaluateTabsOverflow({ scrollWidth, clientWidth, currentlyOverflowing = false }: TabsOverflowMeasurement): boolean {
if (clientWidth <= 0) return false;
return currentlyOverflowing ? scrollWidth > clientWidth : scrollWidth > clientWidth + TERMINAL_TABS_OVERFLOW_HYSTERESIS;
}
function isMacPlatform(): boolean {
@@ -572,8 +571,7 @@ 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 [tabsOverflow, setTabsOverflow] = useState(false);
/*
FNXC:Terminal 2026-07-10-00:00:
FN-7813 embedded mode is parent-layout owned: render in-flow, skip portal/overlay/display-mode chrome, and keep the shared xterm/session/resize observers so Task Detail gets the same terminal behavior without taking over the viewport.
@@ -590,6 +588,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const terminalTabRegionRef = useRef<HTMLDivElement>(null);
const terminalTabsMeasureRef = useRef<HTMLDivElement>(null);
const terminalWorkspacePickerRef = useRef<HTMLDivElement>(null);
const terminalWorkspaceTriggerRef = useRef<HTMLButtonElement>(null);
const terminalWorkspaceMenuRef = useRef<HTMLDivElement>(null);
@@ -650,8 +650,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
*/
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);
@@ -662,6 +660,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
};
}, [isOpen]);
const checkTabsFit = useCallback(() => {
const measuredTabs = terminalTabsMeasureRef.current;
if (!measuredTabs) return;
const { scrollWidth, clientWidth } = measuredTabs;
setTabsOverflow((current) => evaluateTabsOverflow({ scrollWidth, clientWidth, currentlyOverflowing: current }));
}, []);
const setDisplayMode = useCallback((mode: TerminalDisplayMode) => {
setDisplayModeState(writeTerminalDisplayMode(mode, projectId));
window.dispatchEvent(new CustomEvent("fusion:terminal-display-mode-change", { detail: { projectId, mode } }));
@@ -1075,6 +1080,30 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
defaultCwd,
});
useEffect(() => {
if (!isOpen) {
setTabsOverflow(false);
return;
}
checkTabsFit();
window.addEventListener("resize", checkTabsFit);
const observer = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => checkTabsFit());
if (observer) {
if (terminalTabRegionRef.current) observer.observe(terminalTabRegionRef.current);
if (terminalTabsMeasureRef.current) observer.observe(terminalTabsMeasureRef.current);
}
return () => {
window.removeEventListener("resize", checkTabsFit);
observer?.disconnect();
};
}, [checkTabsFit, isOpen, tabs.length]);
useEffect(() => {
checkTabsFit();
}, [checkTabsFit, tabs]);
/*
FNXC:Terminal 2026-07-06-09:15:
FN-7620 root cause: the mobile terminal could render BLANK (not merely
@@ -2334,16 +2363,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
} as CSSProperties;
/*
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` 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.
FNXC:TerminalFooter 2026-07-11-20:20:
FN-7829 moves the single terminal action-control cluster (reconnect/restart, font-size, Clear, Shortcuts toggle, Preferences toggle, connection status, exit code, help text, and the non-mobile pin/pop-out toggles) into the bottom `.terminal-status-bar` footer at every breakpoint. The header never renders `.terminal-actions`; keeping this as one shared fragment rendered in exactly one footer location prevents handler drift across desktop, tablet, mobile, floating, docked, pinned-below, and embedded modes.
*/
const terminalActionControls = (
<>
@@ -2425,6 +2446,99 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</>
);
const renderTerminalTabStrip = (measuring = false) => (
<div
ref={measuring || !tabsOverflow ? terminalTabsMeasureRef : undefined}
className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`}
data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"}
aria-hidden={measuring || undefined}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={measuring ? undefined : () => setActiveTab(tab.id)}
title={tab.title}
role={measuring ? undefined : "tab"}
aria-selected={measuring ? undefined : tab.isActive}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
disabled={measuring}
tabIndex={measuring ? -1 : undefined}
onClick={measuring ? undefined : (e: ReactMouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
closeTab(tab.id);
}}
title={t("terminal.closeTab", "Close tab")}
>
×
</button>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
disabled={measuring}
tabIndex={measuring ? -1 : undefined}
onClick={measuring ? undefined : () => void createTab()}
title={t("terminal.newTerminal", "New terminal")}
aria-label={t("terminal.newTerminal", "New terminal")}
>
+
</button>
</div>
);
const renderTerminalMobileTabs = () => (
<div className="terminal-mobile-tabs" data-testid="terminal-mobile-tabs">
<label className="terminal-mobile-tabs-label" htmlFor="terminal-mobile-tab-select">
{t("terminal.selectTab", "Terminal tab")}
</label>
<select
id="terminal-mobile-tab-select"
className="input terminal-mobile-tab-select"
data-testid="terminal-mobile-tab-select"
value={activeTab?.id ?? ""}
onChange={(event) => {
if (event.currentTarget.value) setActiveTab(event.currentTarget.value);
}}
disabled={tabs.length === 0}
aria-label={t("terminal.selectTab", "Terminal tab")}
>
{tabs.length === 0 && (
<option value="">{t("terminal.noTabs", "No terminal tabs")}</option>
)}
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>{tab.title}</option>
))}
</select>
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--new"
onClick={() => void createTab()}
aria-label={t("terminal.newTerminal", "New terminal")}
data-testid="terminal-mobile-new-tab"
>
<Plus size={14} />
</button>
{tabs.length > 1 && activeTab && (
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--close"
onClick={() => closeTab(activeTab.id)}
title={t("terminal.closeCurrentTab", "Close current tab")}
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
data-testid="terminal-mobile-close-tab"
>
<Trash2 size={14} />
</button>
)}
</div>
);
const terminalPanel = (
<div
ref={modalRef}
@@ -2460,93 +2574,21 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className={`terminal-header${isFloatingMode ? " terminal-header--draggable" : ""}`} onPointerDown={handleFloatingDragPointerDown}>
{/* Tab Bar */}
{!isMobileTerminal && (
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
role="tab"
aria-selected={tab.isActive}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
title={t("terminal.closeTab", "Close tab")}
>
×
</button>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
onClick={() => void createTab()}
title={t("terminal.newTerminal", "New terminal")}
aria-label={t("terminal.newTerminal", "New terminal")}
>
+
</button>
{isMobileTerminal ? renderTerminalMobileTabs() : (
<div className="terminal-tab-region" ref={terminalTabRegionRef}>
{tabsOverflow ? (
<>
{renderTerminalMobileTabs()}
{renderTerminalTabStrip(true)}
</>
) : renderTerminalTabStrip()}
</div>
)}
{/*
FNXC:TerminalTabs 2026-07-01-00:00:
Mobile terminal headers use a native tab dropdown because horizontal tab strips crowd worktree, session, and close controls on narrow screens. Desktop and floating layouts keep the existing tab buttons so fast tab switching, per-tab close, and the + action stay unchanged.
FNXC:TerminalTabs 2026-07-11-20:28:
FN-7829 keeps mobile on the existing native tab dropdown and also reuses that same `.terminal-mobile-tabs` affordance when a non-mobile terminal tab region is too narrow for the horizontal `.terminal-tabs` strip. The overflow decision comes from the tab container's ResizeObserver, not `isMobileTerminal`/viewport width, so narrow floated/docked desktop panels can collapse independently and expand back when room returns.
*/}
{isMobileTerminal && (
<div className="terminal-mobile-tabs" data-testid="terminal-mobile-tabs">
<label className="terminal-mobile-tabs-label" htmlFor="terminal-mobile-tab-select">
{t("terminal.selectTab", "Terminal tab")}
</label>
<select
id="terminal-mobile-tab-select"
className="input terminal-mobile-tab-select"
data-testid="terminal-mobile-tab-select"
value={activeTab?.id ?? ""}
onChange={(event) => {
if (event.currentTarget.value) setActiveTab(event.currentTarget.value);
}}
disabled={tabs.length === 0}
aria-label={t("terminal.selectTab", "Terminal tab")}
>
{tabs.length === 0 && (
<option value="">{t("terminal.noTabs", "No terminal tabs")}</option>
)}
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>{tab.title}</option>
))}
</select>
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--new"
onClick={() => void createTab()}
aria-label={t("terminal.newTerminal", "New terminal")}
data-testid="terminal-mobile-new-tab"
>
<Plus size={14} />
</button>
{tabs.length > 1 && activeTab && (
<button
type="button"
className="terminal-mobile-tab-action terminal-mobile-tab-action--close"
onClick={() => closeTab(activeTab.id)}
title={t("terminal.closeCurrentTab", "Close current tab")}
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
data-testid="terminal-mobile-close-tab"
>
<Trash2 size={14} />
</button>
)}
</div>
)}
{shouldShowTerminalWorkspacePicker && (
<div
@@ -2660,19 +2702,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
)}
{/*
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 (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:TerminalFooter 2026-07-11-20:20:
FN-7829 removes `.terminal-actions` from the header at every width. Mobile keeps the corner-pinned close button, while tablet/desktop/embedded headers keep the title/status, tab affordance, workspace picker, and a plain close button; the shared `terminalActionControls` fragment renders only in the bottom `.terminal-status-bar` footer so the control handlers cannot drift or leave an empty header shell.
FNXC:TerminalHeader 2026-07-04-20:45:
FN-7565: on mobile, being a direct child of `.terminal-header` (not
@@ -2705,33 +2736,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{getStatusIndicator()}
</div>
{isTabletTerminal ? (
!embedded ? (
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
) : null
) : (
/* Actions — labels hidden on mobile via .terminal-action-label */
<div className="terminal-actions" data-testid="terminal-actions">
{terminalActionControls}
{!embedded && (
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
)}
</div>
)}
{!embedded ? (
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
) : null}
</>
)}
</div>
@@ -3004,24 +3018,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
)}
{/*
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.
FNXC:TerminalFooter 2026-07-11-20:20:
FN-7829 renders the shared `terminalActionControls` fragment in this bottom footer at every breakpoint, including true desktop and embedded terminals, so font-size/Clear/Shortcuts/Preferences/connection-status/exit-code plus the non-mobile pin/pop-out toggles stay reachable without crowding the header. This is the only render site for the cluster.
*/}
{(isMobileTerminal || isTabletTerminal) && (
<div className="terminal-status-bar" data-testid="terminal-footer-actions">
{terminalActionControls}
</div>
)}
<div className="terminal-status-bar" data-testid="terminal-footer-actions">
{terminalActionControls}
</div>
</div>
);

View File

@@ -5,7 +5,7 @@ FN-6441 rescued this orphaned component test after standalone dashboard-app exec
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar } from "../TerminalModal";
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar, evaluateTabsOverflow } from "../TerminalModal";
import {
DEFAULT_TERMINAL_PREFERENCES,
LEGACY_TERMINAL_FONT_SIZE_KEY,
@@ -41,6 +41,10 @@ function expectTextSizeAdjustmentDisabledForExactXtermMetrics(cssSource: string)
expect(match?.[1] ?? "").toMatch(/text-size-adjust\s*:\s*none\s*;/);
}
function defineMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) {
Object.defineProperty(element, property, { configurable: true, value });
}
// Mock hooks and API
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: vi.fn(),
@@ -306,6 +310,17 @@ describe("ctrlChar/altChar helpers", () => {
});
});
describe("evaluateTabsOverflow", () => {
it("collapses only after the tab content exceeds available width beyond hysteresis", () => {
expect(evaluateTabsOverflow({ scrollWidth: 201, clientWidth: 200 })).toBe(false);
expect(evaluateTabsOverflow({ scrollWidth: 202, clientWidth: 200 })).toBe(true);
expect(evaluateTabsOverflow({ scrollWidth: 200, clientWidth: 200 })).toBe(false);
expect(evaluateTabsOverflow({ scrollWidth: 201, clientWidth: 200, currentlyOverflowing: true })).toBe(true);
expect(evaluateTabsOverflow({ scrollWidth: 200, clientWidth: 200, currentlyOverflowing: true })).toBe(false);
expect(evaluateTabsOverflow({ scrollWidth: 300, clientWidth: 0 })).toBe(false);
});
});
// Default tab state
const defaultTab = {
id: "tab-1",
@@ -836,26 +851,12 @@ describe("TerminalModal", () => {
expect(belowRule).not.toContain("position: fixed;");
expect(belowRule).toContain("height: var(--terminal-below-height);");
// 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();
// FN-7829: the `.terminal-status-bar` footer is now the in-flow action-control location at every breakpoint, including below mode. It must remain an unconditional base rule with the horizontal-scroll safeguards that keep the single footer cluster reachable.
const footerRule = terminalModalCss.match(/\.terminal-status-bar\s*\{([^}]*)\}/)?.[1] ?? "";
expect(footerRule).toContain("display: flex;");
expect(footerRule).toContain("min-width: 0;");
expect(footerRule).toContain("overflow-x: auto;");
expect(footerRule).toContain("touch-action: pan-x pan-y;");
});
it("exposes floating drag and resize handles and refits after floating resize", async () => {
@@ -1265,6 +1266,64 @@ describe("TerminalModal", () => {
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
});
it("collapses desktop tabs to the mobile dropdown on container overflow and expands when room returns", async () => {
const previousInnerWidth = window.innerWidth;
const mockSetActiveTab = vi.fn();
const mockCreateTab = vi.fn().mockResolvedValue(defaultTab);
const mockCloseTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
{ id: "tab-3", sessionId: "test-session-789", title: "make test", isActive: false, createdAt: Date.now() },
],
setActiveTab: mockSetActiveTab,
createTab: mockCreateTab,
closeTab: mockCloseTab,
});
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const expandedTabs = await screen.findByTestId("terminal-tabs");
defineMetric(expandedTabs, "scrollWidth", 360);
defineMetric(expandedTabs, "clientWidth", 200);
act(() => {
fireEvent(window, new Event("resize"));
});
const collapsedTabs = await screen.findByTestId("terminal-mobile-tabs");
expect(screen.queryByTestId("terminal-tabs")).toBeNull();
const measuringTabs = screen.getByTestId("terminal-tabs-measuring");
expect(measuringTabs).toHaveAttribute("aria-hidden", "true");
expect(measuringTabs.querySelector(".terminal-tab-close")).toBeDisabled();
const select = screen.getByTestId("terminal-mobile-tab-select") as HTMLSelectElement;
expect(collapsedTabs.contains(select)).toBe(true);
fireEvent.change(select, { target: { value: "tab-2" } });
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
fireEvent.click(screen.getByTestId("terminal-mobile-new-tab"));
expect(mockCreateTab).toHaveBeenCalledWith();
fireEvent.click(screen.getByTestId("terminal-mobile-close-tab"));
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
defineMetric(measuringTabs, "scrollWidth", 199);
defineMetric(measuringTabs, "clientWidth", 200);
act(() => {
fireEvent(window, new Event("resize"));
});
await waitFor(() => {
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("renders a mobile tab selector with every tab and switches by tab id", async () => {
const previousInnerWidth = window.innerWidth;
const previousInnerHeight = window.innerHeight;
@@ -1522,29 +1581,14 @@ 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] ?? "";
it("gives the unconditional footer bar the horizontal-scroll pattern (FN-7829)", () => {
// FN-7829: desktop, tablet, mobile, and embedded terminals share the same footer rule, so the scroll-safety declarations must live in the base stylesheet instead of media-query-only overrides.
const footerRule = terminalModalCss.match(/\.terminal-status-bar\s*\{([^}]*)\}/)?.[1] ?? "";
expect(footerRule).toContain("overflow-x: auto;");
expect(footerRule).toContain("min-width: 0;");
});
expect(footerRule).toContain("flex-wrap: nowrap;");
expect(footerRule).toContain("touch-action: pan-x pan-y;");
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\}/,
);
@@ -1554,6 +1598,7 @@ describe("TerminalModal", () => {
)?.[1] ?? "";
expect(tabletBlock).not.toMatch(/\.terminal-shortcuts--header/);
expect(tabletBlock).not.toMatch(/\.terminal-connection-status/);
expect(tabletBlock).not.toMatch(/\.terminal-status-bar/);
});
it("keeps the desktop terminal header controls on one scrollable row when narrow (FN-7823)", () => {
@@ -3543,14 +3588,14 @@ describe("TerminalModal — mobile layout contract", () => {
});
});
it("preserves header structure: tabs, title, and actions are present", async () => {
it("preserves header/footer structure: tabs and title in header, actions in footer", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
// Verify the three structural sections of the header exist
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
expect(screen.getByTestId("terminal-title")).toBeTruthy();
expect(screen.getByTestId("terminal-actions")).toBeTruthy();
expect(screen.getByTestId("terminal-footer-actions")).toBeTruthy();
expect(screen.queryByTestId("terminal-actions")).toBeNull();
});
});
@@ -3632,24 +3677,34 @@ describe("TerminalModal — mobile layout contract", () => {
});
});
it("header actions show connection state without a footer status-bar shell (desktop, FN-7502)", async () => {
// 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.
it("footer actions show connection state on true desktop with no header actions shell", async () => {
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(() => {
expect(screen.queryByTestId("terminal-status-bar")).toBeNull();
expect(screen.queryByTestId("terminal-footer-actions")).toBeNull();
const actions = screen.getByTestId("terminal-actions");
const connectionStatus = actions.querySelector(".terminal-connection-status");
expect(connectionStatus?.textContent).toBe("Disconnected");
});
await waitFor(() => {
const footer = screen.getByTestId("terminal-footer-actions");
expect(footer.className).toContain("terminal-status-bar");
const header = document.querySelector(".terminal-header");
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");
const connectionStatus = footer.querySelector(".terminal-connection-status");
expect(connectionStatus?.textContent).toBe("Disconnected");
for (const control of [clearBtn, shortcutToggle, preferencesToggle, fontSizeValue, pinToggle, popoutToggle]) {
expect(footer.contains(control)).toBe(true);
expect(header?.contains(control)).toBe(false);
}
expect(screen.queryByTestId("terminal-actions")).toBeNull();
expect(header?.contains(screen.getByTestId("terminal-close-btn"))).toBe(true);
expect(header?.contains(screen.getByTestId("terminal-tabs"))).toBe(true);
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
@@ -3685,8 +3740,10 @@ describe("TerminalModal — mobile layout contract", () => {
expect(header?.contains(preferencesToggle)).toBe(false);
expect(header?.contains(fontSizeValue)).toBe(false);
// No empty .terminal-actions shell renders in the mobile header.
// No empty .terminal-actions shell renders in the mobile header, and non-mobile display toggles stay omitted.
expect(header?.querySelector(".terminal-actions")).toBeNull();
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
// The close button and mobile tab dropdown remain in the header.
const closeBtn = screen.getByTestId("terminal-close-btn");
@@ -3848,7 +3905,7 @@ describe("TerminalModal — mobile layout contract", () => {
}
});
it("keeps the desktop close button in .terminal-actions with no mobile-only corner slot (FN-7565)", async () => {
it("keeps the desktop close button as a plain header control with no actions shell (FN-7565)", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
@@ -3859,14 +3916,10 @@ describe("TerminalModal — mobile layout contract", () => {
const closeButtons = screen.getAllByTestId("terminal-close-btn");
expect(closeButtons).toHaveLength(1);
const closeBtn = closeButtons[0];
const actions = screen.getByTestId("terminal-actions");
const header = document.querySelector(".terminal-header");
// Desktop/floating/pinned-below keep the FN-7502 placement: close stays
// the rightmost child of .terminal-actions.
expect(actions.contains(closeBtn)).toBe(true);
expect(actions.lastElementChild).toBe(closeBtn);
// No mobile-only corner class/slot renders on desktop.
expect(header?.contains(closeBtn)).toBe(true);
expect(screen.queryByTestId("terminal-actions")).toBeNull();
expect(closeBtn.className).not.toContain("terminal-close--corner");
});
} finally {