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