diff --git a/web/src/lib/pty-resume-loading.test.ts b/web/src/lib/pty-resume-loading.test.ts index 09a2c16a193..72f6e5f779b 100644 --- a/web/src/lib/pty-resume-loading.test.ts +++ b/web/src/lib/pty-resume-loading.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { PtyResumeSanitizer } from "./pty-resume-sanitizer"; import { PTY_RESUME_LOADING_MAX_MS, shouldFinishResumeHydrationOnChunk, @@ -17,6 +18,40 @@ describe("shouldFinishResumeHydrationOnChunk", () => { }); }); +describe("resume hydration gate over the real sanitizer", () => { + // Regression: the gate must key off the payload actually written to xterm, + // not the raw frame. The sanitizer collapses an erase-only / all-newline / + // partial-CSI resume frame to "", so a nonempty raw first frame would + // otherwise clear the wait notice while the terminal is still blank. + const ESC = String.fromCharCode(27); + const CRLF = String.fromCharCode(13, 10); + const VISIBLE = `Hello world${CRLF}`; + + const controlOnlyFirstFrames: Array<[string, string]> = [ + ["erase-only", `${ESC}[2K`], + ["all-newline", `${CRLF}${CRLF}`], + ["partial-CSI", `${ESC}[`], + ]; + + it.each(controlOnlyFirstFrames)( + "keeps hydrating on a %s first frame, then finishes on visible replay", + (_label, firstFrame) => { + const sanitizer = new PtyResumeSanitizer(); + + // Raw first frame is nonempty, but nothing is written to xterm... + expect(firstFrame.length).toBeGreaterThan(0); + const firstRendered = sanitizer.next(firstFrame); + expect(firstRendered).toBe(""); + expect(shouldFinishResumeHydrationOnChunk(firstRendered)).toBe(false); + + // ...so the notice only clears once real replay output arrives. + const secondRendered = sanitizer.next(VISIBLE); + expect(secondRendered.length).toBeGreaterThan(0); + expect(shouldFinishResumeHydrationOnChunk(secondRendered)).toBe(true); + }, + ); +}); + describe("shouldShowResumeLoadingOverlay", () => { it("shows while a resume target is connecting or open and still hydrating", () => { expect( diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index fd18e6133af..529f1d69f93 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1058,8 +1058,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { : decoder.decode(new Uint8Array(ev.data as ArrayBuffer), { stream: true, }); - term.write(resumeParam ? sanitizer.next(text) : text); - noteResumePtyChunk(text); + // Gate hydration on the payload actually written to xterm. The + // sanitizer can turn a nonempty erase-only / all-newline / partial-CSI + // resume frame into "" (pty-resume-sanitizer.ts); keying off raw `text` + // would hide the wait notice while the terminal is still blank. + const rendered = resumeParam ? sanitizer.next(text) : text; + term.write(rendered); + noteResumePtyChunk(rendered); }; ws.onclose = (ev) => {