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:
7
.changeset/fn-7565-mobile-terminal-close-corner.md
Normal file
7
.changeset/fn-7565-mobile-terminal-close-corner.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user