fix(web): keep the chat terminal input line above the mobile soft keyboard (NS-434)

On mobile the on-screen keyboard overlays the layout viewport instead of
resizing it (iOS Safari always; Android Chrome under its default
interactive-widget=resizes-visual). The dashboard shell is a fixed h-dvh
column, so the xterm host's bounding box never changed when the keyboard
opened: fit() computed identical (cols, rows), no RESIZE reached the PTY,
and the Ink input line — drawn at the bottom of the grid — stayed hidden
under the keyboard.

Fix, in three parts:

1. Keyboard-inset handling (new web/src/lib/keyboard-inset.ts).
   computeKeyboardInset() measures the layout-viewport region obscured by
   the keyboard via window.visualViewport
   (innerHeight - vv.height - vv.offsetTop, with an 80px floor so
   collapsing URL-bar chrome doesn't thrash the grid). ChatPage applies
   it as bottom padding on the terminal wrapper, which shrinks the host →
   the existing ResizeObserver/fit path recomputes rows and sends RESIZE →
   Ink redraws the input line above the keyboard. Listens on both vv
   resize and scroll (offsetTop changes arrive as scroll events on iOS).

2. interactive-widget=resizes-content in the viewport meta. Android
   Chrome 108+ then resizes the layout viewport natively and the JS inset
   computes ~0 (harmless no-op); iOS ignores the directive and takes the
   JS path.

3. Scroll pinning. iOS auto-scrolls the page to reveal xterm's hidden
   textarea on focus, which drags the fixed shell offscreen. While a
   keyboard inset is active we pin window/scrollingElement scroll back to
   0 and term.scrollToBottom() so the freshly-resized input line stays in
   view.

Unit tests cover the inset math (thresholds, offsetTop, rotation races,
fractional geometry, non-finite guards). Grid-level behavior needs a real
device pass — DevTools emulation doesn't model keyboard insets.
This commit is contained in:
Shannon Sands
2026-07-30 13:59:22 +10:00
committed by Teknium
parent c7727540f3
commit 67710548ec
4 changed files with 215 additions and 6 deletions

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>Hermes Agent - Dashboard</title>
</head>

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
computeKeyboardInset,
KEYBOARD_INSET_MIN_PX,
shouldPinScroll,
} from "./keyboard-inset";
describe("computeKeyboardInset", () => {
it("returns 0 when visualViewport is unavailable", () => {
expect(computeKeyboardInset(null, 800)).toBe(0);
expect(computeKeyboardInset(undefined, 800)).toBe(0);
});
it("returns 0 when no keyboard is showing (vv fills layout)", () => {
expect(computeKeyboardInset({ height: 800, offsetTop: 0 }, 800)).toBe(0);
});
it("measures the obscured region below the visual viewport", () => {
// 800px layout, keyboard eats 320px: vv.height = 480.
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 800)).toBe(320);
});
it("accounts for visual-viewport offsetTop (iOS keyboard scroll)", () => {
// iOS nudged the visual viewport down 40px; keyboard covers the rest.
expect(computeKeyboardInset({ height: 480, offsetTop: 40 }, 800)).toBe(
280,
);
});
it("ignores small deltas from collapsing browser chrome", () => {
// URL bar show/hide produces deltas well under a real keyboard height.
const delta = KEYBOARD_INSET_MIN_PX - 1;
expect(
computeKeyboardInset({ height: 800 - delta, offsetTop: 0 }, 800),
).toBe(0);
});
it("accepts insets at exactly the threshold", () => {
expect(
computeKeyboardInset(
{ height: 800 - KEYBOARD_INSET_MIN_PX, offsetTop: 0 },
800,
),
).toBe(KEYBOARD_INSET_MIN_PX);
});
it("never goes negative when vv is larger than layout height", () => {
// Rotation / zoom races can transiently report vv.height > innerHeight.
expect(computeKeyboardInset({ height: 900, offsetTop: 0 }, 800)).toBe(0);
});
it("returns 0 for degenerate layout heights", () => {
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 0)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, -1)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, NaN)).toBe(0);
});
it("returns 0 for non-finite viewport values", () => {
expect(computeKeyboardInset({ height: NaN, offsetTop: 0 }, 800)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: NaN }, 800)).toBe(0);
});
it("rounds fractional geometry to whole pixels", () => {
// iOS reports fractional vv heights under pinch zoom.
expect(
computeKeyboardInset({ height: 479.5, offsetTop: 0.25 }, 800),
).toBe(320);
});
});
describe("shouldPinScroll", () => {
it("pins while a keyboard inset is active", () => {
expect(shouldPinScroll(320)).toBe(true);
});
it("does not pin without a keyboard", () => {
expect(shouldPinScroll(0)).toBe(false);
});
});

View File

