From e72813393c8d40de601cdb450e5cf209ac75759c Mon Sep 17 00:00:00 2001 From: edrethardo <47067768+edrethardo@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:08:03 +0200 Subject: [PATCH] fix(web): jump the iOS viewport to the chat composer when the keyboard opens Pinning the dashboard to (0, 0) on keyboard inset fights Safari's visual viewport and leaves the Ink input line off-screen. Scroll by the delta between the xterm host bottom and visualViewport bottom instead, and re-sync after focus while the keyboard animates. Fixes #110414 --- web/src/lib/keyboard-inset.test.ts | 27 +++++++++++++++++ web/src/lib/keyboard-inset.ts | 17 +++++++++++ web/src/pages/ChatPage.tsx | 47 ++++++++++++++++++------------ 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/web/src/lib/keyboard-inset.test.ts b/web/src/lib/keyboard-inset.test.ts index 42eabe8e2d..8219ff4dfc 100644 --- a/web/src/lib/keyboard-inset.test.ts +++ b/web/src/lib/keyboard-inset.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeKeyboardInset, + keyboardRevealScrollDelta, KEYBOARD_INSET_MIN_PX, shouldPinScroll, } from "./keyboard-inset"; @@ -77,3 +78,29 @@ describe("shouldPinScroll", () => { expect(shouldPinScroll(0)).toBe(false); }); }); + +describe("keyboardRevealScrollDelta", () => { + it("scrolls the page so the composer sits on the visual-viewport bottom", () => { + expect( + keyboardRevealScrollDelta(800, { height: 480, offsetTop: 0 }), + ).toBe(320); + }); + + it("accounts for iOS visual-viewport offsetTop", () => { + expect( + keyboardRevealScrollDelta(800, { height: 480, offsetTop: 40 }), + ).toBe(280); + }); + + it("does not move when the composer is already on the visible bottom", () => { + expect( + keyboardRevealScrollDelta(480, { height: 480, offsetTop: 0 }), + ).toBe(0); + }); + + it("returns 0 for non-finite geometry", () => { + expect( + keyboardRevealScrollDelta(NaN, { height: 480, offsetTop: 0 }), + ).toBe(0); + }); +}); diff --git a/web/src/lib/keyboard-inset.ts b/web/src/lib/keyboard-inset.ts index 118c908957..7592ef5f43 100644 --- a/web/src/lib/keyboard-inset.ts +++ b/web/src/lib/keyboard-inset.ts @@ -69,3 +69,20 @@ export function computeKeyboardInset( export function shouldPinScroll(nextInsetPx: number): boolean { return nextInsetPx > 0; } + +/** Pixels to `window.scrollBy` so the composer (xterm host bottom) sits on + * the visible bottom of the visual viewport — just above the keyboard. + * Pinning the page to (0, 0) fights iOS and can leave the input line off-screen. */ +export function keyboardRevealScrollDelta( + composerBottomPx: number, + visual: ViewportGeometry, +): number { + if ( + !Number.isFinite(composerBottomPx) || + !Number.isFinite(visual.height) || + !Number.isFinite(visual.offsetTop) + ) { + return 0; + } + return Math.round(composerBottomPx - (visual.offsetTop + visual.height)); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 67140c0589..721bd88811 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -64,7 +64,7 @@ import { normalizePtyMobileInput, shouldTreatInputAsMobileReplacement, } from "@/lib/pty-mobile-input"; -import { computeKeyboardInset, shouldPinScroll } from "@/lib/keyboard-inset"; +import { computeKeyboardInset, keyboardRevealScrollDelta } from "@/lib/keyboard-inset"; import { resolvePtyKeyboardShortcut, sendPtyShortcutSequence, @@ -1038,31 +1038,31 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { vv ? { height: vv.height, offsetTop: vv.offsetTop } : null, window.innerHeight, ); - if (shouldPinScroll(inset)) { - // iOS auto-scrolls the page to reveal xterm's hidden textarea when - // the keyboard opens. The shell is a fixed h-dvh column that must - // never scroll — pin it back so the terminal chrome stays put. - window.scrollTo(0, 0); - const scroller = document.scrollingElement; - if (scroller && scroller.scrollTop !== 0) scroller.scrollTop = 0; + if (inset !== appliedKeyboardInset) { + appliedKeyboardInset = inset; + wrap.style.paddingBottom = inset > 0 ? `${inset}px` : ""; + scheduleHostSync(); } - if (inset === appliedKeyboardInset) return; - appliedKeyboardInset = inset; - if (inset > 0) { - wrap.style.paddingBottom = `${inset}px`; - // Keep the freshly-resized input line in view. + const revealComposer = () => { + if (inset <= 0 || !vv) return; try { term.scrollToBottom(); } catch { /* ignore */ } - } else { - wrap.style.paddingBottom = ""; + const delta = keyboardRevealScrollDelta(host.getBoundingClientRect().bottom, { + height: vv.height, + offsetTop: vv.offsetTop, + }); + if (delta) window.scrollBy(0, delta); + }; + if (inset > 0) { + revealComposer(); + requestAnimationFrame(() => { + revealComposer(); + requestAnimationFrame(revealComposer); + }); } - // The wrapper padding change resizes the host; the ResizeObserver - // will refit, but schedule one explicitly in case the observer - // coalesces with an in-flight frame. - scheduleHostSync(); }; const onViewportChange = () => { syncKeyboardInset(); @@ -1080,6 +1080,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { appliedKeyboardInset = 0; if (termWrap) termWrap.style.paddingBottom = ""; }; + let keyboardRevealTimer = 0; + const onTerminalFocus = () => { + onViewportChange(); + window.clearTimeout(keyboardRevealTimer); + keyboardRevealTimer = window.setTimeout(onViewportChange, 350); + }; + term.textarea?.addEventListener("focus", onTerminalFocus); scheduleHostSync(); requestAnimationFrame(() => scheduleHostSync()); @@ -1543,6 +1550,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { host.removeEventListener("drop", handleBrowserDrop, true); if (metricsDebounce) clearTimeout(metricsDebounce); window.removeEventListener("resize", scheduleSyncTerminalMetrics); + window.clearTimeout(keyboardRevealTimer); + term.textarea?.removeEventListener("focus", onTerminalFocus); keyboardInsetSyncRef.current = null; keyboardInsetResetRef.current = null; const wrap = termWrap;