Files
hermes-agent/web/src/lib/pty-scroll.ts
T
chelsealong 9c013eaaf8 fix(dashboard): follow scroll on implicit active-session resume (#93518)
pty_ws already fell back to the per-channel active-session file when a
/chat WS connects with no ?resume= param, replaying the whole session
into the PTY, but the frontend only pinned xterm's viewport to the
bottom when resumeParam came from the URL (#59591). The implicit path
had no way to learn a replay was happening, so the viewport stayed at
the top of the scrollback.

pty_ws now sends a one-off JSON control frame naming the session id it
resolved from the active-session file, before any PTY bytes; PTY
output itself always arrives as binary frames, so this is unambiguous
on the wire. ChatPage tracks an `effectiveResume` value seeded from
resumeParam and updated when this control frame arrives, and the
existing follow-scroll/sanitizer/hydration logic keys off it instead
of the URL param alone.

Fixes #93518.
2026-08-24 03:21:49 -07:00

79 lines
3.0 KiB
TypeScript

/**
* Dashboard chat resume-scroll helpers.
*
* When a chat session is resumed (`/chat?resume=<id>`) the PTY backend replays
* the entire scrollback over the WebSocket the instant it opens. xterm.js writes
* those bytes into its buffer but leaves the viewport wherever it was (e.g. the
* top of a fresh terminal), so the transcript looks truncated until something
* else forces a re-render. See #59591.
*
* The fix pins the viewport to the bottom *as each replayed chunk commits* (via
* xterm's `write` callback) instead of guessing with a fixed double-rAF delay,
* and releases that pin the moment the user scrolls up so their manual review of
* the backlog is never yanked back down.
*
* These two decisions are pulled out here as pure functions so they can be
* unit-tested without a live terminal; `ChatPage` wires them to the real xterm
* instance (see `term.onScroll` and `ws.onmessage`).
*/
/** The subset of xterm's active `IBuffer` these helpers need. */
export interface TerminalViewportPosition {
/** Row index of the top of the current viewport. */
viewportY: number;
/** Row index of the viewport top when scrolled fully to the bottom. */
baseY: number;
}
/**
* True when the viewport is scrolled to (or past) the bottom, i.e. the latest
* output is on screen. xterm reports `viewportY === baseY` at the bottom; the
* `>=` also covers the transient overshoot while scrollback rows are trimmed.
*/
export function isViewportPinnedToBottom(
buffer: TerminalViewportPosition,
): boolean {
return buffer.viewportY >= buffer.baseY;
}
/**
* Whether a freshly written PTY output chunk should scroll the terminal to the
* bottom afterwards. We only auto-follow while resuming a session (the replay
* case) and only while the user hasn't scrolled up to read the backlog. A fresh
* (non-resume) session returns `false` so normal cursor output is never fought.
*/
export function shouldFollowPtyOutput(
resumeParam: string | null,
stickToBottom: boolean,
): boolean {
return Boolean(resumeParam) && stickToBottom;
}
/**
* When `pty_ws` falls back to the per-channel active-session file (no
* `?resume=` on the URL), the server sends a one-off JSON text frame naming
* the session it resolved before any PTY replay bytes arrive, since the
* frontend has no other way to learn a replay is happening (#93518). PTY
* output itself always arrives as binary frames, so any text frame is a
* candidate; this returns the resume id on a match and `null` for anything
* else (including the plain ANSI error banners `pty_ws` still sends as text
* on failure, which must keep rendering into the terminal as before).
*/
export function parseResumeControlMessage(data: string): string | null {
try {
const parsed = JSON.parse(data);
if (
parsed &&
typeof parsed === "object" &&
parsed.type === "resume" &&
typeof parsed.id === "string" &&
parsed.id
) {
return parsed.id;
}
} catch {
/* not JSON — an ANSI banner or other plain-text frame */
}
return null;
}