FN-7456: fix initial iOS terminal keyboard spacing

Fix iOS mobile terminal measurement so keyboard-open sessions size correctly on first render.

- Seed focused iOS terminal viewport baselines from guarded screen dimensions when the layout height is already keyboard-shrunk.
- Apply the same initial keyboard-open overlap handling to TerminalModal and the shared mobile keyboard hook.
- Add regression coverage for 10px and 12px mobile terminal sizing before repair events, plus a patch changeset.

Files changed:
 .changeset/fn-7456-ios-mobile-terminal-spacing.md  |   7 ++
 .../dashboard/app/components/TerminalModal.tsx     |  31 +++++-
 .../__tests__/SessionTerminal.mobile.test.tsx      |  42 ++++++++
 .../components/__tests__/TerminalModal.test.tsx    | 116 +++++++++++++++++++++
 packages/dashboard/app/hooks/useMobileKeyboard.ts  |  31 +++++-
 5 files changed, 225 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7456

Fusion-Task-Lineage: 8f11fc95-51ca-49b0-b9cd-b3e14501ab92

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 18:28:06 -07:00
parent 1430a42dd8
commit 7a4b0bf412
5 changed files with 225 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix iOS mobile terminal spacing when opening terminals with the keyboard already visible.
category: fix
dev: Seeds iOS keyboard-open viewport baselines for TerminalModal and SessionTerminal before xterm fit/resize.

View File

