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:
@@ -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>
|
||||
|
||||
79
web/src/lib/keyboard-inset.test.ts
Normal file
79
web/src/lib/keyboard-inset.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
71
web/src/lib/keyboard-inset.ts
Normal file
71
web/src/lib/keyboard-inset.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user