FN-7281: fix folded terminal viewport baselines

Keep mobile terminals sized against the settled folded viewport before keyboard overlap is applied.

- Re-baseline terminal keyboard metrics when folded devices settle to a new closed-posture width.
- Avoid replacing the baseline from focused keyboard-open samples that would erase real keyboard overlap.
- Cover TerminalModal and SessionTerminal mobile keyboard spacing behavior with regression tests.
- Document the folded-phone terminal spacing fix and add a patch changeset.

Files changed:
 .changeset/fn-7281-mobile-terminal-spacing.md      |  7 ++
 .../mobile-terminal-folded-viewport-baseline.md    | 56 +++++++++++++++
 .../dashboard/app/components/TerminalModal.tsx     | 57 ++++++++++++++--
 .../__tests__/SessionTerminal.mobile.test.tsx      | 39 +++++++++++
 .../components/__tests__/TerminalModal.test.tsx    | 79 ++++++++++++++++++++++
 .../app/hooks/__tests__/useMobileKeyboard.test.ts  | 53 ++++++++++++++-
 packages/dashboard/app/hooks/useMobileKeyboard.ts  | 31 +++++++--
 7 files changed, 309 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7281
Fusion-Task-Lineage: 90ab7398-c583-4312-8cf6-0ef7d6328c8d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 09:09:29 -07:00
parent 082dd55967
commit ac87b1e8e4
7 changed files with 309 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix mobile terminal spacing after folded viewport changes.
category: fix
dev: Re-baselines terminal keyboard viewport metrics when foldable devices settle to a narrower posture.

View File

@@ -0,0 +1,56 @@
---
title: "Mobile terminal folded viewport baseline"
date: 2026-06-30
category: ui-bugs
module: packages/dashboard/app/components/TerminalModal.tsx
problem_type: ui_bug
component: frontend_terminal
applies_when: "A foldable or narrow mobile viewport settles to a closed posture before or during soft-keyboard entry for an xterm surface."
symptoms:
- "Terminal commands render with excessive inter-character spacing or premature wrapping in folded mobile posture"
- "Keyboard-open terminal height/overlap is computed from an earlier unfolded viewport"
- "Embedded CLI session terminal input bar is lifted too far after a fold/narrow transition"
root_cause: stale_unfolded_visualviewport_baseline
resolution_type: code_fix
severity: medium
related_components:
- packages/dashboard/app/components/TerminalModal.tsx
- packages/dashboard/app/hooks/useMobileKeyboard.ts
- packages/dashboard/app/components/SessionTerminal.tsx
- FN-7281
tags:
- terminal
- xterm
- mobile-keyboard
- visualviewport
- foldable
- ios
---
# Mobile terminal folded viewport baseline
## Problem
The terminal has two mobile xterm surfaces: the PTY `TerminalModal` and the embedded `SessionTerminal`. Both depend on visualViewport-derived keyboard metrics before fitting xterm rows/cols. On iOS-style browsers, `innerHeight` can shrink with the keyboard, so the code keeps a baseline viewport height captured while the keyboard is closed.
A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. Both stale geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
## Solution
Treat a keyboard-closed width/posture change as a new baseline, not as keyboard overlap.
- Track the viewport width alongside the baseline height.
- Preserve the max-observed baseline behavior for same-posture recovery from keyboard-open first samples.
- When width changes and the viewport height is a settled folded value, replace the baseline before computing iOS fallback overlap.
- Gate that replacement to keyboard-closed samples; if a keyboard-focusable element is active, keep the previous baseline so a focused keyboard-open folded sample cannot zero out the overlap.
- Keep xterm's measured font family symbols-free; the fix is viewport measurement, not a letter-spacing or cell-width workaround.
## Regression coverage
Guard the invariant at three seams:
- `TerminalModal.test.tsx` simulates unfolded closed → folded closed → folded keyboard-open and asserts `--keyboard-overlap` / `--vv-height` use the folded baseline. It also covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses the folded baseline instead of the stale unfolded height.
- `useMobileKeyboard.test.ts` covers the shared hook so future consumers inherit the posture-aware baseline behavior.
Existing terminal tests continue to cover symbols-free xterm font stacks, glyph fallback for Nerd Font/powerline output, duplicate visualViewport resize coalescing, keyboard close clearing, undefined visualViewport, tab-switch scrollback replay, and desktop/tablet terminal modes.

