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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user