authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-03 04:20:20-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 04:20:20-05:00
log4b26132d2c740ff344ef3fae7ae62e9765759e29
tree1dc525c017d6df99bf46bec37c0a629d456b38b7
parent5b8445b7a777ab1070aa97b062b1618971073a96
signature Signed by PGP key B5690EEEBB952194

fix(web): keep trailing tool groups out of "Worked for" accordion (#9384)


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 );
158158
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 });
751754
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 });
11371234
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 });
11971294
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 });
12041354
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}
493503
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 the506 * 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 */
499function deriveTurnFolds(input: {509function 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 launching588 // Agent-spawn CTA rows never fold: workflows outlive their launching
573 // turn (dynamic spawns, background execution), and folding the CTA589 // 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}
626642
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 */
648function 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
627export function deriveMessagesTimelineRows(input: {726export 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 }
9671083
968 return nextRows;1084 return attachTrailingToolGroupsToAssistant(nextRows);
969}1085}
9701086
971export function computeStableMessagesTimelineRows(1087export 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;
9991115
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 });
10951095
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 <MessagesTimeline1154 <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";
59import {59import {
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 <TooltipTrigger1469 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}
14761476
1477function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {1477function 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
1495function 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
1539function 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 });
14831553
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 =