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:
@@ -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.
|
||||
|
||||
7
.changeset/fn-7829-terminal-shortcuts-footer.md
Normal file
7
.changeset/fn-7829-terminal-shortcuts-footer.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user