From 213a9fe2b88b4592d5aed01ce8072af5dce3a57e Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 14:21:16 -0500 Subject: [PATCH] fix(web): preserve Done status for threads left mid-turn - Seed the visited marker when an active turn starts - Advance it on completion so open threads clear Done normally - Add coverage for in-flight, completed, and pre-turn states --- .../web/src/components/ChatView.logic.test.ts | 36 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 22 ++++++++++++ apps/web/src/components/ChatView.tsx | 23 +++++------- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..5aee78e21e13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -26,6 +26,7 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, scheduleEnvironmentReconnectWarning, @@ -39,6 +40,41 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("resolveActiveThreadVisitedAt", () => { + const thread = { + createdAt: "2026-03-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running" as const, + assistantMessageId: null, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }, + }; + + it("records an in-flight first turn before it completes", () => { + expect(resolveActiveThreadVisitedAt(thread)).toBe("2026-03-29T00:01:01.000Z"); + }); + + it("advances the visit marker to completion while the thread remains open", () => { + expect( + resolveActiveThreadVisitedAt({ + ...thread, + latestTurn: { + ...thread.latestTurn, + state: "completed", + completedAt: "2026-03-29T00:02:00.000Z", + }, + }), + ).toBe("2026-03-29T00:02:00.000Z"); + }); + + it("uses thread creation as the baseline before a turn is projected", () => { + expect(resolveActiveThreadVisitedAt({ ...thread, latestTurn: null })).toBe(thread.createdAt); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..7df98b5e896b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -370,6 +370,28 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } +/** + * The newest server-backed point the open chat has actually presented. + * Recording an in-flight turn gives its first completion a read baseline: + * leaving before completion makes the later completedAt unread, while staying + * open advances the marker to completedAt and clears Done as usual. + */ +export function resolveActiveThreadVisitedAt( + thread: Pick, +): string | null { + const turn = thread.latestTurn; + const candidates = turn + ? [turn.completedAt, turn.startedAt, turn.requestedAt] + : [thread.createdAt]; + + for (const candidate of candidates) { + if (candidate !== null && Number.isFinite(Date.parse(candidate))) { + return candidate; + } + } + return null; +} + // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7f1c7b733ffc..9cf93090ba9f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,6 +339,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveActiveThreadVisitedAt, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -1736,24 +1737,18 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - // Reading a finished thread clears the sidebar's Done badge. The visit is - // stamped at the turn's completion time — not now/updatedAt — so it clears - // exactly the completion the user is looking at: a wake or completion that - // lands later still gets its signal (markThreadVisited never moves the - // timestamp backwards). + const activeThreadVisitedAt = serverThread ? resolveActiveThreadVisitedAt(serverThread) : null; + // Keep the visit marker at the newest server-backed state shown in the open + // chat. While the first turn runs this seeds a baseline before completion, + // so navigating away lets completedAt surface as unread Done. If the chat + // stays open, the completion advances the marker and clears Done normally. useEffect(() => { - const completedAt = serverThread?.latestTurn?.completedAt; - if (!serverThread?.id || !completedAt) return; + if (!serverThread?.id || !activeThreadVisitedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - completedAt, + activeThreadVisitedAt, ); - }, [ - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.latestTurn?.completedAt, - ]); + }, [activeThreadVisitedAt, markThreadVisited, serverThread?.environmentId, serverThread?.id]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({