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 {
4444 type PendingApproval,
4545 deriveWorkLogEntries,
4646 hasToolActivityForTurn,
47 isLatestTurnSettled,
4748 formatElapsed,
4849 formatTimestamp,
4950} from "../session-logic";
......@@ -382,10 +383,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
382383 const diffOpen = diffSearch.diff === "1";
383384 const activeThreadId = activeThread?.id ?? null;
384385 const activeLatestTurn = activeThread?.latestTurn ?? null;
386 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
385387 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
386388
387389 useEffect(() => {
388390 if (!activeThread?.id) return;
391 if (!latestTurnSettled) return;
389392 if (!activeLatestTurn?.completedAt) return;
390393 const turnCompletedAt = Date.parse(activeLatestTurn.completedAt);
391394 if (Number.isNaN(turnCompletedAt)) return;
......@@ -400,6 +403,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
400403 activeThread?.id,
401404 activeThread?.lastVisitedAt,
402405 activeLatestTurn?.completedAt,
406 latestTurnSettled,
403407 dispatch,
404408 ]);
405409
......@@ -483,6 +487,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
483487 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
484488
485489 const completionSummary = useMemo(() => {
490 if (!latestTurnSettled) return null;
486491 if (!activeLatestTurn?.startedAt) return null;
487492 if (!activeLatestTurn.completedAt) return null;
488493 if (!latestTurnHasToolActivity) return null;
......@@ -493,8 +498,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
493498 activeLatestTurn?.completedAt,
494499 activeLatestTurn?.startedAt,
495500 latestTurnHasToolActivity,
501 latestTurnSettled,
496502 ]);
497503 const completionDividerBeforeEntryId = useMemo(() => {
504 if (!latestTurnSettled) return null;
498505 if (!activeLatestTurn?.startedAt) return null;
499506 if (!activeLatestTurn.completedAt) return null;
500507 if (!completionSummary) return null;
......@@ -521,6 +528,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
521528 activeLatestTurn?.completedAt,
522529 activeLatestTurn?.startedAt,
523530 completionSummary,
531 latestTurnSettled,
524532 timelineEntries,
525533 ]);
526534 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
apps/web/src/session-logic.test.ts+40
......@@ -5,6 +5,7 @@ import {
55 derivePendingApprovals,
66 deriveWorkLogEntries,
77 hasToolActivityForTurn,
8 isLatestTurnSettled,
89} from "./session-logic";
910
1011function makeActivity(overrides: {
......@@ -151,3 +152,42 @@ describe("hasToolActivityForTurn", () => {
151152 expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false);
152153 });
153154});
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 @@
11import {
22 ApprovalRequestId,
3 type OrchestrationLatestTurn,
34 type OrchestrationThreadActivity,
45 type ProviderKind,
56 type TurnId,
......@@ -75,6 +76,18 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
7576 return formatDuration(endedAt - startedAt);
7677}
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
7891export function derivePendingApprovals(
7992 activities: ReadonlyArray<OrchestrationThreadActivity>,
8093): PendingApproval[] {