From 4d933b19f62b1a6af0a09f4855f41f924f22167c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 2 Aug 2026 17:50:17 -0700 Subject: [PATCH] FN-8729: move terminal close control after new tab Keep the terminal close control last in the non-embedded terminal header across responsive layouts. - Render one shared close control after terminal tabs, workspace picker, and status. - Preserve the mobile corner ordering and prevent close-control shrinkage. - Add ordering coverage for desktop, mobile, workspace, and status states. - Add a patch changeset for the toolbar fix. Files changed: .changeset/fn-8729-terminal-toolbar.md | 7 +++ .../dashboard/app/components/TerminalModal.css | 15 +++--- .../dashboard/app/components/TerminalModal.tsx | 56 ++++++++-------------- .../components/__tests__/TerminalModal.test.tsx | 51 ++++++++++++++++++++ 4 files changed, 83 insertions(+), 46 deletions(-) Fusion-Task-Id: FN-8729 Fusion-Task-Lineage: ba9b039c-557d-4692-8203-bf764388d039 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8729-terminal-toolbar.md | 7 +++ .../app/components/TerminalModal.css | 15 ++--- .../app/components/TerminalModal.tsx | 56 +++++++------------ .../__tests__/TerminalModal.test.tsx | 51 +++++++++++++++++ 4 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 .changeset/fn-8729-terminal-toolbar.md diff --git a/.changeset/fn-8729-terminal-toolbar.md b/.changeset/fn-8729-terminal-toolbar.md new file mode 100644 index 0000000000..8b6a3aac87 --- /dev/null +++ b/.changeset/fn-8729-terminal-toolbar.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep the terminal close control after New terminal at every screen size. +category: fix +dev: Unifies the non-embedded TerminalModal close render site and adds responsive ordering coverage. diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index d92751596e..ff81dd2618 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -700,6 +700,7 @@ The worktree listbox must escape the mobile terminal header's clipped overflow. .terminal-close { display: flex; + flex: 0 0 auto; align-items: center; justify-content: center; width: 44px; @@ -1607,15 +1608,11 @@ Android folded Chrome can keep a wide layout viewport while visualViewport is th } /* -FNXC:TerminalHeader 2026-07-04-20:45: -FN-7565: pin the mobile close (X) button to the top-right corner of the header. -Without an explicit order, the close button (a direct .terminal-header child) -defaults to order:0 and sorts BEFORE .terminal-mobile-tabs (order:1) / -.terminal-workspace-picker (order:2), landing at the far left instead of the -corner. Giving it the highest order plus margin-inline-start:auto renders it -last and flush against the right edge, matching where users expect an -app-sheet close control. Covers the folded-Android visualViewport path (this -selector applies even when the max-width media query below does not). +FNXC:TerminalModalControls 2026-08-03-00:21: +The shared non-embedded close control is rendered after the new-terminal tab action. +On mobile its explicit order stays above the tab dropdown and workspace picker so the +same final control is also flush right in the wrapped phone header, including the +folded-Android visualViewport path where the media query below may not apply. */ .modal.terminal-modal.terminal-modal--mobile .terminal-close--corner { order: 3; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 29a1ef372e..15a5986ba1 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -2480,6 +2480,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG onClick={measuring ? undefined : () => void createTab()} title={t("terminal.newTerminal", "New terminal")} aria-label={t("terminal.newTerminal", "New terminal")} + data-testid="terminal-new-tab" > + @@ -2699,52 +2700,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG )} - {/* - 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. + {/* Status indicator */} + {(!isMobileTerminal || embedded) && ( +
+ + {getStatusIndicator()} +
+ )} - FNXC:TerminalHeader 2026-07-04-20:45: - FN-7565: on mobile, being a direct child of `.terminal-header` (not - nested in `.terminal-actions`) is necessary but not sufficient to land - in the top-right corner — flex items without an explicit `order` fall - back to `order: 0`, which sorts BEFORE `.terminal-mobile-tabs` - (`order: 1`) and `.terminal-workspace-picker` (`order: 2`), pushing the - close button to the far LEFT of the header instead of the corner users - expect for an app-sheet close control. The `terminal-close--corner` - class (CSS: highest `order` + `margin-inline-start: auto`) fixes this - so the X renders last in flex order and hugs the right edge regardless - of how wide the tab dropdown / workspace picker grow. Tablet keeps the - desktop `.terminal-tabs` (not the mobile dropdown) ahead of title/close - in normal DOM order, so its plain close button needs no order override. + {/* + 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. + 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. */} - {isMobileTerminal && !embedded ? ( + {!embedded && ( - ) : ( - <> - {/* Status indicator */} -
- - {getStatusIndicator()} -
- - {!embedded ? ( - - ) : null} - )} diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index d9a9a6b87f..ce91be46f7 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -47,6 +47,22 @@ function defineMetric(element: Element, property: "clientWidth" | "scrollWidth", Object.defineProperty(element, property, { configurable: true, value }); } +function expectTerminalCloseAfterNewTerminal(newTerminalTestId: string): void { + const header = document.querySelector(".terminal-header"); + expect(header).not.toBeNull(); + + const closeButtons = screen.getAllByTestId("terminal-close-btn"); + expect(closeButtons).toHaveLength(1); + const closeButton = closeButtons[0]; + const newTerminalButton = screen.getByTestId(newTerminalTestId); + + expect(header).toContainElement(closeButton); + expect(newTerminalButton.compareDocumentPosition(closeButton) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(Array.from(header!.querySelectorAll("button")).at(-1)).toBe(closeButton); + expect(header!.querySelector(".terminal-actions")).toBeNull(); +} + // Mock hooks and API vi.mock("../../hooks/useTerminal", () => ({ useTerminal: vi.fn(), @@ -2026,6 +2042,7 @@ describe("TerminalModal", () => { expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2"); fireEvent.click(screen.getByTestId("terminal-mobile-new-tab")); expect(mockCreateTab).toHaveBeenCalledWith(); + expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab"); fireEvent.click(screen.getByTestId("terminal-mobile-close-tab")); expect(mockCloseTab).toHaveBeenCalledWith("tab-1"); @@ -4674,12 +4691,42 @@ describe("TerminalModal — mobile layout contract", () => { expect(footer.contains(closeBtn)).toBe(false); const mobileTabs = screen.getByTestId("terminal-mobile-tabs"); expect(header?.contains(mobileTabs)).toBe(true); + expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab"); }); } finally { Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); } }); + it("keeps the populated-workspace mobile close control after the new-terminal action", async () => { + const previousInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-8729", label: "FN-8729", title: "Terminal toolbar ordering", worktree: "/repo/.worktrees/FN-8729", kind: "task" }, + ], + loading: false, + error: null, + }); + + try { + render(); + + await waitFor(() => { + expect(screen.getByTestId("terminal-workspace-picker")).toBeInTheDocument(); + expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab"); + }); + + fireEvent.click(screen.getByTestId("terminal-mobile-new-tab")); + fireEvent.click(screen.getByTestId("terminal-close-btn")); + expect(manyTabsSessionState.createTab).toHaveBeenCalledWith(); + expect(mockOnClose).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); + } + }); + 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 @@ -4740,6 +4787,7 @@ describe("TerminalModal — mobile layout contract", () => { if (workspacePicker) { expect(header?.contains(workspacePicker)).toBe(true); } + expectTerminalCloseAfterNewTerminal("terminal-new-tab"); }); } finally { Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); @@ -4793,6 +4841,7 @@ describe("TerminalModal — mobile layout contract", () => { const closeBtn = screen.getByTestId("terminal-close-btn"); expect(closeBtn.parentElement).toBe(header); expect(closeBtn.className).toContain("terminal-close--corner"); + expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab"); // Reconnect control lives in the footer, not the header, so it cannot // crowd the corner-pinned close button. expect(screen.getByTestId("terminal-reconnect-btn").closest(".terminal-header")).toBeNull(); @@ -4820,6 +4869,7 @@ describe("TerminalModal — mobile layout contract", () => { const closeBtn = screen.getByTestId("terminal-close-btn"); expect(closeBtn.parentElement).toBe(header); expect(closeBtn.className).toContain("terminal-close--corner"); + expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab"); // Restart control + exit code live in the footer, not the header. expect(screen.getByTestId("terminal-restart-btn").closest(".terminal-header")).toBeNull(); }); @@ -4844,6 +4894,7 @@ describe("TerminalModal — mobile layout contract", () => { expect(header?.contains(closeBtn)).toBe(true); expect(screen.queryByTestId("terminal-actions")).toBeNull(); expect(closeBtn.className).not.toContain("terminal-close--corner"); + expectTerminalCloseAfterNewTerminal("terminal-new-tab"); }); } finally { Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });