diff --git a/web/src/lib/pty-resume-loading.test.ts b/web/src/lib/pty-resume-loading.test.ts new file mode 100644 index 00000000000..09a2c16a193 --- /dev/null +++ b/web/src/lib/pty-resume-loading.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + PTY_RESUME_LOADING_MAX_MS, + shouldFinishResumeHydrationOnChunk, + shouldShowResumeLoadingOverlay, +} from "./pty-resume-loading"; + +describe("shouldFinishResumeHydrationOnChunk", () => { + it("finishes on the first non-empty chunk", () => { + expect(shouldFinishResumeHydrationOnChunk("")).toBe(false); + expect(shouldFinishResumeHydrationOnChunk("hello")).toBe(true); + }); + + it("keeps a positive hard-cap timeout for wedged resumes", () => { + expect(PTY_RESUME_LOADING_MAX_MS).toBeGreaterThan(0); + }); +}); + +describe("shouldShowResumeLoadingOverlay", () => { + it("shows while a resume target is connecting or open and still hydrating", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "connecting", + hydrating: true, + }), + ).toBe(true); + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "open", + hydrating: true, + }), + ).toBe(true); + }); + + it("hides when there is no resume target", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: false, + ptyState: "connecting", + hydrating: true, + }), + ).toBe(false); + }); + + it("hides once hydration finishes", () => { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState: "open", + hydrating: false, + }), + ).toBe(false); + }); + + it("defers to reconnect / closed / ended overlays", () => { + for (const ptyState of ["reconnecting", "closed", "ended"] as const) { + expect( + shouldShowResumeLoadingOverlay({ + hasResumeTarget: true, + ptyState, + hydrating: true, + }), + ).toBe(false); + } + }); +}); diff --git a/web/src/lib/pty-resume-loading.ts b/web/src/lib/pty-resume-loading.ts new file mode 100644 index 00000000000..9a150664d50 --- /dev/null +++ b/web/src/lib/pty-resume-loading.ts @@ -0,0 +1,47 @@ +import type { PtyConnectionState } from "@/lib/pty-reconnect"; + +/** + * Hard cap so a wedged resume (never gets PTY payload) cannot leave the + * wait notice up forever. + */ +export const PTY_RESUME_LOADING_MAX_MS = 30000; + +export const PTY_RESUME_LOADING_MESSAGE = + "Please wait while the conversation loads…"; + +export interface ResumeLoadingOverlayInput { + hasResumeTarget: boolean; + ptyState: PtyConnectionState; + hydrating: boolean; +} + +/** + * Show a wait notice only while a resumed chat is still blank. Once the + * first real PTY payload arrives the terminal has something to show, so + * the notice hides and history can stream in underneath. + * + * Reconnect / ended / closed states keep their own overlays and must not + * stack this one on top. + */ +export function shouldShowResumeLoadingOverlay({ + hasResumeTarget, + ptyState, + hydrating, +}: ResumeLoadingOverlayInput): boolean { + if (!hasResumeTarget || !hydrating) { + return false; + } + if ( + ptyState === "reconnecting" || + ptyState === "closed" || + ptyState === "ended" + ) { + return false; + } + return ptyState === "connecting" || ptyState === "open"; +} + +/** First non-empty PTY chunk means the blank window is over. */ +export function shouldFinishResumeHydrationOnChunk(chunkText: string): boolean { + return chunkText.length > 0; +}