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", () => {
157157 );
158158
159159 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"],
162162 ] 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) => {
165165 const rows = deriveMessagesTimelineRows({
166166 timelineEntries: [
167167 {
......@@ -180,9 +180,12 @@ describe("work entry labels", () => {
180180 turnDiffSummaryByAssistantMessageId: new Map(),
181181 revertTurnCountByUserMessageId: new Map(),
182182 });
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 });
186189 },
187190 );
188191});
......@@ -741,7 +744,7 @@ describe("deriveMessagesTimelineRows", () => {
741744 "user-entry",
742745 "turn-fold:turn-1",
743746 "assistant-first-entry",
744 "work-toggle:work-entry-1",
747 "work-entry-1",
745748 "assistant-final-entry",
746749 ]);
747750 expect(
......@@ -749,6 +752,88 @@ describe("deriveMessagesTimelineRows", () => {
749752 ).toBeDefined();
750753 });
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
752837 it("folds all assistant messages before the terminal message", () => {
753838 const timelineEntries = [
754839 {
......@@ -827,6 +912,18 @@ describe("deriveMessagesTimelineRows", () => {
827912 streaming: false,
828913 },
829914 },
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 },
830927 {
831928 id: "assistant-commentary-entry",
832929 kind: "message",
......@@ -1135,7 +1232,7 @@ describe("deriveMessagesTimelineRows", () => {
11351232 });
11361233 });
11371234
1138 it("labels a single completed tool call without summarizing it", () => {
1235 it("renders a single completed tool call directly", () => {
11391236 const rows = deriveMessagesTimelineRows({
11401237 timelineEntries: [
11411238 {
......@@ -1195,11 +1292,64 @@ describe("deriveMessagesTimelineRows", () => {
11951292 revertTurnCountByUserMessageId: new Map(),
11961293 });
11971294
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",
12021351 });
1352 expect(rows.some((row) => row.kind === "work-toggle")).toBe(false);
12031353 });
12041354
12051355 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 =
284284 createdAt: string;
285285 groupedEntries: WorkLogEntry[];
286286 isExpandedToolGroup: boolean;
287 displayLabel?: string;
287288 }
288289 | {
289290 kind: "work-live";
......@@ -299,6 +300,7 @@ export type MessagesTimelineRow =
299300 kind: "work-toggle";
300301 id: string;
301302 createdAt: string;
303 turnId?: TurnId | null;
302304 groupId: string;
303305 hiddenCount: number;
304306 expanded: boolean;
......@@ -329,6 +331,14 @@ export type MessagesTimelineRow =
329331 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
330332 revertTurnCount?: number | undefined;
331333 }
334 | {
335 kind: "assistant-meta";
336 id: string;
337 createdAt: string;
338 message: ChatMessage;
339 showAssistantCopyButton: boolean;
340 assistantCopyStreaming: boolean;
341 }
332342 | {
333343 kind: "proposed-plan";
334344 id: string;
......@@ -492,9 +502,9 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
492502}
493503
494504/**
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.
498508 */
499509function deriveTurnFolds(input: {
500510 timelineEntries: ReadonlyArray<TimelineEntry>;
......@@ -565,10 +575,16 @@ function deriveTurnFolds(input: {
565575 continue;
566576 }
567577 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()) {
569582 if (entry.id === group.terminalEntry?.id) {
570583 continue;
571584 }
585 if (index > terminalEntryIndex) {
586 continue;
587 }
572588 // Agent-spawn CTA rows never fold: workflows outlive their launching
573589 // turn (dynamic spawns, background execution), and folding the CTA
574590 // when the turn settles makes a still-running fleet invisible.
......@@ -624,6 +640,89 @@ function deriveTurnFolds(input: {
624640 return foldsByAnchorEntryId;
625641}
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
627726export function deriveMessagesTimelineRows(input: {
628727 timelineEntries: ReadonlyArray<TimelineEntry>;
629728 latestTurn?: TimelineLatestTurn | null;
......@@ -847,6 +946,22 @@ export function deriveMessagesTimelineRows(input: {
847946 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
848947 );
849948 }
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 });
850965 } else {
851966 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
852967 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
......@@ -881,6 +996,7 @@ export function deriveMessagesTimelineRows(input: {
881996 kind: "work-toggle",
882997 id: `work-toggle:${timelineEntry.id}`,
883998 createdAt: timelineEntry.createdAt,
999 turnId: timelineEntry.entry.turnId ?? null,
8841000 groupId,
8851001 hiddenCount: visibleGroupedEntries.length,
8861002 expanded,
......@@ -965,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: {
9651081 });
9661082 }
9671083
968 return nextRows;
1084 return attachTrailingToolGroupsToAssistant(nextRows);
9691085}
9701086
9711087export function computeStableMessagesTimelineRows(
......@@ -997,6 +1113,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
9971113 case "thinking":
9981114 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
10001126 case "turn-fold": {
10011127 const bf = b as typeof a;
10021128 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
......@@ -1009,6 +1135,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10091135 const bw = b as typeof a;
10101136 return (
10111137 a.isExpandedToolGroup === bw.isExpandedToolGroup &&
1138 a.displayLabel === bw.displayLabel &&
10121139 Equal.equals(a.groupedEntries, bw.groupedEntries)
10131140 );
10141141 }
......@@ -1029,6 +1156,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10291156 const bw = b as typeof a;
10301157 return (
10311158 a.createdAt === bw.createdAt &&
1159 a.turnId === bw.turnId &&
10321160 a.groupId === bw.groupId &&
10331161 a.hiddenCount === bw.hiddenCount &&
10341162 a.expanded === bw.expanded &&
apps/web/src/components/chat/MessagesTimeline.test.tsx+56
......@@ -1093,6 +1093,62 @@ describe("MessagesTimeline", () => {
10931093 expect(markup).toContain("tool call failed");
10941094 });
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
10961152 it("keeps mixed work logs neutral after a later tool call succeeds", () => {
10971153 const markup = renderToStaticMarkup(
10981154 <MessagesTimeline
apps/web/src/components/chat/MessagesTimeline.tsx+100-24
......@@ -57,6 +57,7 @@ import {
5757 workLogEntryIsToolLike,
5858} from "../../session-logic";
5959import {
60 type ChatMessage,
6061 type ChatFileAttachment,
6162 type ChatImageAttachment,
6263 isBrowserPreviewAttachment,
......@@ -731,6 +732,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
731732 <LegendList<MessagesTimelineRow>
732733 ref={listRef}
733734 data={rows}
735 extraData={rows.length}
734736 keyExtractor={keyExtractor}
735737 getItemType={getItemType}
736738 renderItem={renderItem}
......@@ -1108,11 +1110,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11081110 row.kind === "thinking"
11091111 ? "pb-2"
11101112 : "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,
11121117 )}
11131118 data-timeline-row-id={row.id}
11141119 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 }
11161123 data-message-role={row.kind === "message" ? row.message.role : undefined}
11171124 >
11181125 {row.kind === "work" ? (
......@@ -1120,6 +1127,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11201127 anchorKey={row.id}
11211128 groupedEntries={row.groupedEntries}
11221129 isExpandedToolGroup={row.isExpandedToolGroup}
1130 displayLabel={row.displayLabel}
11231131 />
11241132 ) : null}
11251133 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
......@@ -1129,6 +1137,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11291137 {row.kind === "message" && row.message.role === "assistant" ? (
11301138 <AssistantTimelineRow row={row} />
11311139 ) : null}
1140 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
11321141 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
11331142 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
11341143 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
......@@ -1453,32 +1462,93 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
14531462 onOpenTurnDiff={ctx.onOpenTurnDiff}
14541463 />
14551464 {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 />
14711471 ) : null}
14721472 </div>
14731473 </>
14741474 );
14751475}
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}) {
14781548 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,
14821552 });
14831553
14841554 if (!assistantCopyState.visible) {
......@@ -1583,10 +1653,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
15831653 anchorKey,
15841654 groupedEntries,
15851655 isExpandedToolGroup,
1656 displayLabel,
15861657}: {
15871658 anchorKey: string;
15881659 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
15891660 isExpandedToolGroup: boolean;
1661 displayLabel?: string | undefined;
15901662}) {
15911663 const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);
15921664 const nonEmptyEntries = useMemo(
......@@ -1615,6 +1687,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
16151687 workEntry={workEntry}
16161688 workspaceRoot={workspaceRoot}
16171689 isExpandedToolGroupEntry={false}
1690 displayLabel={displayLabel}
16181691 />
16191692 ))}
16201693 </div>
......@@ -2926,8 +2999,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
29262999 workEntry: TimelineWorkEntry;
29273000 workspaceRoot: string | undefined;
29283001 isExpandedToolGroupEntry: boolean;
3002 displayLabel?: string | undefined;
29293003}) {
2930 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
3004 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
29313005 // Before any hooks: spawn CTA rows render their own component.
29323006 if (workEntry.agentSpawn) {
29333007 return <AgentSpawnCtaRow workEntry={workEntry} />;
......@@ -2937,6 +3011,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
29373011 workEntry={workEntry}
29383012 workspaceRoot={workspaceRoot}
29393013 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
3014 displayLabel={displayLabel}
29403015 />
29413016 );
29423017});
......@@ -2945,8 +3020,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
29453020 workEntry: TimelineWorkEntry;
29463021 workspaceRoot: string | undefined;
29473022 isExpandedToolGroupEntry: boolean;
3023 displayLabel?: string | undefined;
29483024}) {
2949 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
3025 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
29503026 const { threadRef, onImageExpand } = use(TimelineRowCtx);
29513027 const groupView = use(WorkGroupViewCtx);
29523028 const [expanded, setExpanded] = useState(
......@@ -2970,7 +3046,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
29703046 showWarningIndicator || (showFailedIndicator && !hasSpecialToolIcon)
29713047 ? "circle-alert"
29723048 : workEntryIconName(workEntry);
2973 const previewText = workEntryDisplayLabel(workEntry, workspaceRoot);
3049 const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot);
29743050 const displayText =
29753051 !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText;
29763052 const canExpand =