Files
hermes-agent/web/src/lib/pty-scroll.test.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

81 lines
3.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isViewportPinnedToBottom,
parseResumeControlMessage,
shouldFollowPtyOutput,
} from "./pty-scroll";
describe("isViewportPinnedToBottom", () => {
it("is pinned when the viewport sits on the bottom row", () => {
// xterm reports viewportY === baseY when the newest output is on screen.
expect(isViewportPinnedToBottom({ viewportY: 120, baseY: 120 })).toBe(true);
});
it("releases the pin once the user scrolls up into the backlog", () => {
// Scrolling up drops viewportY below baseY — the user is reading history,
// so the resume replay must not yank them back down (#59591 follow-up).
expect(isViewportPinnedToBottom({ viewportY: 40, baseY: 120 })).toBe(false);
});
it("stays pinned if viewportY overshoots baseY while rows are trimmed", () => {
// scrollback eviction can momentarily push viewportY past baseY.
expect(isViewportPinnedToBottom({ viewportY: 121, baseY: 120 })).toBe(true);
});
it("treats a fresh 0x0 buffer as pinned", () => {
expect(isViewportPinnedToBottom({ viewportY: 0, baseY: 0 })).toBe(true);
});
});
describe("shouldFollowPtyOutput", () => {
it("follows replayed output while resuming and stuck to the bottom", () => {
// The core #59591 fix: scroll to bottom as each replay chunk commits.
expect(shouldFollowPtyOutput("sess-123", true)).toBe(true);
});
it("stops following once the user has scrolled up mid-replay", () => {
expect(shouldFollowPtyOutput("sess-123", false)).toBe(false);
});
it("does not follow a fresh (non-resume) session", () => {
// Fresh chats start empty; forcing scroll would fight normal cursor output.
expect(shouldFollowPtyOutput(null, true)).toBe(false);
});
it("does not follow a fresh session even when stickToBottom is true", () => {
expect(shouldFollowPtyOutput(null, false)).toBe(false);
});
it("treats an empty resume param as non-resume", () => {
expect(shouldFollowPtyOutput("", true)).toBe(false);
});
});
describe("parseResumeControlMessage", () => {
it("extracts the id from a resume control frame", () => {
// #93518: the implicit active-session fallback has no `?resume=` on the
// URL, so the server names the session it resolved in a control frame.
expect(
parseResumeControlMessage('{"type":"resume","id":"sess-123"}'),
).toBe("sess-123");
});
it("ignores plain ANSI banner text sent as a text frame", () => {
// pty_ws also sends "Chat unavailable: ..." error banners as text
// frames; those must keep rendering into the terminal, not get
// swallowed as a (mis-parsed) control message.
expect(
parseResumeControlMessage("\r\n\x1b[31mChat unavailable: x\x1b[0m\r\n"),
).toBeNull();
});
it("ignores JSON of the wrong shape", () => {
expect(parseResumeControlMessage('{"type":"other","id":"x"}')).toBeNull();
expect(parseResumeControlMessage('{"type":"resume"}')).toBeNull();
expect(parseResumeControlMessage('{"type":"resume","id":""}')).toBeNull();
expect(parseResumeControlMessage("null")).toBeNull();
expect(parseResumeControlMessage('"resume"')).toBeNull();
});
});