From 67710548ecdc7b9f5af19b5698f2e5c3a60f02a5 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 30 Jul 2026 13:59:22 +1000 Subject: [PATCH] fix(web): keep the chat terminal input line above the mobile soft keyboard (NS-434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/index.html | 2 +- web/src/lib/keyboard-inset.test.ts | 79 ++++++++++++++++++++++++++++++ web/src/lib/keyboard-inset.ts | 71 +++++++++++++++++++++++++++ web/src/pages/ChatPage.tsx | 69 ++++++++++++++++++++++++-- 4 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/keyboard-inset.test.ts create mode 100644 web/src/lib/keyboard-inset.ts diff --git a/web/index.html b/web/index.html index fe7cda519d..67f5ba2676 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ Hermes Agent - Dashboard diff --git a/web/src/lib/keyboard-inset.test.ts b/web/src/lib/keyboard-inset.test.ts new file mode 100644 index 0000000000..42eabe8e2d --- /dev/null +++ b/web/src/lib/keyboard-inset.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/keyboard-inset.ts b/web/src/lib/keyboard-inset.ts new file mode 100644 index 0000000000..118c908957 --- /dev/null +++ b/web/src/lib/keyboard-inset.ts @@ -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; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 6aeda855ed..3d741b716e 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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(null); + const termWrapRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(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 }) {