From 709f961e1c9261efd43673a0e802c6cd0bfca8f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 14:54:28 -0800 Subject: [PATCH] Gate latest-turn completion UI on settled turn state - Add `isLatestTurnSettled` to avoid treating an active running turn as complete - Use the guard in `ChatView` for visited timestamps and completion summary/divider rendering - Add unit tests covering running, ready, and incomplete-timestamp cases --- apps/web/src/components/ChatView.tsx | 8 ++++++ apps/web/src/session-logic.test.ts | 40 ++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 13 +++++++++ 3 files changed, 61 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2e1404f058e32e5b3ef51840dd282f7d102ca355..08a3a2155be7c78797a4072115d7974b6d488c2f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -44,6 +44,7 @@ import { type PendingApproval, deriveWorkLogEntries, hasToolActivityForTurn, + isLatestTurnSettled, formatElapsed, formatTimestamp, } from "../session-logic"; @@ -382,10 +383,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; + const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!activeThread?.id) return; + if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; @@ -400,6 +403,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread?.id, activeThread?.lastVisitedAt, activeLatestTurn?.completedAt, + latestTurnSettled, dispatch, ]); @@ -483,6 +487,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); const completionSummary = useMemo(() => { + if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; @@ -493,8 +498,10 @@ export default function ChatView({ threadId }: ChatViewProps) { activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, latestTurnHasToolActivity, + latestTurnSettled, ]); const completionDividerBeforeEntryId = useMemo(() => { + if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; @@ -521,6 +528,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, completionSummary, + latestTurnSettled, timelineEntries, ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index cafaea5f91dcbbb512ea11d069499678d9c77b9f..dc74adc67f7dbd7b98ac51dd81753b6c8ff6f2bd 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -5,6 +5,7 @@ import { derivePendingApprovals, deriveWorkLogEntries, hasToolActivityForTurn, + isLatestTurnSettled, } from "./session-logic"; function makeActivity(overrides: { @@ -151,3 +152,42 @@ describe("hasToolActivityForTurn", () => { expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false); }); }); + +describe("isLatestTurnSettled", () => { + const latestTurn = { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-27T21:10:00.000Z", + completedAt: "2026-02-27T21:10:06.000Z", + } as const; + + it("returns false while the same turn is still active in a running session", () => { + expect( + isLatestTurnSettled(latestTurn, { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }), + ).toBe(false); + }); + + it("returns true once the session is no longer running that turn", () => { + expect( + isLatestTurnSettled(latestTurn, { + orchestrationStatus: "ready", + activeTurnId: undefined, + }), + ).toBe(true); + }); + + it("returns false when turn timestamps are incomplete", () => { + expect( + isLatestTurnSettled( + { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: null, + completedAt: "2026-02-27T21:10:06.000Z", + }, + null, + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4a4f6025145ddcfd3cd0059bf2b05c485dd30f62..27d75ef6d0674ffcab8c06bcdddb19ab054d54b4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + type OrchestrationLatestTurn, type OrchestrationThreadActivity, type ProviderKind, type TurnId, @@ -75,6 +76,18 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str return formatDuration(endedAt - startedAt); } +export function isLatestTurnSettled( + latestTurn: Pick | null, + session: Pick | null, +): boolean { + if (!latestTurn?.startedAt) return false; + if (!latestTurn.completedAt) return false; + if (!session) return true; + return !( + session.orchestrationStatus === "running" && session.activeTurnId === latestTurn.turnId + ); +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { -- 2.54.0