Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
resolveActiveThreadVisitedAt,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
scheduleEnvironmentReconnectWarning,
Expand All @@ -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());

Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Thread, "createdAt" | "latestTurn">,
): 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
Expand Down
23 changes: 9 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ import {
deriveLockedProvider,
readFileAsDataUrl,
reconcileMountedTerminalThreadIds,
resolveActiveThreadVisitedAt,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
revokeBlobPreviewUrl,
Expand Down Expand Up @@ -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({
Expand Down
Loading