FN-7565: pin mobile terminal close button to top-right corner

Corner-pins the mobile terminal header's close (X) button so it renders last in flex order and hugs the top-right edge, instead of falling back to order:0 and landing at the far left of the header.

- Add .terminal-close--corner class (order:3 + margin-inline-start:auto) applied only on mobile, both in the base .terminal-modal--mobile selector and the max-width:768px media query (covers folded-Android visualViewport path)
- Apply terminal-close--corner alongside terminal-close on the mobile close button in TerminalModal.tsx; desktop/floating/pinned-below placement inside .terminal-actions is unchanged
- Add regression tests asserting the corner-pin class, its persistence across connection/exit states, the unaffected desktop .terminal-actions placement, and the CSS flex-order relationship vs .terminal-mobile-tabs/.terminal-workspace-picker
- Add changeset (patch) documenting the fix for release notes

Files changed:
 .changeset/fn-7565-mobile-terminal-close-corner.md |   7 ++
 .../dashboard/app/components/TerminalModal.css     |  30 +++++
 .../dashboard/app/components/TerminalModal.tsx     |  14 ++-
 .../components/__tests__/TerminalModal.test.tsx    | 126 +++++++++++++++++++++
 4 files changed, 176 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7565
Fusion-Task-Lineage: f5d82128-6c14-4281-9cfc-fd87dbc89d5d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 22:43:12 -07:00
parent 34fd3c7912
commit fb45157401
4 changed files with 176 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Pin the mobile terminal close (X) button to the top-right corner so it is easy to find and tap.
category: fix
dev: On the ≤768px terminal, the `terminal-close` button now carries a `terminal-close--corner` class (order:3 + margin-inline-start:auto) so it renders last in flex order and hugs the right edge next to the tab dropdown, instead of falling back to order:0 (far left). Desktop/floating/pinned-below placement inside `.terminal-actions` is unchanged.

View File

