diff --git a/.changeset/fn-7456-ios-mobile-terminal-spacing.md b/.changeset/fn-7456-ios-mobile-terminal-spacing.md new file mode 100644 index 0000000000..e02e283399 --- /dev/null +++ b/.changeset/fn-7456-ios-mobile-terminal-spacing.md @@ -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. diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 501ff59d7b..6a0cef26db 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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. diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 2caed22ca8..aab44461e5 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -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 }); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index f8bb0d7f80..5cc9df833a 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -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(); + + 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(); + + 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"); diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts index b8b4d97882..5994381d68 100644 --- a/packages/dashboard/app/hooks/useMobileKeyboard.ts +++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts @@ -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) {