| author | |
| committer | |
| log | 4b26132d2c740ff344ef3fae7ae62e9765759e29 |
| tree | 1dc525c017d6df99bf46bec37c0a629d456b38b7 |
| parent | 5b8445b7a777ab1070aa97b062b1618971073a96 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 452 insertions(+), 42 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+163-13| ... | ... | @@ -157,11 +157,11 @@ describe("work entry labels", () => { |
| 157 | 157 | ); |
| 158 | 158 | |
| 159 | 159 | it.each([ |
| 160 | ["preview_click", "Clicked in the preview browser", "browser"], | |
| 161 | ["task_status", "Got delegated task status", "t3-code"], | |
| 160 | ["preview_click", "Clicked in the preview browser"], | |
| 161 | ["task_status", "Got delegated task status"], | |
| 162 | 162 | ] as const)( |
| 163 | "uses the completed %s call presentation for a settled legacy tool", | |
| 164 | (tool, summary, summaryToolIcon) => { | |
| 163 | "renders a settled legacy %s call directly with its completed presentation", | |
| 164 | (tool, label) => { | |
| 165 | 165 | const rows = deriveMessagesTimelineRows({ |
| 166 | 166 | timelineEntries: [ |
| 167 | 167 | { |
| ... | ... | @@ -180,9 +180,12 @@ describe("work entry labels", () => { |
| 180 | 180 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 181 | 181 | revertTurnCountByUserMessageId: new Map(), |
| 182 | 182 | }); |
| 183 | expect(rows).toMatchObject([ | |
| 184 | { kind: "work-toggle", hiddenCount: 1, summary, summaryToolIcon }, | |
| 185 | ]); | |
| 183 | const directRow = rows.find((row) => row.kind === "work"); | |
| 184 | expect(directRow).toMatchObject({ | |
| 185 | groupedEntries: [expect.objectContaining({ id: "tool-1" })], | |
| 186 | isExpandedToolGroup: false, | |
| 187 | displayLabel: label, | |
| 188 | }); | |
| 186 | 189 | }, |
| 187 | 190 | ); |
| 188 | 191 | }); |
| ... | ... | @@ -741,7 +744,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 741 | 744 | "user-entry", |
| 742 | 745 | "turn-fold:turn-1", |
| 743 | 746 | "assistant-first-entry", |
| 744 | "work-toggle:work-entry-1", | |
| 747 | "work-entry-1", | |
| 745 | 748 | "assistant-final-entry", |
| 746 | 749 | ]); |
| 747 | 750 | expect( |
| ... | ... | @@ -749,6 +752,88 @@ describe("deriveMessagesTimelineRows", () => { |
| 749 | 752 | ).toBeDefined(); |
| 750 | 753 | }); |
| 751 | 754 | |
| 755 | it("keeps a tool group after the terminal response visible when the turn is folded", () => { | |
| 756 | const turnId = TurnId.make("turn-1"); | |
| 757 | const timelineEntries = [ | |
| 758 | { | |
| 759 | id: "work-entry-before-text", | |
| 760 | kind: "work" as const, | |
| 761 | createdAt: "2026-01-01T00:00:01Z", | |
| 762 | entry: { | |
| 763 | id: "work-before-text", | |
| 764 | createdAt: "2026-01-01T00:00:01Z", | |
| 765 | turnId, | |
| 766 | label: "Status updated", | |
| 767 | tone: "info" as const, | |
| 768 | }, | |
| 769 | }, | |
| 770 | { | |
| 771 | id: "assistant-final-entry", | |
| 772 | kind: "message" as const, | |
| 773 | createdAt: "2026-01-01T00:00:05Z", | |
| 774 | message: { | |
| 775 | id: "assistant-final" as never, | |
| 776 | role: "assistant" as const, | |
| 777 | text: "I could not finish the task.", | |
| 778 | turnId, | |
| 779 | createdAt: "2026-01-01T00:00:05Z", | |
| 780 | updatedAt: "2026-01-01T00:00:06Z", | |
| 781 | streaming: false, | |
| 782 | }, | |
| 783 | }, | |
| 784 | ...Array.from({ length: 3 }, (_, index) => ({ | |
| 785 | id: `work-entry-after-text-${index}`, | |
| 786 | kind: "work" as const, | |
| 787 | createdAt: `2026-01-01T00:00:0${index + 7}Z`, | |
| 788 | entry: { | |
| 789 | id: `work-after-text-${index}`, | |
| 790 | createdAt: `2026-01-01T00:00:0${index + 7}Z`, | |
| 791 | turnId, | |
| 792 | label: "Ran command", | |
| 793 | tone: "tool" as const, | |
| 794 | itemType: "command_execution" as const, | |
| 795 | toolLifecycleStatus: "completed" as const, | |
| 796 | }, | |
| 797 | })), | |
| 798 | ]; | |
| 799 | ||
| 800 | const rows = deriveMessagesTimelineRows({ | |
| 801 | timelineEntries, | |
| 802 | latestTurn: { | |
| 803 | turnId, | |
| 804 | state: "error", | |
| 805 | startedAt: "2026-01-01T00:00:00Z", | |
| 806 | completedAt: "2026-01-01T00:00:10Z", | |
| 807 | }, | |
| 808 | isWorking: false, | |
| 809 | activeTurnStartedAt: null, | |
| 810 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 811 | revertTurnCountByUserMessageId: new Map(), | |
| 812 | }); | |
| 813 | ||
| 814 | expect(rows.map((row) => row.id)).toEqual([ | |
| 815 | "turn-fold:turn-1", | |
| 816 | "assistant-final-entry", | |
| 817 | "work-toggle:work-entry-after-text-0", | |
| 818 | "assistant-meta:assistant-final", | |
| 819 | ]); | |
| 820 | expect(rows.at(-2)).toMatchObject({ | |
| 821 | kind: "work-toggle", | |
| 822 | hiddenCount: 3, | |
| 823 | summary: "Ran 3 commands", | |
| 824 | }); | |
| 825 | expect(rows.at(-1)).toMatchObject({ | |
| 826 | kind: "assistant-meta", | |
| 827 | message: { id: "assistant-final" }, | |
| 828 | showAssistantCopyButton: true, | |
| 829 | }); | |
| 830 | expect(rows.at(-3)).toMatchObject({ | |
| 831 | kind: "message", | |
| 832 | showAssistantMeta: false, | |
| 833 | showAssistantCopyButton: false, | |
| 834 | }); | |
| 835 | }); | |
| 836 | ||
| 752 | 837 | it("folds all assistant messages before the terminal message", () => { |
| 753 | 838 | const timelineEntries = [ |
| 754 | 839 | { |
| ... | ... | @@ -827,6 +912,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 827 | 912 | streaming: false, |
| 828 | 913 | }, |
| 829 | 914 | }, |
| 915 | { | |
| 916 | id: "work-entry-before-message", | |
| 917 | kind: "work", | |
| 918 | createdAt: "2026-01-01T00:00:07Z", | |
| 919 | entry: { | |
| 920 | id: "work-before-message", | |
| 921 | createdAt: "2026-01-01T00:00:07Z", | |
| 922 | turnId: "turn-1" as never, | |
| 923 | label: "Status updated", | |
| 924 | tone: "info" as const, | |
| 925 | }, | |
| 926 | }, | |
| 830 | 927 | { |
| 831 | 928 | id: "assistant-commentary-entry", |
| 832 | 929 | kind: "message", |
| ... | ... | @@ -1135,7 +1232,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1135 | 1232 | }); |
| 1136 | 1233 | }); |
| 1137 | 1234 | |
| 1138 | it("labels a single completed tool call without summarizing it", () => { | |
| 1235 | it("renders a single completed tool call directly", () => { | |
| 1139 | 1236 | const rows = deriveMessagesTimelineRows({ |
| 1140 | 1237 | timelineEntries: [ |
| 1141 | 1238 | { |
| ... | ... | @@ -1195,11 +1292,64 @@ describe("deriveMessagesTimelineRows", () => { |
| 1195 | 1292 | revertTurnCountByUserMessageId: new Map(), |
| 1196 | 1293 | }); |
| 1197 | 1294 | |
| 1198 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]); | |
| 1199 | expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ | |
| 1200 | hiddenCount: 1, | |
| 1201 | summary: "rg toolCall", | |
| 1295 | expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); | |
| 1296 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ | |
| 1297 | groupedEntries: [{ id: "completed-command", command: "rg toolCall" }], | |
| 1298 | isExpandedToolGroup: false, | |
| 1299 | displayLabel: "rg toolCall", | |
| 1300 | }); | |
| 1301 | }); | |
| 1302 | ||
| 1303 | it("renders one tool call directly after collapsing its lifecycle updates", () => { | |
| 1304 | const turnId = TurnId.make("turn-1"); | |
| 1305 | const rows = deriveMessagesTimelineRows({ | |
| 1306 | timelineEntries: [ | |
| 1307 | { | |
| 1308 | id: "command-started-entry", | |
| 1309 | kind: "work", | |
| 1310 | createdAt: "2026-01-01T00:00:05Z", | |
| 1311 | entry: { | |
| 1312 | id: "command-started", | |
| 1313 | createdAt: "2026-01-01T00:00:05Z", | |
| 1314 | turnId, | |
| 1315 | toolCallId: "call-1", | |
| 1316 | label: "Running rg", | |
| 1317 | command: "rg toolCall", | |
| 1318 | tone: "tool" as const, | |
| 1319 | itemType: "command_execution" as const, | |
| 1320 | toolLifecycleStatus: "inProgress" as const, | |
| 1321 | }, | |
| 1322 | }, | |
| 1323 | { | |
| 1324 | id: "command-completed-entry", | |
| 1325 | kind: "work", | |
| 1326 | createdAt: "2026-01-01T00:00:06Z", | |
| 1327 | entry: { | |
| 1328 | id: "command-completed", | |
| 1329 | createdAt: "2026-01-01T00:00:06Z", | |
| 1330 | turnId, | |
| 1331 | toolCallId: "call-1", | |
| 1332 | label: "Ran rg", | |
| 1333 | command: "rg toolCall", | |
| 1334 | tone: "tool" as const, | |
| 1335 | itemType: "command_execution" as const, | |
| 1336 | toolLifecycleStatus: "completed" as const, | |
| 1337 | }, | |
| 1338 | }, | |
| 1339 | ], | |
| 1340 | expandedTurnIds: new Set([turnId]), | |
| 1341 | isWorking: false, | |
| 1342 | activeTurnStartedAt: null, | |
| 1343 | turnDiffSummaryByAssistantMessageId: new Map(), | |
| 1344 | revertTurnCountByUserMessageId: new Map(), | |
| 1345 | }); | |
| 1346 | ||
| 1347 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ | |
| 1348 | groupedEntries: [{ id: "command-completed", toolCallId: "call-1" }], | |
| 1349 | isExpandedToolGroup: false, | |
| 1350 | displayLabel: "rg toolCall", | |
| 1202 | 1351 | }); |
| 1352 | expect(rows.some((row) => row.kind === "work-toggle")).toBe(false); | |
| 1203 | 1353 | }); |
| 1204 | 1354 | |
| 1205 | 1355 | it("keeps separated in-progress tool runs visible", () => { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+133-5| ... | ... | @@ -284,6 +284,7 @@ export type MessagesTimelineRow = |
| 284 | 284 | createdAt: string; |
| 285 | 285 | groupedEntries: WorkLogEntry[]; |
| 286 | 286 | isExpandedToolGroup: boolean; |
| 287 | displayLabel?: string; | |
| 287 | 288 | } |
| 288 | 289 | | { |
| 289 | 290 | kind: "work-live"; |
| ... | ... | @@ -299,6 +300,7 @@ export type MessagesTimelineRow = |
| 299 | 300 | kind: "work-toggle"; |
| 300 | 301 | id: string; |
| 301 | 302 | createdAt: string; |
| 303 | turnId?: TurnId | null; | |
| 302 | 304 | groupId: string; |
| 303 | 305 | hiddenCount: number; |
| 304 | 306 | expanded: boolean; |
| ... | ... | @@ -329,6 +331,14 @@ export type MessagesTimelineRow = |
| 329 | 331 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 330 | 332 | revertTurnCount?: number | undefined; |
| 331 | 333 | } |
| 334 | | { | |
| 335 | kind: "assistant-meta"; | |
| 336 | id: string; | |
| 337 | createdAt: string; | |
| 338 | message: ChatMessage; | |
| 339 | showAssistantCopyButton: boolean; | |
| 340 | assistantCopyStreaming: boolean; | |
| 341 | } | |
| 332 | 342 | | { |
| 333 | 343 | kind: "proposed-plan"; |
| 334 | 344 | id: string; |
| ... | ... | @@ -492,9 +502,9 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { |
| 492 | 502 | } |
| 493 | 503 | |
| 494 | 504 | /** |
| 495 | * Settled turns keep only their terminal assistant message visible. | |
| 496 | * Everything before it folds behind a "Worked for ..." row anchored at the | |
| 497 | * first hidden entry, so the duration leads directly into the final response. | |
| 505 | * Settled turns fold activity before their terminal assistant message behind | |
| 506 | * a "Worked for ..." row. Work that lands after that message stays visible so | |
| 507 | * failed or interrupted turns do not hide their trailing tool-call summary. | |
| 498 | 508 | */ |
| 499 | 509 | function deriveTurnFolds(input: { |
| 500 | 510 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| ... | ... | @@ -565,10 +575,16 @@ function deriveTurnFolds(input: { |
| 565 | 575 | continue; |
| 566 | 576 | } |
| 567 | 577 | const hiddenEntryIds = new Set<string>(); |
| 568 | for (const entry of group.entries) { | |
| 578 | const terminalEntryIndex = group.terminalEntry | |
| 579 | ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id) | |
| 580 | : group.entries.length; | |
| 581 | for (const [index, entry] of group.entries.entries()) { | |
| 569 | 582 | if (entry.id === group.terminalEntry?.id) { |
| 570 | 583 | continue; |
| 571 | 584 | } |
| 585 | if (index > terminalEntryIndex) { | |
| 586 | continue; | |
| 587 | } | |
| 572 | 588 | // Agent-spawn CTA rows never fold: workflows outlive their launching |
| 573 | 589 | // turn (dynamic spawns, background execution), and folding the CTA |
| 574 | 590 | // when the turn settles makes a still-running fleet invisible. |
| ... | ... | @@ -624,6 +640,89 @@ function deriveTurnFolds(input: { |
| 624 | 640 | return foldsByAnchorEntryId; |
| 625 | 641 | } |
| 626 | 642 | |
| 643 | /** | |
| 644 | * When a settled turn ends with tool calls after its terminal text, treat the | |
| 645 | * text and tools as one visual response. The message metadata becomes the | |
| 646 | * footer for the whole block instead of separating the prose from the tools. | |
| 647 | */ | |
| 648 | function attachTrailingToolGroupsToAssistant( | |
| 649 | rows: ReadonlyArray<MessagesTimelineRow>, | |
| 650 | ): MessagesTimelineRow[] { | |
| 651 | const messageRowsWithoutMeta = new Set<string>(); | |
| 652 | const metaRowsAfterIndex = new Map< | |
| 653 | number, | |
| 654 | Extract<MessagesTimelineRow, { kind: "assistant-meta" }> | |
| 655 | >(); | |
| 656 | ||
| 657 | for (const [messageIndex, row] of rows.entries()) { | |
| 658 | const turnId = row.kind === "message" ? (row.message.turnId ?? null) : null; | |
| 659 | if ( | |
| 660 | row.kind !== "message" || | |
| 661 | row.message.role !== "assistant" || | |
| 662 | !row.showAssistantMeta || | |
| 663 | turnId === null | |
| 664 | ) { | |
| 665 | continue; | |
| 666 | } | |
| 667 | ||
| 668 | let lastTrailingWorkIndex = -1; | |
| 669 | let hasTrailingToolGroup = false; | |
| 670 | for (let index = messageIndex + 1; index < rows.length; index += 1) { | |
| 671 | const candidate = rows[index]; | |
| 672 | if (!candidate || candidate.kind === "message") { | |
| 673 | break; | |
| 674 | } | |
| 675 | if (candidate.kind === "work-toggle" && candidate.turnId === turnId) { | |
| 676 | hasTrailingToolGroup = true; | |
| 677 | lastTrailingWorkIndex = index; | |
| 678 | continue; | |
| 679 | } | |
| 680 | if ( | |
| 681 | candidate.kind === "work" && | |
| 682 | candidate.groupedEntries.some((entry) => entry.turnId === turnId) | |
| 683 | ) { | |
| 684 | if ( | |
| 685 | !candidate.isExpandedToolGroup && | |
| 686 | candidate.groupedEntries.some(workLogEntryIsToolLike) | |
| 687 | ) { | |
| 688 | hasTrailingToolGroup = true; | |
| 689 | } | |
| 690 | if (hasTrailingToolGroup) { | |
| 691 | lastTrailingWorkIndex = index; | |
| 692 | } | |
| 693 | } | |
| 694 | } | |
| 695 | ||
| 696 | if (lastTrailingWorkIndex < 0) { | |
| 697 | continue; | |
| 698 | } | |
| 699 | ||
| 700 | messageRowsWithoutMeta.add(row.id); | |
| 701 | metaRowsAfterIndex.set(lastTrailingWorkIndex, { | |
| 702 | kind: "assistant-meta", | |
| 703 | id: `assistant-meta:${row.message.id}`, | |
| 704 | createdAt: rows[lastTrailingWorkIndex]?.createdAt ?? row.message.updatedAt, | |
| 705 | message: row.message, | |
| 706 | showAssistantCopyButton: row.showAssistantCopyButton, | |
| 707 | assistantCopyStreaming: row.assistantCopyStreaming, | |
| 708 | }); | |
| 709 | } | |
| 710 | ||
| 711 | const result: MessagesTimelineRow[] = []; | |
| 712 | for (const [index, row] of rows.entries()) { | |
| 713 | if (row.kind === "message" && messageRowsWithoutMeta.has(row.id)) { | |
| 714 | result.push({ ...row, showAssistantMeta: false, showAssistantCopyButton: false }); | |
| 715 | } else { | |
| 716 | result.push(row); | |
| 717 | } | |
| 718 | const metaRow = metaRowsAfterIndex.get(index); | |
| 719 | if (metaRow) { | |
| 720 | result.push(metaRow); | |
| 721 | } | |
| 722 | } | |
| 723 | return result; | |
| 724 | } | |
| 725 | ||
| 627 | 726 | export function deriveMessagesTimelineRows(input: { |
| 628 | 727 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 629 | 728 | latestTurn?: TimelineLatestTurn | null; |
| ... | ... | @@ -847,6 +946,22 @@ export function deriveMessagesTimelineRows(input: { |
| 847 | 946 | expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), |
| 848 | 947 | ); |
| 849 | 948 | } |
| 949 | } else if ( | |
| 950 | visibleGroupedEntries.length === 1 && | |
| 951 | workLogEntryIsToolLike(visibleGroupedEntries[0]!) | |
| 952 | ) { | |
| 953 | const singleEntry = visibleGroupedEntries[0]!; | |
| 954 | nextRows.push({ | |
| 955 | kind: "work", | |
| 956 | id: timelineEntry.id, | |
| 957 | createdAt: timelineEntry.createdAt, | |
| 958 | groupedEntries: visibleGroupedEntries, | |
| 959 | isExpandedToolGroup: false, | |
| 960 | displayLabel: | |
| 961 | toolGroupAction(singleEntry) === "edit" | |
| 962 | ? summarizeToolGroup(visibleGroupedEntries) | |
| 963 | : singleToolCallLabel(singleEntry), | |
| 964 | }); | |
| 850 | 965 | } else { |
| 851 | 966 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); |
| 852 | 967 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| ... | ... | @@ -881,6 +996,7 @@ export function deriveMessagesTimelineRows(input: { |
| 881 | 996 | kind: "work-toggle", |
| 882 | 997 | id: `work-toggle:${timelineEntry.id}`, |
| 883 | 998 | createdAt: timelineEntry.createdAt, |
| 999 | turnId: timelineEntry.entry.turnId ?? null, | |
| 884 | 1000 | groupId, |
| 885 | 1001 | hiddenCount: visibleGroupedEntries.length, |
| 886 | 1002 | expanded, |
| ... | ... | @@ -965,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: { |
| 965 | 1081 | }); |
| 966 | 1082 | } |
| 967 | 1083 | |
| 968 | return nextRows; | |
| 1084 | return attachTrailingToolGroupsToAssistant(nextRows); | |
| 969 | 1085 | } |
| 970 | 1086 | |
| 971 | 1087 | export function computeStableMessagesTimelineRows( |
| ... | ... | @@ -997,6 +1113,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 997 | 1113 | case "thinking": |
| 998 | 1114 | return a.createdAt === (b as typeof a).createdAt; |
| 999 | 1115 | |
| 1116 | case "assistant-meta": { | |
| 1117 | const bm = b as typeof a; | |
| 1118 | return ( | |
| 1119 | a.createdAt === bm.createdAt && | |
| 1120 | a.message === bm.message && | |
| 1121 | a.showAssistantCopyButton === bm.showAssistantCopyButton && | |
| 1122 | a.assistantCopyStreaming === bm.assistantCopyStreaming | |
| 1123 | ); | |
| 1124 | } | |
| 1125 | ||
| 1000 | 1126 | case "turn-fold": { |
| 1001 | 1127 | const bf = b as typeof a; |
| 1002 | 1128 | return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; |
| ... | ... | @@ -1009,6 +1135,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1009 | 1135 | const bw = b as typeof a; |
| 1010 | 1136 | return ( |
| 1011 | 1137 | a.isExpandedToolGroup === bw.isExpandedToolGroup && |
| 1138 | a.displayLabel === bw.displayLabel && | |
| 1012 | 1139 | Equal.equals(a.groupedEntries, bw.groupedEntries) |
| 1013 | 1140 | ); |
| 1014 | 1141 | } |
| ... | ... | @@ -1029,6 +1156,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1029 | 1156 | const bw = b as typeof a; |
| 1030 | 1157 | return ( |
| 1031 | 1158 | a.createdAt === bw.createdAt && |
| 1159 | a.turnId === bw.turnId && | |
| 1032 | 1160 | a.groupId === bw.groupId && |
| 1033 | 1161 | a.hiddenCount === bw.hiddenCount && |
| 1034 | 1162 | a.expanded === bw.expanded && |
apps/web/src/components/chat/MessagesTimeline.test.tsx+56| ... | ... | @@ -1093,6 +1093,62 @@ describe("MessagesTimeline", () => { |
| 1093 | 1093 | expect(markup).toContain("tool call failed"); |
| 1094 | 1094 | }); |
| 1095 | 1095 | |
| 1096 | it("renders trailing tool calls as part of the terminal assistant block", () => { | |
| 1097 | const turnId = TurnId.make("turn-trailing-tools"); | |
| 1098 | const assistantMessageId = MessageId.make("assistant-trailing-tools"); | |
| 1099 | const markup = renderToStaticMarkup( | |
| 1100 | <MessagesTimeline | |
| 1101 | {...buildProps()} | |
| 1102 | latestTurn={{ | |
| 1103 | turnId, | |
| 1104 | state: "error", | |
| 1105 | startedAt: "2026-03-17T19:12:20.000Z", | |
| 1106 | completedAt: "2026-03-17T19:12:30.000Z", | |
| 1107 | }} | |
| 1108 | timelineEntries={[ | |
| 1109 | { | |
| 1110 | id: "assistant-entry", | |
| 1111 | kind: "message", | |
| 1112 | createdAt: MESSAGE_CREATED_AT, | |
| 1113 | message: { | |
| 1114 | id: assistantMessageId, | |
| 1115 | role: "assistant", | |
| 1116 | text: "I’ll search for it now.", | |
| 1117 | turnId, | |
| 1118 | createdAt: MESSAGE_CREATED_AT, | |
| 1119 | updatedAt: "2026-03-17T19:12:29.000Z", | |
| 1120 | streaming: false, | |
| 1121 | }, | |
| 1122 | }, | |
| 1123 | { | |
| 1124 | id: "trailing-work-entry", | |
| 1125 | kind: "work", | |
| 1126 | createdAt: "2026-03-17T19:12:30.000Z", | |
| 1127 | entry: { | |
| 1128 | id: "trailing-work", | |
| 1129 | createdAt: "2026-03-17T19:12:30.000Z", | |
| 1130 | turnId, | |
| 1131 | label: "Ran command", | |
| 1132 | tone: "tool", | |
| 1133 | itemType: "command_execution", | |
| 1134 | toolLifecycleStatus: "completed", | |
| 1135 | }, | |
| 1136 | }, | |
| 1137 | ]} | |
| 1138 | />, | |
| 1139 | ); | |
| 1140 | ||
| 1141 | const messageIndex = markup.indexOf('data-timeline-row-id="assistant-entry"'); | |
| 1142 | const toolIndex = markup.indexOf('data-timeline-row-id="trailing-work-entry"'); | |
| 1143 | const metaIndex = markup.indexOf( | |
| 1144 | 'data-timeline-row-id="assistant-meta:assistant-trailing-tools"', | |
| 1145 | ); | |
| 1146 | expect(messageIndex).toBeGreaterThanOrEqual(0); | |
| 1147 | expect(toolIndex).toBeGreaterThan(messageIndex); | |
| 1148 | expect(metaIndex).toBeGreaterThan(toolIndex); | |
| 1149 | expect(markup.match(/I’ll search for it now\./gu)).toHaveLength(1); | |
| 1150 | }); | |
| 1151 | ||
| 1096 | 1152 | it("keeps mixed work logs neutral after a later tool call succeeds", () => { |
| 1097 | 1153 | const markup = renderToStaticMarkup( |
| 1098 | 1154 | <MessagesTimeline |
apps/web/src/components/chat/MessagesTimeline.tsx+100-24| ... | ... | @@ -57,6 +57,7 @@ import { |
| 57 | 57 | workLogEntryIsToolLike, |
| 58 | 58 | } from "../../session-logic"; |
| 59 | 59 | import { |
| 60 | type ChatMessage, | |
| 60 | 61 | type ChatFileAttachment, |
| 61 | 62 | type ChatImageAttachment, |
| 62 | 63 | isBrowserPreviewAttachment, |
| ... | ... | @@ -731,6 +732,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 731 | 732 | <LegendList<MessagesTimelineRow> |
| 732 | 733 | ref={listRef} |
| 733 | 734 | data={rows} |
| 735 | extraData={rows.length} | |
| 734 | 736 | keyExtractor={keyExtractor} |
| 735 | 737 | getItemType={getItemType} |
| 736 | 738 | renderItem={renderItem} |
| ... | ... | @@ -1108,11 +1110,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1108 | 1110 | row.kind === "thinking" |
| 1109 | 1111 | ? "pb-2" |
| 1110 | 1112 | : "pb-4", |
| 1111 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, | |
| 1113 | (row.kind === "message" && row.message.role === "assistant") || | |
| 1114 | row.kind === "assistant-meta" | |
| 1115 | ? "group/assistant" | |
| 1116 | : null, | |
| 1112 | 1117 | )} |
| 1113 | 1118 | data-timeline-row-id={row.id} |
| 1114 | 1119 | data-timeline-row-kind={row.kind} |
| 1115 | data-message-id={row.kind === "message" ? row.message.id : undefined} | |
| 1120 | data-message-id={ | |
| 1121 | row.kind === "message" || row.kind === "assistant-meta" ? row.message.id : undefined | |
| 1122 | } | |
| 1116 | 1123 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 1117 | 1124 | > |
| 1118 | 1125 | {row.kind === "work" ? ( |
| ... | ... | @@ -1120,6 +1127,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1120 | 1127 | anchorKey={row.id} |
| 1121 | 1128 | groupedEntries={row.groupedEntries} |
| 1122 | 1129 | isExpandedToolGroup={row.isExpandedToolGroup} |
| 1130 | displayLabel={row.displayLabel} | |
| 1123 | 1131 | /> |
| 1124 | 1132 | ) : null} |
| 1125 | 1133 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| ... | ... | @@ -1129,6 +1137,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1129 | 1137 | {row.kind === "message" && row.message.role === "assistant" ? ( |
| 1130 | 1138 | <AssistantTimelineRow row={row} /> |
| 1131 | 1139 | ) : null} |
| 1140 | {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null} | |
| 1132 | 1141 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1133 | 1142 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1134 | 1143 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} |
| ... | ... | @@ -1453,32 +1462,93 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 1453 | 1462 | onOpenTurnDiff={ctx.onOpenTurnDiff} |
| 1454 | 1463 | /> |
| 1455 | 1464 | {row.showAssistantMeta ? ( |
| 1456 | <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"> | |
| 1457 | <AssistantCopyButton row={row} /> | |
| 1458 | {!row.message.streaming && ( | |
| 1459 | <Tooltip> | |
| 1460 | <TooltipTrigger | |
| 1461 | render={<p className="text-muted-foreground text-xs tabular-nums" />} | |
| 1462 | > | |
| 1463 | {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} | |
| 1464 | </TooltipTrigger> | |
| 1465 | <TooltipPopup> | |
| 1466 | {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} | |
| 1467 | </TooltipPopup> | |
| 1468 | </Tooltip> | |
| 1469 | )} | |
| 1470 | </div> | |
| 1465 | <AssistantMessageMeta | |
| 1466 | className="mt-1.5" | |
| 1467 | message={row.message} | |
| 1468 | showCopyButton={row.showAssistantCopyButton} | |
| 1469 | copyStreaming={row.assistantCopyStreaming} | |
| 1470 | /> | |
| 1471 | 1471 | ) : null} |
| 1472 | 1472 | </div> |
| 1473 | 1473 | </> |
| 1474 | 1474 | ); |
| 1475 | 1475 | } |
| 1476 | 1476 | |
| 1477 | function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | |
| 1477 | function AssistantMetaTimelineRow({ | |
| 1478 | row, | |
| 1479 | }: { | |
| 1480 | row: Extract<TimelineRow, { kind: "assistant-meta" }>; | |
| 1481 | }) { | |
| 1482 | return ( | |
| 1483 | <div className="px-1"> | |
| 1484 | <AssistantMessageMeta | |
| 1485 | className="mt-0.5" | |
| 1486 | message={row.message} | |
| 1487 | showCopyButton={row.showAssistantCopyButton} | |
| 1488 | copyStreaming={row.assistantCopyStreaming} | |
| 1489 | alwaysVisible | |
| 1490 | /> | |
| 1491 | </div> | |
| 1492 | ); | |
| 1493 | } | |
| 1494 | ||
| 1495 | function AssistantMessageMeta({ | |
| 1496 | className, | |
| 1497 | message, | |
| 1498 | showCopyButton, | |
| 1499 | copyStreaming, | |
| 1500 | alwaysVisible = false, | |
| 1501 | }: { | |
| 1502 | className?: string; | |
| 1503 | message: ChatMessage; | |
| 1504 | showCopyButton: boolean; | |
| 1505 | copyStreaming: boolean; | |
| 1506 | alwaysVisible?: boolean; | |
| 1507 | }) { | |
| 1508 | const ctx = use(TimelineRowCtx); | |
| 1509 | ||
| 1510 | return ( | |
| 1511 | <div | |
| 1512 | className={cn( | |
| 1513 | "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200", | |
| 1514 | alwaysVisible | |
| 1515 | ? "opacity-100" | |
| 1516 | : "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100", | |
| 1517 | className, | |
| 1518 | )} | |
| 1519 | > | |
| 1520 | <AssistantCopyButton | |
| 1521 | message={message} | |
| 1522 | showCopyButton={showCopyButton} | |
| 1523 | streaming={copyStreaming} | |
| 1524 | /> | |
| 1525 | {!message.streaming && ( | |
| 1526 | <Tooltip> | |
| 1527 | <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}> | |
| 1528 | {formatDayAwareTimestamp(message.updatedAt, ctx.timestampFormat)} | |
| 1529 | </TooltipTrigger> | |
| 1530 | <TooltipPopup> | |
| 1531 | {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat)} | |
| 1532 | </TooltipPopup> | |
| 1533 | </Tooltip> | |
| 1534 | )} | |
| 1535 | </div> | |
| 1536 | ); | |
| 1537 | } | |
| 1538 | ||
| 1539 | function AssistantCopyButton({ | |
| 1540 | message, | |
| 1541 | showCopyButton, | |
| 1542 | streaming, | |
| 1543 | }: { | |
| 1544 | message: ChatMessage; | |
| 1545 | showCopyButton: boolean; | |
| 1546 | streaming: boolean; | |
| 1547 | }) { | |
| 1478 | 1548 | const assistantCopyState = resolveAssistantMessageCopyState({ |
| 1479 | text: row.message.text ?? null, | |
| 1480 | showCopyButton: row.showAssistantCopyButton, | |
| 1481 | streaming: row.assistantCopyStreaming, | |
| 1549 | text: message.text ?? null, | |
| 1550 | showCopyButton, | |
| 1551 | streaming, | |
| 1482 | 1552 | }); |
| 1483 | 1553 | |
| 1484 | 1554 | if (!assistantCopyState.visible) { |
| ... | ... | @@ -1583,10 +1653,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1583 | 1653 | anchorKey, |
| 1584 | 1654 | groupedEntries, |
| 1585 | 1655 | isExpandedToolGroup, |
| 1656 | displayLabel, | |
| 1586 | 1657 | }: { |
| 1587 | 1658 | anchorKey: string; |
| 1588 | 1659 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 1589 | 1660 | isExpandedToolGroup: boolean; |
| 1661 | displayLabel?: string | undefined; | |
| 1590 | 1662 | }) { |
| 1591 | 1663 | const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); |
| 1592 | 1664 | const nonEmptyEntries = useMemo( |
| ... | ... | @@ -1615,6 +1687,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1615 | 1687 | workEntry={workEntry} |
| 1616 | 1688 | workspaceRoot={workspaceRoot} |
| 1617 | 1689 | isExpandedToolGroupEntry={false} |
| 1690 | displayLabel={displayLabel} | |
| 1618 | 1691 | /> |
| 1619 | 1692 | ))} |
| 1620 | 1693 | </div> |
| ... | ... | @@ -2926,8 +2999,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2926 | 2999 | workEntry: TimelineWorkEntry; |
| 2927 | 3000 | workspaceRoot: string | undefined; |
| 2928 | 3001 | isExpandedToolGroupEntry: boolean; |
| 3002 | displayLabel?: string | undefined; | |
| 2929 | 3003 | }) { |
| 2930 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | |
| 3004 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; | |
| 2931 | 3005 | // Before any hooks: spawn CTA rows render their own component. |
| 2932 | 3006 | if (workEntry.agentSpawn) { |
| 2933 | 3007 | return <AgentSpawnCtaRow workEntry={workEntry} />; |
| ... | ... | @@ -2937,6 +3011,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2937 | 3011 | workEntry={workEntry} |
| 2938 | 3012 | workspaceRoot={workspaceRoot} |
| 2939 | 3013 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} |
| 3014 | displayLabel={displayLabel} | |
| 2940 | 3015 | /> |
| 2941 | 3016 | ); |
| 2942 | 3017 | }); |
| ... | ... | @@ -2945,8 +3020,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2945 | 3020 | workEntry: TimelineWorkEntry; |
| 2946 | 3021 | workspaceRoot: string | undefined; |
| 2947 | 3022 | isExpandedToolGroupEntry: boolean; |
| 3023 | displayLabel?: string | undefined; | |
| 2948 | 3024 | }) { |
| 2949 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | |
| 3025 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; | |
| 2950 | 3026 | const { threadRef, onImageExpand } = use(TimelineRowCtx); |
| 2951 | 3027 | const groupView = use(WorkGroupViewCtx); |
| 2952 | 3028 | const [expanded, setExpanded] = useState( |
| ... | ... | @@ -2970,7 +3046,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2970 | 3046 | showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) |
| 2971 | 3047 | ? "circle-alert" |
| 2972 | 3048 | : workEntryIconName(workEntry); |
| 2973 | const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); | |
| 3049 | const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); | |
| 2974 | 3050 | const displayText = |
| 2975 | 3051 | !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; |
| 2976 | 3052 | const canExpand = |