FN-9026: move terminal display toggles to header

Move desktop terminal pin and pop-out controls beside the close button.

- Render shared non-mobile display-mode controls in the terminal header
- Keep footer actions focused on terminal session controls
- Update styling, documentation, regression tests, and release metadata

Files changed:
 .changeset/terminal-toolbar-display-toggles.md     |  7 ++
 docs/dashboard-guide.md                            | 12 +--
 .../dashboard/app/components/TerminalModal.css     |  4 +-
 .../dashboard/app/components/TerminalModal.tsx     | 67 ++++++++-------
 .../components/__tests__/TerminalModal.test.tsx    | 99 ++++++++++++++++++----
 5 files changed, 134 insertions(+), 55 deletions(-)

Fusion-Task-Id: FN-9026

Fusion-Task-Lineage: 5b61d4c9-a3d4-41e1-9d89-4c8189f5b8fb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 01:28:14 -07:00
parent 401b057965
commit 6243c2c3f2
5 changed files with 134 additions and 55 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Terminal pin and pop-out toggles now sit in the top toolbar left of the close button.
category: fix
dev: Moves terminal-pin-toggle/terminal-popout-toggle out of terminalActionControls into a shared header fragment in TerminalModal.tsx.

View File

@@ -851,18 +851,18 @@ 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. -->
<!-- FNXC:TerminalFooter 2026-07-11-20:45: FN-7829 keeps terminal action controls (font size, Clear, Shortcuts, Preferences, and status) in the bottom terminal footer at every width, while pin and pop-out sit immediately left of close in the non-mobile top toolbar; desktop/tablet tabs 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. 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. The pinned panel also reserves space above the fixed status footer (the executor status bar), so the terminal's own bottom action-control footer (font size, Clear, Shortcuts, Preferences, connection status, pin/pop-out) stays fully visible instead of being covered by it. Select **Unpin terminal (overlay content)** to return to the overlay docked panel.
Expected outcome: the terminal opens as a bottom-docked overlay panel with the active shell session and a draggable top resize handle. Font size / clear / shortcuts / preferences controls and connection status render in the bottom action-control footer, while pin and pop-out toggles render in the top toolbar immediately left of the close button at every desktop/tablet width.
2. Select **Pin terminal (push content)** from the top toolbar immediately left of Close.
Expected outcome: the terminal moves into a persisted below-application panel that reserves space instead of covering the board, chat, or right sidebar. The pinned panel also reserves space above the fixed status footer (the executor status bar), so its bottom action-control footer (font size, Clear, Shortcuts, Preferences, connection status, and exit code) stays fully visible instead of being covered by it. Select **Unpin terminal (overlay content)** in the top toolbar 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 bottom action-control footer.
4. Select **Pop out** from the top toolbar immediately left of Close.
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. On touch tablets, drag the reserved header grip rather than the horizontally scrollable tab strip.
5. Select **Dock** in the floating terminal.
5. Select **Dock** from the same top-toolbar control in the floating terminal.
Expected outcome: the terminal returns to the bottom docked overlay panel using the saved docked height.
6. Select the scripts chevron beside the footer **Terminal** button.
Expected outcome: the quick scripts menu opens without toggling the terminal; choosing a script runs it in the terminal, and the menu footer opens script management.

View File

