diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 081cccc0c45..fd18e6133af 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -47,6 +47,12 @@ import { shouldBlockPtyInput, shouldReconnectPtyOnPageResume, } from "@/lib/pty-reconnect"; +import { + PTY_RESUME_LOADING_MAX_MS, + PTY_RESUME_LOADING_MESSAGE, + shouldFinishResumeHydrationOnChunk, + shouldShowResumeLoadingOverlay, +} from "@/lib/pty-resume-loading"; import { MOBILE_REPLACEMENT_WINDOW_MS, normalizePtyMobileInput, @@ -204,6 +210,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const [ptyState, setPtyState] = useState("connecting"); const ptyStateRef = useRef("connecting"); + // True until the first real PTY payload arrives for a resumed session. + // Covers the blank terminal + blinking-cursor window so users don't think + // chat is broken; clears as soon as there is something to show. + const [resumeHydrating, setResumeHydrating] = useState(false); const [lastCloseCode, setLastCloseCode] = useState(null); // NS-504: when the agent process exits cleanly (the user typed `/exit`, or // started a new session that ended the current PTY child), the PTY socket @@ -892,12 +902,42 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let onDataDisposable: { dispose(): void } | null = null; let onResizeDisposable: { dispose(): void } | null = null; let eraseSuppressionTimer: ReturnType | null = null; + let resumeMaxTimer: ReturnType | null = null; const clearEraseSuppressionTimer = () => { if (eraseSuppressionTimer) { clearTimeout(eraseSuppressionTimer); eraseSuppressionTimer = null; } }; + const clearResumeLoadingTimers = () => { + if (resumeMaxTimer) { + clearTimeout(resumeMaxTimer); + resumeMaxTimer = null; + } + }; + const finishResumeHydration = () => { + clearResumeLoadingTimers(); + if (!unmounting) { + setResumeHydrating(false); + } + }; + const noteResumePtyChunk = (chunkText: string) => { + if (!resumeParam || unmounting) { + return; + } + if (shouldFinishResumeHydrationOnChunk(chunkText)) { + finishResumeHydration(); + } + }; + if (resumeParam) { + setResumeHydrating(true); + resumeMaxTimer = setTimeout( + finishResumeHydration, + PTY_RESUME_LOADING_MAX_MS, + ); + } else { + setResumeHydrating(false); + } const forceFresh = forceFreshPtyRef.current; forceFreshPtyRef.current = false; // A connect attempt is now in flight — set synchronously (before the async @@ -1019,6 +1059,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { stream: true, }); term.write(resumeParam ? sanitizer.next(text) : text); + noteResumePtyChunk(text); }; ws.onclose = (ev) => { @@ -1180,6 +1221,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { imageUploadDisposed = true; syncMetricsRef.current = null; clearEraseSuppressionTimer(); + clearResumeLoadingTimers(); + setResumeHydrating(false); onDataDisposable?.dispose(); onResizeDisposable?.dispose(); mobileInputCleanup?.(); @@ -1354,6 +1397,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const visibleBanner = banner ?? reconnectBanner; const showReconnectOverlay = ptyState === "reconnecting" || (ptyState === "closed" && !banner); + const showResumeLoadingOverlay = shouldShowResumeLoadingOverlay({ + hasResumeTarget: Boolean(resumeParam), + ptyState, + hydrating: resumeHydrating, + }); const mobileModelToolsPortal = isActive && narrow && @@ -1488,6 +1536,19 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { )} + {showResumeLoadingOverlay && ( +
+
+ {PTY_RESUME_LOADING_MESSAGE} +
+
+ )} + {/* NS-504: the agent process exited (e.g. `/exit` or a new session). Offer an in-place restart so the user never has to refresh the whole page to get a working chat back. */}