| author | |
| committer | |
| log | 709f961e1c9261efd43673a0e802c6cd0bfca8f1 |
| tree | 6185edd44fb6c58140d8d2d3d1c60a063ac8b6ea |
| parent | 165338357341fd96387161476a66521e6a887e13 |
- 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 cases3 files changed, 61 insertions(+), 0 deletions(-)
apps/web/src/components/ChatView.tsx+8| ... | ... | @@ -44,6 +44,7 @@ import { |
| 44 | 44 | type PendingApproval, |
| 45 | 45 | deriveWorkLogEntries, |
| 46 | 46 | hasToolActivityForTurn, |
| 47 | isLatestTurnSettled, | |
| 47 | 48 | formatElapsed, |
| 48 | 49 | formatTimestamp, |
| 49 | 50 | } from "../session-logic"; |
| ... | ... | @@ -382,10 +383,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 382 | 383 | const diffOpen = diffSearch.diff === "1"; |
| 383 | 384 | const activeThreadId = activeThread?.id ?? null; |
| 384 | 385 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 386 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | |
| 385 | 387 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 386 | 388 | |
| 387 | 389 | useEffect(() => { |
| 388 | 390 | if (!activeThread?.id) return; |
| 391 | if (!latestTurnSettled) return; | |
| 389 | 392 | if (!activeLatestTurn?.completedAt) return; |
| 390 | 393 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); |
| 391 | 394 | if (Number.isNaN(turnCompletedAt)) return; |
| ... | ... | @@ -400,6 +403,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 400 | 403 | activeThread?.id, |
| 401 | 404 | activeThread?.lastVisitedAt, |
| 402 | 405 | activeLatestTurn?.completedAt, |
| 406 | latestTurnSettled, | |
| 403 | 407 | dispatch, |
| 404 | 408 | ]); |
| 405 | 409 | |
| ... | ... | @@ -483,6 +487,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 483 | 487 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 484 | 488 | |
| 485 | 489 | const completionSummary = useMemo(() => { |
| 490 | if (!latestTurnSettled) return null; | |
| 486 | 491 | if (!activeLatestTurn?.startedAt) return null; |
| 487 | 492 | if (!activeLatestTurn.completedAt) return null; |
| 488 | 493 | if (!latestTurnHasToolActivity) return null; |
| ... | ... | @@ -493,8 +498,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 493 | 498 | activeLatestTurn?.completedAt, |
| 494 | 499 | activeLatestTurn?.startedAt, |
| 495 | 500 | latestTurnHasToolActivity, |
| 501 | latestTurnSettled, | |
| 496 | 502 | ]); |
| 497 | 503 | const completionDividerBeforeEntryId = useMemo(() => { |
| 504 | if (!latestTurnSettled) return null; | |
| 498 | 505 | if (!activeLatestTurn?.startedAt) return null; |
| 499 | 506 | if (!activeLatestTurn.completedAt) return null; |
| 500 | 507 | if (!completionSummary) return null; |
| ... | ... | @@ -521,6 +528,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 521 | 528 | activeLatestTurn?.completedAt, |
| 522 | 529 | activeLatestTurn?.startedAt, |
| 523 | 530 | completionSummary, |
| 531 | latestTurnSettled, | |
| 524 | 532 | timelineEntries, |
| 525 | 533 | ]); |
| 526 | 534 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
apps/web/src/session-logic.test.ts+40| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | derivePendingApprovals, |
| 6 | 6 | deriveWorkLogEntries, |
| 7 | 7 | hasToolActivityForTurn, |
| 8 | isLatestTurnSettled, | |
| 8 | 9 | } from "./session-logic"; |
| 9 | 10 | |
| 10 | 11 | function makeActivity(overrides: { |
| ... | ... | @@ -151,3 +152,42 @@ describe("hasToolActivityForTurn", () => { |
| 151 | 152 | expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false); |
| 152 | 153 | }); |
| 153 | 154 | }); |
| 155 | ||
| 156 | describe("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 | 1 | import { |
| 2 | 2 | ApprovalRequestId, |
| 3 | type OrchestrationLatestTurn, | |
| 3 | 4 | type OrchestrationThreadActivity, |
| 4 | 5 | type ProviderKind, |
| 5 | 6 | type TurnId, |
| ... | ... | @@ -75,6 +76,18 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 75 | 76 | return formatDuration(endedAt - startedAt); |
| 76 | 77 | } |
| 77 | 78 | |
| 79 | export 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 | ||
| 78 | 91 | export function derivePendingApprovals( |
| 79 | 92 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 80 | 93 | ): PendingApproval[] { |