authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 14:54:28-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 15:21:31-08:00
log709f961e1c9261efd43673a0e802c6cd0bfca8f1
tree6185edd44fb6c58140d8d2d3d1c60a063ac8b6ea
parent165338357341fd96387161476a66521e6a887e13

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

3 files changed, 61 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.tsx+8
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 type PendingApproval,44 type PendingApproval,
45 deriveWorkLogEntries,45 deriveWorkLogEntries,
46 hasToolActivityForTurn,46 hasToolActivityForTurn,
47 isLatestTurnSettled,
47 formatElapsed,48 formatElapsed,
48 formatTimestamp,49 formatTimestamp,
49} from "../session-logic";50} from "../session-logic";
...@@ -382,10 +383,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -382,10 +383,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
382 const diffOpen = diffSearch.diff === "1";383 const diffOpen = diffSearch.diff === "1";
383 const activeThreadId = activeThread?.id ?? null;384 const activeThreadId = activeThread?.id ?? null;
384 const activeLatestTurn = activeThread?.latestTurn ?? null;385 const activeLatestTurn = activeThread?.latestTurn ?? null;
386 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
385 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);387 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
386388
387 useEffect(() => {389 useEffect(() => {
388 if (!activeThread?.id) return;390 if (!activeThread?.id) return;
391 if (!latestTurnSettled) return;
389 if (!activeLatestTurn?.completedAt) return;392 if (!activeLatestTurn?.completedAt) return;
390 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);393 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);
391 if (Number.isNaN(turnCompletedAt)) return;394 if (Number.isNaN(turnCompletedAt)) return;
...@@ -400,6 +403,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -400,6 +403,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
400 activeThread?.id,403 activeThread?.id,
401 activeThread?.lastVisitedAt,404 activeThread?.lastVisitedAt,
402 activeLatestTurn?.completedAt,405 activeLatestTurn?.completedAt,
406 latestTurnSettled,
403 dispatch,407 dispatch,
404 ]);408 ]);
405409
...@@ -483,6 +487,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -483,6 +487,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
483 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);487 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
484488
485 const completionSummary = useMemo(() => {489 const completionSummary = useMemo(() => {
490 if (!latestTurnSettled) return null;
486 if (!activeLatestTurn?.startedAt) return null;491 if (!activeLatestTurn?.startedAt) return null;
487 if (!activeLatestTurn.completedAt) return null;492 if (!activeLatestTurn.completedAt) return null;
488 if (!latestTurnHasToolActivity) return null;493 if (!latestTurnHasToolActivity) return null;
...@@ -493,8 +498,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -493,8 +498,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
493 activeLatestTurn?.completedAt,498 activeLatestTurn?.completedAt,
494 activeLatestTurn?.startedAt,499 activeLatestTurn?.startedAt,
495 latestTurnHasToolActivity,500 latestTurnHasToolActivity,
501 latestTurnSettled,
496 ]);502 ]);
497 const completionDividerBeforeEntryId = useMemo(() => {503 const completionDividerBeforeEntryId = useMemo(() => {
504 if (!latestTurnSettled) return null;
498 if (!activeLatestTurn?.startedAt) return null;505 if (!activeLatestTurn?.startedAt) return null;
499 if (!activeLatestTurn.completedAt) return null;506 if (!activeLatestTurn.completedAt) return null;
500 if (!completionSummary) return null;507 if (!completionSummary) return null;
...@@ -521,6 +528,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -521,6 +528,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
521 activeLatestTurn?.completedAt,528 activeLatestTurn?.completedAt,
522 activeLatestTurn?.startedAt,529 activeLatestTurn?.startedAt,
523 completionSummary,530 completionSummary,
531 latestTurnSettled,
524 timelineEntries,532 timelineEntries,
525 ]);533 ]);
526 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;534 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
apps/web/src/session-logic.test.ts+40
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 derivePendingApprovals,5 derivePendingApprovals,
6 deriveWorkLogEntries,6 deriveWorkLogEntries,
7 hasToolActivityForTurn,7 hasToolActivityForTurn,
8 isLatestTurnSettled,
8} from "./session-logic";9} from "./session-logic";
910
10function makeActivity(overrides: {11function makeActivity(overrides: {
...@@ -151,3 +152,42 @@ describe("hasToolActivityForTurn", () => {...@@ -151,3 +152,42 @@ describe("hasToolActivityForTurn", () => {
151 expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false);152 expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false);
152 });153 });
153});154});
155
156describe("isLatestTurnSettled", () => {
157 const latestTurn = {
158 turnId: TurnId.makeUnsafe("turn-1"),
159 startedAt: "2026-02-27T21:10:00.000Z",
160 completedAt: "2026-02-27T21:10:06.000Z",
161 } as const;
162
163 it("returns false while the same turn is still active in a running session", () => {
164 expect(
165 isLatestTurnSettled(latestTurn, {
166 orchestrationStatus: "running",
167 activeTurnId: TurnId.makeUnsafe("turn-1"),
168 }),
169 ).toBe(false);
170 });
171
172 it("returns true once the session is no longer running that turn", () => {
173 expect(
174 isLatestTurnSettled(latestTurn, {
175 orchestrationStatus: "ready",
176 activeTurnId: undefined,
177 }),
178 ).toBe(true);
179 });
180
181 it("returns false when turn timestamps are incomplete", () => {
182 expect(
183 isLatestTurnSettled(
184 {
185 turnId: TurnId.makeUnsafe("turn-1"),
186 startedAt: null,
187 completedAt: "2026-02-27T21:10:06.000Z",
188 },
189 null,
190 ),
191 ).toBe(false);
192 });
193});
apps/web/src/session-logic.ts+13
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 type OrchestrationLatestTurn,
3 type OrchestrationThreadActivity,4 type OrchestrationThreadActivity,
4 type ProviderKind,5 type ProviderKind,
5 type TurnId,6 type TurnId,
...@@ -75,6 +76,18 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str...@@ -75,6 +76,18 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
75 return formatDuration(endedAt - startedAt);76 return formatDuration(endedAt - startedAt);
76}77}
7778
79export function isLatestTurnSettled(
80 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt"> | null,
81 session: Pick<ThreadSession, "orchestrationStatus" | "activeTurnId"> | null,
82): boolean {
83 if (!latestTurn?.startedAt) return false;
84 if (!latestTurn.completedAt) return false;
85 if (!session) return true;
86 return !(
87 session.orchestrationStatus === "running" && session.activeTurnId === latestTurn.turnId
88 );
89}
90
78export function derivePendingApprovals(91export function derivePendingApprovals(
79 activities: ReadonlyArray<OrchestrationThreadActivity>,92 activities: ReadonlyArray<OrchestrationThreadActivity>,
80): PendingApproval[] {93): PendingApproval[] {