@@ -0,0 +1,71 @@
/**
* Soft-keyboard inset math for the dashboard chat terminal (NS-434).
*
* Problem: when the on-screen keyboard opens on mobile, neither iOS Safari
* nor Android Chrome (default `interactive-widget=resizes-visual`) shrinks
* the *layout* viewport — the keyboard just overlays it. Our app shell is
* `h-dvh`, so the terminal host's bounding box doesn't change, `fit()`
* computes the same (cols, rows), and the PTY never re-lays-out. The Ink
* input line — drawn at the bottom of the grid — ends up hidden underneath
* the keyboard.
*
* The only reliable signal is `window.visualViewport`: its `height` shrinks
* to the visible region above the keyboard, and `offsetTop` reflects any
* visual-viewport scroll (iOS nudges the page when an input focuses). The
* keyboard inset is the part of the layout viewport below the visual one:
*
* inset = layoutHeight - vv.height - vv.offsetTop
*
* ChatPage applies this as bottom padding on the terminal wrapper, which
* shrinks the xterm host → ResizeObserver refit → `term.onResize` sends
* `RESIZE` to the PTY → Ink redraws the input line above the keyboard.
*
* We also set `interactive-widget=resizes-content` in the viewport meta so
* Android Chrome 108+ resizes the layout viewport natively; there the inset
* computes ≈ 0 and this path is a harmless no-op. iOS ignores the directive
* and takes the JS path.
*/
/**
* Insets smaller than this are treated as 0. Collapsing browser chrome
* (URL bar show/hide) produces small transient height deltas that would
* otherwise thrash the terminal grid; real soft keyboards are ≥ ~150px.
*/
export const KEYBOARD_INSET_MIN_PX = 80;
export interface ViewportGeometry {
/** `visualViewport.height` — visible height above the keyboard. */
height: number;
/** `visualViewport.offsetTop` — visual viewport's offset into layout. */
offsetTop: number;
}
/**
* Height (px) of the layout viewport currently obscured by the soft
* keyboard, or 0 when no keyboard is showing / the signal is unusable.
*/
export function computeKeyboardInset(
viewport: ViewportGeometry | null | undefined,
layoutHeightPx: number,
): number {
if (!viewport || !Number.isFinite(layoutHeightPx) || layoutHeightPx <= 0) {
return 0;
}
const { height, offsetTop } = viewport;
if (!Number.isFinite(height) || !Number.isFinite(offsetTop)) return 0;
const inset = Math.round(layoutHeightPx - height - offsetTop);
return inset >= KEYBOARD_INSET_MIN_PX ? inset : 0;
}
/**
* Whether the page scroll should be pinned back to the top.
*
* The dashboard shell is a fixed `h-dvh` column and must never scroll, but
* iOS Safari auto-scrolls the *page* when a focused input would sit under
* the keyboard (xterm's hidden textarea triggers this). Pin whenever a
* keyboard is present so the terminal chrome stays put; the terminal's own
* scrollback handles content visibility.
*/
export function shouldPinScroll(nextInsetPx: number): boolean {
return nextInsetPx > 0;
}

View File

@@ -60,6 +60,7 @@ import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
} from "@/lib/pty-mobile-input";
import { computeKeyboardInset, shouldPinScroll } from "@/lib/keyboard-inset";
import {
resolvePtyKeyboardShortcut,
sendPtyShortcutSequence,
@@ -173,6 +174,7 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number {
export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termWrapRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -503,6 +505,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const host = hostRef.current;
if (!host) return;
// Captured once so the effect cleanup doesn't re-read the ref (which
// may point elsewhere by then — react-hooks/exhaustive-deps).
const termWrap = termWrapRef.current;
const token = window.__HERMES_SESSION_TOKEN__;
const gated = !!window.__HERMES_AUTH_REQUIRED__;
@@ -956,8 +961,61 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const ro = new ResizeObserver(() => scheduleHostSync());
ro.observe(host);
// NS-434: soft-keyboard inset. On mobile the keyboard overlays the
// layout viewport instead of resizing it (iOS always; Android Chrome
// under the default `resizes-visual` — we ask for `resizes-content`
// in the viewport meta, but can't rely on it). The host's bounding
// box therefore doesn't change when the keyboard opens, fit() computes
// identical (cols, rows), and Ink keeps drawing the input line under
// the keyboard. Measure the obscured region via visualViewport and
// apply it as bottom padding on the terminal wrapper — that *does*
// shrink the host, so the ResizeObserver refit path kicks in and the
// PTY re-lays-out above the keyboard.
let appliedKeyboardInset = 0;
const syncKeyboardInset = () => {
const wrap = termWrap;
if (!wrap) return;
const vv = window.visualViewport;
const inset = computeKeyboardInset(
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) return;
appliedKeyboardInset = inset;
if (inset > 0) {
wrap.style.paddingBottom = `${inset}px`;
// Keep the freshly-resized input line in view.
try {
term.scrollToBottom();
} catch {
/* ignore */
}
} else {
wrap.style.paddingBottom = "";
}
// 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();
scheduleSyncTerminalMetrics();
};
window.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("resize", onViewportChange);
// offsetTop changes (keyboard-driven visual scroll on iOS) arrive as
// vv `scroll` events, not `resize`.
window.visualViewport?.addEventListener("scroll", onViewportChange);
scheduleHostSync();
requestAnimationFrame(() => scheduleHostSync());
@@ -1400,10 +1458,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
host.removeEventListener("drop", handleBrowserDrop, true);
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
"resize",
scheduleSyncTerminalMetrics,
);
window.visualViewport?.removeEventListener("resize", onViewportChange);
window.visualViewport?.removeEventListener("scroll", onViewportChange);
const wrap = termWrap;
if (wrap) wrap.style.paddingBottom = "";
ro.disconnect();
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1);
@@ -1673,6 +1731,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
<div
ref={termWrapRef}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
"p-2 sm:p-3",