@@ -302,8 +302,8 @@ stopPropagation in renderTerminalTabStrip.
/*
FNXC:TerminalModalControls 2026-07-31-22:19:
Tablets start docked, so the footer pop-out control needs the same touch-sized reach without
changing its established 14px icon or footer layout. Its pseudo-element only enlarges the button
Tablets start docked, so the header pop-out control needs the same touch-sized reach without
changing its established 14px icon or toolbar layout. Its pseudo-element only enlarges the button
hit area; it is not a second terminal control.
*/
.modal.terminal-modal.terminal-modal--tablet .terminal-clear-btn.terminal-clear-btn--icon[data-testid="terminal-popout-toggle"] {

View File

@@ -2341,7 +2341,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
/*
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.
FN-7829 keeps the single terminal action-control cluster (reconnect/restart, font-size, Clear, Shortcuts toggle, Preferences toggle, connection status, exit code, and help text) in the bottom `.terminal-status-bar` footer at every breakpoint. Pin/pop-out use their own single header fragment beside close; the header still never renders `.terminal-actions`, preventing handler drift across all presentation modes.
*/
const terminalActionControls = (
<>
@@ -2399,30 +2399,36 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{connectionStatus === "disconnected" && t("terminal.statusDisconnected", "Disconnected")}
</span>
{exitCode !== null && <span className="terminal-exit-code" data-testid="terminal-exit-code">{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}</span>}
{!embedded && !isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleBelowMode}
data-testid="terminal-pin-toggle"
title={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-label={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-pressed={isBelowMode}
>
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
{!embedded && !isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleDisplayMode}
data-testid="terminal-popout-toggle"
title={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-label={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-pressed={displayMode === "floating"}
>
{displayMode === "floating" ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
</>
);
/*
FNXC:TerminalModalControls 2026-08-13-08:13:
The operator requires pin and pop-out toggles in the top toolbar immediately left of close.
Keep this shared fragment at one header render site; mobile and embedded terminals render neither.
*/
const terminalDisplayModeControls = (
<>
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleBelowMode}
data-testid="terminal-pin-toggle"
title={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-label={isBelowMode ? t("terminal.unpinTerminal", "Unpin terminal (overlay content)") : t("terminal.pinTerminal", "Pin terminal (push content)")}
aria-pressed={isBelowMode}
>
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
</button>
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleDisplayMode}
data-testid="terminal-popout-toggle"
title={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-label={displayMode === "floating" ? t("terminal.dockTerminal", "Dock terminal") : t("terminal.popOutTerminal", "Pop out terminal")}
aria-pressed={displayMode === "floating"}
>
{displayMode === "floating" ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</>
);
@@ -2711,12 +2717,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{/*
FNXC:TerminalModalControls 2026-08-03-00:21:
Every non-embedded terminal has exactly one modal-close control, rendered after the
tab region (including its new-terminal affordance), optional workspace picker, and
status title. Keeping one shared final render site makes the close-after-plus,
far-right contract structural for desktop, tablet, ResizeObserver overflow, and mobile.
tab region (including its new-terminal affordance), optional workspace picker, status
title, and non-mobile pin/pop-out controls. Keeping one shared final render site makes
the close-after-plus, far-right contract structural for desktop, tablet,
ResizeObserver overflow, and mobile.
Mobile keeps the corner class so its explicit flex order remains last; embedded terminals
intentionally render no modal-close control because their parent owns dismissal.
*/}
{!embedded && !isMobileTerminal && terminalDisplayModeControls}
{!embedded && (
<button
className={`terminal-close${isMobileTerminal ? " terminal-close--corner" : ""}`}
@@ -3146,7 +3155,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{/*
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.
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 stay reachable. Pin/pop-out render once in the non-mobile header beside close; this remains the only footer render site for the action cluster.
*/}
<div className="terminal-status-bar" data-testid="terminal-footer-actions">
{terminalActionControls}

View File

@@ -47,6 +47,23 @@ function defineMetric(element: Element, property: "clientWidth" | "scrollWidth",
Object.defineProperty(element, property, { configurable: true, value });
}
function expectTerminalDisplayModeControlsBeforeClose(): void {
const header = document.querySelector<HTMLElement>(".terminal-header");
expect(header).not.toBeNull();
const pinToggle = screen.getByTestId("terminal-pin-toggle");
const popoutToggle = screen.getByTestId("terminal-popout-toggle");
const closeButton = screen.getByTestId("terminal-close-btn");
for (const control of [pinToggle, popoutToggle]) {
expect(control.parentElement).toBe(header);
expect(control.compareDocumentPosition(closeButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBe(Node.DOCUMENT_POSITION_FOLLOWING);
}
expect(popoutToggle.nextElementSibling).toBe(closeButton);
expect(header!.querySelector(".terminal-actions")).toBeNull();
}
function expectTerminalCloseAfterNewTerminal(newTerminalTestId: string): void {
const header = document.querySelector<HTMLElement>(".terminal-header");
expect(header).not.toBeNull();
@@ -521,6 +538,8 @@ describe("TerminalModal", () => {
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-drag-grip")).toBeNull();
expect(screen.queryByTestId("terminal-actions")).toBeNull();
expect(container.querySelector(".terminal-header .terminal-actions")).toBeNull();
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", {
storageScope: "task:FN-7813",
@@ -988,6 +1007,13 @@ describe("TerminalModal", () => {
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
});
const floatingWindow = screen.getByTestId("floating-window-terminal-popout-toggle-test") as HTMLElement & {
setPointerCapture: (pointerId: number) => void;
};
floatingWindow.setPointerCapture = vi.fn();
fireEvent.pointerDown(screen.getByTestId("terminal-popout-toggle"), { pointerId: 73, clientX: 100, clientY: 100 });
expect(floatingWindow.setPointerCapture).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
await waitFor(() => {
@@ -4592,7 +4618,7 @@ describe("TerminalModal — mobile layout contract", () => {
});
});
it("footer actions show connection state on true desktop with no header actions shell", async () => {
it("puts desktop display-mode controls immediately before close while footer retains actions", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
@@ -4613,11 +4639,17 @@ describe("TerminalModal — mobile layout contract", () => {
expect(connectionStatus?.textContent).toBe("Disconnected");
expect(footer.querySelector(".terminal-shortcuts--header")).toBeNull();
for (const control of [clearBtn, shortcutToggle, preferencesToggle, fontSizeValue, pinToggle, popoutToggle]) {
for (const control of [clearBtn, shortcutToggle, preferencesToggle, fontSizeValue]) {
expect(footer.contains(control)).toBe(true);
expect(header?.contains(control)).toBe(false);
}
for (const control of [pinToggle, popoutToggle]) {
expect(footer.contains(control)).toBe(false);
expect(header?.contains(control)).toBe(true);
}
expect(screen.queryByTestId("terminal-actions")).toBeNull();
expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull();
expectTerminalDisplayModeControlsBeforeClose();
expect(header?.contains(screen.getByTestId("terminal-close-btn"))).toBe(true);
expect(header?.contains(screen.getByTestId("terminal-tabs"))).toBe(true);
});
@@ -4626,6 +4658,39 @@ describe("TerminalModal — mobile layout contract", () => {
}
});
it.each([
["desktop", 1280],
["tablet", 900],
])("keeps populated %s workspace picker before header display-mode controls", async (_label, width) => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
fireEvent(window, new Event("resize"));
mockUseWorkspaces.mockReturnValue({
projectName: "kb",
workspaces: [
{ id: "FN-9026", label: "FN-9026", title: "Terminal toolbar ordering", worktree: "/repo/.worktrees/FN-9026", kind: "task" },
],
loading: false,
error: null,
});
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const header = document.querySelector<HTMLElement>(".terminal-header");
const workspacePicker = screen.getByTestId("terminal-workspace-picker");
expect(header?.contains(workspacePicker)).toBe(true);
expectTerminalDisplayModeControlsBeforeClose();
expect(workspacePicker.compareDocumentPosition(screen.getByTestId("terminal-pin-toggle")) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
fireEvent(window, new Event("resize"));
}
});
it("omits steady-state connected text and shortcut help from the shared footer controls", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
@@ -4682,6 +4747,7 @@ describe("TerminalModal — mobile layout contract", () => {
// 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-actions")).toBeNull();
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
@@ -4727,11 +4793,9 @@ describe("TerminalModal — mobile layout contract", () => {
}
});
it("renders terminal action controls in a tablet footer, not the header, and keeps pin/pop-out toggles (FN-7684)", async () => {
// FN-7684: tablet width (769-1024px) must relocate the same shared
// terminalActionControls fragment into the footer, exactly as FN-7560
// did for mobile — but unlike mobile, tablet keeps the desktop pin/
// pop-out toggles and the desktop tab strip / workspace picker.
it("puts tablet display-mode controls immediately before close while footer retains actions", async () => {
// Tablets retain the desktop pin/pop-out presentation modes and tab strip,
// while their remaining action controls stay in the shared footer fragment.
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
fireEvent(window, new Event("resize"));
@@ -4750,27 +4814,26 @@ describe("TerminalModal — mobile layout contract", () => {
const pinToggle = screen.getByTestId("terminal-pin-toggle");
const popoutToggle = screen.getByTestId("terminal-popout-toggle");
// Controls live inside the footer region, including the desktop-only
// pin/pop-out toggles (tablet keeps docked/floating/pinned-below modes)...
// Footer controls remain in the single shared footer fragment.
expect(footer.contains(clearBtn)).toBe(true);
expect(footer.contains(shortcutToggle)).toBe(true);
expect(footer.contains(preferencesToggle)).toBe(true);
expect(footer.contains(fontSizeValue)).toBe(true);
expect(footer.contains(pinToggle)).toBe(true);
expect(footer.contains(popoutToggle)).toBe(true);
// ...and NOT inside the header.
const header = document.querySelector(".terminal-header");
expect(header).toBeTruthy();
expect(header?.contains(clearBtn)).toBe(false);
expect(header?.contains(shortcutToggle)).toBe(false);
expect(header?.contains(preferencesToggle)).toBe(false);
expect(header?.contains(fontSizeValue)).toBe(false);
expect(header?.contains(pinToggle)).toBe(false);
expect(header?.contains(popoutToggle)).toBe(false);
for (const control of [clearBtn, shortcutToggle, preferencesToggle, fontSizeValue]) {
expect(header?.contains(control)).toBe(false);
}
for (const control of [pinToggle, popoutToggle]) {
expect(footer.contains(control)).toBe(false);
expect(header?.contains(control)).toBe(true);
}
expectTerminalDisplayModeControlsBeforeClose();
// No empty .terminal-actions shell renders in the tablet header.
expect(header?.querySelector(".terminal-actions")).toBeNull();
expect(screen.queryByTestId("terminal-actions")).toBeNull();
// The close button, the desktop tab strip (not the mobile dropdown),
// and the workspace picker remain in the header.