@@ -1419,6 +1419,22 @@ Android folded Chrome can keep a wide layout viewport while visualViewport is th
min-width: 0; min-width: 0;
} }
/*
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).
*/
.modal.terminal-modal.terminal-modal--mobile .terminal-close--corner {
order: 3;
margin-inline-start: auto;
}
/* /*
FNXC:Terminal 2026-07-02-12:30: FNXC:Terminal 2026-07-02-12:30:
The Android keyboard-open recurrence can start with a touch-primary visualViewport narrower than the layout viewport, so `terminal-modal--mobile` must apply the same keyboard-constrained height and current visual viewport width even when CSS media queries still see the wider layout viewport. This keeps xterm's initial 10px fit on contiguous monospace cells instead of a stale wide box that spaces filenames like `AGENTS.md` apart. The Android keyboard-open recurrence can start with a touch-primary visualViewport narrower than the layout viewport, so `terminal-modal--mobile` must apply the same keyboard-constrained height and current visual viewport width even when CSS media queries still see the wider layout viewport. This keeps xterm's initial 10px fit on contiguous monospace cells instead of a stale wide box that spaces filenames like `AGENTS.md` apart.
@@ -1485,6 +1501,20 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
border-left: none; border-left: none;
} }
/*
FNXC:TerminalHeader 2026-07-04-20:45:
FN-7565: same corner-pin rationale as the non-media `.terminal-modal--mobile`
rule above — the close button needs an explicit order higher than
.terminal-mobile-tabs (1) and .terminal-workspace-picker (2) plus
margin-inline-start:auto so it renders last in flex order and hugs the right
edge of the (non-wrapping) top header row, instead of falling back to its
default order:0 and landing on the left.
*/
.terminal-close--corner {
order: 3;
margin-inline-start: auto;
}
.terminal-workspace-picker-trigger { .terminal-workspace-picker-trigger {
width: clamp(92px, 28vw, 132px); width: clamp(92px, 28vw, 132px);
max-width: 132px; max-width: 132px;

View File

@@ -2528,10 +2528,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
the action controls move into the `.terminal-status-bar` footer below the action controls move into the `.terminal-status-bar` footer below
(FN-7560) so they don't crowd the dropdown/close. Both sites render the (FN-7560) so they don't crowd the dropdown/close. Both sites render the
SAME fragment, never a duplicated copy, so handlers cannot drift. SAME fragment, never a duplicated copy, so handlers cannot drift.
FNXC:TerminalHeader 2026-07-04-20:45:
FN-7565: on mobile, being a direct child of `.terminal-header` (not
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.
*/} */}
{isMobileTerminal ? ( {isMobileTerminal ? (
<button <button
className="terminal-close" className="terminal-close terminal-close--corner"
onClick={onClose} onClick={onClose}
data-testid="terminal-close-btn" data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")} title={t("terminal.closeTerminal", "Close terminal")}

View File

@@ -3393,6 +3393,132 @@ describe("TerminalModal — mobile layout contract", () => {
} }
}); });
it("pins the mobile close button to the top-right corner of the header, not buried in .terminal-actions (FN-7565)", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const header = document.querySelector(".terminal-header");
expect(header).toBeTruthy();
// Exactly one close button renders on mobile — no duplicate close target.
const closeButtons = screen.getAllByTestId("terminal-close-btn");
expect(closeButtons).toHaveLength(1);
const closeBtn = closeButtons[0];
// It is a direct child of .terminal-header, not nested inside a wrapping
// .terminal-actions cluster (which no longer renders on mobile at all).
expect(closeBtn.parentElement).toBe(header);
expect(header?.querySelector(".terminal-actions")).toBeNull();
// It carries the mobile corner-pin class so CSS order/margin can place
// it last in flex order, flush against the right edge next to the tab
// dropdown, instead of falling back to order:0 (far left).
expect(closeBtn.className).toContain("terminal-close--corner");
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("keeps the mobile corner-pin invariant across connection/exit states (FN-7565)", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
try {
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "disconnected" }),
);
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const header = document.querySelector(".terminal-header");
const closeBtn = screen.getByTestId("terminal-close-btn");
expect(closeBtn.parentElement).toBe(header);
expect(closeBtn.className).toContain("terminal-close--corner");
// 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();
});
let exitCallback: ((code: number) => void) | null = null;
const customOnExit = vi.fn((cb: (code: number) => void) => {
exitCallback = cb;
return vi.fn();
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "connected", onExit: customOnExit }),
);
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
act(() => {
exitCallback?.(1);
});
await waitFor(() => {
const header = document.querySelector(".terminal-header");
const closeBtn = screen.getByTestId("terminal-close-btn");
expect(closeBtn.parentElement).toBe(header);
expect(closeBtn.className).toContain("terminal-close--corner");
// Restart control + exit code live in the footer, not the header.
expect(screen.getByTestId("terminal-restart-btn").closest(".terminal-header")).toBeNull();
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("keeps the desktop close button in .terminal-actions with no mobile-only corner slot (FN-7565)", async () => {
const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const closeButtons = screen.getAllByTestId("terminal-close-btn");
expect(closeButtons).toHaveLength(1);
const closeBtn = closeButtons[0];
const actions = screen.getByTestId("terminal-actions");
// 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(closeBtn.className).not.toContain("terminal-close--corner");
});
} finally {
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
}
});
it("corner-pins the mobile close button after .terminal-mobile-tabs and .terminal-workspace-picker in flex order (FN-7565)", () => {
const nonMediaMobileRule =
terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--mobile \.terminal-close--corner\s*\{([^}]*)\}/)?.[1] ?? "";
const mediaMobileRule =
terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-close--corner\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileTabsOrderMedia = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-mobile-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
const workspacePickerOrderMedia = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-workspace-picker\s*\{([^}]*)\}/)?.[1] ?? "";
const baseHeaderRule = terminalModalCss.match(/\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? "";
expect(nonMediaMobileRule).toContain("order: 3;");
expect(nonMediaMobileRule).toContain("margin-inline-start: auto;");
expect(mediaMobileRule).toContain("order: 3;");
expect(mediaMobileRule).toContain("margin-inline-start: auto;");
expect(mobileTabsOrderMedia).toContain("order: 1;");
expect(workspacePickerOrderMedia).toContain("order: 2;");
// Base header stays a flex row so order-based corner-pinning applies.
expect(baseHeaderRule).toContain("display: flex;");
});
it("shows the reconnect control in the mobile footer when disconnected (FN-7560)", async () => { it("shows the reconnect control in the mobile footer when disconnected (FN-7560)", async () => {
const previousInnerWidth = window.innerWidth; const previousInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });