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
This commit is contained in:
edrethardo
2026-09-14 02:08:03 +02:00
committed by Teknium
parent b3feb88a95
commit e72813393c
3 changed files with 72 additions and 19 deletions

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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;