@@ -363,6 +363,26 @@ function isKeyboardFocusableElement(el: Element | null): boolean {
* - Fallback: initial viewport height - vv.height - vv.offsetTop
* Works on iOS Safari where window.innerHeight shrinks with the keyboard.
*/
function getScreenViewportBaselineCandidate(viewportWidth: number, viewportHeight: number): number | null {
if (typeof window === "undefined" || !window.screen) return null;
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;
if (!Number.isFinite(screenWidth) || !Number.isFinite(screenHeight) || screenWidth <= 0 || screenHeight <= 0) {
return null;
}
const portraitLike = viewportHeight >= viewportWidth;
const candidate = portraitLike
? Math.max(screenWidth, screenHeight)
: Math.min(screenWidth, screenHeight);
const gap = candidate - viewportHeight;
const minMeaningfulGap = portraitLike
? Math.max(220, candidate * 0.25)
: Math.max(80, candidate * 0.25);
return gap >= minMeaningfulGap ? candidate : null;
}
function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport;
@@ -384,6 +404,9 @@ function getKeyboardOverlap(): number {
FNXC:Terminal 2026-06-30-11:42:
Touch-primary short landscape and folded closed postures can be <=480px tall. A keyboard-closed width/posture sample must replace an unfolded baseline even at that height, while focused keyboard-open samples remain excluded so xterm does not clear overlap before the first correct folded fit.
FNXC:Terminal 2026-07-02-18:12:
iOS Safari can deliver the very first terminal sample with the helper textarea focused, the soft keyboard already open, and both `innerHeight` and `documentElement.clientHeight` shrunk to the visual viewport. Seed that initial focused sample from the device screen only when the missing height is large enough to be a keyboard, so 10px/12px terminals publish --keyboard-overlap/--vv-height/--vv-width before any close/open, orientation, reconnect, or font reset side effect can repair spaced ASCII cells.
*/
if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth)) {
setInitialViewportBaseline(viewportHeight, viewportWidth);
@@ -392,7 +415,13 @@ function getKeyboardOverlap(): number {
// On iOS Safari, window.innerHeight shrinks to match visualViewport.
// Detect keyboard by checking if visual viewport is shorter than initial
// height by more than 80px (with a 30px noise filter).
const initialHeight = getInitialViewportHeight(viewportWidth, viewportHeight);
const screenBaselineCandidate = isKeyboardFocusableElement(document.activeElement)
? getScreenViewportBaselineCandidate(viewportWidth, viewportHeight)
: null;
const initialHeight = Math.max(
getInitialViewportHeight(viewportWidth, screenBaselineCandidate ?? viewportHeight),
screenBaselineCandidate ?? 0,
);
const gap = initialHeight - vv.offsetTop - vv.height;
// Minimum 30px gap required to filter noise (address bar, toolbar changes).
// Threshold of 80px: only consider keyboard present when gap exceeds this.

View File

@@ -558,6 +558,48 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
input.remove();
});
it("keeps initial iOS keyboard-open 12px metrics when layout height already shrank", async () => {
installMatchMedia(true);
const originalScreen = window.screen;
installVisualViewport({ innerHeight: 390, vvHeight: 390, vvWidth: 390 });
Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
Object.defineProperty(document.documentElement, "clientHeight", {
value: 390,
configurable: true,
});
Object.defineProperty(window, "screen", {
configurable: true,
value: { width: 390, height: 844 },
});
window.localStorage.setItem(
TERMINAL_PREFERENCES_KEY,
JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 12 }),
);
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
try {
const { ws } = await renderMobile();
await waitFor(() => {
const root = screen.getByTestId("cli-terminal-mobile-bar").closest(".cli-session-terminal");
expect(root).toHaveClass("cli-session-terminal--mobile");
expect(root).toHaveAttribute("data-keyboard-open", "true");
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
expect(bar.style.bottom).toBe("454px");
});
expect(mockTerm.options.fontSize).toBe(12);
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled());
expect(ws.sent.some((raw) => JSON.parse(raw).type === "resize")).toBe(true);
} finally {
input.remove();
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
}
});
it("keeps Android keyboard-open 10px metrics on visualViewport mobile width", async () => {
installMatchMedia({ width: false, height: false });
installVisualViewport({ innerHeight: 700, vvHeight: 320, vvWidth: 390 });

View File

@@ -5030,6 +5030,122 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
}
});
it("fits initial iOS keyboard-open 12px terminal from the visible viewport before any repair event", async () => {
(window as any).ontouchstart = null;
window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, JSON.stringify({
...DEFAULT_TERMINAL_PREFERENCES,
fontSize: 12,
}));
const originalScreen = window.screen;
const { listeners, mockVV } = simulateIOSSafari(true, 390);
Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
Object.defineProperty(document.documentElement, "clientWidth", {
value: 390,
configurable: true,
});
Object.defineProperty(document.documentElement, "clientHeight", {
value: 390,
configurable: true,
});
Object.defineProperty(window, "screen", {
configurable: true,
value: { width: 390, height: 844 },
});
const helperTextarea = document.createElement("textarea");
document.body.appendChild(helperTextarea);
helperTextarea.focus();
const onDataListeners: Array<(data: string) => void> = [];
const resizeForInitialIOSKeyboard = vi.fn();
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: resizeForInitialIOSKeyboard,
onData: vi.fn((cb: (data: string) => void) => {
onDataListeners.push(cb);
return vi.fn();
}),
onScrollback: vi.fn((cb: (data: string) => void) => {
onDataListeners.push(cb);
return vi.fn();
}),
}));
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("12px"));
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal).toHaveClass("terminal-modal--mobile");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
});
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
act(() => {
for (const cb of onDataListeners) {
cb("❯ test\r\n❯ ls\r\nAGENTS.md README.md package.json  main\r\n");
}
for (const cb of listeners.resize) cb();
});
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("test")));
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md")));
await waitFor(() => expect(resizeForInitialIOSKeyboard).toHaveBeenCalledWith(80, 24));
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
expect(mockTerminalInstance.options.fontSize).toBe(12);
} finally {
helperTextarea.remove();
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
}
});
it("fits initial iOS keyboard-open 10px terminal when layout height already shrank", async () => {
(window as any).ontouchstart = null;
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
const originalScreen = window.screen;
const { mockVV } = simulateIOSSafari(true, 390);
Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
Object.defineProperty(document.documentElement, "clientHeight", {
value: 390,
configurable: true,
});
Object.defineProperty(window, "screen", {
configurable: true,
value: { width: 390, height: 844 },
});
const helperTextarea = document.createElement("textarea");
document.body.appendChild(helperTextarea);
helperTextarea.focus();
const resizeForInitialIOSSmallFont = vi.fn();
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: resizeForInitialIOSSmallFont,
}));
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
});
await waitFor(() => expect(resizeForInitialIOSSmallFont).toHaveBeenCalledWith(80, 24));
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
expect(mockTerminalInstance.options.fontSize).toBe(10);
} finally {
helperTextarea.remove();
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
}
});
it("fits Android keyboard-open 10px terminal to visual viewport width before any repair event", async () => {
(window as any).ontouchstart = null;
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");

View File

@@ -105,6 +105,28 @@ function isCollapsedRestoreViewportSample(baselineHeight: number): boolean {
return window.visualViewport.height >= baselineHeight - IOS_VIEWPORT_SHRINK_MIN_PX;
}
function getScreenViewportBaselineCandidate(viewportWidth: number, viewportHeight: number): number | null {
if (typeof window === "undefined" || !window.screen) {
return null;
}
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;
if (!Number.isFinite(screenWidth) || !Number.isFinite(screenHeight) || screenWidth <= 0 || screenHeight <= 0) {
return null;
}
const portraitLike = viewportHeight >= viewportWidth;
const candidate = portraitLike
? Math.max(screenWidth, screenHeight)
: Math.min(screenWidth, screenHeight);
const gap = candidate - viewportHeight;
const minMeaningfulGap = portraitLike
? Math.max(220, candidate * 0.25)
: Math.max(80, candidate * 0.25);
return gap >= minMeaningfulGap ? candidate : null;
}
function getKeyboardMetrics(
previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS,
{ bypassImpossibleSampleHold = false }: { bypassImpossibleSampleHold?: boolean } = {},
@@ -159,7 +181,14 @@ function getKeyboardMetrics(
// iOS fallback (window.innerHeight shrinks with keyboard). Same focused
// requirement as above — the dismissal animation otherwise leaves the
// gap > the open-threshold for the duration of the slide.
const baselineHeight = getBaselineViewportHeight();
/*
FNXC:Terminal 2026-07-02-18:18:
SessionTerminal uses this shared hook, so it has the same initial iOS keyboard-open failure mode as TerminalModal: the first focused sample can have `innerHeight`, `clientHeight`, and `visualViewport.height` already shrunk. Use a guarded screen-derived baseline only when the missing height is large enough to be a real keyboard, keeping the mobile input bar and xterm resize bridge correct at 10px/12px before later viewport events can repair spacing.
*/
const screenBaselineCandidate = focused
? getScreenViewportBaselineCandidate(getCurrentViewportWidth(), vv.height)
: null;
const baselineHeight = Math.max(getBaselineViewportHeight(), screenBaselineCandidate ?? 0);
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) {