authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 14:38:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 14:38:42-07:00
log1916ac6d5503894e4cd507d8451bd4990d8ca27b
tree0d877c972fbe80a353b903bb431e9b5c4cc809ff
parent343061a0538bde5aa79e6d2c4aca0d42aa7992cb
signature Signed by PGP key B5690EEEBB952194

Rework message metadata, timestamps, and tool work log rows (#3022)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 1562 insertions(+), 494 deletions(-)

apps/web/src/components/ChatView.tsx+2-33
...@@ -50,7 +50,6 @@ import {...@@ -50,7 +50,6 @@ import {
50 parseStandaloneComposerSlashCommand,50 parseStandaloneComposerSlashCommand,
51} from "../composer-logic";51} from "../composer-logic";
52import {52import {
53 deriveCompletionDividerBeforeEntryId,
54 derivePendingApprovals,53 derivePendingApprovals,
55 derivePendingUserInputs,54 derivePendingUserInputs,
56 derivePhase,55 derivePhase,
...@@ -61,9 +60,7 @@ import {...@@ -61,9 +60,7 @@ import {
61 findLatestProposedPlan,60 findLatestProposedPlan,
62 deriveWorkLogEntries,61 deriveWorkLogEntries,
63 hasActionableProposedPlan,62 hasActionableProposedPlan,
64 hasToolActivityForTurn,
65 isLatestTurnSettled,63 isLatestTurnSettled,
66 formatElapsed,
67} from "../session-logic";64} from "../session-logic";
68import { type LegendListRef } from "@legendapp/list/react";65import { type LegendListRef } from "@legendapp/list/react";
69import {66import {
...@@ -1517,14 +1514,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1517,14 +1514,7 @@ export default function ChatView(props: ChatViewProps) {
1517 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;1514 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
1518 const phase = derivePhase(activeThread?.session ?? null);1515 const phase = derivePhase(activeThread?.session ?? null);
1519 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;1516 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
1520 const workLogEntries = useMemo(1517 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
1521 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
1522 [activeLatestTurn?.turnId, threadActivities],
1523 );
1524 const latestTurnHasToolActivity = useMemo(
1525 () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId),
1526 [activeLatestTurn?.turnId, threadActivities],
1527 );
1528 const pendingApprovals = useMemo(1518 const pendingApprovals = useMemo(
1529 () => derivePendingApprovals(threadActivities),1519 () => derivePendingApprovals(threadActivities),
1530 [threadActivities],1520 [threadActivities],
...@@ -1859,25 +1849,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1859,25 +1849,6 @@ export default function ChatView(props: ChatViewProps) {
1859 return byUserMessageId;1849 return byUserMessageId;
1860 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);1850 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
18611851
1862 const completionSummary = useMemo(() => {
1863 if (!latestTurnSettled) return null;
1864 if (!activeLatestTurn?.startedAt) return null;
1865 if (!activeLatestTurn.completedAt) return null;
1866 if (!latestTurnHasToolActivity) return null;
1867
1868 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
1869 return elapsed ? `Worked for ${elapsed}` : null;
1870 }, [
1871 activeLatestTurn?.completedAt,
1872 activeLatestTurn?.startedAt,
1873 latestTurnHasToolActivity,
1874 latestTurnSettled,
1875 ]);
1876 const completionDividerBeforeEntryId = useMemo(() => {
1877 if (!latestTurnSettled) return null;
1878 if (!completionSummary) return null;
1879 return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn);
1880 }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]);
1881 const gitCwd = activeProject1852 const gitCwd = activeProject
1882 ? projectScriptCwd({1853 ? projectScriptCwd({
1883 project: { cwd: activeProject.cwd },1854 project: { cwd: activeProject.cwd },
...@@ -3885,12 +3856,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -3885,12 +3856,10 @@ export default function ChatView(props: ChatViewProps) {
3885 key={activeThread.id}3856 key={activeThread.id}
3886 isWorking={isWorking}3857 isWorking={isWorking}
3887 activeTurnInProgress={isWorking || !latestTurnSettled}3858 activeTurnInProgress={isWorking || !latestTurnSettled}
3888 activeTurnId={activeLatestTurn?.turnId ?? null}
3889 activeTurnStartedAt={activeWorkStartedAt}3859 activeTurnStartedAt={activeWorkStartedAt}
3890 listRef={legendListRef}3860 listRef={legendListRef}
3891 timelineEntries={timelineEntries}3861 timelineEntries={timelineEntries}
3892 completionDividerBeforeEntryId={completionDividerBeforeEntryId}3862 latestTurn={activeLatestTurn}
3893 completionSummary={completionSummary}
3894 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}3863 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3895 activeThreadEnvironmentId={activeThread.environmentId}3864 activeThreadEnvironmentId={activeThread.environmentId}
3896 routeThreadKey={routeThreadKey}3865 routeThreadKey={routeThreadKey}
apps/web/src/components/chat/MessageCopyButton.tsx+2-2
...@@ -68,11 +68,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({...@@ -68,11 +68,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({
68 type="button"68 type="button"
69 size={size}69 size={size}
70 variant={variant}70 variant={variant}
71 className={cn(className)}71 className={cn("text-muted-foreground hover:text-foreground", className)}
72 />72 />
73 }73 }
74 >74 >
75 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}75 {isCopied ? <CheckIcon className="size-3 text-primary" /> : <CopyIcon className="size-3" />}
76 </TooltipTrigger>76 </TooltipTrigger>
77 <TooltipPopup>77 <TooltipPopup>
78 <p>Copy to clipboard</p>78 <p>Copy to clipboard</p>
apps/web/src/components/chat/MessagesTimeline.browser.tsx+81-9
...@@ -54,11 +54,9 @@ function buildProps() {...@@ -54,11 +54,9 @@ function buildProps() {
54 return {54 return {
55 isWorking: false,55 isWorking: false,
56 activeTurnInProgress: false,56 activeTurnInProgress: false,
57 activeTurnId: null,
58 activeTurnStartedAt: null,57 activeTurnStartedAt: null,
59 listRef: createRef<LegendListRef | null>(),58 listRef: createRef<LegendListRef | null>(),
60 completionDividerBeforeEntryId: null,59 latestTurn: null,
61 completionSummary: null,
62 turnDiffSummaryByAssistantMessageId: new Map(),60 turnDiffSummaryByAssistantMessageId: new Map(),
63 routeThreadKey: "environment-local:thread-1",61 routeThreadKey: "environment-local:thread-1",
64 onOpenTurnDiff: vi.fn(),62 onOpenTurnDiff: vi.fn(),
...@@ -131,9 +129,9 @@ describe("MessagesTimeline", () => {...@@ -131,9 +129,9 @@ describe("MessagesTimeline", () => {
131 entry: {129 entry: {
132 id: "work-1",130 id: "work-1",
133 createdAt: "2026-04-13T12:00:00.000Z",131 createdAt: "2026-04-13T12:00:00.000Z",
134 label: "thinking",132 label: "read files",
135 detail: "Inspecting repository state",133 detail: "Inspecting repository state",
136 tone: "thinking",134 tone: "tool",
137 },135 },
138 },136 },
139 ]}137 ]}
...@@ -144,7 +142,7 @@ describe("MessagesTimeline", () => {...@@ -144,7 +142,7 @@ describe("MessagesTimeline", () => {
144 await expect142 await expect
145 .element(page.getByText("Send a message to start the conversation."))143 .element(page.getByText("Send a message to start the conversation."))
146 .not.toBeInTheDocument();144 .not.toBeInTheDocument();
147 await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();145 await expect.element(page.getByText("Inspecting repository state")).toBeVisible();
148 expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull();146 expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull();
149 } finally {147 } finally {
150 await screen.unmount();148 await screen.unmount();
...@@ -231,16 +229,16 @@ describe("MessagesTimeline", () => {...@@ -231,16 +229,16 @@ describe("MessagesTimeline", () => {
231 entry: {229 entry: {
232 id: "work-1",230 id: "work-1",
233 createdAt: "2026-04-13T12:00:00.000Z",231 createdAt: "2026-04-13T12:00:00.000Z",
234 label: "thinking",232 label: "read files",
235 detail: "Inspecting repository state",233 detail: "Inspecting repository state",
236 tone: "thinking",234 tone: "tool",
237 },235 },
238 },236 },
239 ]}237 ]}
240 />,238 />,
241 );239 );
242240
243 await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();241 await expect.element(page.getByText("Inspecting repository state")).toBeVisible();
244 expect(props.onIsAtEndChange).toHaveBeenCalledWith(true);242 expect(props.onIsAtEndChange).toHaveBeenCalledWith(true);
245 expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false });243 expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false });
246 expect(requestAnimationFrameSpy).toHaveBeenCalled();244 expect(requestAnimationFrameSpy).toHaveBeenCalled();
...@@ -415,4 +413,78 @@ describe("MessagesTimeline", () => {...@@ -415,4 +413,78 @@ describe("MessagesTimeline", () => {
415 await screen.unmount();413 await screen.unmount();
416 }414 }
417 });415 });
416
417 it("folds settled-turn work behind a Worked-for row and expands it on click", async () => {
418 const screen = await render(
419 <MessagesTimeline
420 {...buildProps()}
421 timelineEntries={[
422 {
423 id: "entry-commentary",
424 kind: "message" as const,
425 createdAt: "2026-04-13T12:00:00.000Z",
426 message: {
427 id: "message-commentary" as never,
428 role: "assistant" as const,
429 text: "Let me look around first.",
430 turnId: "turn-1" as never,
431 createdAt: "2026-04-13T12:00:00.000Z",
432 completedAt: "2026-04-13T12:00:02.000Z",
433 streaming: false,
434 },
435 },
436 {
437 id: "entry-work",
438 kind: "work" as const,
439 createdAt: "2026-04-13T12:00:05.000Z",
440 entry: {
441 id: "work-1",
442 createdAt: "2026-04-13T12:00:05.000Z",
443 turnId: "turn-1" as never,
444 label: "read files",
445 detail: "Inspecting repository state",
446 tone: "tool" as const,
447 },
448 },
449 {
450 id: "entry-final",
451 kind: "message" as const,
452 createdAt: "2026-04-13T12:00:20.000Z",
453 message: {
454 id: "message-final" as never,
455 role: "assistant" as const,
456 text: "All done.",
457 turnId: "turn-1" as never,
458 createdAt: "2026-04-13T12:00:20.000Z",
459 completedAt: "2026-04-13T12:00:30.000Z",
460 streaming: false,
461 },
462 },
463 ]}
464 />,
465 );
466
467 try {
468 const foldButton = page.getByRole("button", { name: "Worked for 30s" });
469 await expect.element(foldButton).toBeVisible();
470 await expect.element(foldButton).toHaveAttribute("aria-expanded", "false");
471
472 expect(document.body.textContent).toContain("All done.");
473 expect(document.body.textContent).not.toContain("Let me look around first.");
474 expect(document.body.textContent).not.toContain("Inspecting repository state");
475
476 await foldButton.click();
477
478 await expect.element(foldButton).toHaveAttribute("aria-expanded", "true");
479 expect(document.body.textContent).toContain("Let me look around first.");
480 expect(document.body.textContent).toContain("Inspecting repository state");
481
482 await foldButton.click();
483
484 await expect.element(foldButton).toHaveAttribute("aria-expanded", "false");
485 expect(document.body.textContent).not.toContain("Inspecting repository state");
486 } finally {
487 await screen.unmount();
488 }
489 });
418});490});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+348-12
...@@ -250,7 +250,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -250,7 +250,7 @@ describe("deriveMessagesTimelineRows", () => {
250 },250 },
251 },251 },
252 ],252 ],
253 completionDividerBeforeEntryId: "assistant-final-entry",253 expandedTurnIds: new Set(["turn-1" as never]),
254 isWorking: false,254 isWorking: false,
255 activeTurnStartedAt: null,255 activeTurnStartedAt: null,
256 turnDiffSummaryByAssistantMessageId: new Map(),256 turnDiffSummaryByAssistantMessageId: new Map(),
...@@ -265,7 +265,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -265,7 +265,6 @@ describe("deriveMessagesTimelineRows", () => {
265 expect(assistantRows).toHaveLength(2);265 expect(assistantRows).toHaveLength(2);
266 expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);266 expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
267 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);267 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
269 });268 });
270269
271 it("marks only the active assistant turn as streaming for copy controls", () => {270 it("marks only the active assistant turn as streaming for copy controls", () => {
...@@ -300,11 +299,13 @@ describe("deriveMessagesTimelineRows", () => {...@@ -300,11 +299,13 @@ describe("deriveMessagesTimelineRows", () => {
300 },299 },
301 },300 },
302 ],301 ],
303 completionDividerBeforeEntryId: "assistant-two-entry",302 latestTurn: {
304 completionSummary: "done",303 turnId: "turn-2" as never,
304 state: "running",
305 startedAt: "2026-01-01T00:00:19Z",
306 completedAt: null,
307 },
305 isWorking: false,308 isWorking: false,
306 activeTurnInProgress: true,
307 activeTurnId: "turn-2" as never,
308 activeTurnStartedAt: null,309 activeTurnStartedAt: null,
309 turnDiffSummaryByAssistantMessageId: new Map(),310 turnDiffSummaryByAssistantMessageId: new Map(),
310 revertTurnCountByUserMessageId: new Map(),311 revertTurnCountByUserMessageId: new Map(),
...@@ -316,9 +317,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -316,9 +317,7 @@ describe("deriveMessagesTimelineRows", () => {
316 );317 );
317318
318 expect(assistantRows[0]?.assistantCopyStreaming).toBe(false);319 expect(assistantRows[0]?.assistantCopyStreaming).toBe(false);
319 expect(assistantRows[0]?.completionSummary).toBeNull();
320 expect(assistantRows[1]?.assistantCopyStreaming).toBe(true);320 expect(assistantRows[1]?.assistantCopyStreaming).toBe(true);
321 expect(assistantRows[1]?.completionSummary).toBe("done");
322 });321 });
323322
324 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {323 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
...@@ -360,7 +359,6 @@ describe("deriveMessagesTimelineRows", () => {...@@ -360,7 +359,6 @@ describe("deriveMessagesTimelineRows", () => {
360 },359 },
361 },360 },
362 ],361 ],
363 completionDividerBeforeEntryId: null,
364 isWorking: false,362 isWorking: false,
365 activeTurnStartedAt: null,363 activeTurnStartedAt: null,
366 turnDiffSummaryByAssistantMessageId: new Map([364 turnDiffSummaryByAssistantMessageId: new Map([
...@@ -381,6 +379,347 @@ describe("deriveMessagesTimelineRows", () => {...@@ -381,6 +379,347 @@ describe("deriveMessagesTimelineRows", () => {
381 expect(userRow?.revertTurnCount).toBe(1);379 expect(userRow?.revertTurnCount).toBe(1);
382 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);380 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
383 });381 });
382
383 it("folds settled-turn commentary and work behind a Worked-for row", () => {
384 const timelineEntries = [
385 {
386 id: "user-entry",
387 kind: "message" as const,
388 createdAt: "2026-01-01T00:00:00Z",
389 message: {
390 id: "user-1" as never,
391 role: "user" as const,
392 text: "Build it",
393 turnId: null,
394 createdAt: "2026-01-01T00:00:00Z",
395 streaming: false,
396 },
397 },
398 {
399 id: "assistant-thought-entry",
400 kind: "message" as const,
401 createdAt: "2026-01-01T00:00:05Z",
402 message: {
403 id: "assistant-thought" as never,
404 role: "assistant" as const,
405 text: "Looking around first.",
406 turnId: "turn-1" as never,
407 createdAt: "2026-01-01T00:00:05Z",
408 completedAt: "2026-01-01T00:00:06Z",
409 streaming: false,
410 },
411 },
412 {
413 id: "work-entry-1",
414 kind: "work" as const,
415 createdAt: "2026-01-01T00:00:08Z",
416 entry: {
417 id: "work-1",
418 createdAt: "2026-01-01T00:00:08Z",
419 turnId: "turn-1" as never,
420 label: "Ran command",
421 tone: "tool" as const,
422 },
423 },
424 {
425 id: "assistant-final-entry",
426 kind: "message" as const,
427 createdAt: "2026-01-01T00:00:20Z",
428 message: {
429 id: "assistant-final" as never,
430 role: "assistant" as const,
431 text: "Done",
432 turnId: "turn-1" as never,
433 createdAt: "2026-01-01T00:00:20Z",
434 completedAt: "2026-01-01T00:00:22Z",
435 streaming: false,
436 },
437 },
438 ];
439
440 const collapsedRows = deriveMessagesTimelineRows({
441 timelineEntries,
442 isWorking: false,
443 activeTurnStartedAt: null,
444 turnDiffSummaryByAssistantMessageId: new Map(),
445 revertTurnCountByUserMessageId: new Map(),
446 });
447
448 const foldRow = collapsedRows.find(
449 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>
450 row.kind === "turn-fold",
451 );
452 expect(foldRow?.turnId).toBe("turn-1");
453 expect(foldRow?.expanded).toBe(false);
454 // First fold entry (00:00:05) → terminal message completedAt (00:00:22).
455 expect(foldRow?.label).toBe("Worked for 17s");
456 expect(collapsedRows.map((row) => row.id)).toEqual([
457 "user-entry",
458 "turn-fold:turn-1",
459 "assistant-final-entry",
460 ]);
461
462 const expandedRows = deriveMessagesTimelineRows({
463 timelineEntries,
464 expandedTurnIds: new Set(["turn-1" as never]),
465 isWorking: false,
466 activeTurnStartedAt: null,
467 turnDiffSummaryByAssistantMessageId: new Map(),
468 revertTurnCountByUserMessageId: new Map(),
469 });
470
471 expect(expandedRows.map((row) => row.id)).toEqual([
472 "user-entry",
473 "turn-fold:turn-1",
474 "assistant-thought-entry",
475 "work-entry-1",
476 "assistant-final-entry",
477 ]);
478 expect(
479 expandedRows.find((row) => row.kind === "turn-fold" && row.expanded === true),
480 ).toBeDefined();
481 });
482
483 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
484 const rows = deriveMessagesTimelineRows({
485 timelineEntries: [
486 {
487 id: "work-entry-1",
488 kind: "work",
489 createdAt: "2026-01-01T00:00:05Z",
490 entry: {
491 id: "work-1",
492 createdAt: "2026-01-01T00:00:05Z",
493 turnId: "turn-1" as never,
494 label: "Ran command",
495 tone: "tool" as const,
496 },
497 },
498 ],
499 latestTurn: {
500 turnId: "turn-1" as never,
501 state: "interrupted",
502 startedAt: "2026-01-01T00:00:00Z",
503 completedAt: "2026-01-01T00:00:47Z",
504 },
505 isWorking: false,
506 activeTurnStartedAt: null,
507 turnDiffSummaryByAssistantMessageId: new Map(),
508 revertTurnCountByUserMessageId: new Map(),
509 });
510
511 expect(rows).toEqual([
512 expect.objectContaining({
513 kind: "turn-fold",
514 turnId: "turn-1",
515 label: "You stopped after 47s",
516 expanded: false,
517 }),
518 ]);
519 });
520
521 it("keeps the previous turn folded while a newly sent message awaits its turn", () => {
522 // Right after send, isWorking is true but latestTurn still points at the
523 // previous, settled turn — it must stay folded through that window.
524 const rows = deriveMessagesTimelineRows({
525 timelineEntries: [
526 {
527 id: "work-entry-1",
528 kind: "work",
529 createdAt: "2026-01-01T00:00:05Z",
530 entry: {
531 id: "work-1",
532 createdAt: "2026-01-01T00:00:05Z",
533 turnId: "turn-1" as never,
534 label: "Ran command",
535 tone: "tool" as const,
536 },
537 },
538 {
539 id: "assistant-final-entry",
540 kind: "message",
541 createdAt: "2026-01-01T00:00:20Z",
542 message: {
543 id: "assistant-final" as never,
544 role: "assistant",
545 text: "Done",
546 turnId: "turn-1" as never,
547 createdAt: "2026-01-01T00:00:20Z",
548 completedAt: "2026-01-01T00:00:22Z",
549 streaming: false,
550 },
551 },
552 {
553 id: "user-followup-entry",
554 kind: "message",
555 createdAt: "2026-01-01T00:01:00Z",
556 message: {
557 id: "user-followup" as never,
558 role: "user",
559 text: "yooo",
560 turnId: null,
561 createdAt: "2026-01-01T00:01:00Z",
562 streaming: false,
563 },
564 },
565 ],
566 latestTurn: {
567 turnId: "turn-1" as never,
568 state: "completed",
569 startedAt: "2026-01-01T00:00:00Z",
570 completedAt: "2026-01-01T00:00:22Z",
571 },
572 isWorking: true,
573 activeTurnStartedAt: "2026-01-01T00:01:00Z",
574 turnDiffSummaryByAssistantMessageId: new Map(),
575 revertTurnCountByUserMessageId: new Map(),
576 });
577
578 expect(rows.map((row) => row.id)).toEqual([
579 "turn-fold:turn-1",
580 "assistant-final-entry",
581 "user-followup-entry",
582 "working-indicator-row",
583 ]);
584 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
585 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
586 });
587
588 it("does not fold the active in-progress turn", () => {
589 const rows = deriveMessagesTimelineRows({
590 timelineEntries: [
591 {
592 id: "assistant-thought-entry",
593 kind: "message",
594 createdAt: "2026-01-01T00:00:05Z",
595 message: {
596 id: "assistant-thought" as never,
597 role: "assistant",
598 text: "Working on it.",
599 turnId: "turn-1" as never,
600 createdAt: "2026-01-01T00:00:05Z",
601 completedAt: "2026-01-01T00:00:06Z",
602 streaming: false,
603 },
604 },
605 {
606 id: "work-entry-1",
607 kind: "work",
608 createdAt: "2026-01-01T00:00:08Z",
609 entry: {
610 id: "work-1",
611 createdAt: "2026-01-01T00:00:08Z",
612 turnId: "turn-1" as never,
613 label: "Ran command",
614 tone: "tool" as const,
615 },
616 },
617 ],
618 latestTurn: {
619 turnId: "turn-1" as never,
620 state: "running",
621 startedAt: "2026-01-01T00:00:00Z",
622 completedAt: null,
623 },
624 isWorking: true,
625 activeTurnStartedAt: "2026-01-01T00:00:00Z",
626 turnDiffSummaryByAssistantMessageId: new Map(),
627 revertTurnCountByUserMessageId: new Map(),
628 });
629
630 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
631 expect(rows.map((row) => row.id)).toEqual([
632 "assistant-thought-entry",
633 "work-entry-1",
634 "working-indicator-row",
635 ]);
636 });
637
638 it("only shows assistant metadata on the terminal assistant message", () => {
639 const rows = deriveMessagesTimelineRows({
640 timelineEntries: [
641 {
642 id: "assistant-thought-entry",
643 kind: "message",
644 createdAt: "2026-01-01T00:00:10Z",
645 message: {
646 id: "assistant-thought" as never,
647 role: "assistant",
648 text: "Checking first.",
649 turnId: "turn-1" as never,
650 createdAt: "2026-01-01T00:00:10Z",
651 completedAt: "2026-01-01T00:00:11Z",
652 streaming: false,
653 },
654 },
655 {
656 id: "assistant-final-entry",
657 kind: "message",
658 createdAt: "2026-01-01T00:00:20Z",
659 message: {
660 id: "assistant-final" as never,
661 role: "assistant",
662 text: "Done.",
663 turnId: "turn-1" as never,
664 createdAt: "2026-01-01T00:00:20Z",
665 completedAt: "2026-01-01T00:00:30Z",
666 streaming: false,
667 },
668 },
669 ],
670 expandedTurnIds: new Set(["turn-1" as never]),
671 isWorking: false,
672 activeTurnStartedAt: null,
673 turnDiffSummaryByAssistantMessageId: new Map(),
674 revertTurnCountByUserMessageId: new Map(),
675 });
676
677 const assistantRows = rows.filter(
678 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
679 row.kind === "message" && row.message.role === "assistant",
680 );
681
682 expect(assistantRows.map((row) => row.showAssistantMeta)).toEqual([false, true]);
683 });
684
685 it("withholds assistant metadata while the active turn is still in progress", () => {
686 const rows = deriveMessagesTimelineRows({
687 timelineEntries: [
688 {
689 id: "assistant-thought-entry",
690 kind: "message",
691 createdAt: "2026-01-01T00:00:10Z",
692 message: {
693 id: "assistant-thought" as never,
694 role: "assistant",
695 text: "Working on it.",
696 turnId: "turn-1" as never,
697 createdAt: "2026-01-01T00:00:10Z",
698 completedAt: "2026-01-01T00:00:11Z",
699 streaming: false,
700 },
701 },
702 ],
703 latestTurn: {
704 turnId: "turn-1" as never,
705 state: "running",
706 startedAt: "2026-01-01T00:00:00Z",
707 completedAt: null,
708 },
709 isWorking: true,
710 activeTurnStartedAt: "2026-01-01T00:00:00Z",
711 turnDiffSummaryByAssistantMessageId: new Map(),
712 revertTurnCountByUserMessageId: new Map(),
713 });
714
715 const assistantRow = rows.find(
716 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
717 row.kind === "message" && row.message.role === "assistant",
718 );
719
720 expect(assistantRow?.showAssistantMeta).toBe(false);
721 expect(assistantRow?.showAssistantCopyButton).toBe(false);
722 });
384});723});
385724
386describe("computeStableMessagesTimelineRows", () => {725describe("computeStableMessagesTimelineRows", () => {
...@@ -417,7 +756,6 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -417,7 +756,6 @@ describe("computeStableMessagesTimelineRows", () => {
417 message: secondUserMessage,756 message: secondUserMessage,
418 },757 },
419 ],758 ],
420 completionDividerBeforeEntryId: null,
421 isWorking: false,759 isWorking: false,
422 activeTurnStartedAt: null,760 activeTurnStartedAt: null,
423 turnDiffSummaryByAssistantMessageId: new Map(),761 turnDiffSummaryByAssistantMessageId: new Map(),
...@@ -467,7 +805,6 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -467,7 +805,6 @@ describe("computeStableMessagesTimelineRows", () => {
467 entry: secondWorkEntry,805 entry: secondWorkEntry,
468 },806 },
469 ],807 ],
470 completionDividerBeforeEntryId: null,
471 isWorking: false,808 isWorking: false,
472 activeTurnStartedAt: null,809 activeTurnStartedAt: null,
473 turnDiffSummaryByAssistantMessageId: new Map(),810 turnDiffSummaryByAssistantMessageId: new Map(),
...@@ -522,7 +859,6 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -522,7 +859,6 @@ describe("computeStableMessagesTimelineRows", () => {
522 message: secondUserMessage,859 message: secondUserMessage,
523 },860 },
524 ],861 ],
525 completionDividerBeforeEntryId: null,
526 isWorking: false,862 isWorking: false,
527 activeTurnStartedAt: null,863 activeTurnStartedAt: null,
528 turnDiffSummaryByAssistantMessageId: new Map(),864 turnDiffSummaryByAssistantMessageId: new Map(),
apps/web/src/components/chat/MessagesTimeline.logic.ts+211-24
...@@ -1,9 +1,16 @@...@@ -1,9 +1,16 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";2import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic";
3import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";3import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
4import { type MessageId, type TurnId } from "@t3tools/contracts";4import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
55
6export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;6export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
7
8function computeElapsedMs(startIso: string, endIso: string): number | null {
9 const start = Date.parse(startIso);
10 const end = Date.parse(endIso);
11 if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
12 return Math.max(0, end - start);
13}
714
8export interface TimelineDurationMessage {15export interface TimelineDurationMessage {
9 id: string;16 id: string;
...@@ -12,6 +19,11 @@ export interface TimelineDurationMessage {...@@ -12,6 +19,11 @@ export interface TimelineDurationMessage {
12 completedAt?: string | undefined;19 completedAt?: string | undefined;
13}20}
1421
22export type TimelineLatestTurn = Pick<
23 OrchestrationLatestTurn,
24 "turnId" | "state" | "startedAt" | "completedAt"
25>;
26
15export type MessagesTimelineRow =27export type MessagesTimelineRow =
16 | {28 | {
17 kind: "work";29 kind: "work";
...@@ -19,14 +31,21 @@ export type MessagesTimelineRow =...@@ -19,14 +31,21 @@ export type MessagesTimelineRow =
19 createdAt: string;31 createdAt: string;
20 groupedEntries: WorkLogEntry[];32 groupedEntries: WorkLogEntry[];
21 }33 }
34 | {
35 kind: "turn-fold";
36 id: string;
37 createdAt: string;
38 turnId: TurnId;
39 label: string;
40 expanded: boolean;
41 }
22 | {42 | {
23 kind: "message";43 kind: "message";
24 id: string;44 id: string;
25 createdAt: string;45 createdAt: string;
26 message: ChatMessage;46 message: ChatMessage;
27 durationStart: string;47 durationStart: string;
28 showCompletionDivider: boolean;48 showAssistantMeta: boolean;
29 completionSummary: string | null;
30 showAssistantCopyButton: boolean;49 showAssistantCopyButton: boolean;
31 assistantCopyStreaming: boolean;50 assistantCopyStreaming: boolean;
32 assistantTurnDiffSummary?: TurnDiffSummary | undefined;51 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
...@@ -110,13 +129,137 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli...@@ -110,13 +129,137 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli
110 return new Set(lastAssistantMessageIdByResponseKey.values());129 return new Set(lastAssistantMessageIdByResponseKey.values());
111}130}
112131
132interface TurnFold {
133 turnId: TurnId;
134 anchorEntryId: string;
135 createdAt: string;
136 hiddenEntryIds: ReadonlySet<string>;
137 label: string;
138}
139
140/**
141 * The latest turn counts as unsettled while it is still running (or has not
142 * recorded a completion). This is deliberately keyed on the turn's own
143 * lifecycle rather than transient working state: right after the user sends
144 * a message, the previous turn is still the "active" one until the server
145 * creates the new turn, and folding must not flicker through that window.
146 */
147function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | null {
148 if (!latestTurn) {
149 return null;
150 }
151 const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running";
152 return isSettled ? null : latestTurn.turnId;
153}
154
155/**
156 * Settled turns fold their commentary and tool activity behind a
157 * "Worked for ..." row anchored at the turn's first foldable entry; the
158 * terminal assistant message stays visible below the fold.
159 */
160function deriveTurnFolds(input: {
161 timelineEntries: ReadonlyArray<TimelineEntry>;
162 terminalAssistantMessageIds: ReadonlySet<string>;
163 latestTurn: TimelineLatestTurn | null;
164 unsettledTurnId: TurnId | null;
165}): ReadonlyMap<string, TurnFold> {
166 interface TurnGroup {
167 entries: Array<TimelineEntry>;
168 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
169 hasStreamingMessage: boolean;
170 }
171 const groupsByTurnId = new Map<TurnId, TurnGroup>();
172
173 for (const entry of input.timelineEntries) {
174 const turnId =
175 entry.kind === "message" && entry.message.role === "assistant"
176 ? (entry.message.turnId ?? null)
177 : entry.kind === "work"
178 ? (entry.entry.turnId ?? null)
179 : null;
180 if (!turnId) {
181 continue;
182 }
183 const group = groupsByTurnId.get(turnId) ?? {
184 entries: [],
185 terminalEntry: null,
186 hasStreamingMessage: false,
187 };
188 group.entries.push(entry);
189 if (entry.kind === "message") {
190 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
191 group.terminalEntry = entry;
192 }
193 if (entry.message.streaming) {
194 group.hasStreamingMessage = true;
195 }
196 }
197 groupsByTurnId.set(turnId, group);
198 }
199
200 const foldsByAnchorEntryId = new Map<string, TurnFold>();
201 for (const [turnId, group] of groupsByTurnId) {
202 if (turnId === input.unsettledTurnId) {
203 continue;
204 }
205 if (group.hasStreamingMessage) {
206 continue;
207 }
208 const hiddenEntryIds = new Set<string>();
209 for (const entry of group.entries) {
210 if (entry.id !== group.terminalEntry?.id) {
211 hiddenEntryIds.add(entry.id);
212 }
213 }
214 if (hiddenEntryIds.size === 0) {
215 continue;
216 }
217
218 const firstEntry = group.entries[0];
219 const lastEntry = group.entries.at(-1);
220 if (!firstEntry || !lastEntry) {
221 continue;
222 }
223
224 const isLatestInterruptedTurn =
225 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
226 const elapsedMs =
227 input.latestTurn?.turnId === turnId &&
228 input.latestTurn.startedAt &&
229 input.latestTurn.completedAt
230 ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)
231 : computeElapsedMs(
232 firstEntry.createdAt,
233 group.terminalEntry?.message.completedAt ??
234 (lastEntry.kind === "message"
235 ? (lastEntry.message.completedAt ?? lastEntry.createdAt)
236 : lastEntry.createdAt),
237 );
238 const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;
239 const label = isLatestInterruptedTurn
240 ? duration
241 ? `You stopped after ${duration}`
242 : "You stopped this response"
243 : duration
244 ? `Worked for ${duration}`
245 : "Worked";
246
247 foldsByAnchorEntryId.set(firstEntry.id, {
248 turnId,
249 anchorEntryId: firstEntry.id,
250 createdAt: firstEntry.createdAt,
251 hiddenEntryIds,
252 label,
253 });
254 }
255 return foldsByAnchorEntryId;
256}
257
113export function deriveMessagesTimelineRows(input: {258export function deriveMessagesTimelineRows(input: {
114 timelineEntries: ReadonlyArray<TimelineEntry>;259 timelineEntries: ReadonlyArray<TimelineEntry>;
115 completionDividerBeforeEntryId: string | null;260 latestTurn?: TimelineLatestTurn | null;
116 completionSummary?: string | null;261 expandedTurnIds?: ReadonlySet<TurnId>;
117 isWorking: boolean;262 isWorking: boolean;
118 activeTurnInProgress?: boolean;
119 activeTurnId?: TurnId | null;
120 activeTurnStartedAt: string | null;263 activeTurnStartedAt: string | null;
121 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;264 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
122 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;265 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
...@@ -126,6 +269,21 @@ export function deriveMessagesTimelineRows(input: {...@@ -126,6 +269,21 @@ export function deriveMessagesTimelineRows(input: {
126 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),269 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
127 );270 );
128 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);271 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
272 const unsettledTurnId = deriveUnsettledTurnId(input.latestTurn ?? null);
273 const foldsByAnchorEntryId = deriveTurnFolds({
274 timelineEntries: input.timelineEntries,
275 terminalAssistantMessageIds,
276 latestTurn: input.latestTurn ?? null,
277 unsettledTurnId,
278 });
279 const collapsedEntryIds = new Set<string>();
280 for (const fold of foldsByAnchorEntryId.values()) {
281 if (!input.expandedTurnIds?.has(fold.turnId)) {
282 for (const entryId of fold.hiddenEntryIds) {
283 collapsedEntryIds.add(entryId);
284 }
285 }
286 }
129287
130 for (let index = 0; index < input.timelineEntries.length; index += 1) {288 for (let index = 0; index < input.timelineEntries.length; index += 1) {
131 const timelineEntry = input.timelineEntries[index];289 const timelineEntry = input.timelineEntries[index];
...@@ -133,12 +291,35 @@ export function deriveMessagesTimelineRows(input: {...@@ -133,12 +291,35 @@ export function deriveMessagesTimelineRows(input: {
133 continue;291 continue;
134 }292 }
135293
294 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
295 if (turnFold) {
296 nextRows.push({
297 kind: "turn-fold",
298 id: `turn-fold:${turnFold.turnId}`,
299 createdAt: turnFold.createdAt,
300 turnId: turnFold.turnId,
301 label: turnFold.label,
302 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
303 });
304 }
305
306 if (collapsedEntryIds.has(timelineEntry.id)) {
307 continue;
308 }
309
136 if (timelineEntry.kind === "work") {310 if (timelineEntry.kind === "work") {
137 const groupedEntries = [timelineEntry.entry];311 const groupedEntries = [timelineEntry.entry];
138 let cursor = index + 1;312 let cursor = index + 1;
139 while (cursor < input.timelineEntries.length) {313 while (cursor < input.timelineEntries.length) {
140 const nextEntry = input.timelineEntries[cursor];314 const nextEntry = input.timelineEntries[cursor];
141 if (!nextEntry || nextEntry.kind !== "work") break;315 if (
316 !nextEntry ||
317 nextEntry.kind !== "work" ||
318 collapsedEntryIds.has(nextEntry.id) ||
319 foldsByAnchorEntryId.has(nextEntry.id)
320 ) {
321 break;
322 }
142 groupedEntries.push(nextEntry.entry);323 groupedEntries.push(nextEntry.entry);
143 cursor += 1;324 cursor += 1;
144 }325 }
...@@ -164,26 +345,28 @@ export function deriveMessagesTimelineRows(input: {...@@ -164,26 +345,28 @@ export function deriveMessagesTimelineRows(input: {
164345
165 const assistantTurnStillInProgress =346 const assistantTurnStillInProgress =
166 timelineEntry.message.role === "assistant" &&347 timelineEntry.message.role === "assistant" &&
167 input.activeTurnInProgress === true &&348 unsettledTurnId !== null &&
168 input.activeTurnId != null &&349 timelineEntry.message.turnId === unsettledTurnId;
169 timelineEntry.message.turnId === input.activeTurnId;
170350
171 const showCompletionDivider =351 const durationStart =
352 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
353
354 // While the turn is still running, the latest assistant message is only
355 // provisionally terminal — withhold the metadata row until the turn
356 // settles so commentary doesn't flash timestamps mid-work.
357 const showAssistantMeta =
172 timelineEntry.message.role === "assistant" &&358 timelineEntry.message.role === "assistant" &&
173 input.completionDividerBeforeEntryId === timelineEntry.id;359 terminalAssistantMessageIds.has(timelineEntry.message.id) &&
360 !assistantTurnStillInProgress;
174361
175 nextRows.push({362 nextRows.push({
176 kind: "message",363 kind: "message",
177 id: timelineEntry.id,364 id: timelineEntry.id,
178 createdAt: timelineEntry.createdAt,365 createdAt: timelineEntry.createdAt,
179 message: timelineEntry.message,366 message: timelineEntry.message,
180 durationStart:367 durationStart,
181 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,368 showAssistantMeta,
182 showCompletionDivider,369 showAssistantCopyButton: showAssistantMeta,
183 completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null,
184 showAssistantCopyButton:
185 timelineEntry.message.role === "assistant" &&
186 terminalAssistantMessageIds.has(timelineEntry.message.id),
187 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,370 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,
188 assistantTurnDiffSummary:371 assistantTurnDiffSummary:
189 timelineEntry.message.role === "assistant"372 timelineEntry.message.role === "assistant"
...@@ -235,6 +418,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -235,6 +418,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
235 case "working":418 case "working":
236 return a.createdAt === (b as typeof a).createdAt;419 return a.createdAt === (b as typeof a).createdAt;
237420
421 case "turn-fold": {
422 const bf = b as typeof a;
423 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
424 }
425
238 case "proposed-plan":426 case "proposed-plan":
239 return a.proposedPlan === (b as typeof a).proposedPlan;427 return a.proposedPlan === (b as typeof a).proposedPlan;
240428
...@@ -246,8 +434,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -246,8 +434,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
246 return (434 return (
247 a.message === bm.message &&435 a.message === bm.message &&
248 a.durationStart === bm.durationStart &&436 a.durationStart === bm.durationStart &&
249 a.showCompletionDivider === bm.showCompletionDivider &&437 a.showAssistantMeta === bm.showAssistantMeta &&
250 a.completionSummary === bm.completionSummary &&
251 a.showAssistantCopyButton === bm.showAssistantCopyButton &&438 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
252 a.assistantCopyStreaming === bm.assistantCopyStreaming &&439 a.assistantCopyStreaming === bm.assistantCopyStreaming &&
253 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&440 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
apps/web/src/components/chat/MessagesTimeline.test.tsx+29-4
...@@ -94,11 +94,9 @@ function buildProps() {...@@ -94,11 +94,9 @@ function buildProps() {
94 return {94 return {
95 isWorking: false,95 isWorking: false,
96 activeTurnInProgress: false,96 activeTurnInProgress: false,
97 activeTurnId: null,
98 activeTurnStartedAt: null,97 activeTurnStartedAt: null,
99 listRef: createRef<LegendListRef | null>(),98 listRef: createRef<LegendListRef | null>(),
100 completionDividerBeforeEntryId: null,99 latestTurn: null,
101 completionSummary: null,
102 turnDiffSummaryByAssistantMessageId: new Map(),100 turnDiffSummaryByAssistantMessageId: new Map(),
103 routeThreadKey: "environment-local:thread-1",101 routeThreadKey: "environment-local:thread-1",
104 onOpenTurnDiff: () => {},102 onOpenTurnDiff: () => {},
...@@ -229,7 +227,7 @@ describe("MessagesTimeline", () => {...@@ -229,7 +227,7 @@ describe("MessagesTimeline", () => {
229 );227 );
230228
231 expect(markup).toContain("Context compacted");229 expect(markup).toContain("Context compacted");
232 expect(markup).toContain("Work log");230 expect(markup).toContain("work log");
233 });231 });
234232
235 it("formats changed file paths from the workspace root", async () => {233 it("formats changed file paths from the workspace root", async () => {
...@@ -297,4 +295,31 @@ describe("MessagesTimeline", () => {...@@ -297,4 +295,31 @@ describe("MessagesTimeline", () => {
297 expect(markup).not.toContain("&lt;review_comment");295 expect(markup).not.toContain("&lt;review_comment");
298 expect(markup).not.toContain("&lt;/review_comment&gt;");296 expect(markup).not.toContain("&lt;/review_comment&gt;");
299 });297 });
298
299 it("renders a failure marker for failed tool lifecycle entries", async () => {
300 const { MessagesTimeline } = await import("./MessagesTimeline");
301 const markup = renderToStaticMarkup(
302 <MessagesTimeline
303 {...buildProps()}
304 timelineEntries={[
305 {
306 id: "entry-1",
307 kind: "work",
308 createdAt: "2026-03-17T19:12:28.000Z",
309 entry: {
310 id: "work-1",
311 createdAt: "2026-03-17T19:12:28.000Z",
312 label: "Glob",
313 tone: "tool",
314 toolLifecycleStatus: "failed",
315 detail: "No files found",
316 },
317 },
318 ]}
319 />,
320 );
321
322 expect(markup).toContain("lucide-x");
323 expect(markup).toContain('aria-label="Tool call failed"');
324 });
300});325});
apps/web/src/components/chat/MessagesTimeline.tsx+469-237
...@@ -6,20 +6,28 @@ import {...@@ -6,20 +6,28 @@ import {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {7import {
8 createContext,8 createContext,
9 Fragment,
9 memo,10 memo,
10 use,11 use,
11 useCallback,12 useCallback,
12 useEffect,13 useEffect,
13 Fragment,
14 useMemo,14 useMemo,
15 useRef,15 useRef,
16 useState,16 useState,
17 type KeyboardEvent,
17 type ReactNode,18 type ReactNode,
18} from "react";19} from "react";
19import { LegendList, type LegendListRef } from "@legendapp/list/react";20import { LegendList, type LegendListRef } from "@legendapp/list/react";
20import { FileDiff } from "@pierre/diffs/react";21import { FileDiff } from "@pierre/diffs/react";
21import { deriveTimelineEntries, formatElapsed } from "../../session-logic";22import {
23 deriveTimelineEntries,
24 workEntryIndicatesToolFailure,
25 workEntryIndicatesToolNeutralStatus,
26 workEntryIndicatesToolSuccess,
27 workLogEntryIsToolLike,
28} from "../../session-logic";
22import { type TurnDiffSummary } from "../../types";29import { type TurnDiffSummary } from "../../types";
30import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
23import {31import {
24 getRenderablePatch,32 getRenderablePatch,
25 resolveDiffThemeName,33 resolveDiffThemeName,
...@@ -29,21 +37,28 @@ import ChatMarkdown from "../ChatMarkdown";...@@ -29,21 +37,28 @@ import ChatMarkdown from "../ChatMarkdown";
29import {37import {
30 BotIcon,38 BotIcon,
31 CheckIcon,39 CheckIcon,
40 ChevronDownIcon,
41 ChevronRightIcon,
42 ChevronUpIcon,
32 CircleAlertIcon,43 CircleAlertIcon,
33 EyeIcon,44 EyeIcon,
34 GlobeIcon,45 GlobeIcon,
35 HammerIcon,46 HammerIcon,
36 type LucideIcon,47 type LucideIcon,
48 MessageCircleIcon,
49 MinusIcon,
37 SquarePenIcon,50 SquarePenIcon,
38 TerminalIcon,51 TerminalIcon,
39 Undo2Icon,52 Undo2Icon,
40 WrenchIcon,53 WrenchIcon,
54 XIcon,
41 ZapIcon,55 ZapIcon,
42} from "lucide-react";56} from "lucide-react";
43import { Button } from "../ui/button";57import { Button } from "../ui/button";
44import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";58import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
45import { ProposedPlanCard } from "./ProposedPlanCard";59import { ProposedPlanCard } from "./ProposedPlanCard";
46import { ChangedFilesCard } from "./ChangedFilesTree";60import { ChangedFilesTree } from "./ChangedFilesTree";
61import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
47import { MessageCopyButton } from "./MessageCopyButton";62import { MessageCopyButton } from "./MessageCopyButton";
48import {63import {
49 computeStableMessagesTimelineRows,64 computeStableMessagesTimelineRows,
...@@ -53,6 +68,7 @@ import {...@@ -53,6 +68,7 @@ import {
53 resolveAssistantMessageCopyState,68 resolveAssistantMessageCopyState,
54 type StableMessagesTimelineRowsState,69 type StableMessagesTimelineRowsState,
55 type MessagesTimelineRow,70 type MessagesTimelineRow,
71 type TimelineLatestTurn,
56} from "./MessagesTimeline.logic";72} from "./MessagesTimeline.logic";
57import { TerminalContextInlineChip } from "./TerminalContextInlineChip";73import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
58import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";74import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -63,7 +79,7 @@ import {...@@ -63,7 +79,7 @@ import {
63import { cn } from "~/lib/utils";79import { cn } from "~/lib/utils";
64import { useUiStateStore } from "~/uiStateStore";80import { useUiStateStore } from "~/uiStateStore";
65import { type TimestampFormat } from "@t3tools/contracts/settings";81import { type TimestampFormat } from "@t3tools/contracts/settings";
66import { formatTimestamp } from "../../timestampFormat";82import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
6783
68import {84import {
69 buildInlineTerminalContextText,85 buildInlineTerminalContextText,
...@@ -96,11 +112,13 @@ interface TimelineRowSharedState {...@@ -96,11 +112,13 @@ interface TimelineRowSharedState {
96 onRevertUserMessage: (messageId: MessageId) => void;112 onRevertUserMessage: (messageId: MessageId) => void;
97 onImageExpand: (preview: ExpandedImagePreview) => void;113 onImageExpand: (preview: ExpandedImagePreview) => void;
98 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;114 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
115 onToggleTurnFold: (turnId: TurnId) => void;
99}116}
100117
101interface TimelineRowActivityState {118interface TimelineRowActivityState {
102 isWorking: boolean;119 isWorking: boolean;
103 isRevertingCheckpoint: boolean;120 isRevertingCheckpoint: boolean;
121 activeTurnInProgress: boolean;
104}122}
105123
106const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);124const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -116,12 +134,10 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d...@@ -116,12 +134,10 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d
116interface MessagesTimelineProps {134interface MessagesTimelineProps {
117 isWorking: boolean;135 isWorking: boolean;
118 activeTurnInProgress: boolean;136 activeTurnInProgress: boolean;
119 activeTurnId?: TurnId | null;
120 activeTurnStartedAt: string | null;137 activeTurnStartedAt: string | null;
121 listRef: React.RefObject<LegendListRef | null>;138 listRef: React.RefObject<LegendListRef | null>;
122 timelineEntries: ReturnType<typeof deriveTimelineEntries>;139 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
123 completionDividerBeforeEntryId: string | null;140 latestTurn: TimelineLatestTurn | null;
124 completionSummary: string | null;
125 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;141 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
126 routeThreadKey: string;142 routeThreadKey: string;
127 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;143 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
...@@ -145,12 +161,10 @@ interface MessagesTimelineProps {...@@ -145,12 +161,10 @@ interface MessagesTimelineProps {
145export const MessagesTimeline = memo(function MessagesTimeline({161export const MessagesTimeline = memo(function MessagesTimeline({
146 isWorking,162 isWorking,
147 activeTurnInProgress,163 activeTurnInProgress,
148 activeTurnId,
149 activeTurnStartedAt,164 activeTurnStartedAt,
150 listRef,165 listRef,
151 timelineEntries,166 timelineEntries,
152 completionDividerBeforeEntryId,167 latestTurn,
153 completionSummary,
154 turnDiffSummaryByAssistantMessageId,168 turnDiffSummaryByAssistantMessageId,
155 routeThreadKey,169 routeThreadKey,
156 onOpenTurnDiff,170 onOpenTurnDiff,
...@@ -166,26 +180,90 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -166,26 +180,90 @@ export const MessagesTimeline = memo(function MessagesTimeline({
166 skills = EMPTY_TIMELINE_SKILLS,180 skills = EMPTY_TIMELINE_SKILLS,
167 onIsAtEndChange,181 onIsAtEndChange,
168}: MessagesTimelineProps) {182}: MessagesTimelineProps) {
183 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
184
185 // Toggling a fold inserts/removes rows between the fold row and the final
186 // message — everything above the trigger is unchanged, so the trigger stays
187 // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do
188 // exactly that (pin the bottom content when row data changes while scrolled
189 // to the end), yanking the trigger out of view. Suppress it for the frames
190 // in which the toggle's data change and item measurements settle.
191 const [foldToggleSettling, setFoldToggleSettling] = useState(false);
192 const onToggleTurnFold = useCallback((turnId: TurnId) => {
193 setFoldToggleSettling(true);
194 setExpandedTurnIds((existing) => {
195 const next = new Set(existing);
196 if (next.has(turnId)) {
197 next.delete(turnId);
198 } else {
199 next.add(turnId);
200 }
201 return next;
202 });
203 }, []);
204 useEffect(() => {
205 if (!foldToggleSettling) {
206 return;
207 }
208 let secondFrameId: number | null = null;
209 const firstFrameId = window.requestAnimationFrame(() => {
210 secondFrameId = window.requestAnimationFrame(() => {
211 setFoldToggleSettling(false);
212 });
213 });
214 return () => {
215 window.cancelAnimationFrame(firstFrameId);
216 if (secondFrameId !== null) {
217 window.cancelAnimationFrame(secondFrameId);
218 }
219 };
220 }, [foldToggleSettling]);
221
222 // An in-session interrupt leaves its turn expanded so the user keeps their
223 // place; the next turn (or a reload, since this is local state) folds it.
224 const previousLatestTurnRef = useRef(latestTurn);
225 useEffect(() => {
226 const previous = previousLatestTurnRef.current;
227 previousLatestTurnRef.current = latestTurn;
228 if (!latestTurn || previous?.turnId === undefined) {
229 return;
230 }
231 if (latestTurn.turnId === previous.turnId) {
232 if (previous.state === "running" && latestTurn.state === "interrupted") {
233 setExpandedTurnIds((existing) => {
234 const next = new Set(existing);
235 next.add(latestTurn.turnId);
236 return next;
237 });
238 }
239 return;
240 }
241 setExpandedTurnIds((existing) => {
242 if (!existing.has(previous.turnId)) {
243 return existing;
244 }
245 const next = new Set(existing);
246 next.delete(previous.turnId);
247 return next;
248 });
249 }, [latestTurn]);
250
169 const rawRows = useMemo(251 const rawRows = useMemo(
170 () =>252 () =>
171 deriveMessagesTimelineRows({253 deriveMessagesTimelineRows({
172 timelineEntries,254 timelineEntries,
173 completionDividerBeforeEntryId,255 latestTurn,
174 completionSummary,256 expandedTurnIds,
175 isWorking,257 isWorking,
176 activeTurnInProgress,
177 activeTurnId: activeTurnId ?? null,
178 activeTurnStartedAt,258 activeTurnStartedAt,
179 turnDiffSummaryByAssistantMessageId,259 turnDiffSummaryByAssistantMessageId,
180 revertTurnCountByUserMessageId,260 revertTurnCountByUserMessageId,
181 }),261 }),
182 [262 [
183 timelineEntries,263 timelineEntries,
184 completionDividerBeforeEntryId,264 latestTurn,
185 completionSummary,265 expandedTurnIds,
186 isWorking,266 isWorking,
187 activeTurnInProgress,
188 activeTurnId,
189 activeTurnStartedAt,267 activeTurnStartedAt,
190 turnDiffSummaryByAssistantMessageId,268 turnDiffSummaryByAssistantMessageId,
191 revertTurnCountByUserMessageId,269 revertTurnCountByUserMessageId,
...@@ -230,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -230,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
230 onRevertUserMessage,308 onRevertUserMessage,
231 onImageExpand,309 onImageExpand,
232 onOpenTurnDiff,310 onOpenTurnDiff,
311 onToggleTurnFold,
233 }),312 }),
234 [313 [
235 timestampFormat,314 timestampFormat,
...@@ -242,14 +321,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -242,14 +321,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
242 onRevertUserMessage,321 onRevertUserMessage,
243 onImageExpand,322 onImageExpand,
244 onOpenTurnDiff,323 onOpenTurnDiff,
324 onToggleTurnFold,
245 ],325 ],
246 );326 );
247 const activityState = useMemo<TimelineRowActivityState>(327 const activityState = useMemo<TimelineRowActivityState>(
248 () => ({328 () => ({
249 isWorking,329 isWorking,
250 isRevertingCheckpoint,330 isRevertingCheckpoint,
331 activeTurnInProgress,
251 }),332 }),
252 [isRevertingCheckpoint, isWorking],333 [activeTurnInProgress, isRevertingCheckpoint, isWorking],
253 );334 );
254335
255 // Stable renderItem — no closure deps. Row components read shared state336 // Stable renderItem — no closure deps. Row components read shared state
...@@ -283,7 +364,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -283,7 +364,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
283 renderItem={renderItem}364 renderItem={renderItem}
284 estimatedItemSize={90}365 estimatedItemSize={90}
285 initialScrollAtEnd366 initialScrollAtEnd
286 maintainScrollAtEnd367 maintainScrollAtEnd={!foldToggleSettling}
287 maintainScrollAtEndThreshold={0.1}368 maintainScrollAtEndThreshold={0.1}
288 maintainVisibleContentPosition369 maintainVisibleContentPosition
289 onScroll={handleScroll}370 onScroll={handleScroll}
...@@ -313,7 +394,11 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -313,7 +394,11 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
313 return (394 return (
314 <div395 <div
315 className={cn(396 className={cn(
316 "pb-4",397 // Commentary (non-terminal assistant) rows carry no metadata row, so
398 // they sit closer to the work that follows them.
399 row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta
400 ? "pb-2"
401 : "pb-4",
317 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,402 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
318 )}403 )}
319 data-timeline-row-id={row.id}404 data-timeline-row-id={row.id}
...@@ -322,6 +407,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -322,6 +407,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
322 data-message-role={row.kind === "message" ? row.message.role : undefined}407 data-message-role={row.kind === "message" ? row.message.role : undefined}
323 >408 >
324 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}409 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
410 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
325 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}411 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
326 {row.kind === "message" && row.message.role === "assistant" ? (412 {row.kind === "message" && row.message.role === "assistant" ? (
327 <AssistantTimelineRow row={row} />413 <AssistantTimelineRow row={row} />
...@@ -340,8 +426,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -340,8 +426,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
340 const canRevertAgentWork = typeof row.revertTurnCount === "number";426 const canRevertAgentWork = typeof row.revertTurnCount === "number";
341427
342 return (428 return (
343 <div className="flex justify-end">429 <div className="group flex flex-col items-end gap-1">
344 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">430 <div className="relative max-w-[80%] rounded-2xl border border-border bg-secondary p-3">
345 {userImages.length > 0 && (431 {userImages.length > 0 && (
346 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">432 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
347 {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (433 {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
...@@ -380,21 +466,26 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -380,21 +466,26 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
380 terminalContexts={terminalContexts}466 terminalContexts={terminalContexts}
381 skills={ctx.skills}467 skills={ctx.skills}
382 markdownCwd={ctx.markdownCwd}468 markdownCwd={ctx.markdownCwd}
383 footer={
384 <>
385 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
386 {displayedUserMessage.copyText && (
387 <MessageCopyButton text={displayedUserMessage.copyText} />
388 )}
389 {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
390 </div>
391 <p className="text-right text-xs text-muted-foreground/50">
392 {formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
393 </p>
394 </>
395 }
396 />469 />
397 </div>470 </div>
471 <div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
472 <div className="flex shrink-0 items-center gap-2">
473 <Tooltip>
474 <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
475 {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)}
476 </TooltipTrigger>
477 <TooltipPopup>
478 {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
479 </TooltipPopup>
480 </Tooltip>
481 <div className="flex items-center gap-0.5">
482 {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
483 {displayedUserMessage.copyText && (
484 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
485 )}
486 </div>
487 </div>
488 </div>
398 </div>489 </div>
399 );490 );
400}491}
...@@ -410,7 +501,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {...@@ -410,7 +501,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
410 <Button501 <Button
411 type="button"502 type="button"
412 size="xs"503 size="xs"
413 variant="outline"504 variant="ghost"
414 disabled={activity.isRevertingCheckpoint || activity.isWorking}505 disabled={activity.isRevertingCheckpoint || activity.isWorking}
415 onClick={() => ctx.onRevertUserMessage(messageId)}506 onClick={() => ctx.onRevertUserMessage(messageId)}
416 aria-label="Revert to this message"507 aria-label="Revert to this message"
...@@ -424,16 +515,33 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {...@@ -424,16 +515,33 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
424 );515 );
425}516}
426517
518function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
519 const ctx = use(TimelineRowCtx);
520 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
521
522 return (
523 <div className="border-b border-border/60 pb-2 pt-1">
524 <button
525 type="button"
526 aria-expanded={row.expanded}
527 data-scroll-anchor-ignore
528 onClick={() => ctx.onToggleTurnFold(row.turnId)}
529 className="flex items-center gap-1 px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground"
530 >
531 <span>{row.label}</span>
532 <Icon className="size-3.5" />
533 </button>
534 </div>
535 );
536}
537
427function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {538function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
428 const ctx = use(TimelineRowCtx);539 const ctx = use(TimelineRowCtx);
429 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");540 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
430541
431 return (542 return (
432 <>543 <>
433 {row.showCompletionDivider && (544 <div className="relative min-w-0 px-1 py-0.5">
434 <AssistantCompletionDivider completionSummary={row.completionSummary} />
435 )}
436 <div className="min-w-0 px-1 py-0.5">
437 <ChatMarkdown545 <ChatMarkdown
438 text={messageText}546 text={messageText}
439 cwd={ctx.markdownCwd}547 cwd={ctx.markdownCwd}
...@@ -446,41 +554,34 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -446,41 +554,34 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
446 resolvedTheme={ctx.resolvedTheme}554 resolvedTheme={ctx.resolvedTheme}
447 onOpenTurnDiff={ctx.onOpenTurnDiff}555 onOpenTurnDiff={ctx.onOpenTurnDiff}
448 />556 />
449 <div className="mt-1.5 flex items-center gap-2">557 {row.showAssistantMeta ? (
450 <p className="text-[10px] text-muted-foreground/30">558 <div className="mt-1.5 flex items-center gap-2 text-xs tabular-nums opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover/assistant:opacity-100">
451 {row.message.streaming ? (559 <AssistantCopyButton row={row} />
452 <LiveMessageMeta560 {!row.message.streaming && (
453 createdAt={row.message.createdAt}561 <Tooltip>
454 durationStart={row.durationStart}562 <TooltipTrigger
455 timestampFormat={ctx.timestampFormat}563 render={<p className="text-muted-foreground text-xs tabular-nums" />}
456 />564 >
457 ) : (565 {formatShortTimestamp(
458 formatMessageMeta(566 row.message.completedAt ?? row.message.createdAt,
459 row.message.createdAt,567 ctx.timestampFormat,
460 formatElapsed(row.durationStart, row.message.completedAt),568 )}
461 ctx.timestampFormat,569 </TooltipTrigger>
462 )570 <TooltipPopup>
571 {formatChatTimestampTooltip(
572 row.message.completedAt ?? row.message.createdAt,
573 ctx.timestampFormat,
574 )}
575 </TooltipPopup>
576 </Tooltip>
463 )}577 )}
464 </p>578 </div>
465 <AssistantCopyButton row={row} />579 ) : null}
466 </div>
467 </div>580 </div>
468 </>581 </>
469 );582 );
470}583}
471584
472function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) {
473 return (
474 <div className="my-3 flex items-center gap-3">
475 <span className="h-px flex-1 bg-border" />
476 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
477 {completionSummary ? `Response • ${completionSummary}` : "Response"}
478 </span>
479 <span className="h-px flex-1 bg-border" />
480 </div>
481 );
482}
483
484function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {585function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
485 const assistantCopyState = resolveAssistantMessageCopyState({586 const assistantCopyState = resolveAssistantMessageCopyState({
486 text: row.message.text ?? null,587 text: row.message.text ?? null,
...@@ -492,16 +593,7 @@ function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "messa...@@ -492,16 +593,7 @@ function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "messa
492 return null;593 return null;
493 }594 }
494595
495 return (596 return <MessageCopyButton text={assistantCopyState.text ?? ""} variant="ghost" />;
496 <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100">
497 <MessageCopyButton
498 text={assistantCopyState.text ?? ""}
499 size="icon-xs"
500 variant="outline"
501 className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
502 />
503 </div>
504 );
505}597}
506598
507function ProposedPlanTimelineRow({599function ProposedPlanTimelineRow({
...@@ -526,7 +618,7 @@ function ProposedPlanTimelineRow({...@@ -526,7 +618,7 @@ function ProposedPlanTimelineRow({
526function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {618function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
527 return (619 return (
528 <div className="py-0.5 pl-1.5">620 <div className="py-0.5 pl-1.5">
529 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">621 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
530 <span className="inline-flex items-center gap-[3px]">622 <span className="inline-flex items-center gap-[3px]">
531 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />623 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
532 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />624 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
...@@ -567,41 +659,11 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -567,41 +659,11 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
567 return () => clearInterval(id);659 return () => clearInterval(id);
568 }, [createdAt]);660 }, [createdAt]);
569661
570 return <span ref={textRef}>{initialText}</span>;662 return (
571}663 <span ref={textRef} className="tabular-nums">
572664 {initialText}
573/** Live timestamp + elapsed duration for a streaming assistant message. */665 </span>
574function LiveMessageMeta({666 );
575 createdAt,
576 durationStart,
577 timestampFormat,
578}: {
579 createdAt: string;
580 durationStart: string | null | undefined;
581 timestampFormat: TimestampFormat;
582}) {
583 const textRef = useRef<HTMLSpanElement>(null);
584 const initialText = formatLiveMessageMetaNow(createdAt, durationStart, timestampFormat);
585
586 useEffect(() => {
587 const updateText = () => {
588 if (textRef.current) {
589 textRef.current.textContent = formatLiveMessageMetaNow(
590 createdAt,
591 durationStart,
592 timestampFormat,
593 );
594 }
595 };
596 updateText();
597 if (!durationStart) {
598 return;
599 }
600 const id = setInterval(updateText, 1000);
601 return () => clearInterval(id);
602 }, [createdAt, durationStart, timestampFormat]);
603
604 return <span ref={textRef}>{initialText}</span>;
605}667}
606668
607// ---------------------------------------------------------------------------669// ---------------------------------------------------------------------------
...@@ -609,8 +671,7 @@ function LiveMessageMeta({...@@ -609,8 +671,7 @@ function LiveMessageMeta({
609// re-render only the affected row, not the entire list.671// re-render only the affected row, not the entire list.
610// ---------------------------------------------------------------------------672// ---------------------------------------------------------------------------
611673
612/** Owns its own expand/collapse state so toggling re-renders only this row.674/** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */
613 * State resets on unmount which is fine — work groups start collapsed. */
614const WorkGroupSection = memo(function WorkGroupSection({675const WorkGroupSection = memo(function WorkGroupSection({
615 groupedEntries,676 groupedEntries,
616}: {677}: {
...@@ -618,38 +679,53 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -618,38 +679,53 @@ const WorkGroupSection = memo(function WorkGroupSection({
618}) {679}) {
619 const { workspaceRoot } = use(TimelineRowCtx);680 const { workspaceRoot } = use(TimelineRowCtx);
620 const [isExpanded, setIsExpanded] = useState(false);681 const [isExpanded, setIsExpanded] = useState(false);
621 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;682 const nonEmptyEntries = useMemo(
683 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),
684 [groupedEntries],
685 );
686 const hasOverflow = nonEmptyEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
622 const visibleEntries =687 const visibleEntries =
623 hasOverflow && !isExpanded688 hasOverflow && !isExpanded
624 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)689 ? nonEmptyEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
625 : groupedEntries;690 : nonEmptyEntries;
626 const hiddenCount = groupedEntries.length - visibleEntries.length;691 const hiddenCount = nonEmptyEntries.length - visibleEntries.length;
627 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");692 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
628 const showHeader = hasOverflow || !onlyToolEntries;693 const headerTitle = onlyToolEntries
629 const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";694 ? nonEmptyEntries.length === 1
695 ? "1 tool call"
696 : `${nonEmptyEntries.length} tool calls`
697 : "work log";
698
699 if (nonEmptyEntries.length === 0) return null;
630700
631 return (701 return (
632 <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">702 <div className="rounded-2xl border border-input bg-background p-2 pt-3 shadow-xs/5 not-dark:bg-clip-padding dark:bg-input/32">
633 {showHeader && (703 <div className="mb-2 flex items-center justify-between gap-2 px-2">
634 <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">704 <p className="font-medium text-foreground text-xs">{headerTitle}</p>
635 <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">705 {hasOverflow && (
636 {groupLabel} ({groupedEntries.length})706 <button
637 </p>707 type="button"
638 {hasOverflow && (708 className="inline-flex cursor-pointer items-center gap-1 text-muted-foreground text-xs transition-colors duration-150 hover:text-foreground/80"
639 <button709 onClick={() => setIsExpanded((v) => !v)}
640 type="button"710 >
641 className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"711 {isExpanded ? (
642 onClick={() => setIsExpanded((v) => !v)}712 <>
643 >713 Show less
644 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}714 <ChevronUpIcon className="size-3.5 shrink-0 opacity-80" />
645 </button>715 </>
646 )}716 ) : (
647 </div>717 <>
648 )}718 Show {hiddenCount} more
719 <ChevronDownIcon className="size-3.5 shrink-0 opacity-80" />
720 </>
721 )}
722 </button>
723 )}
724 </div>
649 <div className="space-y-0.5">725 <div className="space-y-0.5">
650 {visibleEntries.map((workEntry) => (726 {visibleEntries.map((workEntry) => (
651 <SimpleWorkEntryRow727 <SimpleWorkEntryRow
652 key={`work-row:${workEntry.id}`}728 key={workEntry.id}
653 workEntry={workEntry}729 workEntry={workEntry}
654 workspaceRoot={workspaceRoot}730 workspaceRoot={workspaceRoot}
655 />731 />
...@@ -706,18 +782,50 @@ function AssistantChangedFilesSectionInner({...@@ -706,18 +782,50 @@ function AssistantChangedFilesSectionInner({
706 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,782 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
707 );783 );
708 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);784 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
785 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
786 const changedFileCountLabel = String(checkpointFiles.length);
709787
710 return (788 return (
711 <ChangedFilesCard789 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
712 turnId={turnSummary.turnId}790 <div className="sticky top-2 z-10 mb-1.5 flex items-center justify-between gap-2 bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:absolute before:inset-x-0 before:-top-2 before:h-2 before:bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:content-['']">
713 files={checkpointFiles}791 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
714 allDirectoriesExpanded={allDirectoriesExpanded}792 <span>Changed files ({changedFileCountLabel})</span>
715 resolvedTheme={resolvedTheme}793 {hasNonZeroStat(summaryStat) && (
716 onToggleAllDirectories={() =>794 <>
717 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)795 <span className="mx-1">•</span>
718 }796 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
719 onOpenTurnDiff={onOpenTurnDiff}797 </>
720 />798 )}
799 </p>
800 <div className="flex items-center gap-1.5">
801 <Button
802 type="button"
803 size="xs"
804 variant="outline"
805 data-scroll-anchor-ignore
806 onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}
807 >
808 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
809 </Button>
810 <Button
811 type="button"
812 size="xs"
813 variant="outline"
814 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
815 >
816 View diff
817 </Button>
818 </div>
819 </div>
820 <ChangedFilesTree
821 key={`changed-files-tree:${turnSummary.turnId}`}
822 turnId={turnSummary.turnId}
823 files={checkpointFiles}
824 allDirectoriesExpanded={allDirectoriesExpanded}
825 resolvedTheme={resolvedTheme}
826 onOpenTurnDiff={onOpenTurnDiff}
827 />
828 </div>
721 );829 );
722}830}
723831
...@@ -1075,24 +1183,6 @@ function formatWorkingTimerNow(startIso: string): string {...@@ -1075,24 +1183,6 @@ function formatWorkingTimerNow(startIso: string): string {
1075 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";1183 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
1076}1184}
10771185
1078function formatLiveMessageMetaNow(
1079 createdAt: string,
1080 durationStart: string | null | undefined,
1081 timestampFormat: TimestampFormat,
1082): string {
1083 const elapsed = durationStart ? formatElapsed(durationStart, new Date().toISOString()) : null;
1084 return formatMessageMeta(createdAt, elapsed, timestampFormat);
1085}
1086
1087function formatMessageMeta(
1088 createdAt: string,
1089 duration: string | null,
1090 timestampFormat: TimestampFormat,
1091): string {
1092 if (!duration) return formatTimestamp(createdAt, timestampFormat);
1093 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
1094}
1095
1096function workToneIcon(tone: TimelineWorkEntry["tone"]): {1186function workToneIcon(tone: TimelineWorkEntry["tone"]): {
1097 icon: LucideIcon;1187 icon: LucideIcon;
1098 className: string;1188 className: string;
...@@ -1112,7 +1202,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {...@@ -1112,7 +1202,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
1112 if (tone === "info") {1202 if (tone === "info") {
1113 return {1203 return {
1114 icon: CheckIcon,1204 icon: CheckIcon,
1115 className: "text-foreground/92",1205 className: "text-muted-foreground",
1116 };1206 };
1117 }1207 }
1118 return {1208 return {
...@@ -1121,13 +1211,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {...@@ -1121,13 +1211,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
1121 };1211 };
1122}1212}
11231213
1124function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
1125 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
1126 if (tone === "tool") return "text-muted-foreground/70";
1127 if (tone === "thinking") return "text-muted-foreground/50";
1128 return "text-muted-foreground/40";
1129}
1130
1131function workEntryPreview(1214function workEntryPreview(
1132 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,1215 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
1133 workspaceRoot: string | undefined,1216 workspaceRoot: string | undefined,
...@@ -1153,7 +1236,30 @@ function workEntryRawCommand(...@@ -1153,7 +1236,30 @@ function workEntryRawCommand(
1153 return rawCommand === workEntry.command.trim() ? null : rawCommand;1236 return rawCommand === workEntry.command.trim() ? null : rawCommand;
1154}1237}
11551238
1239function buildToolCallExpandedBody(workEntry: TimelineWorkEntry): string | null {
1240 const blocks: string[] = [];
1241 if (workEntry.detail?.trim()) {
1242 blocks.push(workEntry.detail.trim());
1243 }
1244 const raw = workEntryRawCommand(workEntry);
1245 if (raw?.trim()) {
1246 blocks.push(`Full command\n${raw.trim()}`);
1247 } else if (workEntry.command?.trim()) {
1248 blocks.push(`Command\n${workEntry.command.trim()}`);
1249 }
1250 if (blocks.length === 0) {
1251 return null;
1252 }
1253 return blocks.join("\n\n");
1254}
1255
1156function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {1256function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
1257 if (
1258 workEntry.sourceActivityKind === "user-input.requested" ||
1259 workEntry.sourceActivityKind === "user-input.resolved"
1260 ) {
1261 return MessageCircleIcon;
1262 }
1157 if (workEntry.requestKind === "command") return TerminalIcon;1263 if (workEntry.requestKind === "command") return TerminalIcon;
1158 if (workEntry.requestKind === "file-read") return EyeIcon;1264 if (workEntry.requestKind === "file-read") return EyeIcon;
1159 if (workEntry.requestKind === "file-change") return SquarePenIcon;1265 if (workEntry.requestKind === "file-change") return SquarePenIcon;
...@@ -1193,13 +1299,18 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {...@@ -1193,13 +1299,18 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
1193 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));1299 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
1194}1300}
11951301
1302const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
1303
1196const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {1304const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
1197 workEntry: TimelineWorkEntry;1305 workEntry: TimelineWorkEntry;
1198 workspaceRoot: string | undefined;1306 workspaceRoot: string | undefined;
1199}) {1307}) {
1200 const { workEntry, workspaceRoot } = props;1308 const { workEntry, workspaceRoot } = props;
1309 const activity = use(TimelineRowActivityCtx);
1310 const [expanded, setExpanded] = useState(false);
1201 const iconConfig = workToneIcon(workEntry.tone);1311 const iconConfig = workToneIcon(workEntry.tone);
1202 const EntryIcon = workEntryIcon(workEntry);1312 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
1313 const EntryIcon = showWarningIndicator ? XIcon : workEntryIcon(workEntry);
1203 const heading = toolWorkEntryHeading(workEntry);1314 const heading = toolWorkEntryHeading(workEntry);
1204 const rawPreview = workEntryPreview(workEntry, workspaceRoot);1315 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
1205 const preview =1316 const preview =
...@@ -1210,78 +1321,199 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -1210,78 +1321,199 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
1210 : rawPreview;1321 : rawPreview;
1211 const rawCommand = workEntryRawCommand(workEntry);1322 const rawCommand = workEntryRawCommand(workEntry);
1212 const displayText = preview ? `${heading} - ${preview}` : heading;1323 const displayText = preview ? `${heading} - ${preview}` : heading;
1324 const expandedBody = buildToolCallExpandedBody(workEntry);
1325 const canExpand = expandedBody !== null;
1213 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;1326 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
1214 const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail;1327 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
1328 const showDestructiveRowStyle =
1329 showFailedIndicator &&
1330 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
1331 const iconWrapperClass = cn(
1332 "flex size-5 shrink-0 items-center justify-center",
1333 showWarningIndicator
1334 ? "text-destructive"
1335 : showDestructiveRowStyle
1336 ? "text-destructive"
1337 : workEntry.tone === "tool" || showFailedIndicator
1338 ? "text-muted-foreground"
1339 : iconConfig.className,
1340 );
1341 const headingClass = showWarningIndicator
1342 ? "font-medium text-warning"
1343 : showDestructiveRowStyle
1344 ? "font-medium text-destructive"
1345 : "font-medium text-foreground";
1346 const turnSettled = !activity.activeTurnInProgress;
1347 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);
1348 const showSuccessIndicator =
1349 workEntryIndicatesToolSuccess(workEntry) ||
1350 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
1351 const rowToggleProps = canExpand
1352 ? {
1353 role: "button" as const,
1354 tabIndex: 0 as const,
1355 onClick: () => setExpanded((v) => !v),
1356 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
1357 if (e.key === "Enter" || e.key === " ") {
1358 e.preventDefault();
1359 setExpanded((v) => !v);
1360 }
1361 },
1362 }
1363 : {};
12151364
1216 return (1365 return (
1217 <div className="rounded-lg px-1 py-1">1366 <div
1367 className={cn(
1368 "flex flex-col rounded-xl border border-transparent px-2 py-2 transition-colors",
1369 canExpand &&
1370 "cursor-pointer hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
1371 )}
1372 {...rowToggleProps}
1373 >
1218 <div className="flex items-center gap-2 transition-[opacity,translate] duration-200">1374 <div className="flex items-center gap-2 transition-[opacity,translate] duration-200">
1219 <span1375 <span className={iconWrapperClass}>
1220 className={cn("flex size-5 shrink-0 items-center justify-center", iconConfig.className)}1376 <EntryIcon className="block size-3.5 shrink-0" aria-hidden />
1221 >
1222 <EntryIcon className="size-3" />
1223 </span>1377 </span>
1224 <div className="min-w-0 flex-1 overflow-hidden">1378 <div className="flex min-w-0 flex-1 items-center gap-2">
1225 {rawCommand ? (1379 <div className="min-w-0 flex-1 overflow-hidden">
1226 <div className="max-w-full">1380 {rawCommand ? (
1381 <div className="max-w-full">
1382 <Tooltip>
1383 <TooltipTrigger
1384 closeDelay={0}
1385 delay={75}
1386 onClick={stopRowToggle}
1387 onPointerDown={stopRowToggle}
1388 render={
1389 <p
1390 className="flex min-w-0 w-full items-center gap-2 text-xs leading-5"
1391 aria-label={displayText}
1392 />
1393 }
1394 >
1395 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
1396 {preview && (
1397 <span className="min-w-0 flex-1 cursor-default truncate text-muted-foreground transition-colors hover:text-muted-foreground/90 focus-visible:text-muted-foreground/90">
1398 {preview}
1399 </span>
1400 )}
1401 </TooltipTrigger>
1402 <TooltipPopup
1403 align="start"
1404 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
1405 side="top"
1406 >
1407 <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap">
1408 {rawCommand}
1409 </div>
1410 </TooltipPopup>
1411 </Tooltip>
1412 </div>
1413 ) : (
1227 <Tooltip>1414 <Tooltip>
1228 <TooltipTrigger1415 <TooltipTrigger
1229 closeDelay={0}1416 className="block min-w-0 w-full text-left"
1230 delay={75}1417 aria-label={displayText}
1231 render={1418 onClick={stopRowToggle}
1232 <p1419 onPointerDown={stopRowToggle}
1233 className={cn(
1234 "truncate rounded-sm text-xs leading-5",
1235 workToneClass(workEntry.tone),
1236 preview ? "text-muted-foreground/70" : "",
1237 )}
1238 aria-label={displayText}
1239 />
1240 }
1241 >1420 >
1242 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>1421 <p className="flex min-w-0 w-full items-center gap-2 text-[11px] leading-5">
1243 {heading}1422 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
1244 </span>1423 {preview && (
1245 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}1424 <span className="min-w-0 flex-1 truncate text-muted-foreground">
1425 {preview}
1426 </span>
1427 )}
1428 </p>
1246 </TooltipTrigger>1429 </TooltipTrigger>
1247 <TooltipPopup1430 <TooltipPopup className="max-w-[min(720px,calc(100vw-2rem))]">
1248 align="start"1431 <p className="whitespace-pre-wrap wrap-break-word text-xs leading-5">
1249 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"1432 {displayText}
1250 side="top"1433 </p>
1251 >
1252 <div className="max-w-[min(56rem,calc(100vw-2rem))] overflow-x-auto px-1.5 py-1 font-mono text-[11px] leading-4 whitespace-nowrap">
1253 {rawCommand}
1254 </div>
1255 </TooltipPopup>1434 </TooltipPopup>
1256 </Tooltip>1435 </Tooltip>
1257 </div>1436 )}
1258 ) : (1437 </div>
1259 <Tooltip>1438 <div className="flex shrink-0 items-center gap-0.5">
1260 <TooltipTrigger className="block min-w-0 w-full text-left" aria-label={displayText}>1439 <span
1261 <p1440 className="flex size-5 shrink-0 items-center justify-center"
1441 aria-hidden={!canExpand}
1442 >
1443 {canExpand ? (
1444 <ChevronDownIcon
1262 className={cn(1445 className={cn(
1263 "truncate text-[11px] leading-5",1446 "size-3.5 shrink-0 text-muted-foreground opacity-80 transition-transform duration-200",
1264 workToneClass(workEntry.tone),1447 expanded && "rotate-180",
1265 preview ? "text-muted-foreground/70" : "",
1266 )}1448 )}
1267 >1449 aria-hidden
1268 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>1450 />
1269 {heading}1451 ) : null}
1270 </span>1452 </span>
1271 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}1453 <span className="flex size-5 shrink-0 items-center justify-center">
1272 </p>1454 {showFailedIndicator ? (
1273 </TooltipTrigger>1455 <Tooltip>
1274 <TooltipPopup className="max-w-[min(720px,calc(100vw-2rem))]">1456 <TooltipTrigger
1275 <p className="whitespace-pre-wrap wrap-break-word text-xs leading-5">1457 render={
1276 {displayText}1458 <span
1277 </p>1459 className="flex size-5 items-center justify-center"
1278 </TooltipPopup>1460 aria-label="Tool call failed"
1279 </Tooltip>1461 />
1280 )}1462 }
1463 >
1464 <XIcon className="block size-3.5 shrink-0 text-destructive" aria-hidden />
1465 </TooltipTrigger>
1466 <TooltipPopup>Failed</TooltipPopup>
1467 </Tooltip>
1468 ) : showSuccessIndicator ? (
1469 <Tooltip>
1470 <TooltipTrigger
1471 render={<span className="flex size-5 items-center justify-center" />}
1472 >
1473 <span className="inline-flex size-5 items-center justify-center text-muted-foreground">
1474 <CheckIcon
1475 className="block size-3.5 shrink-0 stroke-current"
1476 stroke="currentColor"
1477 aria-hidden
1478 />
1479 </span>
1480 </TooltipTrigger>
1481 <TooltipPopup>Completed</TooltipPopup>
1482 </Tooltip>
1483 ) : showNeutralIndicator ? (
1484 <Tooltip>
1485 <TooltipTrigger
1486 render={<span className="flex size-5 items-center justify-center" />}
1487 >
1488 <MinusIcon
1489 className="block size-3.5 shrink-0 text-muted-foreground opacity-80"
1490 aria-hidden
1491 />
1492 </TooltipTrigger>
1493 <TooltipPopup>Empty</TooltipPopup>
1494 </Tooltip>
1495 ) : null}
1496 </span>
1497 </div>
1281 </div>1498 </div>
1282 </div>1499 </div>
1283 {hasChangedFiles && !previewIsChangedFiles && (1500 {expanded && canExpand && expandedBody ? (
1284 <div className="mt-1 flex flex-wrap gap-1 pl-6">1501 <div
1502 className="mt-2 ms-7 border-s border-border/45 ps-3 pt-0.5"
1503 onClick={stopRowToggle}
1504 onPointerDown={stopRowToggle}
1505 >
1506 <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted-foreground">
1507 {expandedBody}
1508 </pre>
1509 </div>
1510 ) : null}
1511 {hasChangedFiles && (
1512 <div
1513 className="mt-1 flex flex-wrap gap-1"
1514 onClick={stopRowToggle}
1515 onPointerDown={stopRowToggle}
1516 >
1285 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {1517 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
1286 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);1518 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
1287 return (1519 return (
apps/web/src/session-logic.test.ts+218-113
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
99
10import {10import {
11 deriveCompletionDividerBeforeEntryId,
12 deriveActiveWorkStartedAt,11 deriveActiveWorkStartedAt,
13 deriveActivePlanState,12 deriveActivePlanState,
14 derivePendingApprovals,13 derivePendingApprovals,
...@@ -18,8 +17,10 @@ import {...@@ -18,8 +17,10 @@ import {
18 findLatestProposedPlan,17 findLatestProposedPlan,
19 findSidebarProposedPlan,18 findSidebarProposedPlan,
20 hasActionableProposedPlan,19 hasActionableProposedPlan,
21 hasToolActivityForTurn,
22 isLatestTurnSettled,20 isLatestTurnSettled,
21 workEntryIndicatesToolFailure,
22 workEntryIndicatesToolNeutralStatus,
23 workEntryIndicatesToolSuccess,
23} from "./session-logic";24} from "./session-logic";
2425
25let nextActivityId = 0;26let nextActivityId = 0;
...@@ -572,6 +573,123 @@ describe("findSidebarProposedPlan", () => {...@@ -572,6 +573,123 @@ describe("findSidebarProposedPlan", () => {
572 });573 });
573});574});
574575
576describe("workEntryIndicatesToolFailure", () => {
577 const base = {
578 id: "w1",
579 createdAt: "2026-01-01T00:00:00.000Z",
580 label: "Read",
581 };
582
583 it("is true for error tone", () => {
584 expect(
585 workEntryIndicatesToolFailure({
586 ...base,
587 tone: "error",
588 detail: "nothing special",
589 }),
590 ).toBe(true);
591 });
592
593 it("is true when lifecycle says failed even if detail is empty", () => {
594 expect(
595 workEntryIndicatesToolFailure({
596 ...base,
597 tone: "tool",
598 toolLifecycleStatus: "failed",
599 }),
600 ).toBe(true);
601 });
602
603 it("detects file-not-found style tool output with completed lifecycle", () => {
604 expect(
605 workEntryIndicatesToolFailure({
606 ...base,
607 tone: "tool",
608 toolLifecycleStatus: "completed",
609 detail: "File not found: C:\\foo\\nonexistent.ts",
610 }),
611 ).toBe(true);
612 });
613
614 it("detects glob no files and PowerShell command errors", () => {
615 expect(
616 workEntryIndicatesToolFailure({
617 ...base,
618 label: "Glob",
619 tone: "tool",
620 detail: "No files found",
621 }),
622 ).toBe(true);
623 expect(
624 workEntryIndicatesToolFailure({
625 ...base,
626 label: "Bash",
627 tone: "tool",
628 detail:
629 "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.",
630 }),
631 ).toBe(true);
632 });
633
634 it("is false for successful completed tools", () => {
635 expect(
636 workEntryIndicatesToolFailure({
637 ...base,
638 tone: "tool",
639 toolLifecycleStatus: "completed",
640 detail: "Found 3 matching files",
641 }),
642 ).toBe(false);
643 });
644
645 it("treats successful tool rows as success candidates", () => {
646 expect(
647 workEntryIndicatesToolSuccess({
648 ...base,
649 tone: "tool",
650 toolLifecycleStatus: "completed",
651 detail: "ok",
652 }),
653 ).toBe(true);
654 expect(
655 workEntryIndicatesToolSuccess({
656 ...base,
657 tone: "tool",
658 toolLifecycleStatus: "inProgress",
659 detail: "…",
660 }),
661 ).toBe(false);
662 expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false);
663 expect(
664 workEntryIndicatesToolNeutralStatus({
665 ...base,
666 tone: "tool",
667 toolLifecycleStatus: "inProgress",
668 detail: "…",
669 }),
670 ).toBe(true);
671 expect(
672 workEntryIndicatesToolNeutralStatus({
673 ...base,
674 tone: "tool",
675 toolLifecycleStatus: "completed",
676 detail: "ok",
677 }),
678 ).toBe(false);
679 });
680
681 it("does not run heuristics on non-tool info rows", () => {
682 expect(
683 workEntryIndicatesToolFailure({
684 ...base,
685 label: "Context compacted",
686 tone: "info",
687 detail: "File not found in conversation",
688 }),
689 ).toBe(false);
690 });
691});
692
575describe("deriveWorkLogEntries", () => {693describe("deriveWorkLogEntries", () => {
576 it("omits tool started entries and keeps completed entries", () => {694 it("omits tool started entries and keeps completed entries", () => {
577 const activities: OrchestrationThreadActivity[] = [695 const activities: OrchestrationThreadActivity[] = [
...@@ -589,7 +707,7 @@ describe("deriveWorkLogEntries", () => {...@@ -589,7 +707,7 @@ describe("deriveWorkLogEntries", () => {
589 }),707 }),
590 ];708 ];
591709
592 const entries = deriveWorkLogEntries(activities, undefined);710 const entries = deriveWorkLogEntries(activities);
593 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);711 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
594 });712 });
595713
...@@ -618,7 +736,7 @@ describe("deriveWorkLogEntries", () => {...@@ -618,7 +736,7 @@ describe("deriveWorkLogEntries", () => {
618 }),736 }),
619 ];737 ];
620738
621 const entries = deriveWorkLogEntries(activities, undefined);739 const entries = deriveWorkLogEntries(activities);
622 expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);740 expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);
623 });741 });
624742
...@@ -634,7 +752,7 @@ describe("deriveWorkLogEntries", () => {...@@ -634,7 +752,7 @@ describe("deriveWorkLogEntries", () => {
634 }),752 }),
635 ];753 ];
636754
637 const entries = deriveWorkLogEntries(activities, undefined);755 const entries = deriveWorkLogEntries(activities);
638 expect(entries[0]?.label).toBe("Searching for API endpoints");756 expect(entries[0]?.label).toBe("Searching for API endpoints");
639 });757 });
640758
...@@ -650,25 +768,33 @@ describe("deriveWorkLogEntries", () => {...@@ -650,25 +768,33 @@ describe("deriveWorkLogEntries", () => {
650 }),768 }),
651 ];769 ];
652770
653 const entries = deriveWorkLogEntries(activities, undefined);771 const entries = deriveWorkLogEntries(activities);
654 expect(entries[0]?.label).toBe("Failed to deploy changes");772 expect(entries[0]?.label).toBe("Failed to deploy changes");
655 expect(entries[0]?.tone).toBe("error");773 expect(entries[0]?.tone).toBe("error");
656 });774 });
657775
658 it("filters by turn id when provided", () => {776 it("keeps tool entries from every turn and tags each with its turn id", () => {
659 const activities: OrchestrationThreadActivity[] = [777 const activities: OrchestrationThreadActivity[] = [
660 makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }),
661 makeActivity({778 makeActivity({
662 id: "turn-2",779 id: "turn-1-tool",
780 turnId: "turn-1",
781 summary: "Tool call complete",
782 kind: "tool.completed",
783 }),
784 makeActivity({
785 id: "turn-2-tool",
663 turnId: "turn-2",786 turnId: "turn-2",
664 summary: "Tool call complete",787 summary: "Tool call complete",
665 kind: "tool.completed",788 kind: "tool.completed",
666 }),789 }),
667 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
668 ];790 ];
669791
670 const entries = deriveWorkLogEntries(activities, TurnId.make("turn-2"));792 const entries = deriveWorkLogEntries(activities);
671 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);793 expect(entries.map((entry) => entry.id)).toEqual(["turn-1-tool", "turn-2-tool"]);
794 expect(entries.map((entry) => entry.turnId)).toEqual([
795 TurnId.make("turn-1"),
796 TurnId.make("turn-2"),
797 ]);
672 });798 });
673799
674 it("omits checkpoint captured info entries", () => {800 it("omits checkpoint captured info entries", () => {
...@@ -688,7 +814,7 @@ describe("deriveWorkLogEntries", () => {...@@ -688,7 +814,7 @@ describe("deriveWorkLogEntries", () => {
688 }),814 }),
689 ];815 ];
690816
691 const entries = deriveWorkLogEntries(activities, undefined);817 const entries = deriveWorkLogEntries(activities);
692 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);818 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
693 });819 });
694820
...@@ -724,7 +850,7 @@ describe("deriveWorkLogEntries", () => {...@@ -724,7 +850,7 @@ describe("deriveWorkLogEntries", () => {
724 }),850 }),
725 ];851 ];
726852
727 const entries = deriveWorkLogEntries(activities, undefined);853 const entries = deriveWorkLogEntries(activities);
728 expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]);854 expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]);
729 });855 });
730856
...@@ -746,7 +872,7 @@ describe("deriveWorkLogEntries", () => {...@@ -746,7 +872,7 @@ describe("deriveWorkLogEntries", () => {
746 }),872 }),
747 ];873 ];
748874
749 const entries = deriveWorkLogEntries(activities, undefined);875 const entries = deriveWorkLogEntries(activities);
750 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);876 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
751 });877 });
752878
...@@ -767,10 +893,47 @@ describe("deriveWorkLogEntries", () => {...@@ -767,10 +893,47 @@ describe("deriveWorkLogEntries", () => {
767 }),893 }),
768 ];894 ];
769895
770 const [entry] = deriveWorkLogEntries(activities, undefined);896 const [entry] = deriveWorkLogEntries(activities);
771 expect(entry?.command).toBe("bun run lint");897 expect(entry?.command).toBe("bun run lint");
772 });898 });
773899
900 it("extracts failed tool lifecycle status from item payloads", () => {
901 const activities: OrchestrationThreadActivity[] = [
902 makeActivity({
903 id: "tool-failed",
904 kind: "tool.updated",
905 summary: "Glob",
906 tone: "tool",
907 payload: {
908 itemType: "mcp_tool_call",
909 status: "failed",
910 detail: "No files found",
911 },
912 }),
913 ];
914
915 const [entry] = deriveWorkLogEntries(activities);
916 expect(entry?.toolLifecycleStatus).toBe("failed");
917 });
918
919 it("defaults tool.completed entries to completed lifecycle status", () => {
920 const activities: OrchestrationThreadActivity[] = [
921 makeActivity({
922 id: "tool-done",
923 kind: "tool.completed",
924 summary: "Glob",
925 tone: "tool",
926 payload: {
927 itemType: "mcp_tool_call",
928 detail: "Found 3 files",
929 },
930 }),
931 ];
932
933 const [entry] = deriveWorkLogEntries(activities);
934 expect(entry?.toolLifecycleStatus).toBe("completed");
935 });
936
774 it("unwraps PowerShell command wrappers for displayed command text", () => {937 it("unwraps PowerShell command wrappers for displayed command text", () => {
775 const activities: OrchestrationThreadActivity[] = [938 const activities: OrchestrationThreadActivity[] = [
776 makeActivity({939 makeActivity({
...@@ -788,7 +951,7 @@ describe("deriveWorkLogEntries", () => {...@@ -788,7 +951,7 @@ describe("deriveWorkLogEntries", () => {
788 }),951 }),
789 ];952 ];
790953
791 const [entry] = deriveWorkLogEntries(activities, undefined);954 const [entry] = deriveWorkLogEntries(activities);
792 expect(entry?.command).toBe("bun run lint");955 expect(entry?.command).toBe("bun run lint");
793 expect(entry?.rawCommand).toBe(956 expect(entry?.rawCommand).toBe(
794 "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",957 "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
...@@ -812,7 +975,7 @@ describe("deriveWorkLogEntries", () => {...@@ -812,7 +975,7 @@ describe("deriveWorkLogEntries", () => {
812 }),975 }),
813 ];976 ];
814977
815 const [entry] = deriveWorkLogEntries(activities, undefined);978 const [entry] = deriveWorkLogEntries(activities);
816 expect(entry?.command).toBe("rg -n foo .");979 expect(entry?.command).toBe("rg -n foo .");
817 expect(entry?.rawCommand).toBe(980 expect(entry?.rawCommand).toBe(
818 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."',981 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."',
...@@ -833,7 +996,7 @@ describe("deriveWorkLogEntries", () => {...@@ -833,7 +996,7 @@ describe("deriveWorkLogEntries", () => {
833 }),996 }),
834 ];997 ];
835998
836 const [entry] = deriveWorkLogEntries(activities, undefined);999 const [entry] = deriveWorkLogEntries(activities);
837 expect(entry?.command).toBe('rg -n -F "new Date()" .');1000 expect(entry?.command).toBe('rg -n -F "new Date()" .');
838 expect(entry?.rawCommand).toBe(1001 expect(entry?.rawCommand).toBe(
839 `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`,1002 `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`,
...@@ -857,7 +1020,7 @@ describe("deriveWorkLogEntries", () => {...@@ -857,7 +1020,7 @@ describe("deriveWorkLogEntries", () => {
857 }),1020 }),
858 ];1021 ];
8591022
860 const [entry] = deriveWorkLogEntries(activities, undefined);1023 const [entry] = deriveWorkLogEntries(activities);
861 expect(entry?.command).toBe("bash script.sh");1024 expect(entry?.command).toBe("bash script.sh");
862 expect(entry?.rawCommand).toBeUndefined();1025 expect(entry?.rawCommand).toBeUndefined();
863 });1026 });
...@@ -886,7 +1049,7 @@ describe("deriveWorkLogEntries", () => {...@@ -886,7 +1049,7 @@ describe("deriveWorkLogEntries", () => {
886 }),1049 }),
887 ];1050 ];
8881051
889 const [entry] = deriveWorkLogEntries(activities, undefined);1052 const [entry] = deriveWorkLogEntries(activities);
890 expect(entry).toMatchObject({1053 expect(entry).toMatchObject({
891 command: "bun run dev",1054 command: "bun run dev",
892 detail: '{ "dev": "vite dev --port 3000" }',1055 detail: '{ "dev": "vite dev --port 3000" }',
...@@ -915,7 +1078,7 @@ describe("deriveWorkLogEntries", () => {...@@ -915,7 +1078,7 @@ describe("deriveWorkLogEntries", () => {
915 }),1078 }),
916 ];1079 ];
9171080
918 const [entry] = deriveWorkLogEntries(activities, undefined);1081 const [entry] = deriveWorkLogEntries(activities);
919 expect(entry?.changedFiles).toEqual([1082 expect(entry?.changedFiles).toEqual([
920 "apps/web/src/components/ChatView.tsx",1083 "apps/web/src/components/ChatView.tsx",
921 "apps/web/src/session-logic.ts",1084 "apps/web/src/session-logic.ts",
...@@ -936,7 +1099,7 @@ describe("deriveWorkLogEntries", () => {...@@ -936,7 +1099,7 @@ describe("deriveWorkLogEntries", () => {
936 }),1099 }),
937 ];1100 ];
9381101
939 const [entry] = deriveWorkLogEntries(activities, undefined);1102 const [entry] = deriveWorkLogEntries(activities);
940 expect(entry?.toolTitle).toBe("Read File");1103 expect(entry?.toolTitle).toBe("Read File");
941 expect(entry?.detail).toBeUndefined();1104 expect(entry?.detail).toBeUndefined();
942 });1105 });
...@@ -980,7 +1143,7 @@ describe("deriveWorkLogEntries", () => {...@@ -980,7 +1143,7 @@ describe("deriveWorkLogEntries", () => {
980 }),1143 }),
981 ];1144 ];
9821145
983 const entries = deriveWorkLogEntries(activities, undefined);1146 const entries = deriveWorkLogEntries(activities);
984 expect(entries).toHaveLength(1);1147 expect(entries).toHaveLength(1);
985 expect(entries[0]).toMatchObject({1148 expect(entries[0]).toMatchObject({
986 id: "grep-complete",1149 id: "grep-complete",
...@@ -1029,7 +1192,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1029,7 +1192,7 @@ describe("deriveWorkLogEntries", () => {
1029 }),1192 }),
1030 ];1193 ];
10311194
1032 const entries = deriveWorkLogEntries(activities, undefined);1195 const entries = deriveWorkLogEntries(activities);
1033 expect(entries).toHaveLength(1);1196 expect(entries).toHaveLength(1);
1034 expect(entries[0]).toMatchObject({1197 expect(entries[0]).toMatchObject({
1035 id: "read-complete",1198 id: "read-complete",
...@@ -1063,7 +1226,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1063,7 +1226,7 @@ describe("deriveWorkLogEntries", () => {
1063 }),1226 }),
1064 ];1227 ];
10651228
1066 const [entry] = deriveWorkLogEntries(activities, undefined);1229 const [entry] = deriveWorkLogEntries(activities);
1067 expect(entry).toMatchObject({1230 expect(entry).toMatchObject({
1068 id: "cursor-command-complete",1231 id: "cursor-command-complete",
1069 label: "Ran command",1232 label: "Ran command",
...@@ -1105,7 +1268,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1105,7 +1268,7 @@ describe("deriveWorkLogEntries", () => {
1105 }),1268 }),
1106 ];1269 ];
11071270
1108 const entries = deriveWorkLogEntries(activities, undefined);1271 const entries = deriveWorkLogEntries(activities);
1109 expect(entries).toHaveLength(1);1272 expect(entries).toHaveLength(1);
1110 expect(entries[0]).toMatchObject({1273 expect(entries[0]).toMatchObject({
1111 id: "legacy-read-complete",1274 id: "legacy-read-complete",
...@@ -1157,7 +1320,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1157,7 +1320,7 @@ describe("deriveWorkLogEntries", () => {
1157 }),1320 }),
1158 ];1321 ];
11591322
1160 const entries = deriveWorkLogEntries(activities, undefined);1323 const entries = deriveWorkLogEntries(activities);
11611324
1162 expect(entries).toHaveLength(1);1325 expect(entries).toHaveLength(1);
1163 expect(entries[0]).toMatchObject({1326 expect(entries[0]).toMatchObject({
...@@ -1219,7 +1382,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1219,7 +1382,7 @@ describe("deriveWorkLogEntries", () => {
1219 }),1382 }),
1220 ];1383 ];
12211384
1222 const entries = deriveWorkLogEntries(activities, undefined);1385 const entries = deriveWorkLogEntries(activities);
12231386
1224 expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]);1387 expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]);
1225 });1388 });
...@@ -1261,7 +1424,7 @@ describe("deriveWorkLogEntries", () => {...@@ -1261,7 +1424,7 @@ describe("deriveWorkLogEntries", () => {
1261 }),1424 }),
1262 ];1425 ];
12631426
1264 const entries = deriveWorkLogEntries(activities, undefined);1427 const entries = deriveWorkLogEntries(activities);
12651428
1266 expect(entries).toHaveLength(1);1429 expect(entries).toHaveLength(1);
1267 expect(entries[0]?.id).toBe("a-complete-same-timestamp");1430 expect(entries[0]?.id).toBe("a-complete-same-timestamp");
...@@ -1311,105 +1474,47 @@ describe("deriveTimelineEntries", () => {...@@ -1311,105 +1474,47 @@ describe("deriveTimelineEntries", () => {
1311 },1474 },
1312 });1475 });
1313 });1476 });
1314
1315 it("anchors the completion divider to latestTurn.assistantMessageId before timestamp fallback", () => {
1316 const entries = deriveTimelineEntries(
1317 [
1318 {
1319 id: MessageId.make("assistant-earlier"),
1320 role: "assistant",
1321 text: "progress update",
1322 createdAt: "2026-02-23T00:00:01.000Z",
1323 streaming: false,
1324 },
1325 {
1326 id: MessageId.make("assistant-final"),
1327 role: "assistant",
1328 text: "final answer",
1329 createdAt: "2026-02-23T00:00:01.000Z",
1330 streaming: false,
1331 },
1332 ],
1333 [],
1334 [],
1335 );
1336
1337 expect(
1338 deriveCompletionDividerBeforeEntryId(entries, {
1339 assistantMessageId: MessageId.make("assistant-final"),
1340 startedAt: "2026-02-23T00:00:00.000Z",
1341 completedAt: "2026-02-23T00:00:02.000Z",
1342 }),
1343 ).toBe("assistant-final");
1344 });
1345});1477});
13461478
1347describe("deriveWorkLogEntries context window handling", () => {1479describe("deriveWorkLogEntries context window handling", () => {
1348 it("excludes context window updates from the work log", () => {1480 it("excludes context window updates from the work log", () => {
1349 const entries = deriveWorkLogEntries(1481 const entries = deriveWorkLogEntries([
1350 [1482 makeActivity({
1351 makeActivity({1483 id: "context-1",
1352 id: "context-1",1484 turnId: "turn-1",
1353 turnId: "turn-1",1485 kind: "context-window.updated",
1354 kind: "context-window.updated",1486 summary: "Context window updated",
1355 summary: "Context window updated",1487 tone: "info",
1356 tone: "info",1488 }),
1357 }),1489 makeActivity({
1358 makeActivity({1490 id: "tool-1",
1359 id: "tool-1",1491 turnId: "turn-1",
1360 turnId: "turn-1",1492 kind: "tool.completed",
1361 kind: "tool.completed",1493 summary: "Ran command",
1362 summary: "Ran command",1494 tone: "tool",
1363 tone: "tool",1495 }),
1364 }),1496 ]);
1365 ],
1366 TurnId.make("turn-1"),
1367 );
13681497
1369 expect(entries).toHaveLength(1);1498 expect(entries).toHaveLength(1);
1370 expect(entries[0]?.label).toBe("Ran command");1499 expect(entries[0]?.label).toBe("Ran command");
1371 });1500 });
13721501
1373 it("keeps context compaction activities as normal work log entries", () => {1502 it("keeps context compaction activities as normal work log entries", () => {
1374 const entries = deriveWorkLogEntries(1503 const entries = deriveWorkLogEntries([
1375 [1504 makeActivity({
1376 makeActivity({1505 id: "compaction-1",
1377 id: "compaction-1",1506 turnId: "turn-1",
1378 turnId: "turn-1",1507 kind: "context-compaction",
1379 kind: "context-compaction",1508 summary: "Context compacted",
1380 summary: "Context compacted",1509 tone: "info",
1381 tone: "info",1510 }),
1382 }),1511 ]);
1383 ],
1384 TurnId.make("turn-1"),
1385 );
13861512
1387 expect(entries).toHaveLength(1);1513 expect(entries).toHaveLength(1);
1388 expect(entries[0]?.label).toBe("Context compacted");1514 expect(entries[0]?.label).toBe("Context compacted");
1389 });1515 });
1390});1516});
13911517
1392describe("hasToolActivityForTurn", () => {
1393 it("returns false when turn id is missing", () => {
1394 const activities: OrchestrationThreadActivity[] = [
1395 makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }),
1396 ];
1397
1398 expect(hasToolActivityForTurn(activities, undefined)).toBe(false);
1399 expect(hasToolActivityForTurn(activities, null)).toBe(false);
1400 });
1401
1402 it("returns true only for matching tool activity in the target turn", () => {
1403 const activities: OrchestrationThreadActivity[] = [
1404 makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }),
1405 makeActivity({ id: "info-1", turnId: "turn-2", kind: "turn.completed", tone: "info" }),
1406 ];
1407
1408 expect(hasToolActivityForTurn(activities, TurnId.make("turn-1"))).toBe(true);
1409 expect(hasToolActivityForTurn(activities, TurnId.make("turn-2"))).toBe(false);
1410 });
1411});
1412
1413describe("isLatestTurnSettled", () => {1518describe("isLatestTurnSettled", () => {
1414 const latestTurn = {1519 const latestTurn = {
1415 turnId: TurnId.make("turn-1"),1520 turnId: TurnId.make("turn-1"),
apps/web/src/session-logic.ts+168-60
...@@ -53,9 +53,17 @@ export const PROVIDER_OPTIONS: Array<{...@@ -53,9 +53,17 @@ export const PROVIDER_OPTIONS: Array<{
53 },53 },
54];54];
5555
56export type WorkLogToolLifecycleStatus =
57 | "inProgress"
58 | "completed"
59 | "failed"
60 | "declined"
61 | "stopped";
62
56export interface WorkLogEntry {63export interface WorkLogEntry {
57 id: string;64 id: string;
58 createdAt: string;65 createdAt: string;
66 turnId?: TurnId | null;
59 label: string;67 label: string;
60 detail?: string;68 detail?: string;
61 command?: string;69 command?: string;
...@@ -65,6 +73,10 @@ export interface WorkLogEntry {...@@ -65,6 +73,10 @@ export interface WorkLogEntry {
65 toolTitle?: string;73 toolTitle?: string;
66 itemType?: ToolLifecycleItemType;74 itemType?: ToolLifecycleItemType;
67 requestKind?: PendingApproval["requestKind"];75 requestKind?: PendingApproval["requestKind"];
76 /** From runtime item / task payload `status` when present (e.g. tool.updated). */
77 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
78 /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */
79 sourceActivityKind?: OrchestrationThreadActivity["kind"];
68}80}
6981
70interface DerivedWorkLogEntry extends WorkLogEntry {82interface DerivedWorkLogEntry extends WorkLogEntry {
...@@ -126,6 +138,129 @@ export type TimelineEntry =...@@ -126,6 +138,129 @@ export type TimelineEntry =
126 entry: WorkLogEntry;138 entry: WorkLogEntry;
127 };139 };
128140
141export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
142 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
143 return true;
144 }
145 if (entry.command !== undefined && entry.command.trim().length > 0) {
146 return true;
147 }
148 if (entry.requestKind !== undefined) {
149 return true;
150 }
151 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
152}
153
154/** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */
155function toolDetailTextLooksLikeFailure(text: string): boolean {
156 const t = text.toLowerCase();
157 if (t.includes("file not found")) {
158 return true;
159 }
160 if (t.includes("no files found")) {
161 return true;
162 }
163 if (
164 t.includes("enoent") ||
165 t.includes("no such file or directory") ||
166 t.includes("no such file")
167 ) {
168 return true;
169 }
170 if (t.includes("cannot find path") && t.includes("because it does not exist")) {
171 return true;
172 }
173 if (t.includes("commandnotfoundexception")) {
174 return true;
175 }
176 if (t.includes("is not recognized as the name of a cmdlet")) {
177 return true;
178 }
179 if (t.includes("is not recognized") && t.includes("the term '")) {
180 return true;
181 }
182 if (t.includes("a parameter cannot be found that matches parameter name")) {
183 return true;
184 }
185 if (t.includes("command not found")) {
186 return true;
187 }
188 if (/<exited with exit code\s+[1-9]\d*\s*>/i.test(text)) {
189 return true;
190 }
191 if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) {
192 return true;
193 }
194 if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) {
195 return true;
196 }
197 return false;
198}
199
200/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */
201export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
202 if (entry.tone === "error") {
203 return true;
204 }
205 const ls = entry.toolLifecycleStatus;
206 if (ls === "failed" || ls === "declined") {
207 return true;
208 }
209 if (!workLogEntryIsToolLike(entry)) {
210 return false;
211 }
212 const parts: string[] = [];
213 if (entry.detail) {
214 parts.push(entry.detail);
215 }
216 if (entry.command) {
217 parts.push(entry.command);
218 }
219 const blob = parts.join("\n");
220 if (blob.length === 0) {
221 return false;
222 }
223 return toolDetailTextLooksLikeFailure(blob);
224}
225
226/** Tool/command row completed without failure (blue check affordance). */
227export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
228 if (!workLogEntryIsToolLike(entry)) {
229 return false;
230 }
231 if (workEntryIndicatesToolFailure(entry)) {
232 return false;
233 }
234 if (entry.tone === "thinking") {
235 return false;
236 }
237 const ls = entry.toolLifecycleStatus;
238 if (ls === "failed" || ls === "declined") {
239 return false;
240 }
241 if (ls === "inProgress") {
242 return false;
243 }
244 if (ls === "stopped") {
245 return false;
246 }
247 return true;
248}
249
250/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
251export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
252 if (!workLogEntryIsToolLike(entry)) {
253 return false;
254 }
255 if (workEntryIndicatesToolFailure(entry)) {
256 return false;
257 }
258 if (workEntryIndicatesToolSuccess(entry)) {
259 return false;
260 }
261 return true;
262}
263
129export function formatDuration(durationMs: number): string {264export function formatDuration(durationMs: number): string {
130 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";265 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
131 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;266 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
...@@ -485,12 +620,10 @@ export function hasActionableProposedPlan(...@@ -485,12 +620,10 @@ export function hasActionableProposedPlan(
485620
486export function deriveWorkLogEntries(621export function deriveWorkLogEntries(
487 activities: ReadonlyArray<OrchestrationThreadActivity>,622 activities: ReadonlyArray<OrchestrationThreadActivity>,
488 latestTurnId: TurnId | undefined,
489): WorkLogEntry[] {623): WorkLogEntry[] {
490 const ordered = [...activities].toSorted(compareActivitiesByOrder);624 const ordered = [...activities].toSorted(compareActivitiesByOrder);
491 const entries: DerivedWorkLogEntry[] = [];625 const entries: DerivedWorkLogEntry[] = [];
492 for (const activity of ordered) {626 for (const activity of ordered) {
493 if (latestTurnId && activity.turnId !== latestTurnId) continue;
494 if (activity.kind === "tool.started") continue;627 if (activity.kind === "tool.started") continue;
495 if (activity.kind === "task.started") continue;628 if (activity.kind === "task.started") continue;
496 if (activity.kind === "context-window.updated") continue;629 if (activity.kind === "context-window.updated") continue;
...@@ -498,9 +631,10 @@ export function deriveWorkLogEntries(...@@ -498,9 +631,10 @@ export function deriveWorkLogEntries(
498 if (isPlanBoundaryToolActivity(activity)) continue;631 if (isPlanBoundaryToolActivity(activity)) continue;
499 entries.push(toDerivedWorkLogEntry(activity));632 entries.push(toDerivedWorkLogEntry(activity));
500 }633 }
501 return collapseDerivedWorkLogEntries(entries).map(634 return collapseDerivedWorkLogEntries(entries).map((entry) => {
502 ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,635 const { activityKind, collapseKey: _collapseKey, ...rest } = entry;
503 );636 return Object.assign(rest, { sourceActivityKind: activityKind });
637 });
504}638}
505639
506function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {640function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
...@@ -515,6 +649,25 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool...@@ -515,6 +649,25 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
515 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");649 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
516}650}
517651
652function extractWorkLogToolLifecycleStatus(
653 payload: Record<string, unknown> | null,
654): WorkLogToolLifecycleStatus | undefined {
655 if (!payload) {
656 return undefined;
657 }
658 const s = payload.status;
659 if (
660 s === "inProgress" ||
661 s === "completed" ||
662 s === "failed" ||
663 s === "declined" ||
664 s === "stopped"
665 ) {
666 return s;
667 }
668 return undefined;
669}
670
518function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {671function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
519 const payload =672 const payload =
520 activity.payload && typeof activity.payload === "object"673 activity.payload && typeof activity.payload === "object"
...@@ -548,6 +701,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -548,6 +701,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
548 const entry: DerivedWorkLogEntry = {701 const entry: DerivedWorkLogEntry = {
549 id: activity.id,702 id: activity.id,
550 createdAt: activity.createdAt,703 createdAt: activity.createdAt,
704 turnId: activity.turnId,
551 label: taskLabel || activity.summary,705 label: taskLabel || activity.summary,
552 tone:706 tone:
553 activity.kind === "task.progress"707 activity.kind === "task.progress"
...@@ -583,6 +737,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -583,6 +737,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
583 if (toolCallId) {737 if (toolCallId) {
584 entry.toolCallId = toolCallId;738 entry.toolCallId = toolCallId;
585 }739 }
740 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
741 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
742 toolLifecycleStatus = "completed";
743 }
744 if (toolLifecycleStatus) {
745 entry.toolLifecycleStatus = toolLifecycleStatus;
746 }
586 const collapseKey = deriveToolLifecycleCollapseKey(entry);747 const collapseKey = deriveToolLifecycleCollapseKey(entry);
587 if (collapseKey) {748 if (collapseKey) {
588 entry.collapseKey = collapseKey;749 entry.collapseKey = collapseKey;
...@@ -643,6 +804,7 @@ function mergeDerivedWorkLogEntries(...@@ -643,6 +804,7 @@ function mergeDerivedWorkLogEntries(
643 const requestKind = next.requestKind ?? previous.requestKind;804 const requestKind = next.requestKind ?? previous.requestKind;
644 const collapseKey = next.collapseKey ?? previous.collapseKey;805 const collapseKey = next.collapseKey ?? previous.collapseKey;
645 const toolCallId = next.toolCallId ?? previous.toolCallId;806 const toolCallId = next.toolCallId ?? previous.toolCallId;
807 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
646 return {808 return {
647 ...previous,809 ...previous,
648 ...next,810 ...next,
...@@ -655,6 +817,7 @@ function mergeDerivedWorkLogEntries(...@@ -655,6 +817,7 @@ function mergeDerivedWorkLogEntries(
655 ...(requestKind ? { requestKind } : {}),817 ...(requestKind ? { requestKind } : {}),
656 ...(collapseKey ? { collapseKey } : {}),818 ...(collapseKey ? { collapseKey } : {}),
657 ...(toolCallId ? { toolCallId } : {}),819 ...(toolCallId ? { toolCallId } : {}),
820 ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}),
658 };821 };
659}822}
660823
...@@ -1160,14 +1323,6 @@ function compareActivityLifecycleRank(kind: string): number {...@@ -1160,14 +1323,6 @@ function compareActivityLifecycleRank(kind: string): number {
1160 return 1;1323 return 1;
1161}1324}
11621325
1163export function hasToolActivityForTurn(
1164 activities: ReadonlyArray<OrchestrationThreadActivity>,
1165 turnId: TurnId | null | undefined,
1166): boolean {
1167 if (!turnId) return false;
1168 return activities.some((activity) => activity.turnId === turnId && activity.tone === "tool");
1169}
1170
1171export function deriveTimelineEntries(1326export function deriveTimelineEntries(
1172 messages: ChatMessage[],1327 messages: ChatMessage[],
1173 proposedPlans: ProposedPlan[],1328 proposedPlans: ProposedPlan[],
...@@ -1196,53 +1351,6 @@ export function deriveTimelineEntries(...@@ -1196,53 +1351,6 @@ export function deriveTimelineEntries(
1196 );1351 );
1197}1352}
11981353
1199export function deriveCompletionDividerBeforeEntryId(
1200 timelineEntries: ReadonlyArray<TimelineEntry>,
1201 latestTurn: Pick<
1202 OrchestrationLatestTurn,
1203 "assistantMessageId" | "startedAt" | "completedAt"
1204 > | null,
1205): string | null {
1206 if (!latestTurn?.startedAt || !latestTurn.completedAt) {
1207 return null;
1208 }
1209
1210 if (latestTurn.assistantMessageId) {
1211 const exactMatch = timelineEntries.find(
1212 (timelineEntry) =>
1213 timelineEntry.kind === "message" &&
1214 timelineEntry.message.role === "assistant" &&
1215 timelineEntry.message.id === latestTurn.assistantMessageId,
1216 );
1217 if (exactMatch) {
1218 return exactMatch.id;
1219 }
1220 }
1221
1222 const turnStartedAt = Date.parse(latestTurn.startedAt);
1223 const turnCompletedAt = Date.parse(latestTurn.completedAt);
1224 if (Number.isNaN(turnStartedAt) || Number.isNaN(turnCompletedAt)) {
1225 return null;
1226 }
1227
1228 let inRangeMatch: string | null = null;
1229 let fallbackMatch: string | null = null;
1230 for (const timelineEntry of timelineEntries) {
1231 if (timelineEntry.kind !== "message" || timelineEntry.message.role !== "assistant") {
1232 continue;
1233 }
1234 const messageAt = Date.parse(timelineEntry.message.createdAt);
1235 if (Number.isNaN(messageAt) || messageAt < turnStartedAt) {
1236 continue;
1237 }
1238 fallbackMatch = timelineEntry.id;
1239 if (messageAt <= turnCompletedAt) {
1240 inRangeMatch = timelineEntry.id;
1241 }
1242 }
1243 return inRangeMatch ?? fallbackMatch;
1244}
1245
1246export function inferCheckpointTurnCountByTurnId(1354export function inferCheckpointTurnCountByTurnId(
1247 summaries: TurnDiffSummary[],1355 summaries: TurnDiffSummary[],
1248): Record<TurnId, number> {1356): Record<TurnId, number> {
apps/web/src/timestampFormat.ts+34
...@@ -44,6 +44,40 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma...@@ -44,6 +44,40 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma
44 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));44 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
45}45}
4646
47const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
48
49function ordinalSuffix(day: number): string {
50 const lastTwo = day % 100;
51 if (lastTwo >= 11 && lastTwo <= 13) return "th";
52 switch (day % 10) {
53 case 1:
54 return "st";
55 case 2:
56 return "nd";
57 case 3:
58 return "rd";
59 default:
60 return "th";
61 }
62}
63
64/**
65 * Long-form tooltip label, e.g. `12:04, 4th June`.
66 * Renders the wall-clock time without seconds followed by the ordinal day and month name.
67 */
68export function formatChatTimestampTooltip(
69 isoDate: string,
70 timestampFormat: TimestampFormat,
71): string {
72 const date = new Date(isoDate);
73 if (Number.isNaN(date.getTime())) return "";
74 const time = formatShortTimestamp(isoDate, timestampFormat);
75 const day = date.getDate();
76 const month = monthNameFormatter.format(date);
77 const year = date.getFullYear();
78 return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`;
79}
80
47export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {81export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
48 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));82 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
49}83}