View File

@@ -182,6 +182,8 @@ const TERMINAL_KEY_LABELS = {
pxUnit: "px", pxUnit: "px",
} as const; } as const;
const SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX = 480;
export function ctrlChar(key: string): string { export function ctrlChar(key: string): string {
if (!key) { if (!key) {
return ""; return "";
@@ -310,6 +312,16 @@ function isMacPlatform(): boolean {
return /mac/i.test(platform) || /mac/i.test(userAgent); return /mac/i.test(platform) || /mac/i.test(userAgent);
} }
function isKeyboardFocusableElement(el: Element | null): boolean {
if (!el) return false;
if (el instanceof HTMLTextAreaElement) return true;
if (el instanceof HTMLInputElement) {
const nonTextTypes = new Set(["checkbox", "radio", "button", "submit", "reset", "file", "range", "color", "hidden"]);
return !nonTextTypes.has(el.type);
}
return el instanceof HTMLElement && el.isContentEditable;
}
/** /**
* Compute how many CSS pixels the virtual keyboard covers from the bottom * Compute how many CSS pixels the virtual keyboard covers from the bottom
* of the layout viewport. Returns 0 on desktop or when visualViewport is * of the layout viewport. Returns 0 on desktop or when visualViewport is
@@ -324,35 +336,70 @@ function isMacPlatform(): boolean {
function getKeyboardOverlap(): number { function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0; if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport; const vv = window.visualViewport;
const viewportWidth = vv.width > 0 ? vv.width : window.innerWidth;
const viewportHeight = Math.max(window.innerHeight, vv.height);
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height); const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
if (chromeOverlap > 0) return chromeOverlap; if (chromeOverlap > 0) return chromeOverlap;
/*
FNXC:Terminal 2026-06-30-08:48:
Folded phones can report an unfolded iOS fallback baseline first, then settle to a narrower closed-posture viewport before the keyboard opens. If that closed sample does not replace the old baseline, the terminal overestimates --keyboard-overlap, fits against a too-short/wrong-width box, and commands like `pnpm build` wrap into spaced glyphs. Re-baseline on settled width/posture changes before computing the iOS gap; do not touch xterm's symbols-free font stack.
FNXC:Terminal 2026-06-30-09:38:
A later folded-posture width sample can arrive while xterm's helper textarea is focused and the soft keyboard is already open. Never re-baseline from that focused keyboard-open sample, because it makes the keyboard height look like the closed viewport and clears --keyboard-overlap/--vv-height before the final fit.
*/
if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth, viewportHeight)) {
setInitialViewportBaseline(viewportHeight, viewportWidth);
}
// On iOS Safari, window.innerHeight shrinks to match visualViewport. // On iOS Safari, window.innerHeight shrinks to match visualViewport.
// Detect keyboard by checking if visual viewport is shorter than initial // Detect keyboard by checking if visual viewport is shorter than initial
// height by more than 80px (with a 30px noise filter). // height by more than 80px (with a 30px noise filter).
const initialHeight = getInitialViewportHeight(); const initialHeight = getInitialViewportHeight(viewportWidth, viewportHeight);
const gap = initialHeight - vv.offsetTop - vv.height; const gap = initialHeight - vv.offsetTop - vv.height;
// Minimum 30px gap required to filter noise (address bar, toolbar changes). // Minimum 30px gap required to filter noise (address bar, toolbar changes).
// Threshold of 80px: only consider keyboard present when gap exceeds this. // Threshold of 80px: only consider keyboard present when gap exceeds this.
return gap >= 30 && gap > 80 ? gap : 0; if (gap >= 30 && gap > 80) {
return gap;
}
setInitialViewportBaseline(viewportHeight, viewportWidth);
return 0;
} }
/** Cached initial viewport height before any keyboard opened. */ /** Cached initial viewport height before any keyboard opened. */
let _initialViewportHeight: number | null = null; let _initialViewportHeight: number | null = null;
let _initialViewportWidth: number | null = null;
function setInitialViewportBaseline(height: number, width: number): void {
_initialViewportHeight = height;
_initialViewportWidth = width;
}
function hasSettledViewportPostureChange(width: number, height: number): boolean {
return (
_initialViewportHeight !== null &&
_initialViewportWidth !== null &&
Math.abs(width - _initialViewportWidth) >= 1 &&
height >= SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX
);
}
/** /**
* Returns the viewport height at page load (before any keyboard opens). * Returns the viewport height at page load (before any keyboard opens).
* Cached after first read. * Cached after first read.
*/ */
function getInitialViewportHeight(): number { function getInitialViewportHeight(width: number, height: number): number {
if (_initialViewportHeight === null) { if (_initialViewportHeight === null) {
_initialViewportHeight = window.innerHeight; setInitialViewportBaseline(height, width);
} }
return _initialViewportHeight; return _initialViewportHeight ?? height;
} }
/** Reset the cached initial viewport height. Exported for tests only. */ /** Reset the cached initial viewport height. Exported for tests only. */
export function _resetInitialViewportHeight(): void { export function _resetInitialViewportHeight(): void {
_initialViewportHeight = null; _initialViewportHeight = null;
_initialViewportWidth = null;
} }
interface TerminalModalProps { interface TerminalModalProps {

View File

@@ -469,6 +469,45 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
input.remove(); input.remove();
}); });
it("re-baselines folded iOS viewport before lifting the mobile input bar", async () => {
const { listeners, mockVV } = installVisualViewport({ innerHeight: 844, vvHeight: 844 });
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
// Fold/narrow the device while the keyboard is still closed; this must
// replace the prior unfolded baseline before a focused input opens.
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 667, writable: true, configurable: true });
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 300, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
expect(bar.style.bottom).toBe("367px");
});
input.remove();
});
it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => { it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => {
installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 }); installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 });
const input = document.createElement("textarea"); const input = document.createElement("textarea");

