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;