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 {
5050 parseStandaloneComposerSlashCommand,
5151} from "../composer-logic";
5252import {
53 deriveCompletionDividerBeforeEntryId,
5453 derivePendingApprovals,
5554 derivePendingUserInputs,
5655 derivePhase,
......@@ -61,9 +60,7 @@ import {
6160 findLatestProposedPlan,
6261 deriveWorkLogEntries,
6362 hasActionableProposedPlan,
64 hasToolActivityForTurn,
6563 isLatestTurnSettled,
66 formatElapsed,
6764} from "../session-logic";
6865import { type LegendListRef } from "@legendapp/list/react";
6966import {
......@@ -1517,14 +1514,7 @@ export default function ChatView(props: ChatViewProps) {
15171514 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
15181515 const phase = derivePhase(activeThread?.session ?? null);
15191516 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
1520 const workLogEntries = useMemo(
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 );
1517 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
15281518 const pendingApprovals = useMemo(
15291519 () => derivePendingApprovals(threadActivities),
15301520 [threadActivities],
......@@ -1859,25 +1849,6 @@ export default function ChatView(props: ChatViewProps) {
18591849 return byUserMessageId;
18601850 }, [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]);
18811852 const gitCwd = activeProject
18821853 ? projectScriptCwd({
18831854 project: { cwd: activeProject.cwd },
......@@ -3885,12 +3856,10 @@ export default function ChatView(props: ChatViewProps) {
38853856 key={activeThread.id}
38863857 isWorking={isWorking}
38873858 activeTurnInProgress={isWorking || !latestTurnSettled}
3888 activeTurnId={activeLatestTurn?.turnId ?? null}
38893859 activeTurnStartedAt={activeWorkStartedAt}
38903860 listRef={legendListRef}
38913861 timelineEntries={timelineEntries}
3892 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
3893 completionSummary={completionSummary}
3862 latestTurn={activeLatestTurn}
38943863 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
38953864 activeThreadEnvironmentId={activeThread.environmentId}
38963865 routeThreadKey={routeThreadKey}
apps/web/src/components/chat/MessageCopyButton.tsx+2-2
......@@ -68,11 +68,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({
6868 type="button"
6969 size={size}
7070 variant={variant}
71 className={cn(className)}
71 className={cn("text-muted-foreground hover:text-foreground", className)}
7272 />
7373 }
7474 >
75 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
75 {isCopied ? <CheckIcon className="size-3 text-primary" /> : <CopyIcon className="size-3" />}
7676 </TooltipTrigger>
7777 <TooltipPopup>
7878 <p>Copy to clipboard</p>
apps/web/src/components/chat/MessagesTimeline.browser.tsx+81-9
......@@ -54,11 +54,9 @@ function buildProps() {
5454 return {
5555 isWorking: false,
5656 activeTurnInProgress: false,
57 activeTurnId: null,
5857 activeTurnStartedAt: null,
5958 listRef: createRef<LegendListRef | null>(),
60 completionDividerBeforeEntryId: null,
61 completionSummary: null,
59 latestTurn: null,
6260 turnDiffSummaryByAssistantMessageId: new Map(),
6361 routeThreadKey: "environment-local:thread-1",
6462 onOpenTurnDiff: vi.fn(),
......@@ -131,9 +129,9 @@ describe("MessagesTimeline", () => {
131129 entry: {
132130 id: "work-1",
133131 createdAt: "2026-04-13T12:00:00.000Z",
134 label: "thinking",
132 label: "read files",
135133 detail: "Inspecting repository state",
136 tone: "thinking",
134 tone: "tool",
137135 },
138136 },
139137 ]}
......@@ -144,7 +142,7 @@ describe("MessagesTimeline", () => {
144142 await expect
145143 .element(page.getByText("Send a message to start the conversation."))
146144 .not.toBeInTheDocument();
147 await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
145 await expect.element(page.getByText("Inspecting repository state")).toBeVisible();
148146 expect(document.querySelector('[data-testid="legend-list"] [title]')).toBeNull();
149147 } finally {
150148 await screen.unmount();
......@@ -231,16 +229,16 @@ describe("MessagesTimeline", () => {
231229 entry: {
232230 id: "work-1",
233231 createdAt: "2026-04-13T12:00:00.000Z",
234 label: "thinking",
232 label: "read files",
235233 detail: "Inspecting repository state",
236 tone: "thinking",
234 tone: "tool",
237235 },
238236 },
239237 ]}
240238 />,
241239 );
242240
243 await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible();
241 await expect.element(page.getByText("Inspecting repository state")).toBeVisible();
244242 expect(props.onIsAtEndChange).toHaveBeenCalledWith(true);
245243 expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false });
246244 expect(requestAnimationFrameSpy).toHaveBeenCalled();
......@@ -415,4 +413,78 @@ describe("MessagesTimeline", () => {
415413 await screen.unmount();
416414 }
417415 });
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 });
418490});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+348-12
......@@ -250,7 +250,7 @@ describe("deriveMessagesTimelineRows", () => {
250250 },
251251 },
252252 ],
253 completionDividerBeforeEntryId: "assistant-final-entry",
253 expandedTurnIds: new Set(["turn-1" as never]),
254254 isWorking: false,
255255 activeTurnStartedAt: null,
256256 turnDiffSummaryByAssistantMessageId: new Map(),
......@@ -265,7 +265,6 @@ describe("deriveMessagesTimelineRows", () => {
265265 expect(assistantRows).toHaveLength(2);
266266 expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
267267 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
268 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
269268 });
270269
271270 it("marks only the active assistant turn as streaming for copy controls", () => {
......@@ -300,11 +299,13 @@ describe("deriveMessagesTimelineRows", () => {
300299 },
301300 },
302301 ],
303 completionDividerBeforeEntryId: "assistant-two-entry",
304 completionSummary: "done",
302 latestTurn: {
303 turnId: "turn-2" as never,
304 state: "running",
305 startedAt: "2026-01-01T00:00:19Z",
306 completedAt: null,
307 },
305308 isWorking: false,
306 activeTurnInProgress: true,
307 activeTurnId: "turn-2" as never,
308309 activeTurnStartedAt: null,
309310 turnDiffSummaryByAssistantMessageId: new Map(),
310311 revertTurnCountByUserMessageId: new Map(),
......@@ -316,9 +317,7 @@ describe("deriveMessagesTimelineRows", () => {
316317 );
317318
318319 expect(assistantRows[0]?.assistantCopyStreaming).toBe(false);
319 expect(assistantRows[0]?.completionSummary).toBeNull();
320320 expect(assistantRows[1]?.assistantCopyStreaming).toBe(true);
321 expect(assistantRows[1]?.completionSummary).toBe("done");
322321 });
323322
324323 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
......@@ -360,7 +359,6 @@ describe("deriveMessagesTimelineRows", () => {
360359 },
361360 },
362361 ],
363 completionDividerBeforeEntryId: null,
364362 isWorking: false,
365363 activeTurnStartedAt: null,
366364 turnDiffSummaryByAssistantMessageId: new Map([
......@@ -381,6 +379,347 @@ describe("deriveMessagesTimelineRows", () => {
381379 expect(userRow?.revertTurnCount).toBe(1);
382380 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
383381 });
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 });
384723});
385724
386725describe("computeStableMessagesTimelineRows", () => {
......@@ -417,7 +756,6 @@ describe("computeStableMessagesTimelineRows", () => {
417756 message: secondUserMessage,
418757 },
419758 ],
420 completionDividerBeforeEntryId: null,
421759 isWorking: false,
422760 activeTurnStartedAt: null,
423761 turnDiffSummaryByAssistantMessageId: new Map(),
......@@ -467,7 +805,6 @@ describe("computeStableMessagesTimelineRows", () => {
467805 entry: secondWorkEntry,
468806 },
469807 ],
470 completionDividerBeforeEntryId: null,
471808 isWorking: false,
472809 activeTurnStartedAt: null,
473810 turnDiffSummaryByAssistantMessageId: new Map(),
......@@ -522,7 +859,6 @@ describe("computeStableMessagesTimelineRows", () => {
522859 message: secondUserMessage,
523860 },
524861 ],
525 completionDividerBeforeEntryId: null,
526862 isWorking: false,
527863 activeTurnStartedAt: null,
528864 turnDiffSummaryByAssistantMessageId: new Map(),
apps/web/src/components/chat/MessagesTimeline.logic.ts+211-24
......@@ -1,9 +1,16 @@
11import * as Equal from "effect/Equal";
2import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";
2import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic";
33import { 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
815export interface TimelineDurationMessage {
916 id: string;
......@@ -12,6 +19,11 @@ export interface TimelineDurationMessage {
1219 completedAt?: string | undefined;
1320}
1421
22export type TimelineLatestTurn = Pick<
23 OrchestrationLatestTurn,
24 "turnId" | "state" | "startedAt" | "completedAt"
25>;
26
1527export type MessagesTimelineRow =
1628 | {
1729 kind: "work";
......@@ -19,14 +31,21 @@ export type MessagesTimelineRow =
1931 createdAt: string;
2032 groupedEntries: WorkLogEntry[];
2133 }
34 | {
35 kind: "turn-fold";
36 id: string;
37 createdAt: string;
38 turnId: TurnId;
39 label: string;
40 expanded: boolean;
41 }
2242 | {
2343 kind: "message";
2444 id: string;
2545 createdAt: string;
2646 message: ChatMessage;
2747 durationStart: string;
28 showCompletionDivider: boolean;
29 completionSummary: string | null;
48 showAssistantMeta: boolean;
3049 showAssistantCopyButton: boolean;
3150 assistantCopyStreaming: boolean;
3251 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
......@@ -110,13 +129,137 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<Timeli
110129 return new Set(lastAssistantMessageIdByResponseKey.values());
111130}
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
113258export function deriveMessagesTimelineRows(input: {
114259 timelineEntries: ReadonlyArray<TimelineEntry>;
115 completionDividerBeforeEntryId: string | null;
116 completionSummary?: string | null;
260 latestTurn?: TimelineLatestTurn | null;
261 expandedTurnIds?: ReadonlySet<TurnId>;
117262 isWorking: boolean;
118 activeTurnInProgress?: boolean;
119 activeTurnId?: TurnId | null;
120263 activeTurnStartedAt: string | null;
121264 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
122265 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
......@@ -126,6 +269,21 @@ export function deriveMessagesTimelineRows(input: {
126269 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
127270 );
128271 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
130288 for (let index = 0; index < input.timelineEntries.length; index += 1) {
131289 const timelineEntry = input.timelineEntries[index];
......@@ -133,12 +291,35 @@ export function deriveMessagesTimelineRows(input: {
133291 continue;
134292 }
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
136310 if (timelineEntry.kind === "work") {
137311 const groupedEntries = [timelineEntry.entry];
138312 let cursor = index + 1;
139313 while (cursor < input.timelineEntries.length) {
140314 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 }
142323 groupedEntries.push(nextEntry.entry);
143324 cursor += 1;
144325 }
......@@ -164,26 +345,28 @@ export function deriveMessagesTimelineRows(input: {
164345
165346 const assistantTurnStillInProgress =
166347 timelineEntry.message.role === "assistant" &&
167 input.activeTurnInProgress === true &&
168 input.activeTurnId != null &&
169 timelineEntry.message.turnId === input.activeTurnId;
348 unsettledTurnId !== null &&
349 timelineEntry.message.turnId === unsettledTurnId;
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 =
172358 timelineEntry.message.role === "assistant" &&
173 input.completionDividerBeforeEntryId === timelineEntry.id;
359 terminalAssistantMessageIds.has(timelineEntry.message.id) &&
360 !assistantTurnStillInProgress;
174361
175362 nextRows.push({
176363 kind: "message",
177364 id: timelineEntry.id,
178365 createdAt: timelineEntry.createdAt,
179366 message: timelineEntry.message,
180 durationStart:
181 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,
182 showCompletionDivider,
183 completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null,
184 showAssistantCopyButton:
185 timelineEntry.message.role === "assistant" &&
186 terminalAssistantMessageIds.has(timelineEntry.message.id),
367 durationStart,
368 showAssistantMeta,
369 showAssistantCopyButton: showAssistantMeta,
187370 assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,
188371 assistantTurnDiffSummary:
189372 timelineEntry.message.role === "assistant"
......@@ -235,6 +418,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
235418 case "working":
236419 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
238426 case "proposed-plan":
239427 return a.proposedPlan === (b as typeof a).proposedPlan;
240428
......@@ -246,8 +434,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
246434 return (
247435 a.message === bm.message &&
248436 a.durationStart === bm.durationStart &&
249 a.showCompletionDivider === bm.showCompletionDivider &&
250 a.completionSummary === bm.completionSummary &&
437 a.showAssistantMeta === bm.showAssistantMeta &&
251438 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
252439 a.assistantCopyStreaming === bm.assistantCopyStreaming &&
253440 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
apps/web/src/components/chat/MessagesTimeline.test.tsx+29-4
......@@ -94,11 +94,9 @@ function buildProps() {
9494 return {
9595 isWorking: false,
9696 activeTurnInProgress: false,
97 activeTurnId: null,
9897 activeTurnStartedAt: null,
9998 listRef: createRef<LegendListRef | null>(),
100 completionDividerBeforeEntryId: null,
101 completionSummary: null,
99 latestTurn: null,
102100 turnDiffSummaryByAssistantMessageId: new Map(),
103101 routeThreadKey: "environment-local:thread-1",
104102 onOpenTurnDiff: () => {},
......@@ -229,7 +227,7 @@ describe("MessagesTimeline", () => {
229227 );
230228
231229 expect(markup).toContain("Context compacted");
232 expect(markup).toContain("Work log");
230 expect(markup).toContain("work log");
233231 });
234232
235233 it("formats changed file paths from the workspace root", async () => {
......@@ -297,4 +295,31 @@ describe("MessagesTimeline", () => {
297295 expect(markup).not.toContain("&lt;review_comment");
298296 expect(markup).not.toContain("&lt;/review_comment&gt;");
299297 });
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 });
300325});
apps/web/src/components/chat/MessagesTimeline.tsx+469-237
......@@ -6,20 +6,28 @@ import {
66} from "@t3tools/contracts";
77import {
88 createContext,
9 Fragment,
910 memo,
1011 use,
1112 useCallback,
1213 useEffect,
13 Fragment,
1414 useMemo,
1515 useRef,
1616 useState,
17 type KeyboardEvent,
1718 type ReactNode,
1819} from "react";
1920import { LegendList, type LegendListRef } from "@legendapp/list/react";
2021import { 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";
2229import { type TurnDiffSummary } from "../../types";
30import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
2331import {
2432 getRenderablePatch,
2533 resolveDiffThemeName,
......@@ -29,21 +37,28 @@ import ChatMarkdown from "../ChatMarkdown";
2937import {
3038 BotIcon,
3139 CheckIcon,
40 ChevronDownIcon,
41 ChevronRightIcon,
42 ChevronUpIcon,
3243 CircleAlertIcon,
3344 EyeIcon,
3445 GlobeIcon,
3546 HammerIcon,
3647 type LucideIcon,
48 MessageCircleIcon,
49 MinusIcon,
3750 SquarePenIcon,
3851 TerminalIcon,
3952 Undo2Icon,
4053 WrenchIcon,
54 XIcon,
4155 ZapIcon,
4256} from "lucide-react";
4357import { Button } from "../ui/button";
4458import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
4559import { ProposedPlanCard } from "./ProposedPlanCard";
46import { ChangedFilesCard } from "./ChangedFilesTree";
60import { ChangedFilesTree } from "./ChangedFilesTree";
61import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4762import { MessageCopyButton } from "./MessageCopyButton";
4863import {
4964 computeStableMessagesTimelineRows,
......@@ -53,6 +68,7 @@ import {
5368 resolveAssistantMessageCopyState,
5469 type StableMessagesTimelineRowsState,
5570 type MessagesTimelineRow,
71 type TimelineLatestTurn,
5672} from "./MessagesTimeline.logic";
5773import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
5874import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -63,7 +79,7 @@ import {
6379import { cn } from "~/lib/utils";
6480import { useUiStateStore } from "~/uiStateStore";
6581import { type TimestampFormat } from "@t3tools/contracts/settings";
66import { formatTimestamp } from "../../timestampFormat";
82import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
6783
6884import {
6985 buildInlineTerminalContextText,
......@@ -96,11 +112,13 @@ interface TimelineRowSharedState {
96112 onRevertUserMessage: (messageId: MessageId) => void;
97113 onImageExpand: (preview: ExpandedImagePreview) => void;
98114 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
115 onToggleTurnFold: (turnId: TurnId) => void;
99116}
100117
101118interface TimelineRowActivityState {
102119 isWorking: boolean;
103120 isRevertingCheckpoint: boolean;
121 activeTurnInProgress: boolean;
104122}
105123
106124const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -116,12 +134,10 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d
116134interface MessagesTimelineProps {
117135 isWorking: boolean;
118136 activeTurnInProgress: boolean;
119 activeTurnId?: TurnId | null;
120137 activeTurnStartedAt: string | null;
121138 listRef: React.RefObject<LegendListRef | null>;
122139 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
123 completionDividerBeforeEntryId: string | null;
124 completionSummary: string | null;
140 latestTurn: TimelineLatestTurn | null;
125141 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
126142 routeThreadKey: string;
127143 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
......@@ -145,12 +161,10 @@ interface MessagesTimelineProps {
145161export const MessagesTimeline = memo(function MessagesTimeline({
146162 isWorking,
147163 activeTurnInProgress,
148 activeTurnId,
149164 activeTurnStartedAt,
150165 listRef,
151166 timelineEntries,
152 completionDividerBeforeEntryId,
153 completionSummary,
167 latestTurn,
154168 turnDiffSummaryByAssistantMessageId,
155169 routeThreadKey,
156170 onOpenTurnDiff,
......@@ -166,26 +180,90 @@ export const MessagesTimeline = memo(function MessagesTimeline({
166180 skills = EMPTY_TIMELINE_SKILLS,
167181 onIsAtEndChange,
168182}: 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
169251 const rawRows = useMemo(
170252 () =>
171253 deriveMessagesTimelineRows({
172254 timelineEntries,
173 completionDividerBeforeEntryId,
174 completionSummary,
255 latestTurn,
256 expandedTurnIds,
175257 isWorking,
176 activeTurnInProgress,
177 activeTurnId: activeTurnId ?? null,
178258 activeTurnStartedAt,
179259 turnDiffSummaryByAssistantMessageId,
180260 revertTurnCountByUserMessageId,
181261 }),
182262 [
183263 timelineEntries,
184 completionDividerBeforeEntryId,
185 completionSummary,
264 latestTurn,
265 expandedTurnIds,
186266 isWorking,
187 activeTurnInProgress,
188 activeTurnId,
189267 activeTurnStartedAt,
190268 turnDiffSummaryByAssistantMessageId,
191269 revertTurnCountByUserMessageId,
......@@ -230,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
230308 onRevertUserMessage,
231309 onImageExpand,
232310 onOpenTurnDiff,
311 onToggleTurnFold,
233312 }),
234313 [
235314 timestampFormat,
......@@ -242,14 +321,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
242321 onRevertUserMessage,
243322 onImageExpand,
244323 onOpenTurnDiff,
324 onToggleTurnFold,
245325 ],
246326 );
247327 const activityState = useMemo<TimelineRowActivityState>(
248328 () => ({
249329 isWorking,
250330 isRevertingCheckpoint,
331 activeTurnInProgress,
251332 }),
252 [isRevertingCheckpoint, isWorking],
333 [activeTurnInProgress, isRevertingCheckpoint, isWorking],
253334 );
254335
255336 // Stable renderItem — no closure deps. Row components read shared state
......@@ -283,7 +364,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
283364 renderItem={renderItem}
284365 estimatedItemSize={90}
285366 initialScrollAtEnd
286 maintainScrollAtEnd
367 maintainScrollAtEnd={!foldToggleSettling}
287368 maintainScrollAtEndThreshold={0.1}
288369 maintainVisibleContentPosition
289370 onScroll={handleScroll}
......@@ -313,7 +394,11 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
313394 return (
314395 <div
315396 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",
317402 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
318403 )}
319404 data-timeline-row-id={row.id}
......@@ -322,6 +407,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
322407 data-message-role={row.kind === "message" ? row.message.role : undefined}
323408 >
324409 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
410 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
325411 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
326412 {row.kind === "message" && row.message.role === "assistant" ? (
327413 <AssistantTimelineRow row={row} />
......@@ -340,8 +426,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
340426 const canRevertAgentWork = typeof row.revertTurnCount === "number";
341427
342428 return (
343 <div className="flex justify-end">
344 <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
429 <div className="group flex flex-col items-end gap-1">
430 <div className="relative max-w-[80%] rounded-2xl border border-border bg-secondary p-3">
345431 {userImages.length > 0 && (
346432 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
347433 {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => (
......@@ -380,21 +466,26 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
380466 terminalContexts={terminalContexts}
381467 skills={ctx.skills}
382468 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 }
396469 />
397470 </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>
398489 </div>
399490 );
400491}
......@@ -410,7 +501,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
410501 <Button
411502 type="button"
412503 size="xs"
413 variant="outline"
504 variant="ghost"
414505 disabled={activity.isRevertingCheckpoint || activity.isWorking}
415506 onClick={() => ctx.onRevertUserMessage(messageId)}
416507 aria-label="Revert to this message"
......@@ -424,16 +515,33 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
424515 );
425516}
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
427538function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
428539 const ctx = use(TimelineRowCtx);
429540 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
430541
431542 return (
432543 <>
433 {row.showCompletionDivider && (
434 <AssistantCompletionDivider completionSummary={row.completionSummary} />
435 )}
436 <div className="min-w-0 px-1 py-0.5">
544 <div className="relative min-w-0 px-1 py-0.5">
437545 <ChatMarkdown
438546 text={messageText}
439547 cwd={ctx.markdownCwd}
......@@ -446,41 +554,34 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
446554 resolvedTheme={ctx.resolvedTheme}
447555 onOpenTurnDiff={ctx.onOpenTurnDiff}
448556 />
449 <div className="mt-1.5 flex items-center gap-2">
450 <p className="text-[10px] text-muted-foreground/30">
451 {row.message.streaming ? (
452 <LiveMessageMeta
453 createdAt={row.message.createdAt}
454 durationStart={row.durationStart}
455 timestampFormat={ctx.timestampFormat}
456 />
457 ) : (
458 formatMessageMeta(
459 row.message.createdAt,
460 formatElapsed(row.durationStart, row.message.completedAt),
461 ctx.timestampFormat,
462 )
557 {row.showAssistantMeta ? (
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">
559 <AssistantCopyButton row={row} />
560 {!row.message.streaming && (
561 <Tooltip>
562 <TooltipTrigger
563 render={<p className="text-muted-foreground text-xs tabular-nums" />}
564 >
565 {formatShortTimestamp(
566 row.message.completedAt ?? row.message.createdAt,
567 ctx.timestampFormat,
568 )}
569 </TooltipTrigger>
570 <TooltipPopup>
571 {formatChatTimestampTooltip(
572 row.message.completedAt ?? row.message.createdAt,
573 ctx.timestampFormat,
574 )}
575 </TooltipPopup>
576 </Tooltip>
463577 )}
464 </p>
465 <AssistantCopyButton row={row} />
466 </div>
578 </div>
579 ) : null}
467580 </div>
468581 </>
469582 );
470583}
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
484585function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
485586 const assistantCopyState = resolveAssistantMessageCopyState({
486587 text: row.message.text ?? null,
......@@ -492,16 +593,7 @@ function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "messa
492593 return null;
493594 }
494595
495 return (
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 );
596 return <MessageCopyButton text={assistantCopyState.text ?? ""} variant="ghost" />;
505597}
506598
507599function ProposedPlanTimelineRow({
......@@ -526,7 +618,7 @@ function ProposedPlanTimelineRow({
526618function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
527619 return (
528620 <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">
530622 <span className="inline-flex items-center gap-[3px]">
531623 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
532624 <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 }) {
567659 return () => clearInterval(id);
568660 }, [createdAt]);
569661
570 return <span ref={textRef}>{initialText}</span>;
571}
572
573/** Live timestamp + elapsed duration for a streaming assistant message. */
574function LiveMessageMeta({
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>;
662 return (
663 <span ref={textRef} className="tabular-nums">
664 {initialText}
665 </span>
666 );
605667}
606668
607669// ---------------------------------------------------------------------------
......@@ -609,8 +671,7 @@ function LiveMessageMeta({
609671// re-render only the affected row, not the entire list.
610672// ---------------------------------------------------------------------------
611673
612/** Owns its own expand/collapse state so toggling re-renders only this row.
613 * State resets on unmount which is fine — work groups start collapsed. */
674/** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */
614675const WorkGroupSection = memo(function WorkGroupSection({
615676 groupedEntries,
616677}: {
......@@ -618,38 +679,53 @@ const WorkGroupSection = memo(function WorkGroupSection({
618679}) {
619680 const { workspaceRoot } = use(TimelineRowCtx);
620681 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;
622687 const visibleEntries =
623688 hasOverflow && !isExpanded
624 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
625 : groupedEntries;
626 const hiddenCount = groupedEntries.length - visibleEntries.length;
627 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
628 const showHeader = hasOverflow || !onlyToolEntries;
629 const groupLabel = onlyToolEntries ? "Tool calls" : "Work log";
689 ? nonEmptyEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
690 : nonEmptyEntries;
691 const hiddenCount = nonEmptyEntries.length - visibleEntries.length;
692 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
693 const headerTitle = onlyToolEntries
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
631701 return (
632 <div className="rounded-xl border border-border/45 bg-card/25 px-2 py-1.5">
633 {showHeader && (
634 <div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
635 <p className="text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55">
636 {groupLabel} ({groupedEntries.length})
637 </p>
638 {hasOverflow && (
639 <button
640 type="button"
641 className="text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
642 onClick={() => setIsExpanded((v) => !v)}
643 >
644 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
645 </button>
646 )}
647 </div>
648 )}
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">
703 <div className="mb-2 flex items-center justify-between gap-2 px-2">
704 <p className="font-medium text-foreground text-xs">{headerTitle}</p>
705 {hasOverflow && (
706 <button
707 type="button"
708 className="inline-flex cursor-pointer items-center gap-1 text-muted-foreground text-xs transition-colors duration-150 hover:text-foreground/80"
709 onClick={() => setIsExpanded((v) => !v)}
710 >
711 {isExpanded ? (
712 <>
713 Show less
714 <ChevronUpIcon className="size-3.5 shrink-0 opacity-80" />
715 </>
716 ) : (
717 <>
718 Show {hiddenCount} more
719 <ChevronDownIcon className="size-3.5 shrink-0 opacity-80" />
720 </>
721 )}
722 </button>
723 )}
724 </div>
649725 <div className="space-y-0.5">
650726 {visibleEntries.map((workEntry) => (
651727 <SimpleWorkEntryRow
652 key={`work-row:${workEntry.id}`}
728 key={workEntry.id}
653729 workEntry={workEntry}
654730 workspaceRoot={workspaceRoot}
655731 />
......@@ -706,18 +782,50 @@ function AssistantChangedFilesSectionInner({
706782 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
707783 );
708784 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
785 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
786 const changedFileCountLabel = String(checkpointFiles.length);
709787
710788 return (
711 <ChangedFilesCard
712 turnId={turnSummary.turnId}
713 files={checkpointFiles}
714 allDirectoriesExpanded={allDirectoriesExpanded}
715 resolvedTheme={resolvedTheme}
716 onToggleAllDirectories={() =>
717 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
718 }
719 onOpenTurnDiff={onOpenTurnDiff}
720 />
789 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
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-['']">
791 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
792 <span>Changed files ({changedFileCountLabel})</span>
793 {hasNonZeroStat(summaryStat) && (
794 <>
795 <span className="mx-1">•</span>
796 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
797 </>
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>
721829 );
722830}
723831
......@@ -1075,24 +1183,6 @@ function formatWorkingTimerNow(startIso: string): string {
10751183 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
10761184}
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
10961186function workToneIcon(tone: TimelineWorkEntry["tone"]): {
10971187 icon: LucideIcon;
10981188 className: string;
......@@ -1112,7 +1202,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
11121202 if (tone === "info") {
11131203 return {
11141204 icon: CheckIcon,
1115 className: "text-foreground/92",
1205 className: "text-muted-foreground",
11161206 };
11171207 }
11181208 return {
......@@ -1121,13 +1211,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): {
11211211 };
11221212}
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
11311214function workEntryPreview(
11321215 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
11331216 workspaceRoot: string | undefined,
......@@ -1153,7 +1236,30 @@ function workEntryRawCommand(
11531236 return rawCommand === workEntry.command.trim() ? null : rawCommand;
11541237}
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
11561256function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon {
1257 if (
1258 workEntry.sourceActivityKind === "user-input.requested" ||
1259 workEntry.sourceActivityKind === "user-input.resolved"
1260 ) {
1261 return MessageCircleIcon;
1262 }
11571263 if (workEntry.requestKind === "command") return TerminalIcon;
11581264 if (workEntry.requestKind === "file-read") return EyeIcon;
11591265 if (workEntry.requestKind === "file-change") return SquarePenIcon;
......@@ -1193,13 +1299,18 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
11931299 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
11941300}
11951301
1302const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
1303
11961304const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
11971305 workEntry: TimelineWorkEntry;
11981306 workspaceRoot: string | undefined;
11991307}) {
12001308 const { workEntry, workspaceRoot } = props;
1309 const activity = use(TimelineRowActivityCtx);
1310 const [expanded, setExpanded] = useState(false);
12011311 const iconConfig = workToneIcon(workEntry.tone);
1202 const EntryIcon = workEntryIcon(workEntry);
1312 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
1313 const EntryIcon = showWarningIndicator ? XIcon : workEntryIcon(workEntry);
12031314 const heading = toolWorkEntryHeading(workEntry);
12041315 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
12051316 const preview =
......@@ -1210,78 +1321,199 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
12101321 : rawPreview;
12111322 const rawCommand = workEntryRawCommand(workEntry);
12121323 const displayText = preview ? `${heading} - ${preview}` : heading;
1324 const expandedBody = buildToolCallExpandedBody(workEntry);
1325 const canExpand = expandedBody !== null;
12131326 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
12161365 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 >
12181374 <div className="flex items-center gap-2 transition-[opacity,translate] duration-200">
1219 <span
1220 className={cn("flex size-5 shrink-0 items-center justify-center", iconConfig.className)}
1221 >
1222 <EntryIcon className="size-3" />
1375 <span className={iconWrapperClass}>
1376 <EntryIcon className="block size-3.5 shrink-0" aria-hidden />
12231377 </span>
1224 <div className="min-w-0 flex-1 overflow-hidden">
1225 {rawCommand ? (
1226 <div className="max-w-full">
1378 <div className="flex min-w-0 flex-1 items-center gap-2">
1379 <div className="min-w-0 flex-1 overflow-hidden">
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 ) : (
12271414 <Tooltip>
12281415 <TooltipTrigger
1229 closeDelay={0}
1230 delay={75}
1231 render={
1232 <p
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 }
1416 className="block min-w-0 w-full text-left"
1417 aria-label={displayText}
1418 onClick={stopRowToggle}
1419 onPointerDown={stopRowToggle}
12411420 >
1242 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1243 {heading}
1244 </span>
1245 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
1421 <p className="flex min-w-0 w-full items-center gap-2 text-[11px] leading-5">
1422 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
1423 {preview && (
1424 <span className="min-w-0 flex-1 truncate text-muted-foreground">
1425 {preview}
1426 </span>
1427 )}
1428 </p>
12461429 </TooltipTrigger>
1247 <TooltipPopup
1248 align="start"
1249 className="max-w-[min(56rem,calc(100vw-2rem))] px-0 py-0"
1250 side="top"
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>
1430 <TooltipPopup className="max-w-[min(720px,calc(100vw-2rem))]">
1431 <p className="whitespace-pre-wrap wrap-break-word text-xs leading-5">
1432 {displayText}
1433 </p>
12551434 </TooltipPopup>
12561435 </Tooltip>
1257 </div>
1258 ) : (
1259 <Tooltip>
1260 <TooltipTrigger className="block min-w-0 w-full text-left" aria-label={displayText}>
1261 <p
1436 )}
1437 </div>
1438 <div className="flex shrink-0 items-center gap-0.5">
1439 <span
1440 className="flex size-5 shrink-0 items-center justify-center"
1441 aria-hidden={!canExpand}
1442 >
1443 {canExpand ? (
1444 <ChevronDownIcon
12621445 className={cn(
1263 "truncate text-[11px] leading-5",
1264 workToneClass(workEntry.tone),
1265 preview ? "text-muted-foreground/70" : "",
1446 "size-3.5 shrink-0 text-muted-foreground opacity-80 transition-transform duration-200",
1447 expanded && "rotate-180",
12661448 )}
1267 >
1268 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1269 {heading}
1270 </span>
1271 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
1272 </p>
1273 </TooltipTrigger>
1274 <TooltipPopup className="max-w-[min(720px,calc(100vw-2rem))]">
1275 <p className="whitespace-pre-wrap wrap-break-word text-xs leading-5">
1276 {displayText}
1277 </p>
1278 </TooltipPopup>
1279 </Tooltip>
1280 )}
1449 aria-hidden
1450 />
1451 ) : null}
1452 </span>
1453 <span className="flex size-5 shrink-0 items-center justify-center">
1454 {showFailedIndicator ? (
1455 <Tooltip>
1456 <TooltipTrigger
1457 render={
1458 <span
1459 className="flex size-5 items-center justify-center"
1460 aria-label="Tool call failed"
1461 />
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>
12811498 </div>
12821499 </div>
1283 {hasChangedFiles && !previewIsChangedFiles && (
1284 <div className="mt-1 flex flex-wrap gap-1 pl-6">
1500 {expanded && canExpand && expandedBody ? (
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 >
12851517 {workEntry.changedFiles?.slice(0, 4).map((filePath) => {
12861518 const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
12871519 return (
apps/web/src/session-logic.test.ts+218-113
......@@ -8,7 +8,6 @@ import {
88import { describe, expect, it } from "vite-plus/test";
99
1010import {
11 deriveCompletionDividerBeforeEntryId,
1211 deriveActiveWorkStartedAt,
1312 deriveActivePlanState,
1413 derivePendingApprovals,
......@@ -18,8 +17,10 @@ import {
1817 findLatestProposedPlan,
1918 findSidebarProposedPlan,
2019 hasActionableProposedPlan,
21 hasToolActivityForTurn,
2220 isLatestTurnSettled,
21 workEntryIndicatesToolFailure,
22 workEntryIndicatesToolNeutralStatus,
23 workEntryIndicatesToolSuccess,
2324} from "./session-logic";
2425
2526let nextActivityId = 0;
......@@ -572,6 +573,123 @@ describe("findSidebarProposedPlan", () => {
572573 });
573574});
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
575693describe("deriveWorkLogEntries", () => {
576694 it("omits tool started entries and keeps completed entries", () => {
577695 const activities: OrchestrationThreadActivity[] = [
......@@ -589,7 +707,7 @@ describe("deriveWorkLogEntries", () => {
589707 }),
590708 ];
591709
592 const entries = deriveWorkLogEntries(activities, undefined);
710 const entries = deriveWorkLogEntries(activities);
593711 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
594712 });
595713
......@@ -618,7 +736,7 @@ describe("deriveWorkLogEntries", () => {
618736 }),
619737 ];
620738
621 const entries = deriveWorkLogEntries(activities, undefined);
739 const entries = deriveWorkLogEntries(activities);
622740 expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);
623741 });
624742
......@@ -634,7 +752,7 @@ describe("deriveWorkLogEntries", () => {
634752 }),
635753 ];
636754
637 const entries = deriveWorkLogEntries(activities, undefined);
755 const entries = deriveWorkLogEntries(activities);
638756 expect(entries[0]?.label).toBe("Searching for API endpoints");
639757 });
640758
......@@ -650,25 +768,33 @@ describe("deriveWorkLogEntries", () => {
650768 }),
651769 ];
652770
653 const entries = deriveWorkLogEntries(activities, undefined);
771 const entries = deriveWorkLogEntries(activities);
654772 expect(entries[0]?.label).toBe("Failed to deploy changes");
655773 expect(entries[0]?.tone).toBe("error");
656774 });
657775
658 it("filters by turn id when provided", () => {
776 it("keeps tool entries from every turn and tags each with its turn id", () => {
659777 const activities: OrchestrationThreadActivity[] = [
660 makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }),
661778 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",
663786 turnId: "turn-2",
664787 summary: "Tool call complete",
665788 kind: "tool.completed",
666789 }),
667 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
668790 ];
669791
670 const entries = deriveWorkLogEntries(activities, TurnId.make("turn-2"));
671 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);
792 const entries = deriveWorkLogEntries(activities);
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 ]);
672798 });
673799
674800 it("omits checkpoint captured info entries", () => {
......@@ -688,7 +814,7 @@ describe("deriveWorkLogEntries", () => {
688814 }),
689815 ];
690816
691 const entries = deriveWorkLogEntries(activities, undefined);
817 const entries = deriveWorkLogEntries(activities);
692818 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
693819 });
694820
......@@ -724,7 +850,7 @@ describe("deriveWorkLogEntries", () => {
724850 }),
725851 ];
726852
727 const entries = deriveWorkLogEntries(activities, undefined);
853 const entries = deriveWorkLogEntries(activities);
728854 expect(entries.map((entry) => entry.id)).toEqual(["real-work-log"]);
729855 });
730856
......@@ -746,7 +872,7 @@ describe("deriveWorkLogEntries", () => {
746872 }),
747873 ];
748874
749 const entries = deriveWorkLogEntries(activities, undefined);
875 const entries = deriveWorkLogEntries(activities);
750876 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
751877 });
752878
......@@ -767,10 +893,47 @@ describe("deriveWorkLogEntries", () => {
767893 }),
768894 ];
769895
770 const [entry] = deriveWorkLogEntries(activities, undefined);
896 const [entry] = deriveWorkLogEntries(activities);
771897 expect(entry?.command).toBe("bun run lint");
772898 });
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
774937 it("unwraps PowerShell command wrappers for displayed command text", () => {
775938 const activities: OrchestrationThreadActivity[] = [
776939 makeActivity({
......@@ -788,7 +951,7 @@ describe("deriveWorkLogEntries", () => {
788951 }),
789952 ];
790953
791 const [entry] = deriveWorkLogEntries(activities, undefined);
954 const [entry] = deriveWorkLogEntries(activities);
792955 expect(entry?.command).toBe("bun run lint");
793956 expect(entry?.rawCommand).toBe(
794957 "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'",
......@@ -812,7 +975,7 @@ describe("deriveWorkLogEntries", () => {
812975 }),
813976 ];
814977
815 const [entry] = deriveWorkLogEntries(activities, undefined);
978 const [entry] = deriveWorkLogEntries(activities);
816979 expect(entry?.command).toBe("rg -n foo .");
817980 expect(entry?.rawCommand).toBe(
818981 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."',
......@@ -833,7 +996,7 @@ describe("deriveWorkLogEntries", () => {
833996 }),
834997 ];
835998
836 const [entry] = deriveWorkLogEntries(activities, undefined);
999 const [entry] = deriveWorkLogEntries(activities);
8371000 expect(entry?.command).toBe('rg -n -F "new Date()" .');
8381001 expect(entry?.rawCommand).toBe(
8391002 `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`,
......@@ -857,7 +1020,7 @@ describe("deriveWorkLogEntries", () => {
8571020 }),
8581021 ];
8591022
860 const [entry] = deriveWorkLogEntries(activities, undefined);
1023 const [entry] = deriveWorkLogEntries(activities);
8611024 expect(entry?.command).toBe("bash script.sh");
8621025 expect(entry?.rawCommand).toBeUndefined();
8631026 });
......@@ -886,7 +1049,7 @@ describe("deriveWorkLogEntries", () => {
8861049 }),
8871050 ];
8881051
889 const [entry] = deriveWorkLogEntries(activities, undefined);
1052 const [entry] = deriveWorkLogEntries(activities);
8901053 expect(entry).toMatchObject({
8911054 command: "bun run dev",
8921055 detail: '{ "dev": "vite dev --port 3000" }',
......@@ -915,7 +1078,7 @@ describe("deriveWorkLogEntries", () => {
9151078 }),
9161079 ];
9171080
918 const [entry] = deriveWorkLogEntries(activities, undefined);
1081 const [entry] = deriveWorkLogEntries(activities);
9191082 expect(entry?.changedFiles).toEqual([
9201083 "apps/web/src/components/ChatView.tsx",
9211084 "apps/web/src/session-logic.ts",
......@@ -936,7 +1099,7 @@ describe("deriveWorkLogEntries", () => {
9361099 }),
9371100 ];
9381101
939 const [entry] = deriveWorkLogEntries(activities, undefined);
1102 const [entry] = deriveWorkLogEntries(activities);
9401103 expect(entry?.toolTitle).toBe("Read File");
9411104 expect(entry?.detail).toBeUndefined();
9421105 });
......@@ -980,7 +1143,7 @@ describe("deriveWorkLogEntries", () => {
9801143 }),
9811144 ];
9821145
983 const entries = deriveWorkLogEntries(activities, undefined);
1146 const entries = deriveWorkLogEntries(activities);
9841147 expect(entries).toHaveLength(1);
9851148 expect(entries[0]).toMatchObject({
9861149 id: "grep-complete",
......@@ -1029,7 +1192,7 @@ describe("deriveWorkLogEntries", () => {
10291192 }),
10301193 ];
10311194
1032 const entries = deriveWorkLogEntries(activities, undefined);
1195 const entries = deriveWorkLogEntries(activities);
10331196 expect(entries).toHaveLength(1);
10341197 expect(entries[0]).toMatchObject({
10351198 id: "read-complete",
......@@ -1063,7 +1226,7 @@ describe("deriveWorkLogEntries", () => {
10631226 }),
10641227 ];
10651228
1066 const [entry] = deriveWorkLogEntries(activities, undefined);
1229 const [entry] = deriveWorkLogEntries(activities);
10671230 expect(entry).toMatchObject({
10681231 id: "cursor-command-complete",
10691232 label: "Ran command",
......@@ -1105,7 +1268,7 @@ describe("deriveWorkLogEntries", () => {
11051268 }),
11061269 ];
11071270
1108 const entries = deriveWorkLogEntries(activities, undefined);
1271 const entries = deriveWorkLogEntries(activities);
11091272 expect(entries).toHaveLength(1);
11101273 expect(entries[0]).toMatchObject({
11111274 id: "legacy-read-complete",
......@@ -1157,7 +1320,7 @@ describe("deriveWorkLogEntries", () => {
11571320 }),
11581321 ];
11591322
1160 const entries = deriveWorkLogEntries(activities, undefined);
1323 const entries = deriveWorkLogEntries(activities);
11611324
11621325 expect(entries).toHaveLength(1);
11631326 expect(entries[0]).toMatchObject({
......@@ -1219,7 +1382,7 @@ describe("deriveWorkLogEntries", () => {
12191382 }),
12201383 ];
12211384
1222 const entries = deriveWorkLogEntries(activities, undefined);
1385 const entries = deriveWorkLogEntries(activities);
12231386
12241387 expect(entries.map((entry) => entry.id)).toEqual(["tool-1-complete", "tool-2-complete"]);
12251388 });
......@@ -1261,7 +1424,7 @@ describe("deriveWorkLogEntries", () => {
12611424 }),
12621425 ];
12631426
1264 const entries = deriveWorkLogEntries(activities, undefined);
1427 const entries = deriveWorkLogEntries(activities);
12651428
12661429 expect(entries).toHaveLength(1);
12671430 expect(entries[0]?.id).toBe("a-complete-same-timestamp");
......@@ -1311,105 +1474,47 @@ describe("deriveTimelineEntries", () => {
13111474 },
13121475 });
13131476 });
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 });
13451477});
13461478
13471479describe("deriveWorkLogEntries context window handling", () => {
13481480 it("excludes context window updates from the work log", () => {
1349 const entries = deriveWorkLogEntries(
1350 [
1351 makeActivity({
1352 id: "context-1",
1353 turnId: "turn-1",
1354 kind: "context-window.updated",
1355 summary: "Context window updated",
1356 tone: "info",
1357 }),
1358 makeActivity({
1359 id: "tool-1",
1360 turnId: "turn-1",
1361 kind: "tool.completed",
1362 summary: "Ran command",
1363 tone: "tool",
1364 }),
1365 ],
1366 TurnId.make("turn-1"),
1367 );
1481 const entries = deriveWorkLogEntries([
1482 makeActivity({
1483 id: "context-1",
1484 turnId: "turn-1",
1485 kind: "context-window.updated",
1486 summary: "Context window updated",
1487 tone: "info",
1488 }),
1489 makeActivity({
1490 id: "tool-1",
1491 turnId: "turn-1",
1492 kind: "tool.completed",
1493 summary: "Ran command",
1494 tone: "tool",
1495 }),
1496 ]);
13681497
13691498 expect(entries).toHaveLength(1);
13701499 expect(entries[0]?.label).toBe("Ran command");
13711500 });
13721501
13731502 it("keeps context compaction activities as normal work log entries", () => {
1374 const entries = deriveWorkLogEntries(
1375 [
1376 makeActivity({
1377 id: "compaction-1",
1378 turnId: "turn-1",
1379 kind: "context-compaction",
1380 summary: "Context compacted",
1381 tone: "info",
1382 }),
1383 ],
1384 TurnId.make("turn-1"),
1385 );
1503 const entries = deriveWorkLogEntries([
1504 makeActivity({
1505 id: "compaction-1",
1506 turnId: "turn-1",
1507 kind: "context-compaction",
1508 summary: "Context compacted",
1509 tone: "info",
1510 }),
1511 ]);
13861512
13871513 expect(entries).toHaveLength(1);
13881514 expect(entries[0]?.label).toBe("Context compacted");
13891515 });
13901516});
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
14131518describe("isLatestTurnSettled", () => {
14141519 const latestTurn = {
14151520 turnId: TurnId.make("turn-1"),
apps/web/src/session-logic.ts+168-60
......@@ -53,9 +53,17 @@ export const PROVIDER_OPTIONS: Array<{
5353 },
5454];
5555
56export type WorkLogToolLifecycleStatus =
57 | "inProgress"
58 | "completed"
59 | "failed"
60 | "declined"
61 | "stopped";
62
5663export interface WorkLogEntry {
5764 id: string;
5865 createdAt: string;
66 turnId?: TurnId | null;
5967 label: string;
6068 detail?: string;
6169 command?: string;
......@@ -65,6 +73,10 @@ export interface WorkLogEntry {
6573 toolTitle?: string;
6674 itemType?: ToolLifecycleItemType;
6775 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"];
6880}
6981
7082interface DerivedWorkLogEntry extends WorkLogEntry {
......@@ -126,6 +138,129 @@ export type TimelineEntry =
126138 entry: WorkLogEntry;
127139 };
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
129264export function formatDuration(durationMs: number): string {
130265 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
131266 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
......@@ -485,12 +620,10 @@ export function hasActionableProposedPlan(
485620
486621export function deriveWorkLogEntries(
487622 activities: ReadonlyArray<OrchestrationThreadActivity>,
488 latestTurnId: TurnId | undefined,
489623): WorkLogEntry[] {
490624 const ordered = [...activities].toSorted(compareActivitiesByOrder);
491625 const entries: DerivedWorkLogEntry[] = [];
492626 for (const activity of ordered) {
493 if (latestTurnId && activity.turnId !== latestTurnId) continue;
494627 if (activity.kind === "tool.started") continue;
495628 if (activity.kind === "task.started") continue;
496629 if (activity.kind === "context-window.updated") continue;
......@@ -498,9 +631,10 @@ export function deriveWorkLogEntries(
498631 if (isPlanBoundaryToolActivity(activity)) continue;
499632 entries.push(toDerivedWorkLogEntry(activity));
500633 }
501 return collapseDerivedWorkLogEntries(entries).map(
502 ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
503 );
634 return collapseDerivedWorkLogEntries(entries).map((entry) => {
635 const { activityKind, collapseKey: _collapseKey, ...rest } = entry;
636 return Object.assign(rest, { sourceActivityKind: activityKind });
637 });
504638}
505639
506640function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
......@@ -515,6 +649,25 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool
515649 return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:");
516650}
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
518671function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry {
519672 const payload =
520673 activity.payload && typeof activity.payload === "object"
......@@ -548,6 +701,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
548701 const entry: DerivedWorkLogEntry = {
549702 id: activity.id,
550703 createdAt: activity.createdAt,
704 turnId: activity.turnId,
551705 label: taskLabel || activity.summary,
552706 tone:
553707 activity.kind === "task.progress"
......@@ -583,6 +737,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
583737 if (toolCallId) {
584738 entry.toolCallId = toolCallId;
585739 }
740 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
741 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
742 toolLifecycleStatus = "completed";
743 }
744 if (toolLifecycleStatus) {
745 entry.toolLifecycleStatus = toolLifecycleStatus;
746 }
586747 const collapseKey = deriveToolLifecycleCollapseKey(entry);
587748 if (collapseKey) {
588749 entry.collapseKey = collapseKey;
......@@ -643,6 +804,7 @@ function mergeDerivedWorkLogEntries(
643804 const requestKind = next.requestKind ?? previous.requestKind;
644805 const collapseKey = next.collapseKey ?? previous.collapseKey;
645806 const toolCallId = next.toolCallId ?? previous.toolCallId;
807 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
646808 return {
647809 ...previous,
648810 ...next,
......@@ -655,6 +817,7 @@ function mergeDerivedWorkLogEntries(
655817 ...(requestKind ? { requestKind } : {}),
656818 ...(collapseKey ? { collapseKey } : {}),
657819 ...(toolCallId ? { toolCallId } : {}),
820 ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}),
658821 };
659822}
660823
......@@ -1160,14 +1323,6 @@ function compareActivityLifecycleRank(kind: string): number {
11601323 return 1;
11611324}
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
11711326export function deriveTimelineEntries(
11721327 messages: ChatMessage[],
11731328 proposedPlans: ProposedPlan[],
......@@ -1196,53 +1351,6 @@ export function deriveTimelineEntries(
11961351 );
11971352}
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
12461354export function inferCheckpointTurnCountByTurnId(
12471355 summaries: TurnDiffSummary[],
12481356): Record<TurnId, number> {
apps/web/src/timestampFormat.ts+34
......@@ -44,6 +44,40 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma
4444 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
4545}
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
4781export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
4882 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
4983}