| 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,11 +157,11 @@ describe("work entry labels", () => { |
| 157 | ); | 157 | ); |
| 158 | 158 | ||
| 159 | it.each([ | 159 | it.each([ |
| 160 | ["preview_click", "Clicked in the preview browser", "browser"], | 160 | ["preview_click", "Clicked in the preview browser"], |
| 161 | ["task_status", "Got delegated task status", "t3-code"], | 161 | ["task_status", "Got delegated task status"], |
| 162 | ] as const)( | 162 | ] as const)( |
| 163 | "uses the completed %s call presentation for a settled legacy tool", | 163 | "renders a settled legacy %s call directly with its completed presentation", |
| 164 | (tool, summary, summaryToolIcon) => { | 164 | (tool, label) => { |
| 165 | const rows = deriveMessagesTimelineRows({ | 165 | const rows = deriveMessagesTimelineRows({ |
| 166 | timelineEntries: [ | 166 | timelineEntries: [ |
| 167 | { | 167 | { |
| ... | @@ -180,9 +180,12 @@ describe("work entry labels", () => { | ... | @@ -180,9 +180,12 @@ describe("work entry labels", () => { |
| 180 | turnDiffSummaryByAssistantMessageId: new Map(), | 180 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 181 | revertTurnCountByUserMessageId: new Map(), | 181 | revertTurnCountByUserMessageId: new Map(), |
| 182 | }); | 182 | }); |
| 183 | expect(rows).toMatchObject([ | 183 | const directRow = rows.find((row) => row.kind === "work"); |
| 184 | { kind: "work-toggle", hiddenCount: 1, summary, summaryToolIcon }, | 184 | expect(directRow).toMatchObject({ |
| 185 | ]); | 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,7 +744,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 741 | "user-entry", | 744 | "user-entry", |
| 742 | "turn-fold:turn-1", | 745 | "turn-fold:turn-1", |
| 743 | "assistant-first-entry", | 746 | "assistant-first-entry", |
| 744 | "work-toggle:work-entry-1", | 747 | "work-entry-1", |
| 745 | "assistant-final-entry", | 748 | "assistant-final-entry", |
| 746 | ]); | 749 | ]); |
| 747 | expect( | 750 | expect( |
| ... | @@ -749,6 +752,88 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -749,6 +752,88 @@ describe("deriveMessagesTimelineRows", () => { |
| 749 | ).toBeDefined(); | 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 | it("folds all assistant messages before the terminal message", () => { | 837 | it("folds all assistant messages before the terminal message", () => { |
| 753 | const timelineEntries = [ | 838 | const timelineEntries = [ |
| 754 | { | 839 | { |
| ... | @@ -827,6 +912,18 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -827,6 +912,18 @@ describe("deriveMessagesTimelineRows", () => { |
| 827 | streaming: false, | 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 | id: "assistant-commentary-entry", | 928 | id: "assistant-commentary-entry", |
| 832 | kind: "message", | 929 | kind: "message", |
| ... | @@ -1135,7 +1232,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -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 | const rows = deriveMessagesTimelineRows({ | 1236 | const rows = deriveMessagesTimelineRows({ |
| 1140 | timelineEntries: [ | 1237 | timelineEntries: [ |
| 1141 | { | 1238 | { |
| ... | @@ -1195,11 +1292,64 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1195,11 +1292,64 @@ describe("deriveMessagesTimelineRows", () => { |
| 1195 | revertTurnCountByUserMessageId: new Map(), | 1292 | revertTurnCountByUserMessageId: new Map(), |
| 1196 | }); | 1293 | }); |
| 1197 | 1294 | ||
| 1198 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]); | 1295 | expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); |
| 1199 | expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ | 1296 | expect(rows.find((row) => row.kind === "work")).toMatchObject({ |
| 1200 | hiddenCount: 1, | 1297 | groupedEntries: [{ id: "completed-command", command: "rg toolCall" }], |
| 1201 | summary: "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 | it("keeps separated in-progress tool runs visible", () => { | 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,6 +284,7 @@ export type MessagesTimelineRow = |
| 284 | createdAt: string; | 284 | createdAt: string; |
| 285 | groupedEntries: WorkLogEntry[]; | 285 | groupedEntries: WorkLogEntry[]; |
| 286 | isExpandedToolGroup: boolean; | 286 | isExpandedToolGroup: boolean; |
| 287 | displayLabel?: string; | ||
| 287 | } | 288 | } |
| 288 | | { | 289 | | { |
| 289 | kind: "work-live"; | 290 | kind: "work-live"; |
| ... | @@ -299,6 +300,7 @@ export type MessagesTimelineRow = | ... | @@ -299,6 +300,7 @@ export type MessagesTimelineRow = |
| 299 | kind: "work-toggle"; | 300 | kind: "work-toggle"; |
| 300 | id: string; | 301 | id: string; |
| 301 | createdAt: string; | 302 | createdAt: string; |
| 303 | turnId?: TurnId | null; | ||
| 302 | groupId: string; | 304 | groupId: string; |
| 303 | hiddenCount: number; | 305 | hiddenCount: number; |
| 304 | expanded: boolean; | 306 | expanded: boolean; |
| ... | @@ -329,6 +331,14 @@ export type MessagesTimelineRow = | ... | @@ -329,6 +331,14 @@ export type MessagesTimelineRow = |
| 329 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; | 331 | assistantTurnDiffSummary?: TurnDiffSummary | undefined; |
| 330 | revertTurnCount?: number | undefined; | 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 | kind: "proposed-plan"; | 343 | kind: "proposed-plan"; |
| 334 | id: string; | 344 | id: string; |
| ... | @@ -492,9 +502,9 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { | ... | @@ -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. | 505 | * Settled turns fold activity before their terminal assistant message behind |
| 496 | * Everything before it folds behind a "Worked for ..." row anchored at the | 506 | * a "Worked for ..." row. Work that lands after that message stays visible so |
| 497 | * first hidden entry, so the duration leads directly into the final response. | 507 | * failed or interrupted turns do not hide their trailing tool-call summary. |
| 498 | */ | 508 | */ |
| 499 | function deriveTurnFolds(input: { | 509 | function deriveTurnFolds(input: { |
| 500 | timelineEntries: ReadonlyArray<TimelineEntry>; | 510 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| ... | @@ -565,10 +575,16 @@ function deriveTurnFolds(input: { | ... | @@ -565,10 +575,16 @@ function deriveTurnFolds(input: { |
| 565 | continue; | 575 | continue; |
| 566 | } | 576 | } |
| 567 | const hiddenEntryIds = new Set<string>(); | 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 | if (entry.id === group.terminalEntry?.id) { | 582 | if (entry.id === group.terminalEntry?.id) { |
| 570 | continue; | 583 | continue; |
| 571 | } | 584 | } |
| 585 | if (index > terminalEntryIndex) { | ||
| 586 | continue; | ||
| 587 | } | ||
| 572 | // Agent-spawn CTA rows never fold: workflows outlive their launching | 588 | // Agent-spawn CTA rows never fold: workflows outlive their launching |
| 573 | // turn (dynamic spawns, background execution), and folding the CTA | 589 | // turn (dynamic spawns, background execution), and folding the CTA |
| 574 | // when the turn settles makes a still-running fleet invisible. | 590 | // when the turn settles makes a still-running fleet invisible. |
| ... | @@ -624,6 +640,89 @@ function deriveTurnFolds(input: { | ... | @@ -624,6 +640,89 @@ function deriveTurnFolds(input: { |
| 624 | return foldsByAnchorEntryId; | 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 | export function deriveMessagesTimelineRows(input: { | 726 | export function deriveMessagesTimelineRows(input: { |
| 628 | timelineEntries: ReadonlyArray<TimelineEntry>; | 727 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 629 | latestTurn?: TimelineLatestTurn | null; | 728 | latestTurn?: TimelineLatestTurn | null; |
| ... | @@ -847,6 +946,22 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -847,6 +946,22 @@ export function deriveMessagesTimelineRows(input: { |
| 847 | expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), | 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 | } else { | 965 | } else { |
| 851 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); | 966 | const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); |
| 852 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 967 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| ... | @@ -881,6 +996,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -881,6 +996,7 @@ export function deriveMessagesTimelineRows(input: { |
| 881 | kind: "work-toggle", | 996 | kind: "work-toggle", |
| 882 | id: `work-toggle:${timelineEntry.id}`, | 997 | id: `work-toggle:${timelineEntry.id}`, |
| 883 | createdAt: timelineEntry.createdAt, | 998 | createdAt: timelineEntry.createdAt, |
| 999 | turnId: timelineEntry.entry.turnId ?? null, | ||
| 884 | groupId, | 1000 | groupId, |
| 885 | hiddenCount: visibleGroupedEntries.length, | 1001 | hiddenCount: visibleGroupedEntries.length, |
| 886 | expanded, | 1002 | expanded, |
| ... | @@ -965,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -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 | export function computeStableMessagesTimelineRows( | 1087 | export function computeStableMessagesTimelineRows( |
| ... | @@ -997,6 +1113,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -997,6 +1113,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 997 | case "thinking": | 1113 | case "thinking": |
| 998 | return a.createdAt === (b as typeof a).createdAt; | 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 | case "turn-fold": { | 1126 | case "turn-fold": { |
| 1001 | const bf = b as typeof a; | 1127 | const bf = b as typeof a; |
| 1002 | return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; | 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,6 +1135,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1009 | const bw = b as typeof a; | 1135 | const bw = b as typeof a; |
| 1010 | return ( | 1136 | return ( |
| 1011 | a.isExpandedToolGroup === bw.isExpandedToolGroup && | 1137 | a.isExpandedToolGroup === bw.isExpandedToolGroup && |
| 1138 | a.displayLabel === bw.displayLabel && | ||
| 1012 | Equal.equals(a.groupedEntries, bw.groupedEntries) | 1139 | Equal.equals(a.groupedEntries, bw.groupedEntries) |
| 1013 | ); | 1140 | ); |
| 1014 | } | 1141 | } |
| ... | @@ -1029,6 +1156,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -1029,6 +1156,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1029 | const bw = b as typeof a; | 1156 | const bw = b as typeof a; |
| 1030 | return ( | 1157 | return ( |
| 1031 | a.createdAt === bw.createdAt && | 1158 | a.createdAt === bw.createdAt && |
| 1159 | a.turnId === bw.turnId && | ||
| 1032 | a.groupId === bw.groupId && | 1160 | a.groupId === bw.groupId && |
| 1033 | a.hiddenCount === bw.hiddenCount && | 1161 | a.hiddenCount === bw.hiddenCount && |
| 1034 | a.expanded === bw.expanded && | 1162 | a.expanded === bw.expanded && |
apps/web/src/components/chat/MessagesTimeline.test.tsx+56| ... | @@ -1093,6 +1093,62 @@ describe("MessagesTimeline", () => { | ... | @@ -1093,6 +1093,62 @@ describe("MessagesTimeline", () => { |
| 1093 | expect(markup).toContain("tool call failed"); | 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 | it("keeps mixed work logs neutral after a later tool call succeeds", () => { | 1152 | it("keeps mixed work logs neutral after a later tool call succeeds", () => { |
| 1097 | const markup = renderToStaticMarkup( | 1153 | const markup = renderToStaticMarkup( |
| 1098 | <MessagesTimeline | 1154 | <MessagesTimeline |
apps/web/src/components/chat/MessagesTimeline.tsx+100-24| ... | @@ -57,6 +57,7 @@ import { | ... | @@ -57,6 +57,7 @@ import { |
| 57 | workLogEntryIsToolLike, | 57 | workLogEntryIsToolLike, |
| 58 | } from "../../session-logic"; | 58 | } from "../../session-logic"; |
| 59 | import { | 59 | import { |
| 60 | type ChatMessage, | ||
| 60 | type ChatFileAttachment, | 61 | type ChatFileAttachment, |
| 61 | type ChatImageAttachment, | 62 | type ChatImageAttachment, |
| 62 | isBrowserPreviewAttachment, | 63 | isBrowserPreviewAttachment, |
| ... | @@ -731,6 +732,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -731,6 +732,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 731 | <LegendList<MessagesTimelineRow> | 732 | <LegendList<MessagesTimelineRow> |
| 732 | ref={listRef} | 733 | ref={listRef} |
| 733 | data={rows} | 734 | data={rows} |
| 735 | extraData={rows.length} | ||
| 734 | keyExtractor={keyExtractor} | 736 | keyExtractor={keyExtractor} |
| 735 | getItemType={getItemType} | 737 | getItemType={getItemType} |
| 736 | renderItem={renderItem} | 738 | renderItem={renderItem} |
| ... | @@ -1108,11 +1110,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1108,11 +1110,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1108 | row.kind === "thinking" | 1110 | row.kind === "thinking" |
| 1109 | ? "pb-2" | 1111 | ? "pb-2" |
| 1110 | : "pb-4", | 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 | data-timeline-row-id={row.id} | 1118 | data-timeline-row-id={row.id} |
| 1114 | data-timeline-row-kind={row.kind} | 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 | data-message-role={row.kind === "message" ? row.message.role : undefined} | 1123 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 1117 | > | 1124 | > |
| 1118 | {row.kind === "work" ? ( | 1125 | {row.kind === "work" ? ( |
| ... | @@ -1120,6 +1127,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1120,6 +1127,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1120 | anchorKey={row.id} | 1127 | anchorKey={row.id} |
| 1121 | groupedEntries={row.groupedEntries} | 1128 | groupedEntries={row.groupedEntries} |
| 1122 | isExpandedToolGroup={row.isExpandedToolGroup} | 1129 | isExpandedToolGroup={row.isExpandedToolGroup} |
| 1130 | displayLabel={row.displayLabel} | ||
| 1123 | /> | 1131 | /> |
| 1124 | ) : null} | 1132 | ) : null} |
| 1125 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} | 1133 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| ... | @@ -1129,6 +1137,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1129,6 +1137,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1129 | {row.kind === "message" && row.message.role === "assistant" ? ( | 1137 | {row.kind === "message" && row.message.role === "assistant" ? ( |
| 1130 | <AssistantTimelineRow row={row} /> | 1138 | <AssistantTimelineRow row={row} /> |
| 1131 | ) : null} | 1139 | ) : null} |
| 1140 | {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null} | ||
| 1132 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} | 1141 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1133 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} | 1142 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1134 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} | 1143 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} |
| ... | @@ -1453,32 +1462,93 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -1453,32 +1462,93 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 1453 | onOpenTurnDiff={ctx.onOpenTurnDiff} | 1462 | onOpenTurnDiff={ctx.onOpenTurnDiff} |
| 1454 | /> | 1463 | /> |
| 1455 | {row.showAssistantMeta ? ( | 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"> | 1465 | <AssistantMessageMeta |
| 1457 | <AssistantCopyButton row={row} /> | 1466 | className="mt-1.5" |
| 1458 | {!row.message.streaming && ( | 1467 | message={row.message} |
| 1459 | <Tooltip> | 1468 | showCopyButton={row.showAssistantCopyButton} |
| 1460 | <TooltipTrigger | 1469 | copyStreaming={row.assistantCopyStreaming} |
| 1461 | render={<p className="text-muted-foreground text-xs tabular-nums" />} | 1470 | /> |
| 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> | ||
| 1471 | ) : null} | 1471 | ) : null} |
| 1472 | </div> | 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 | const assistantCopyState = resolveAssistantMessageCopyState({ | 1548 | const assistantCopyState = resolveAssistantMessageCopyState({ |
| 1479 | text: row.message.text ?? null, | 1549 | text: message.text ?? null, |
| 1480 | showCopyButton: row.showAssistantCopyButton, | 1550 | showCopyButton, |
| 1481 | streaming: row.assistantCopyStreaming, | 1551 | streaming, |
| 1482 | }); | 1552 | }); |
| 1483 | 1553 | ||
| 1484 | if (!assistantCopyState.visible) { | 1554 | if (!assistantCopyState.visible) { |
| ... | @@ -1583,10 +1653,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1583,10 +1653,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1583 | anchorKey, | 1653 | anchorKey, |
| 1584 | groupedEntries, | 1654 | groupedEntries, |
| 1585 | isExpandedToolGroup, | 1655 | isExpandedToolGroup, |
| 1656 | displayLabel, | ||
| 1586 | }: { | 1657 | }: { |
| 1587 | anchorKey: string; | 1658 | anchorKey: string; |
| 1588 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; | 1659 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 1589 | isExpandedToolGroup: boolean; | 1660 | isExpandedToolGroup: boolean; |
| 1661 | displayLabel?: string | undefined; | ||
| 1590 | }) { | 1662 | }) { |
| 1591 | const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); | 1663 | const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); |
| 1592 | const nonEmptyEntries = useMemo( | 1664 | const nonEmptyEntries = useMemo( |
| ... | @@ -1615,6 +1687,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -1615,6 +1687,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 1615 | workEntry={workEntry} | 1687 | workEntry={workEntry} |
| 1616 | workspaceRoot={workspaceRoot} | 1688 | workspaceRoot={workspaceRoot} |
| 1617 | isExpandedToolGroupEntry={false} | 1689 | isExpandedToolGroupEntry={false} |
| 1690 | displayLabel={displayLabel} | ||
| 1618 | /> | 1691 | /> |
| 1619 | ))} | 1692 | ))} |
| 1620 | </div> | 1693 | </div> |
| ... | @@ -2926,8 +2999,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | ... | @@ -2926,8 +2999,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2926 | workEntry: TimelineWorkEntry; | 2999 | workEntry: TimelineWorkEntry; |
| 2927 | workspaceRoot: string | undefined; | 3000 | workspaceRoot: string | undefined; |
| 2928 | isExpandedToolGroupEntry: boolean; | 3001 | isExpandedToolGroupEntry: boolean; |
| 3002 | displayLabel?: string | undefined; | ||
| 2929 | }) { | 3003 | }) { |
| 2930 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | 3004 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; |
| 2931 | // Before any hooks: spawn CTA rows render their own component. | 3005 | // Before any hooks: spawn CTA rows render their own component. |
| 2932 | if (workEntry.agentSpawn) { | 3006 | if (workEntry.agentSpawn) { |
| 2933 | return <AgentSpawnCtaRow workEntry={workEntry} />; | 3007 | return <AgentSpawnCtaRow workEntry={workEntry} />; |
| ... | @@ -2937,6 +3011,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | ... | @@ -2937,6 +3011,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 2937 | workEntry={workEntry} | 3011 | workEntry={workEntry} |
| 2938 | workspaceRoot={workspaceRoot} | 3012 | workspaceRoot={workspaceRoot} |
| 2939 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} | 3013 | isExpandedToolGroupEntry={isExpandedToolGroupEntry} |
| 3014 | displayLabel={displayLabel} | ||
| 2940 | /> | 3015 | /> |
| 2941 | ); | 3016 | ); |
| 2942 | }); | 3017 | }); |
| ... | @@ -2945,8 +3020,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2945,8 +3020,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2945 | workEntry: TimelineWorkEntry; | 3020 | workEntry: TimelineWorkEntry; |
| 2946 | workspaceRoot: string | undefined; | 3021 | workspaceRoot: string | undefined; |
| 2947 | isExpandedToolGroupEntry: boolean; | 3022 | isExpandedToolGroupEntry: boolean; |
| 3023 | displayLabel?: string | undefined; | ||
| 2948 | }) { | 3024 | }) { |
| 2949 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; | 3025 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; |
| 2950 | const { threadRef, onImageExpand } = use(TimelineRowCtx); | 3026 | const { threadRef, onImageExpand } = use(TimelineRowCtx); |
| 2951 | const groupView = use(WorkGroupViewCtx); | 3027 | const groupView = use(WorkGroupViewCtx); |
| 2952 | const [expanded, setExpanded] = useState( | 3028 | const [expanded, setExpanded] = useState( |
| ... | @@ -2970,7 +3046,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { | ... | @@ -2970,7 +3046,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 2970 | showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) | 3046 | showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon) |
| 2971 | ? "circle-alert" | 3047 | ? "circle-alert" |
| 2972 | : workEntryIconName(workEntry); | 3048 | : workEntryIconName(workEntry); |
| 2973 | const previewText = workEntryDisplayLabel(workEntry, workspaceRoot); | 3049 | const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); |
| 2974 | const displayText = | 3050 | const displayText = |
| 2975 | !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; | 3051 | !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; |
| 2976 | const canExpand = | 3052 | const canExpand = |