View File

@@ -4774,6 +4774,85 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
}); });
}); });
it("re-baselines iOS keyboard overlap after folded posture settles before input", async () => {
const { listeners, mockVV } = simulateIOSSafari(false, 844);
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
});
// Device is folded/narrow while the keyboard is still closed; this settled
// viewport must replace the previous unfolded baseline before input opens.
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 667, writable: true, configurable: true });
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
});
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 300, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
});
});
it("does not re-baseline folded viewport width changes from focused keyboard-open samples", async () => {
const { listeners, mockVV } = simulateIOSSafari(false, 844);
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
});
const helperTextarea = document.createElement("textarea");
document.body.appendChild(helperTextarea);
helperTextarea.focus();
try {
// Fold/orientation can deliver the first narrow width sample while the
// soft keyboard is already open. With iOS-style innerHeight==vv.height,
// re-baselining from this focused sample would make gap=0 and clear the
// terminal keyboard CSS vars that drive the final xterm fit.
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("324px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("520px");
});
} finally {
helperTextarea.remove();
}
});
it("does not set --vv-height when no keyboard overlap", async () => { it("does not set --vv-height when no keyboard overlap", async () => {
const { listeners } = simulateChromeAndroid(0); const { listeners } = simulateChromeAndroid(0);

View File

@@ -51,10 +51,12 @@ describe("useMobileKeyboard", () => {
innerHeight, innerHeight,
vvHeight, vvHeight,
vvOffsetTop = 0, vvOffsetTop = 0,
width = 375,
}: { }: {
innerHeight: number; innerHeight: number;
vvHeight: number; vvHeight: number;
vvOffsetTop?: number; vvOffsetTop?: number;
width?: number;
}) { }) {
(window as any).ontouchstart = null; (window as any).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { Object.defineProperty(navigator, "maxTouchPoints", {
@@ -62,7 +64,7 @@ describe("useMobileKeyboard", () => {
configurable: true, configurable: true,
}); });
Object.defineProperty(window, "innerWidth", { Object.defineProperty(window, "innerWidth", {
value: 375, value: width,
writable: true, writable: true,
configurable: true, configurable: true,
}); });
@@ -78,7 +80,7 @@ describe("useMobileKeyboard", () => {
}; };
const mockVV = { const mockVV = {
width: 375, width,
height: vvHeight, height: vvHeight,
offsetTop: vvOffsetTop, offsetTop: vvOffsetTop,
offsetLeft: 0, offsetLeft: 0,
@@ -233,6 +235,53 @@ describe("useMobileKeyboard", () => {
input.remove(); input.remove();
}); });
it("re-baselines iOS fallback after a folded viewport settles while the keyboard is closed", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
width: 700,
});
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 667, writable: true, configurable: true });
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 300, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(367);
expect(result.current.viewportHeight).toBe(300);
});
input.remove();
});
it("reports moderate iOS fallback overlap below 80px", async () => { it("reports moderate iOS fallback overlap below 80px", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({ const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844, innerHeight: 844,

View File

@@ -4,6 +4,7 @@ const IOS_FALLBACK_MIN_GAP_PX = 30;
const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16; const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16;
const IOS_VIEWPORT_SHRINK_MIN_PX = 16; const IOS_VIEWPORT_SHRINK_MIN_PX = 16;
const IMPOSSIBLE_VIEWPORT_EPSILON_PX = 2; const IMPOSSIBLE_VIEWPORT_EPSILON_PX = 2;
const SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX = 480;
/** Whether the current device is likely mobile (touch-primary, small viewport). */ /** Whether the current device is likely mobile (touch-primary, small viewport). */
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
@@ -19,23 +20,41 @@ function isMobileDevice(): boolean {
* Kept as max-observed value to recover if first sample was keyboard-open. * Kept as max-observed value to recover if first sample was keyboard-open.
*/ */
let _baselineViewportHeight: number | null = null; let _baselineViewportHeight: number | null = null;
let _baselineViewportWidth: number | null = null;
function getCurrentViewportWidth(): number {
return window.visualViewport?.width && window.visualViewport.width > 0
? window.visualViewport.width
: window.innerWidth;
}
function setBaselineViewport(height: number, width: number): void {
_baselineViewportHeight = height;
_baselineViewportWidth = width;
}
function getBaselineViewportHeight(): number { function getBaselineViewportHeight(): number {
if (_baselineViewportHeight === null) { if (_baselineViewportHeight === null) {
_baselineViewportHeight = window.visualViewport?.height ?? window.innerHeight; setBaselineViewport(window.visualViewport?.height ?? window.innerHeight, getCurrentViewportWidth());
} }
return _baselineViewportHeight; return _baselineViewportHeight ?? (window.visualViewport?.height ?? window.innerHeight);
} }
function updateBaselineViewportHeight(nextHeight: number): void { function updateBaselineViewportHeight(nextHeight: number, nextWidth: number): void {
const current = getBaselineViewportHeight(); const current = getBaselineViewportHeight();
if (nextHeight > current) { const widthChanged = _baselineViewportWidth !== null && Math.abs(nextWidth - _baselineViewportWidth) >= 1;
_baselineViewportHeight = nextHeight; /*
FNXC:Terminal 2026-06-30-08:51:
SessionTerminal shares the folded-phone root cause: a keyboard-closed width/posture settle can be shorter than the previous unfolded baseline, so max-only baselines overestimate later iOS keyboard overlap and lift/refit the embedded terminal against stale geometry. Replace the baseline on settled folded posture changes while preserving the max-observed recovery for same-posture keyboard-open first samples.
*/
if (nextHeight > current || (widthChanged && nextHeight >= SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX)) {
setBaselineViewport(nextHeight, nextWidth);
} }
} }
function resetBaselineViewportHeight(): void { function resetBaselineViewportHeight(): void {
_baselineViewportHeight = null; _baselineViewportHeight = null;
_baselineViewportWidth = null;
} }
function isKeyboardFocusableElement(el: Element | null): boolean { function isKeyboardFocusableElement(el: Element | null): boolean {
@@ -101,7 +120,7 @@ function getKeyboardMetrics(
// Only refresh baseline while keyboard is likely closed. // Only refresh baseline while keyboard is likely closed.
if (!focused) { if (!focused) {
updateBaselineViewportHeight(vv.height); updateBaselineViewportHeight(vv.height, getCurrentViewportWidth());
} }
// FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard // FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard