authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 22:55:05-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 22:55:05-04:00
log4a9edff4c1093c1d31b7c97d6d1f32489ea035bd
treee1e96a418ed32d6adb01f3ae3462290d415db4c4
parent9027d62679c6669d8e45e2e419b311ec8dfdaa4f
signature Signed by PGP key B5690EEEBB952194

feat(web): collapse tool activity into one line (#7152)


8 files changed, 1710 insertions(+), 187 deletions(-)

apps/web/src/components/ChatView.tsx+7-3
......@@ -561,8 +561,10 @@ function useLocalDispatchState(input: {
561561 threadError: string | null | undefined;
562562}) {
563563 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
564 const latestUserMessageId =
565 input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null;
564 const latestUserMessage = input.activeThread?.messages.findLast(
565 (message) => message.role === "user",
566 );
567 const latestUserMessageId = latestUserMessage?.id ?? null;
566568
567569 const resetLocalDispatch = useCallback(() => {
568570 setLocalDispatch(null);
......@@ -612,6 +614,7 @@ function useLocalDispatchState(input: {
612614 beginLocalDispatch,
613615 resetLocalDispatch,
614616 localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null,
617 latestUserMessageAt: latestUserMessage?.createdAt ?? null,
615618 isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
616619 isSendBusy: activeLocalDispatch !== null,
617620 };
......@@ -2325,6 +2328,7 @@ function ChatViewContent(props: ChatViewProps) {
23252328 beginLocalDispatch,
23262329 resetLocalDispatch,
23272330 localDispatchStartedAt,
2331 latestUserMessageAt,
23282332 isPreparingWorktree,
23292333 isSendBusy,
23302334 } = useLocalDispatchState({
......@@ -2340,6 +2344,7 @@ function ChatViewContent(props: ChatViewProps) {
23402344 activeLatestTurn,
23412345 activeThread?.session ?? null,
23422346 localDispatchStartedAt,
2347 latestUserMessageAt,
23432348 );
23442349 useEffect(() => {
23452350 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
......@@ -6389,7 +6394,6 @@ function ChatViewContent(props: ChatViewProps) {
63896394 key={activeThread.id}
63906395 isWorking={isWorking}
63916396 workingStepLabel={workingStepLabel}
6392 activeTurnInProgress={isWorking || !latestTurnSettled}
63936397 activeTurnStartedAt={activeWorkStartedAt}
63946398 listRef={legendListRef}
63956399 timelineEntries={timelineEntries}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+358-7
......@@ -545,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => {
545545 "user-entry",
546546 "turn-fold:turn-1",
547547 "assistant-thought-entry",
548 "work-entry-1",
548 "work-toggle:work-entry-1",
549549 "assistant-final-entry",
550550 ]);
551551 expect(
......@@ -800,12 +800,362 @@ describe("deriveMessagesTimelineRows", () => {
800800
801801 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
802802 expect(rows.map((row) => row.id)).toEqual([
803 "assistant-thought-entry",
804 "work-entry-1",
805803 "working-indicator-row",
804 "assistant-thought-entry",
805 "work-live:work-entry-1",
806 ]);
807 });
808
809 it("keeps adjacent active tool calls in one replacing row", () => {
810 const rows = deriveMessagesTimelineRows({
811 timelineEntries: [
812 {
813 id: "completed-command-entry",
814 kind: "work",
815 createdAt: "2026-01-01T00:00:05Z",
816 entry: {
817 id: "completed-command",
818 createdAt: "2026-01-01T00:00:05Z",
819 turnId: "turn-1" as never,
820 label: "Ran rg",
821 command: "rg toolCall",
822 requestKind: "command",
823 tone: "tool" as const,
824 toolLifecycleStatus: "completed" as const,
825 },
826 },
827 {
828 id: "completed-edit-entry",
829 kind: "work",
830 createdAt: "2026-01-01T00:00:06Z",
831 entry: {
832 id: "completed-edit",
833 createdAt: "2026-01-01T00:00:06Z",
834 turnId: "turn-1" as never,
835 label: "Edited files",
836 requestKind: "file-change",
837 changedFiles: ["src/one.ts", "src/two.ts"],
838 tone: "tool" as const,
839 toolLifecycleStatus: "completed" as const,
840 },
841 },
842 {
843 id: "running-command-entry",
844 kind: "work",
845 createdAt: "2026-01-01T00:00:07Z",
846 entry: {
847 id: "running-command",
848 createdAt: "2026-01-01T00:00:07Z",
849 turnId: "turn-1" as never,
850 label: "Running tests",
851 command: "vp test run",
852 requestKind: "command",
853 tone: "tool" as const,
854 toolLifecycleStatus: "inProgress" as const,
855 },
856 },
857 ],
858 latestTurn: {
859 turnId: "turn-1" as never,
860 state: "running",
861 startedAt: "2026-01-01T00:00:00Z",
862 completedAt: null,
863 },
864 isWorking: true,
865 activeTurnStartedAt: "2026-01-01T00:00:00Z",
866 turnDiffSummaryByAssistantMessageId: new Map(),
867 revertTurnCountByUserMessageId: new Map(),
868 });
869
870 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
871 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
872 entry: { id: "running-command" },
873 groupedEntries: [
874 { id: "completed-command" },
875 { id: "completed-edit" },
876 { id: "running-command" },
877 ],
878 });
879 });
880
881 it("summarizes a tool run after commentary starts a new run", () => {
882 const rows = deriveMessagesTimelineRows({
883 timelineEntries: [
884 {
885 id: "completed-command-entry",
886 kind: "work",
887 createdAt: "2026-01-01T00:00:05Z",
888 entry: {
889 id: "completed-command",
890 createdAt: "2026-01-01T00:00:05Z",
891 turnId: "turn-1" as never,
892 label: "Ran rg",
893 command: "rg toolCall",
894 requestKind: "command",
895 tone: "tool" as const,
896 toolLifecycleStatus: "completed" as const,
897 },
898 },
899 {
900 id: "assistant-commentary-entry",
901 kind: "message",
902 createdAt: "2026-01-01T00:00:06Z",
903 message: {
904 id: "assistant-commentary" as never,
905 role: "assistant",
906 text: "Checking another thing.",
907 turnId: "turn-1" as never,
908 createdAt: "2026-01-01T00:00:06Z",
909 updatedAt: "2026-01-01T00:00:06Z",
910 streaming: false,
911 },
912 },
913 {
914 id: "running-command-entry",
915 kind: "work",
916 createdAt: "2026-01-01T00:00:07Z",
917 entry: {
918 id: "running-command",
919 createdAt: "2026-01-01T00:00:07Z",
920 turnId: "turn-1" as never,
921 label: "Running tests",
922 command: "vp test run",
923 requestKind: "command",
924 tone: "tool" as const,
925 toolLifecycleStatus: "inProgress" as const,
926 },
927 },
928 ],
929 latestTurn: {
930 turnId: "turn-1" as never,
931 state: "running",
932 startedAt: "2026-01-01T00:00:00Z",
933 completedAt: null,
934 },
935 isWorking: true,
936 activeTurnStartedAt: "2026-01-01T00:00:00Z",
937 turnDiffSummaryByAssistantMessageId: new Map(),
938 revertTurnCountByUserMessageId: new Map(),
939 });
940
941 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
942 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
943 hiddenCount: 1,
944 summary: "Ran 1 command",
945 });
946 });
947
948 it("keeps separated in-progress tool runs visible", () => {
949 const rows = deriveMessagesTimelineRows({
950 timelineEntries: [
951 {
952 id: "first-running-entry",
953 kind: "work",
954 createdAt: "2026-01-01T00:00:05Z",
955 entry: {
956 id: "first-running",
957 createdAt: "2026-01-01T00:00:05Z",
958 turnId: "turn-1" as never,
959 label: "Running first command",
960 command: "rg first",
961 requestKind: "command",
962 tone: "tool" as const,
963 toolLifecycleStatus: "inProgress" as const,
964 },
965 },
966 {
967 id: "assistant-commentary-entry",
968 kind: "message",
969 createdAt: "2026-01-01T00:00:06Z",
970 message: {
971 id: "assistant-commentary" as never,
972 role: "assistant",
973 text: "Starting another command.",
974 turnId: "turn-1" as never,
975 createdAt: "2026-01-01T00:00:06Z",
976 updatedAt: "2026-01-01T00:00:06Z",
977 streaming: false,
978 },
979 },
980 {
981 id: "second-running-entry",
982 kind: "work",
983 createdAt: "2026-01-01T00:00:07Z",
984 entry: {
985 id: "second-running",
986 createdAt: "2026-01-01T00:00:07Z",
987 turnId: "turn-1" as never,
988 label: "Running second command",
989 command: "rg second",
990 requestKind: "command",
991 tone: "tool" as const,
992 toolLifecycleStatus: "inProgress" as const,
993 },
994 },
995 ],
996 latestTurn: {
997 turnId: "turn-1" as never,
998 state: "running",
999 startedAt: "2026-01-01T00:00:00Z",
1000 completedAt: null,
1001 },
1002 isWorking: true,
1003 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1004 turnDiffSummaryByAssistantMessageId: new Map(),
1005 revertTurnCountByUserMessageId: new Map(),
1006 });
1007
1008 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
1009 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
1010 "first-running",
1011 "second-running",
8061012 ]);
8071013 });
8081014
1015 it("does not revive stale in-progress tools before a fresh send has a turn id", () => {
1016 const rows = deriveMessagesTimelineRows({
1017 timelineEntries: [
1018 {
1019 id: "stale-running-entry",
1020 kind: "work",
1021 createdAt: "2026-01-01T00:00:05Z",
1022 entry: {
1023 id: "stale-running",
1024 createdAt: "2026-01-01T00:00:05Z",
1025 turnId: "turn-1" as never,
1026 label: "Running stale command",
1027 command: "rg stale",
1028 requestKind: "command",
1029 tone: "tool" as const,
1030 toolLifecycleStatus: "inProgress" as const,
1031 },
1032 },
1033 {
1034 id: "user-followup-entry",
1035 kind: "message",
1036 createdAt: "2026-01-01T00:01:00Z",
1037 message: {
1038 id: "user-followup" as never,
1039 role: "user",
1040 text: "continue",
1041 turnId: null,
1042 createdAt: "2026-01-01T00:01:00Z",
1043 updatedAt: "2026-01-01T00:01:00Z",
1044 streaming: false,
1045 },
1046 },
1047 ],
1048 latestTurn: null,
1049 isWorking: true,
1050 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1051 turnDiffSummaryByAssistantMessageId: new Map(),
1052 revertTurnCountByUserMessageId: new Map(),
1053 });
1054
1055 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
1056 });
1057
1058 it("does not revive separated historical task progress", () => {
1059 const rows = deriveMessagesTimelineRows({
1060 timelineEntries: [
1061 {
1062 id: "stale-progress-entry",
1063 kind: "work",
1064 createdAt: "2026-01-01T00:00:05Z",
1065 entry: {
1066 id: "stale-progress",
1067 createdAt: "2026-01-01T00:00:05Z",
1068 turnId: "turn-1" as never,
1069 label: "Old progress",
1070 tone: "thinking" as const,
1071 sourceActivityKind: "task.progress" as const,
1072 },
1073 },
1074 {
1075 id: "assistant-commentary-entry",
1076 kind: "message",
1077 createdAt: "2026-01-01T00:00:06Z",
1078 message: {
1079 id: "assistant-commentary" as never,
1080 role: "assistant",
1081 text: "Starting another command.",
1082 turnId: "turn-1" as never,
1083 createdAt: "2026-01-01T00:00:06Z",
1084 updatedAt: "2026-01-01T00:00:06Z",
1085 streaming: false,
1086 },
1087 },
1088 {
1089 id: "running-command-entry",
1090 kind: "work",
1091 createdAt: "2026-01-01T00:00:07Z",
1092 entry: {
1093 id: "running-command",
1094 createdAt: "2026-01-01T00:00:07Z",
1095 turnId: "turn-1" as never,
1096 label: "Running command",
1097 command: "rg current",
1098 requestKind: "command",
1099 tone: "tool" as const,
1100 toolLifecycleStatus: "inProgress" as const,
1101 },
1102 },
1103 ],
1104 latestTurn: {
1105 turnId: "turn-1" as never,
1106 state: "running",
1107 startedAt: "2026-01-01T00:00:00Z",
1108 completedAt: null,
1109 },
1110 isWorking: true,
1111 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1112 turnDiffSummaryByAssistantMessageId: new Map(),
1113 revertTurnCountByUserMessageId: new Map(),
1114 });
1115
1116 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
1117 "running-command",
1118 ]);
1119 });
1120
1121 it("keeps the latest completed tool call live while the turn is running", () => {
1122 const rows = deriveMessagesTimelineRows({
1123 timelineEntries: [
1124 {
1125 id: "latest-command-entry",
1126 kind: "work",
1127 createdAt: "2026-01-01T00:00:05Z",
1128 entry: {
1129 id: "latest-command",
1130 createdAt: "2026-01-01T00:00:05Z",
1131 turnId: "turn-1" as never,
1132 label: "Ran rg",
1133 command: "rg toolCall",
1134 requestKind: "command",
1135 tone: "tool" as const,
1136 toolLifecycleStatus: "completed" as const,
1137 },
1138 },
1139 ],
1140 latestTurn: {
1141 turnId: "turn-1" as never,
1142 state: "running",
1143 startedAt: "2026-01-01T00:00:00Z",
1144 completedAt: null,
1145 },
1146 isWorking: true,
1147 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1148 turnDiffSummaryByAssistantMessageId: new Map(),
1149 revertTurnCountByUserMessageId: new Map(),
1150 });
1151
1152 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
1153 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
1154 entry: { id: "latest-command" },
1155 groupedEntries: [{ id: "latest-command" }],
1156 });
1157 });
1158
8091159 it("does not fold the session's running turn when latestTurn regresses", () => {
8101160 const rows = deriveMessagesTimelineRows({
8111161 timelineEntries: [
......@@ -864,7 +1214,7 @@ describe("deriveMessagesTimelineRows", () => {
8641214 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
8651215 "turn-1",
8661216 ]);
867 expect(rows.map((row) => row.id)).toContain("running-work-entry");
1217 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
8681218 });
8691219
8701220 it("only shows assistant metadata on the terminal assistant message", () => {
......@@ -1006,18 +1356,19 @@ describe("deriveMessagesTimelineRows", () => {
10061356 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
10071357 });
10081358
1009 expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
1359 expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]);
10101360 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
10111361 groupId: "work-group:work-entry-1",
1012 hiddenCount: 2,
1362 hiddenCount: 3,
10131363 expanded: false,
10141364 onlyToolEntries: true,
1365 summary: "Used 3 tools",
10151366 });
10161367 expect(expandedRows.map((row) => row.id)).toEqual([
1368 "work-toggle:work-entry-1",
10171369 "work-1",
10181370 "work-2",
10191371 "work-3",
1020 "work-toggle:work-entry-1",
10211372 ]);
10221373 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
10231374 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+436-24
......@@ -1,6 +1,7 @@
11import * as Equal from "effect/Equal";
22import {
33 formatDuration,
4 workEntryDisplayIndicatesToolFailure,
45 workEntryIndicatesToolNeutralStatus,
56 workLogEntryIsToolLike,
67 type TimelineEntry,
......@@ -17,6 +18,18 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
1718export const TIMELINE_CONTENT_MAX_WIDTH = 768;
1819export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
1920
21export function workEntryIsVisibleInGroup(
22 entry: WorkLogEntry,
23 expandedToolGroupEntry = false,
24): boolean {
25 return (
26 (expandedToolGroupEntry &&
27 (entry.toolLifecycleStatus === "inProgress" ||
28 entry.sourceActivityKind === "task.progress")) ||
29 !workEntryIndicatesToolNeutralStatus(entry)
30 );
31}
32
2033export interface TimelineEndState {
2134 readonly isAtEnd?: boolean;
2235 readonly contentLength?: number;
......@@ -170,6 +183,17 @@ export type MessagesTimelineRow =
170183 id: string;
171184 createdAt: string;
172185 groupedEntries: WorkLogEntry[];
186 isExpandedToolGroupEntry: boolean;
187 isLastExpandedToolGroupEntry: boolean;
188 }
189 | {
190 kind: "work-live";
191 id: string;
192 createdAt: string;
193 entry: WorkLogEntry;
194 groupedEntries: WorkLogEntry[];
195 groupId: string;
196 expanded: boolean;
173197 }
174198 | {
175199 kind: "work-toggle";
......@@ -179,6 +203,9 @@ export type MessagesTimelineRow =
179203 hiddenCount: number;
180204 expanded: boolean;
181205 onlyToolEntries: boolean;
206 summary: string | null;
207 summaryKind: ToolGroupSummaryKind | null;
208 hasFailure: boolean;
182209 }
183210 | {
184211 kind: "turn-fold";
......@@ -212,7 +239,12 @@ export type MessagesTimelineRow =
212239 createdAt: string;
213240 turnPlan: TurnPlanEntry;
214241 }
215 | { kind: "working"; id: string; createdAt: string | null };
242 | {
243 kind: "working";
244 id: string;
245 createdAt: string | null;
246 showThinking: boolean;
247 };
216248
217249export interface StableMessagesTimelineRowsState {
218250 byId: Map<string, MessagesTimelineRow>;
......@@ -242,6 +274,161 @@ export function normalizeCompactToolLabel(value: string): string {
242274 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
243275}
244276
277type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
278type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
279
280export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
281 return (
282 entry.itemType === "web_search" &&
283 /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
284 );
285}
286
287export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
288 if (
289 entry.requestKind === "file-read" ||
290 entry.itemType === "image_view" ||
291 (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
292 ) {
293 return "read";
294 }
295 if (
296 entry.requestKind === "file-change" ||
297 entry.itemType === "file_change" ||
298 (entry.changedFiles?.length ?? 0) > 0
299 ) {
300 return "edit";
301 }
302 if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
303 return "command";
304 }
305 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
306 if (entry.itemType === "web_search") return "search";
307 return "other";
308}
309
310function toolGroupActionCount(
311 action: ToolGroupAction,
312 entries: ReadonlyArray<WorkLogEntry>,
313): number {
314 if (action !== "edit") return entries.length;
315
316 const changedFiles = new Set<string>();
317 let editsWithoutFileDetails = 0;
318 for (const entry of entries) {
319 if (!entry.changedFiles || entry.changedFiles.length === 0) {
320 editsWithoutFileDetails += 1;
321 continue;
322 }
323 for (const file of entry.changedFiles) changedFiles.add(file);
324 }
325 return changedFiles.size + editsWithoutFileDetails;
326}
327
328function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
329 switch (action) {
330 case "read":
331 return `Read ${count} ${count === 1 ? "file" : "files"}`;
332 case "edit":
333 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
334 case "command":
335 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
336 case "search":
337 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
338 case "code-search":
339 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
340 case "other":
341 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
342 }
343}
344
345/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
346export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
347 const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);
348 const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
349 for (const entry of summaryEntries) {
350 const action = toolGroupAction(entry);
351 const group = groupedEntries.get(action);
352 if (group) group.push(entry);
353 else groupedEntries.set(action, [entry]);
354 }
355 const labels = [...groupedEntries].map(([action, actionEntries]) =>
356 toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
357 );
358 const sentenceLabels = labels.map((label, index) =>
359 index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
360 );
361 if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
362 if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
363 return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
364}
365
366function omitSupersededLifecycleMarkers<T>(
367 entries: readonly T[],
368 workEntryFor: (entry: T) => WorkLogEntry,
369): T[] {
370 const laterTerminalIdentities = new Set<string>();
371 const reversedEntries: T[] = [];
372
373 for (let index = entries.length - 1; index >= 0; index -= 1) {
374 const entry = entries[index]!;
375 const workEntry = workEntryFor(entry);
376 const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);
377 const identity = [
378 workEntry.turnId ?? "no-turn",
379 workEntry.itemType ?? "",
380 normalizedLabel,
381 ].join("\u001f");
382 const isStatuslessIdlessMarker =
383 workEntry.toolCallId === undefined &&
384 workEntry.toolLifecycleStatus === undefined &&
385 (workEntry.sourceActivityKind === "tool.started" ||
386 workEntry.sourceActivityKind === "tool.updated");
387 if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;
388
389 reversedEntries.push(entry);
390 if (
391 workEntry.sourceActivityKind === "tool.completed" ||
392 (workEntry.toolLifecycleStatus !== undefined &&
393 workEntry.toolLifecycleStatus !== "inProgress")
394 ) {
395 laterTerminalIdentities.add(identity);
396 }
397 }
398
399 return reversedEntries.toReversed();
400}
401
402function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind {
403 const actions = new Set(entries.map(toolGroupAction));
404 if (actions.size !== 1) return "mixed";
405
406 const action = actions.values().next().value!;
407 if (action !== "other") return action;
408
409 const fallbackKinds = new Set(
410 entries.map((entry): ToolGroupSummaryKind => {
411 if (entry.itemType === "mcp_tool_call") return "other";
412 if (entry.itemType === "dynamic_tool_call") return "dynamic-tool";
413 if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool";
414 if (entry.tone === "thinking") return "agent-tool";
415 if (entry.tone === "tool") return "tone-tool";
416 return "other";
417 }),
418 );
419 return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
420}
421
422function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
423 return entry.toolCallId
424 ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`
425 : timelineEntryId;
426}
427
428function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {
429 return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;
430}
431
245432export function resolveAssistantMessageCopyState({
246433 text,
247434 showCopyButton,
......@@ -314,6 +501,25 @@ function deriveUnsettledTurnId(
314501 return isSettled ? null : latestTurn.turnId;
315502}
316503
504function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number {
505 return timelineEntries.findLastIndex(
506 (entry) => entry.kind === "message" && entry.message.role === "user",
507 );
508}
509
510function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
511 if (entry.kind === "message") {
512 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;
513 }
514 if (entry.kind === "turn-plan") {
515 return entry.turnPlan.turnId;
516 }
517 if (entry.kind === "proposed-plan") {
518 return entry.proposedPlan.turnId;
519 }
520 return entry.kind === "work" ? (entry.entry.turnId ?? null) : null;
521}
522
317523/**
318524 * Settled turns fold their commentary and tool activity behind a
319525 * "Worked for ..." row anchored at the turn's first foldable entry; the
......@@ -481,21 +687,132 @@ export function deriveMessagesTimelineRows(input: {
481687 }
482688 }
483689
690 let activeTurnHeaderIndex = input.timelineEntries.length;
691 if (input.isWorking) {
692 const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
693 const firstOwnedAfterUser =
694 unsettledTurnId === null
695 ? -1
696 : input.timelineEntries.findIndex(
697 (entry, index) =>
698 index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
699 );
700 activeTurnHeaderIndex =
701 firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
702 }
703 const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
704 input.isWorking &&
705 index >= activeTurnHeaderIndex &&
706 (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
707 const workEntryIsInActiveRun = (entry: WorkLogEntry) =>
708 input.isWorking &&
709 unsettledTurnId !== null &&
710 entry.toolLifecycleStatus === "inProgress" &&
711 entry.turnId === unsettledTurnId;
712 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
713 workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
714 const activeEntries = input.isWorking
715 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
716 : [];
717 const activeTurnHasVisibleContent = activeEntries.some((entry) => {
718 if (entry.kind === "message") {
719 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
720 }
721 if (entry.kind === "work") {
722 return (
723 entry.entry.agentSpawn === undefined &&
724 workLogEntryIsToolLike(entry.entry) &&
725 entry.entry.toolLifecycleStatus === "inProgress"
726 );
727 }
728 if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
729 return false;
730 });
731
732 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
733 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
734 const entry = input.timelineEntries[index]!;
735 if (
736 !entryBelongsToActiveTurn(entry, index) ||
737 entry.kind !== "work" ||
738 entry.entry.agentSpawn !== undefined ||
739 entry.entry.tone === "error" ||
740 !workLogEntryIsToolLike(entry.entry)
741 ) {
742 break;
743 }
744 activeToolEntries.unshift(entry);
745 }
746 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
747 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
748 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
749 (entry) => entry.entry,
750 );
751 const activeWorkAnchor = activeToolEntries[0];
752 const latestActiveToolEntry = visibleActiveToolEntries.at(-1);
753 const activeWorkPlacementEntryId = latestActiveToolEntry?.id;
754 const activeWorkRow =
755 activeWorkAnchor && latestActiveToolEntry
756 ? (() => {
757 const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
758 return {
759 kind: "work-live" as const,
760 id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
761 createdAt: activeWorkAnchor.createdAt,
762 entry: latestActiveToolEntry.entry,
763 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
764 groupId,
765 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
766 };
767 })()
768 : null;
769 const appendWorkingRow = () => {
770 nextRows.push({
771 kind: "working",
772 id: "working-indicator-row",
773 createdAt: input.activeTurnStartedAt,
774 showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
775 });
776 };
777 const appendActiveWorkRows = () => {
778 if (activeWorkRow === null) return;
779 nextRows.push(activeWorkRow);
780 if (!activeWorkRow.expanded) return;
781 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
782 nextRows.push({
783 kind: "work",
784 id: workEntry.id,
785 createdAt: workEntry.createdAt,
786 groupedEntries: [workEntry],
787 isExpandedToolGroupEntry: true,
788 isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
789 });
790 }
791 };
792
484793 for (let index = 0; index < input.timelineEntries.length; index += 1) {
485794 const timelineEntry = input.timelineEntries[index];
486795 if (!timelineEntry) {
487796 continue;
488797 }
489798
490 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
491 if (turnFold) {
799 if (input.isWorking && index === activeTurnHeaderIndex) {
800 appendWorkingRow();
801 }
802
803 if (timelineEntry.id === activeWorkPlacementEntryId) {
804 appendActiveWorkRows();
805 }
806
807 const anchoredTurnFold = foldsByAnchorEntryId.get(timelineEntry.id);
808 if (anchoredTurnFold) {
492809 nextRows.push({
493810 kind: "turn-fold",
494 id: `turn-fold:${turnFold.turnId}`,
495 createdAt: turnFold.createdAt,
496 turnId: turnFold.turnId,
497 label: turnFold.label,
498 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
811 id: `turn-fold:${anchoredTurnFold.turnId}`,
812 createdAt: anchoredTurnFold.createdAt,
813 turnId: anchoredTurnFold.turnId,
814 label: anchoredTurnFold.label,
815 expanded: input.expandedTurnIds?.has(anchoredTurnFold.turnId) ?? false,
499816 });
500817 }
501818
......@@ -503,6 +820,10 @@ export function deriveMessagesTimelineRows(input: {
503820 continue;
504821 }
505822
823 if (activeWorkEntryIds.has(timelineEntry.id)) {
824 continue;
825 }
826
506827 if (timelineEntry.kind === "work") {
507828 const groupedEntries = [timelineEntry.entry];
508829 let cursor = index + 1;
......@@ -511,6 +832,7 @@ export function deriveMessagesTimelineRows(input: {
511832 if (
512833 !nextEntry ||
513834 nextEntry.kind !== "work" ||
835 activeWorkEntryIds.has(nextEntry.id) ||
514836 collapsedEntryIds.has(nextEntry.id) ||
515837 foldsByAnchorEntryId.has(nextEntry.id)
516838 ) {
......@@ -519,19 +841,86 @@ export function deriveMessagesTimelineRows(input: {
519841 groupedEntries.push(nextEntry.entry);
520842 cursor += 1;
521843 }
522 const visibleGroupedEntries = groupedEntries.filter(
523 (entry) => !workEntryIndicatesToolNeutralStatus(entry),
844 const visibleGroupedEntries = omitSupersededLifecycleMarkers(
845 groupedEntries.filter((entry) =>
846 workEntryIsVisibleInGroup(entry, workEntryIsInActiveRun(entry)),
847 ),
848 (entry) => entry,
524849 );
525850 if (visibleGroupedEntries.length > 0) {
526 if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
851 const onlyToolEntries = visibleGroupedEntries.every(
852 (entry) =>
853 workLogEntryIsToolLike(entry) &&
854 entry.agentSpawn === undefined &&
855 entry.tone !== "error",
856 );
857 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
858 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
859 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
860 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
861 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
862 nextRows.push({
863 kind: "work-live",
864 id: `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`,
865 createdAt: timelineEntry.createdAt,
866 entry: latestActiveToolEntry,
867 groupedEntries: visibleGroupedEntries,
868 groupId,
869 expanded,
870 });
871 if (expanded) {
872 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
873 nextRows.push({
874 kind: "work",
875 id: workEntry.id,
876 createdAt: workEntry.createdAt,
877 groupedEntries: [workEntry],
878 isExpandedToolGroupEntry: true,
879 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
880 });
881 }
882 }
883 } else if (onlyToolEntries) {
884 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
885 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
886 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
887 nextRows.push({
888 kind: "work-toggle",
889 id: `work-toggle:${timelineEntry.id}`,
890 createdAt: timelineEntry.createdAt,
891 groupId,
892 hiddenCount: visibleGroupedEntries.length,
893 expanded,
894 onlyToolEntries: true,
895 summary: summarizeToolGroup(visibleGroupedEntries),
896 summaryKind,
897 hasFailure: visibleGroupedEntries.some((entry) =>
898 workEntryDisplayIndicatesToolFailure(entry),
899 ),
900 });
901 if (expanded) {
902 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
903 nextRows.push({
904 kind: "work",
905 id: workEntry.id,
906 createdAt: workEntry.createdAt,
907 groupedEntries: [workEntry],
908 isExpandedToolGroupEntry: true,
909 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
910 });
911 }
912 }
913 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
527914 nextRows.push({
528915 kind: "work",
529916 id: timelineEntry.id,
530917 createdAt: timelineEntry.createdAt,
531918 groupedEntries: visibleGroupedEntries,
919 isExpandedToolGroupEntry: false,
920 isLastExpandedToolGroupEntry: false,
532921 });
533922 } else {
534 const groupId = `work-group:${timelineEntry.id}`;
923 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
535924 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
536925 // Agent-spawn CTA rows are always visible: a running fleet must
537926 // never hide behind a "+N tool calls" toggle. Selection is by
......@@ -555,6 +944,8 @@ export function deriveMessagesTimelineRows(input: {
555944 id: workEntry.id,
556945 createdAt: workEntry.createdAt,
557946 groupedEntries: [workEntry],
947 isExpandedToolGroupEntry: false,
948 isLastExpandedToolGroupEntry: false,
558949 });
559950 }
560951
......@@ -566,8 +957,11 @@ export function deriveMessagesTimelineRows(input: {
566957 groupId,
567958 hiddenCount: hiddenEntries.length,
568959 expanded,
569 onlyToolEntries: visibleGroupedEntries.every((entry) =>
570 workLogEntryIsToolLike(entry),
960 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
961 summary: null,
962 summaryKind: null,
963 hasFailure: hiddenEntries.some((entry) =>
964 workEntryDisplayIndicatesToolFailure(entry),
571965 ),
572966 });
573967 }
......@@ -633,12 +1027,8 @@ export function deriveMessagesTimelineRows(input: {
6331027 });
6341028 }
6351029
636 if (input.isWorking) {
637 nextRows.push({
638 kind: "working",
639 id: "working-indicator-row",
640 createdAt: input.activeTurnStartedAt,
641 });
1030 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1031 appendWorkingRow();
6421032 }
6431033
6441034 return nextRows;
......@@ -670,7 +1060,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6701060
6711061 switch (a.kind) {
6721062 case "working":
673 return a.createdAt === (b as typeof a).createdAt;
1063 return (
1064 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1065 );
6741066
6751067 case "turn-fold": {
6761068 const bf = b as typeof a;
......@@ -687,8 +1079,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6871079 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
6881080 }
6891081
690 case "work":
691 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
1082 case "work": {
1083 const bw = b as typeof a;
1084 return (
1085 a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&
1086 a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
1087 Equal.equals(a.groupedEntries, bw.groupedEntries)
1088 );
1089 }
1090
1091 case "work-live": {
1092 const bw = b as typeof a;
1093 return (
1094 a.createdAt === bw.createdAt &&
1095 a.groupId === bw.groupId &&
1096 a.expanded === bw.expanded &&
1097 Equal.equals(a.entry, bw.entry) &&
1098 Equal.equals(a.groupedEntries, bw.groupedEntries)
1099 );
1100 }
6921101
6931102 case "work-toggle": {
6941103 const bw = b as typeof a;
......@@ -697,7 +1106,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6971106 a.groupId === bw.groupId &&
6981107 a.hiddenCount === bw.hiddenCount &&
6991108 a.expanded === bw.expanded &&
700 a.onlyToolEntries === bw.onlyToolEntries
1109 a.onlyToolEntries === bw.onlyToolEntries &&
1110 a.summary === bw.summary &&
1111 a.summaryKind === bw.summaryKind &&
1112 a.hasFailure === bw.hasFailure
7011113 );
7021114 }
7031115
apps/web/src/components/chat/MessagesTimeline.test.tsx+189-3
......@@ -176,7 +176,6 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
176176function buildProps() {
177177 return {
178178 isWorking: false,
179 activeTurnInProgress: false,
180179 activeTurnStartedAt: null,
181180 listRef: createRef<LegendListRef | null>(),
182181 latestTurn: null,
......@@ -237,6 +236,44 @@ function buildAssistantTimelineEntry(text: string) {
237236}
238237
239238describe("MessagesTimeline", () => {
239 it("renders the worked-for row at assistant response text size", () => {
240 const turnId = TurnId.make("turn-with-fold");
241 const assistantEntry = buildAssistantTimelineEntry("Done.");
242 const markup = renderToStaticMarkup(
243 <MessagesTimeline
244 {...buildProps()}
245 latestTurn={{
246 turnId,
247 state: "completed",
248 startedAt: "2026-03-17T19:12:20.000Z",
249 completedAt: "2026-03-17T19:12:28.000Z",
250 }}
251 timelineEntries={[
252 {
253 id: "work-entry-with-fold",
254 kind: "work",
255 createdAt: "2026-03-17T19:12:22.000Z",
256 entry: {
257 id: "work-with-fold",
258 createdAt: "2026-03-17T19:12:22.000Z",
259 turnId,
260 label: "Ran command",
261 tone: "tool",
262 toolLifecycleStatus: "completed",
263 },
264 },
265 {
266 ...assistantEntry,
267 message: { ...assistantEntry.message, turnId },
268 },
269 ]}
270 />,
271 );
272
273 expect(markup).toContain("Worked for 8.0s");
274 expect(markup).toContain("px-1 text-sm leading-relaxed text-muted-foreground");
275 });
276
240277 it("uses the larger leading inset only when the top fade is enabled", () => {
241278 const timelineEntries = [buildUserTimelineEntry("Hello")];
242279
......@@ -700,7 +737,7 @@ describe("MessagesTimeline", () => {
700737 expect(markup).toContain("Work Log");
701738 });
702739
703 it("formats changed file paths from the workspace root", () => {
740 it("summarizes changed files in one line", () => {
704741 const markup = renderToStaticMarkup(
705742 <MessagesTimeline
706743 {...buildProps()}
......@@ -722,10 +759,159 @@ describe("MessagesTimeline", () => {
722759 />,
723760 );
724761
725 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");
762 expect(markup).toContain("Changed 1 file");
726763 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
727764 });
728765
766 it("shows the animated one-line label for a live tool group", () => {
767 const turnId = TurnId.make("turn-live");
768 const markup = renderToStaticMarkup(
769 <MessagesTimeline
770 {...buildProps()}
771 isWorking
772 activeTurnStartedAt={MESSAGE_CREATED_AT}
773 latestTurn={{
774 turnId,
775 state: "running",
776 startedAt: MESSAGE_CREATED_AT,
777 completedAt: null,
778 }}
779 runningTurnId={turnId}
780 timelineEntries={[
781 {
782 id: "entry-live",
783 kind: "work",
784 createdAt: MESSAGE_CREATED_AT,
785 entry: {
786 id: "work-live",
787 createdAt: MESSAGE_CREATED_AT,
788 turnId,
789 toolCallId: "call-live",
790 label: "Run tests",
791 tone: "tool",
792 itemType: "command_execution",
793 command: "pnpm test",
794 toolLifecycleStatus: "inProgress",
795 },
796 },
797 ]}
798 />,
799 );
800
801 expect(markup).toContain("Working for");
802 expect(markup).toContain("Running pnpm");
803 expect(markup).toContain("live-activity-focus");
804 });
805
806 it("scopes a live row failure to the tool named by the row", () => {
807 const turnId = TurnId.make("turn-live");
808 const markup = renderToStaticMarkup(
809 <MessagesTimeline
810 {...buildProps()}
811 isWorking
812 activeTurnStartedAt={MESSAGE_CREATED_AT}
813 latestTurn={{
814 turnId,
815 state: "running",
816 startedAt: MESSAGE_CREATED_AT,
817 completedAt: null,
818 }}
819 runningTurnId={turnId}
820 timelineEntries={[
821 {
822 id: "entry-failed",
823 kind: "work",
824 createdAt: MESSAGE_CREATED_AT,
825 entry: {
826 id: "work-failed",
827 createdAt: MESSAGE_CREATED_AT,
828 turnId,
829 toolCallId: "call-failed",
830 label: "Run lint",
831 tone: "tool",
832 itemType: "command_execution",
833 command: "pnpm lint",
834 toolLifecycleStatus: "failed",
835 },
836 },
837 {
838 id: "entry-running",
839 kind: "work",
840 createdAt: MESSAGE_CREATED_AT,
841 entry: {
842 id: "work-running",
843 createdAt: MESSAGE_CREATED_AT,
844 turnId,
845 toolCallId: "call-running",
846 label: "Run tests",
847 tone: "tool",
848 itemType: "command_execution",
849 command: "pnpm test",
850 toolLifecycleStatus: "inProgress",
851 },
852 },
853 ]}
854 />,
855 );
856
857 expect(markup).toContain("Running pnpm");
858 expect(markup).not.toContain("tool call failed");
859 });
860
861 it("keeps terminal command copy live while the parent turn is active", () => {
862 const turnId = TurnId.make("turn-live");
863 const markup = renderToStaticMarkup(
864 <MessagesTimeline
865 {...buildProps()}
866 isWorking
867 activeTurnStartedAt={MESSAGE_CREATED_AT}
868 latestTurn={{
869 turnId,
870 state: "running",
871 startedAt: MESSAGE_CREATED_AT,
872 completedAt: null,
873 }}
874 runningTurnId={turnId}
875 timelineEntries={[
876 {
877 id: "entry-failed",
878 kind: "work",
879 createdAt: MESSAGE_CREATED_AT,
880 entry: {
881 id: "work-failed",
882 createdAt: MESSAGE_CREATED_AT,
883 turnId,
884 toolCallId: "call-failed",
885 label: "Run lint",
886 tone: "tool",
887 itemType: "command_execution",
888 command: "pnpm lint",
889 toolLifecycleStatus: "failed",
890 },
891 },
892 ]}
893 />,
894 );
895
896 expect(markup).toContain("Running pnpm");
897 expect(markup).toContain("tool call failed");
898 });
899
900 it("aligns the iconless Thinking row with the working timer", () => {
901 const markup = renderToStaticMarkup(
902 <MessagesTimeline
903 {...buildProps()}
904 isWorking
905 activeTurnStartedAt={MESSAGE_CREATED_AT}
906 timelineEntries={[]}
907 />,
908 );
909
910 expect(markup).toContain("Working for");
911 expect(markup).toContain("Thinking");
912 expect(markup).toContain("gap-1.5 py-0.5 px-1");
913 });
914
729915 it("renders review comment contexts as structured cards instead of raw tags", () => {
730916 const markup = renderToStaticMarkup(
731917 <MessagesTimeline
apps/web/src/components/chat/MessagesTimeline.tsx+472-142
......@@ -33,9 +33,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";
3333import { FileDiff } from "@pierre/diffs/react";
3434import {
3535 deriveTimelineEntries,
36 workEntryIndicatesToolFailure,
37 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
36 workEntryDisplayIndicatesToolFailure,
3937 workLogEntryIsToolLike,
4038} from "../../session-logic";
4139import { type TurnDiffSummary } from "../../types";
......@@ -57,7 +55,7 @@ import {
5755 MessageCircleIcon,
5856 MousePointerClickIcon,
5957 PaintbrushIcon,
60 MinusIcon,
58 SearchIcon,
6159 SquarePenIcon,
6260 TerminalIcon,
6361 Undo2Icon,
......@@ -84,6 +82,8 @@ import {
8482 resolveTimelineMinimapInteractiveWidth,
8583 resolveTimelineMinimapTopPercent,
8684 shouldPreserveAssistantLineBreaks,
85 toolGroupAction,
86 workEntryIsVisibleInGroup,
8787 type StableMessagesTimelineRowsState,
8888 type MessagesTimelineRow,
8989 TIMELINE_MINIMAP_MIN_ITEMS,
......@@ -150,7 +150,6 @@ interface TimelineRowSharedState {
150150interface TimelineRowActivityState {
151151 isWorking: boolean;
152152 isRevertingCheckpoint: boolean;
153 activeTurnInProgress: boolean;
154153 latestTurnId: TurnId | null;
155154 /** Current plan step label for the working row, when the turn has a plan. */
156155 workingStepLabel: string | null;
......@@ -207,7 +206,6 @@ interface MessagesTimelineProps {
207206 onOpenAgents?: () => void;
208207 isWorking: boolean;
209208 workingStepLabel?: string | null;
210 activeTurnInProgress: boolean;
211209 activeTurnStartedAt: string | null;
212210 listRef: React.RefObject<LegendListRef | null>;
213211 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
......@@ -251,7 +249,6 @@ interface MessagesTimelineProps {
251249export const MessagesTimeline = memo(function MessagesTimeline({
252250 isWorking,
253251 workingStepLabel = null,
254 activeTurnInProgress,
255252 activeTurnStartedAt,
256253 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
257254 onOpenAgents = NOOP_OPEN_AGENTS,
......@@ -540,11 +537,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
540537 () => ({
541538 isWorking,
542539 isRevertingCheckpoint,
543 activeTurnInProgress,
544540 latestTurnId: latestTurn?.turnId ?? null,
545541 workingStepLabel,
546542 }),
547 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
543 [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
548544 );
549545
550546 // Stable renderItem — no closure deps. Row components read shared state
......@@ -921,17 +917,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
921917type TimelineRow = MessagesTimelineRow;
922918
923919const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
920 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
921 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
922 const isExpandedToolGroupHeader =
923 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
924 (row.kind === "work-live" && row.expanded);
925
924926 return (
925927 <div
926928 className={cn(
927929 // Commentary (non-terminal assistant) rows carry no metadata row, so
928930 // they sit closer to the work that follows them.
929 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
930 row.kind === "work" ||
931 row.kind === "work-toggle" ||
932 row.kind === "turn-plan"
933 ? "pb-2"
934 : "pb-4",
931 isExpandedToolGroupEntry
932 ? isLastExpandedToolGroupEntry
933 ? "pb-1"
934 : "pb-0"
935 : isExpandedToolGroupHeader
936 ? "pb-0"
937 : row.kind === "turn-fold" || row.kind === "working"
938 ? "pb-1.5"
939 : (row.kind === "message" &&
940 row.message.role === "assistant" &&
941 !row.showAssistantMeta) ||
942 row.kind === "work" ||
943 row.kind === "work-live" ||
944 row.kind === "work-toggle" ||
945 row.kind === "turn-plan"
946 ? "pb-2"
947 : "pb-4",
935948 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
936949 )}
937950 data-timeline-row-id={row.id}
......@@ -939,7 +952,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
939952 data-message-id={row.kind === "message" ? row.message.id : undefined}
940953 data-message-role={row.kind === "message" ? row.message.role : undefined}
941954 >
942 {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
955 {row.kind === "work" ? (
956 <WorkGroupSection
957 groupedEntries={row.groupedEntries}
958 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}
959 />
960 ) : null}
961 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
943962 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
944963 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
945964 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
......@@ -1093,7 +1112,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
10931112 aria-expanded={row.expanded}
10941113 data-scroll-anchor-ignore
10951114 onClick={() => ctx.onToggleTurnFold(row.turnId)}
1096 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1115 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
10971116 >
10981117 <span>{row.label}</span>
10991118 <Icon className="size-3.5" />
......@@ -1282,14 +1301,9 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
12821301function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
12831302 const { workingStepLabel } = use(TimelineRowActivityCtx);
12841303 return (
1285 <div className="py-0.5 pl-1.5">
1286 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">
1287 <span className="inline-flex items-center gap-[3px]">
1288 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
1289 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
1290 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
1291 </span>
1292 <span className="shrink-0">
1304 <div>
1305 <div className="border-b border-border/60 pb-2 pt-1">
1306 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
12931307 {row.createdAt ? (
12941308 <>
12951309 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1297,11 +1311,16 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
12971311 ) : (
12981312 "Working..."
12991313 )}
1300 </span>
1301 {workingStepLabel ? (
1302 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1303 ) : null}
1314 {workingStepLabel ? (
1315 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>
1316 ) : null}
1317 </div>
13041318 </div>
1319 {row.showThinking ? (
1320 <div className="mt-1">
1321 <ThinkingActivityRow />
1322 </div>
1323 ) : null}
13051324 </div>
13061325 );
13071326}
......@@ -1342,13 +1361,16 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
13421361/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
13431362const WorkGroupSection = memo(function WorkGroupSection({
13441363 groupedEntries,
1364 isExpandedToolGroupEntry,
13451365}: {
13461366 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1367 isExpandedToolGroupEntry: boolean;
13471368}) {
13481369 const { workspaceRoot } = use(TimelineRowCtx);
13491370 const nonEmptyEntries = useMemo(
1350 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),
1351 [groupedEntries],
1371 () =>
1372 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
1373 [groupedEntries, isExpandedToolGroupEntry],
13521374 );
13531375 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
13541376 const groupLabel = onlyToolEntries
......@@ -1356,11 +1378,15 @@ const WorkGroupSection = memo(function WorkGroupSection({
13561378 ? "1 tool call"
13571379 : `${nonEmptyEntries.length} tool calls`
13581380 : "Work Log";
1381 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
13591382
13601383 if (nonEmptyEntries.length === 0) return null;
13611384
13621385 return (
1363 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label={groupLabel}>
1386 <GroupContainer
1387 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1388 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}
1389 >
13641390 {!onlyToolEntries && (
13651391 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
13661392 )}
......@@ -1370,19 +1396,170 @@ const WorkGroupSection = memo(function WorkGroupSection({
13701396 key={workEntry.id}
13711397 workEntry={workEntry}
13721398 workspaceRoot={workspaceRoot}
1399 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
13731400 />
13741401 ))}
13751402 </div>
1376 </section>
1403 </GroupContainer>
13771404 );
13781405});
13791406
1407function LiveActivityRow({
1408 label,
1409 iconName,
1410 failed = false,
1411}: {
1412 label: string;
1413 iconName?: WorkEntryIconName;
1414 failed?: boolean;
1415}) {
1416 return (
1417 <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
1418 <LiveActivityContent
1419 label={label}
1420 iconName={iconName}
1421 failed={failed}
1422 announceFailure={failed}
1423 />
1424 <div
1425 aria-hidden
1426 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
1427 >
1428 <div className="live-activity-focus-counter">
1429 <div className="live-activity-focus-aligned">
1430 <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
1431 </div>
1432 </div>
1433 </div>
1434 </div>
1435 );
1436}
1437
1438function ThinkingActivityRow() {
1439 return <LiveActivityRow label="Thinking" />;
1440}
1441
1442function LiveActivityContent({
1443 label,
1444 iconName,
1445 failed = false,
1446 announceFailure = false,
1447 highlighted = false,
1448}: {
1449 label: string;
1450 iconName: WorkEntryIconName | undefined;
1451 failed?: boolean;
1452 announceFailure?: boolean;
1453 highlighted?: boolean;
1454}) {
1455 const resolvedIconName = failed ? "x" : iconName;
1456
1457 return (
1458 <div
1459 className={cn(
1460 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
1461 resolvedIconName ? "px-0.5" : "px-1",
1462 highlighted ? "text-foreground" : "text-secondary-label",
1463 )}
1464 >
1465 {resolvedIconName ? (
1466 <span
1467 className={cn(
1468 "flex size-6 shrink-0 items-center justify-center",
1469 failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
1470 )}
1471 role={announceFailure ? "img" : undefined}
1472 aria-label={announceFailure ? "Tool call failed" : undefined}
1473 >
1474 <WorkEntryIconSvg
1475 name={resolvedIconName}
1476 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
1477 />
1478 </span>
1479 ) : null}
1480 <span className="min-w-0 flex-1 truncate">{label}</span>
1481 </div>
1482 );
1483}
1484
1485function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
1486 const ctx = use(TimelineRowCtx);
1487 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
1488 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
1489
1490 return (
1491 <button
1492 type="button"
1493 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1494 aria-label={failed ? `${label}, tool call failed` : undefined}
1495 aria-expanded={row.expanded}
1496 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1497 >
1498 <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
1499 </button>
1500 );
1501}
1502
1503function toolGroupSummaryIconName(
1504 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
1505): WorkEntryIconName {
1506 switch (kind) {
1507 case "read":
1508 return "eye";
1509 case "edit":
1510 return "square-pen";
1511 case "command":
1512 return "terminal";
1513 case "search":
1514 return "globe";
1515 case "code-search":
1516 return "search";
1517 case "other":
1518 return "wrench";
1519 case "dynamic-tool":
1520 return "hammer";
1521 case "agent-tool":
1522 return "bot";
1523 case "tone-tool":
1524 return "zap";
1525 case "mixed":
1526 case null:
1527 return "hammer";
1528 }
1529}
1530
13801531function WorkGroupToggleTimelineRow({
13811532 row,
13821533}: {
13831534 row: Extract<TimelineRow, { kind: "work-toggle" }>;
13841535}) {
13851536 const ctx = use(TimelineRowCtx);
1537 if (row.onlyToolEntries && row.summary) {
1538 return (
1539 <button
1540 type="button"
1541 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1542 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1543 aria-expanded={row.expanded}
1544 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1545 >
1546 <span
1547 className={cn(
1548 "flex size-6 shrink-0 items-center justify-center",
1549 row.hasFailure ? "text-destructive" : "text-icon-muted",
1550 )}
1551 role={row.hasFailure ? "img" : undefined}
1552 aria-label={row.hasFailure ? "Tool call failed" : undefined}
1553 >
1554 <WorkEntryIconSvg
1555 name={row.hasFailure ? "x" : toolGroupSummaryIconName(row.summaryKind)}
1556 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1557 />
1558 </span>
1559 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1560 </button>
1561 );
1562 }
13861563 const labelNoun = row.onlyToolEntries
13871564 ? row.hiddenCount === 1
13881565 ? "tool call"
......@@ -1390,21 +1567,33 @@ function WorkGroupToggleTimelineRow({
13901567 : row.hiddenCount === 1
13911568 ? "log entry"
13921569 : "log entries";
1570 const showHiddenFailure = row.hasFailure && !row.expanded;
13931571
13941572 return (
13951573 <button
13961574 type="button"
1397 className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1575 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
13981576 aria-expanded={row.expanded}
13991577 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
14001578 >
1401 <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">
1402 <ChevronDownIcon
1403 className={cn(
1404 "size-3.5 shrink-0 opacity-70 transition-transform duration-200",
1405 row.expanded && "rotate-180",
1406 )}
1407 />
1579 <span
1580 className={cn(
1581 "flex size-6 shrink-0 items-center justify-center",
1582 showHiddenFailure ? "text-destructive" : "text-icon-muted",
1583 )}
1584 role={showHiddenFailure ? "img" : undefined}
1585 aria-label={showHiddenFailure ? "Hidden work includes a failure" : undefined}
1586 >
1587 {showHiddenFailure ? (
1588 <WorkEntryIconSvg name="x" className="size-4 shrink-0 stroke-[1.8] opacity-70" />
1589 ) : (
1590 <ChevronDownIcon
1591 className={cn(
1592 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1593 row.expanded && "rotate-180",
1594 )}
1595 />
1596 )}
14081597 </span>
14091598 {row.expanded ? (
14101599 <span className="font-medium text-foreground">
......@@ -1947,6 +2136,7 @@ type WorkEntryIconName =
19472136 | "globe"
19482137 | "hammer"
19492138 | "message-circle"
2139 | "search"
19502140 | "square-pen"
19512141 | "terminal"
19522142 | "wrench"
......@@ -1969,6 +2159,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
19692159 return <HammerIcon className={className} aria-hidden />;
19702160 case "message-circle":
19712161 return <MessageCircleIcon className={className} aria-hidden />;
2162 case "search":
2163 return <SearchIcon className={className} aria-hidden />;
19722164 case "square-pen":
19732165 return <SquarePenIcon className={className} aria-hidden />;
19742166 case "terminal":
......@@ -2035,6 +2227,192 @@ function workEntryRawCommand(
20352227 return rawCommand === workEntry.command.trim() ? null : rawCommand;
20362228}
20372229
2230type CommandWrapper = "env" | "sudo";
2231
2232const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
2233 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
2234 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
2235};
2236
2237const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
2238 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
2239 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
2240};
2241
2242function tokenizeShellCommand(command: string): string[] | null {
2243 const input = command.trim();
2244 const tokens: string[] = [];
2245 let current = "";
2246 let quote: '"' | "'" | null = null;
2247 let escaping = false;
2248 let substitutionDepth = 0;
2249 let tokenStarted = false;
2250
2251 for (let index = 0; index < input.length; index += 1) {
2252 const character = input[index]!;
2253 if (escaping) {
2254 current += character;
2255 escaping = false;
2256 tokenStarted = true;
2257 continue;
2258 }
2259 if (character === "\\" && quote !== "'") {
2260 const nextCharacter = input[index + 1];
2261 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
2262 if (
2263 (quote === '"' || isWindowsDrivePath) &&
2264 nextCharacter !== undefined &&
2265 nextCharacter !== '"' &&
2266 nextCharacter !== "\\" &&
2267 nextCharacter !== "$" &&
2268 nextCharacter !== "`" &&
2269 nextCharacter !== "\n"
2270 ) {
2271 current += character;
2272 tokenStarted = true;
2273 continue;
2274 }
2275 escaping = true;
2276 tokenStarted = true;
2277 continue;
2278 }
2279 if (quote !== null) {
2280 if (character === quote) {
2281 quote = null;
2282 } else {
2283 current += character;
2284 }
2285 tokenStarted = true;
2286 continue;
2287 }
2288 if (character === "$" && input[index + 1] === "(") {
2289 current += "$(";
2290 substitutionDepth += 1;
2291 tokenStarted = true;
2292 index += 1;
2293 continue;
2294 }
2295 if (character === ")" && substitutionDepth > 0) {
2296 current += character;
2297 substitutionDepth -= 1;
2298 tokenStarted = true;
2299 continue;
2300 }
2301 if (character === '"' || character === "'") {
2302 quote = character;
2303 tokenStarted = true;
2304 continue;
2305 }
2306 if (/\s/u.test(character)) {
2307 if (substitutionDepth > 0) {
2308 current += character;
2309 tokenStarted = true;
2310 continue;
2311 }
2312 if (tokenStarted) {
2313 tokens.push(current);
2314 current = "";
2315 tokenStarted = false;
2316 }
2317 continue;
2318 }
2319 current += character;
2320 tokenStarted = true;
2321 }
2322
2323 if (quote !== null || escaping || substitutionDepth > 0) return null;
2324 if (tokenStarted) tokens.push(current);
2325 return tokens;
2326}
2327
2328function commandProgramName(command: string, depth = 0): string | null {
2329 if (depth >= 8) return null;
2330 const tokens = tokenizeShellCommand(command);
2331 if (tokens === null) return null;
2332 let index = 0;
2333 let wrapper: CommandWrapper | null = null;
2334
2335 while (index < tokens.length) {
2336 const token = tokens[index];
2337 if (!token) return null;
2338 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
2339 index += 1;
2340 continue;
2341 }
2342 const tokenProgram = token.split(/[\\/]/).at(-1);
2343 if (tokenProgram === "env" || tokenProgram === "sudo") {
2344 wrapper = tokenProgram;
2345 index += 1;
2346 continue;
2347 }
2348 if (wrapper !== null && token === "--") {
2349 wrapper = null;
2350 index += 1;
2351 continue;
2352 }
2353 if (wrapper !== null && token.startsWith("-")) {
2354 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
2355 const splitCommand = tokens[index + 1];
2356 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
2357 }
2358 if (wrapper === "env" && token.startsWith("--split-string=")) {
2359 return commandProgramName(token.slice("--split-string=".length), depth + 1);
2360 }
2361 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
2362 if (tokens[index + 1] === undefined) return null;
2363 index += 2;
2364 continue;
2365 }
2366 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
2367 index += 1;
2368 continue;
2369 }
2370 const equalsIndex = token.indexOf("=");
2371 if (token.startsWith("--") && equalsIndex > 2) {
2372 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
2373 return null;
2374 }
2375 index += 1;
2376 continue;
2377 }
2378 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
2379 let consumesNextToken = false;
2380 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
2381 const shortOption = `-${option}`;
2382 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
2383 consumesNextToken = optionIndex === token.length - 2;
2384 break;
2385 }
2386 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
2387 }
2388 if (consumesNextToken && tokens[index + 1] === undefined) return null;
2389 index += consumesNextToken ? 2 : 1;
2390 continue;
2391 }
2392 return null;
2393 }
2394 return token.split(/[\\/]/).at(-1) || null;
2395 }
2396
2397 return null;
2398}
2399
2400function liveWorkEntryLabel(
2401 workEntry: TimelineWorkEntry,
2402 workspaceRoot: string | undefined,
2403): string {
2404 const command = workEntry.command?.trim();
2405 if (command) {
2406 // This row describes the active parent turn, not the command lifecycle.
2407 // Keep its live "Running" copy until the turn or contiguous tool run settles.
2408 const program = commandProgramName(command);
2409 if (program) return `Running ${program}`;
2410 return "Running command";
2411 }
2412
2413 return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2414}
2415
20382416function buildToolCallExpandedBody(
20392417 workEntry: TimelineWorkEntry,
20402418 workspaceRoot: string | undefined,
......@@ -2073,18 +2451,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
20732451 ) {
20742452 return "message-circle";
20752453 }
2076 if (workEntry.requestKind === "command") return "terminal";
2077 if (workEntry.requestKind === "file-read") return "eye";
2078 if (workEntry.requestKind === "file-change") return "square-pen";
2079
2080 if (workEntry.itemType === "command_execution" || workEntry.command) {
2081 return "terminal";
2082 }
2083 if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) {
2084 return "square-pen";
2085 }
2086 if (workEntry.itemType === "web_search") return "globe";
2087 if (workEntry.itemType === "image_view") return "eye";
2454 const action = toolGroupAction(workEntry);
2455 if (action !== "other") return toolGroupSummaryIconName(action);
20882456
20892457 switch (workEntry.itemType) {
20902458 case "mcp_tool_call":
......@@ -2217,43 +2585,43 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
22172585const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
22182586 workEntry: TimelineWorkEntry;
22192587 workspaceRoot: string | undefined;
2588 isExpandedToolGroupEntry: boolean;
22202589}) {
2221 const { workEntry, workspaceRoot } = props;
2590 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
22222591 // Before any hooks: spawn CTA rows render their own component.
22232592 if (workEntry.agentSpawn) {
22242593 return <AgentSpawnCtaRow workEntry={workEntry} />;
22252594 }
2226 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;
2595 return (
2596 <PlainWorkEntryRow
2597 workEntry={workEntry}
2598 workspaceRoot={workspaceRoot}
2599 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2600 />
2601 );
22272602});
22282603
22292604const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22302605 workEntry: TimelineWorkEntry;
22312606 workspaceRoot: string | undefined;
2607 isExpandedToolGroupEntry: boolean;
22322608}) {
2233 const { workEntry, workspaceRoot } = props;
2234 const activity = use(TimelineRowActivityCtx);
2609 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
22352610 const [expanded, setExpanded] = useState(false);
22362611 const iconConfig = workToneIcon(workEntry.tone);
22372612 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2238 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);
2239 const heading = toolWorkEntryHeading(workEntry);
2240 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
2241 const preview =
2242 rawPreview &&
2243 normalizeCompactToolLabel(rawPreview).toLowerCase() ===
2244 normalizeCompactToolLabel(heading).toLowerCase()
2245 ? null
2246 : rawPreview;
2247 const displayText = preview ? `${heading} - ${preview}` : heading;
2613 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
2614 const entryIconName =
2615 showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);
2616 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
22482617 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
22492618 const canExpand = expandedBody !== null;
2250 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
22512619 const showDestructiveRowStyle =
22522620 showFailedIndicator &&
22532621 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
22542622 const iconWrapperClass = cn(
2255 "flex size-5 shrink-0 items-center justify-center",
2256 showWarningIndicator
2623 "flex size-6 shrink-0 items-center justify-center",
2624 showWarningIndicator || showFailedIndicator
22572625 ? "text-destructive"
22582626 : showDestructiveRowStyle
22592627 ? "text-destructive"
......@@ -2265,17 +2633,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22652633 ? "font-medium text-warning"
22662634 : showDestructiveRowStyle
22672635 ? "font-medium text-destructive"
2268 : "font-medium text-foreground";
2269 const turnSettled = !activity.activeTurnInProgress;
2270 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);
2271 const showSuccessIndicator =
2272 workEntryIndicatesToolSuccess(workEntry) ||
2273 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));
2636 : workLogEntryIsToolLike(workEntry)
2637 ? "text-secondary-label"
2638 : "text-foreground/80";
2639 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
2640 const accessibleDisplayText = showFailedIndicator
2641 ? `${displayText}, tool call failed`
2642 : displayText;
22742643 const rowToggleProps = canExpand
22752644 ? {
22762645 role: "button" as const,
22772646 tabIndex: 0 as const,
2278 "aria-label": displayText,
2647 "aria-label": accessibleDisplayText,
2648 "aria-expanded": expanded,
22792649 onClick: () => setExpanded((v) => !v),
22802650 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
22812651 if (e.key === "Enter" || e.key === " ") {
......@@ -2289,85 +2659,45 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22892659 return (
22902660 <div
22912661 className={cn(
2292 "flex flex-col rounded-md px-0.5 py-0.5 transition-colors",
2662 "flex flex-col rounded-md px-0.5 transition-colors",
2663 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
22932664 canExpand &&
22942665 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
22952666 )}
22962667 {...rowToggleProps}
22972668 >
22982669 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
2299 <span className={iconWrapperClass}>
2670 <span
2671 className={cn(iconWrapperClass, !showEntryIcon && "invisible")}
2672 role={showFailedIndicator ? "img" : undefined}
2673 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
2674 aria-hidden={!showEntryIcon}
2675 >
23002676 <WorkEntryIconSvg
23012677 name={entryIconName}
2302 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
2678 className="block size-4 shrink-0 stroke-[1.8] opacity-70"
23032679 />
23042680 </span>
23052681 <div className="flex min-w-0 flex-1 items-center gap-1.5">
23062682 <div className="min-w-0 flex-1 overflow-hidden">
2307 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
2308 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
2309 {preview && (
2310 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
2311 )}
2683 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
2684 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
23122685 </p>
23132686 </div>
2314 <div className="flex shrink-0 items-center gap-px text-icon-muted">
2315 <span
2316 className="flex size-4 shrink-0 items-center justify-center"
2317 aria-hidden={!canExpand}
2318 >
2319 {canExpand ? (
2320 <ChevronDownIcon
2321 className={cn(
2322 "size-3 shrink-0 opacity-70 transition-transform duration-200",
2323 expanded && "rotate-180",
2324 )}
2325 aria-hidden
2326 />
2327 ) : null}
2328 </span>
2329 <span className="flex size-4 shrink-0 items-center justify-center">
2330 {showFailedIndicator ? (
2331 <Tooltip>
2332 <TooltipTrigger
2333 render={
2334 <span
2335 className="flex size-4 items-center justify-center"
2336 aria-label="Tool call failed"
2337 />
2338 }
2339 >
2340 <XIcon className="block size-3 shrink-0 text-destructive" aria-hidden />
2341 </TooltipTrigger>
2342 <TooltipPopup>Failed</TooltipPopup>
2343 </Tooltip>
2344 ) : showSuccessIndicator ? (
2345 <Tooltip>
2346 <TooltipTrigger
2347 render={<span className="flex size-4 items-center justify-center" />}
2348 >
2349 <span className="inline-flex size-4 items-center justify-center">
2350 <CheckIcon
2351 className="block size-3 shrink-0 stroke-current"
2352 stroke="currentColor"
2353 aria-hidden
2354 />
2355 </span>
2356 </TooltipTrigger>
2357 <TooltipPopup>Completed</TooltipPopup>
2358 </Tooltip>
2359 ) : showNeutralIndicator ? (
2360 <Tooltip>
2361 <TooltipTrigger
2362 render={<span className="flex size-4 items-center justify-center" />}
2363 >
2364 <MinusIcon className="block size-3 shrink-0 opacity-70" aria-hidden />
2365 </TooltipTrigger>
2366 <TooltipPopup>Empty</TooltipPopup>
2367 </Tooltip>
2368 ) : null}
2369 </span>
2370 </div>
2687 <span
2688 className={cn(
2689 "flex size-4 shrink-0 items-center justify-center",
2690 !canExpand && "invisible",
2691 )}
2692 aria-hidden
2693 >
2694 <ChevronDownIcon
2695 className={cn(
2696 "size-3 shrink-0 text-icon-muted opacity-70 transition-transform duration-200",
2697 expanded && "rotate-180",
2698 )}
2699 />
2700 </span>
23712701 </div>
23722702 </div>
23732703 {expanded && canExpand && expandedBody ? (
apps/web/src/index.css+74
......@@ -431,6 +431,80 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
431431 }
432432}
433433
434@keyframes live-activity-focus {
435 0% {
436 transform: translateX(0);
437 }
438 100% {
439 transform: translateX(100%);
440 }
441}
442
443@keyframes live-activity-focus-counter {
444 0% {
445 transform: translateX(0);
446 }
447 100% {
448 transform: translateX(-100%);
449 }
450}
451
452@utility live-activity-focus {
453 --live-activity-focus-width: 4.5rem;
454
455 right: auto;
456 left: calc(-1 * var(--live-activity-focus-width));
457 width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width));
458 -webkit-mask-image: linear-gradient(
459 to right,
460 transparent 0,
461 rgb(0 0 0 / 12%) 0.675rem,
462 rgb(0 0 0 / 55%) 1.575rem,
463 black 2.25rem,
464 rgb(0 0 0 / 55%) 2.925rem,
465 rgb(0 0 0 / 12%) 3.825rem,
466 transparent var(--live-activity-focus-width),
467 transparent 100%
468 );
469 -webkit-mask-repeat: no-repeat;
470 mask-image: linear-gradient(
471 to right,
472 transparent 0,
473 rgb(0 0 0 / 12%) 0.675rem,
474 rgb(0 0 0 / 55%) 1.575rem,
475 black 2.25rem,
476 rgb(0 0 0 / 55%) 2.925rem,
477 rgb(0 0 0 / 12%) 3.825rem,
478 transparent var(--live-activity-focus-width),
479 transparent 100%
480 );
481 mask-repeat: no-repeat;
482 animation: live-activity-focus 2.2s linear infinite;
483 will-change: transform;
484
485 @media (prefers-reduced-motion: reduce) {
486 animation: none;
487 opacity: 0;
488 will-change: auto;
489 }
490}
491
492@utility live-activity-focus-counter {
493 width: 100%;
494 animation: live-activity-focus-counter 2.2s linear infinite;
495 will-change: transform;
496
497 @media (prefers-reduced-motion: reduce) {
498 animation: none;
499 will-change: auto;
500 }
501}
502
503@utility live-activity-focus-aligned {
504 width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width));
505 margin-left: var(--live-activity-focus-width);
506}
507
434508@layer base {
435509 :root {
436510 /* Keep the original T3 Code artwork palettes as the defaults. Built-in
apps/web/src/session-logic.test.ts+116
......@@ -1184,6 +1184,108 @@ describe("deriveWorkLogEntries", () => {
11841184
11851185 const [entry] = deriveWorkLogEntries(activities);
11861186 expect(entry?.toolData).toEqual(item);
1187 expect(entry?.toolCallId).toBe("call-1");
1188 });
1189
1190 it("collapses interleaved lifecycle updates by tool call id", () => {
1191 const activities: OrchestrationThreadActivity[] = [
1192 makeActivity({
1193 id: "tool-a-progress",
1194 createdAt: "2026-02-23T00:00:01.000Z",
1195 turnId: "turn-1",
1196 kind: "tool.updated",
1197 summary: "Tool A",
1198 payload: {
1199 itemType: "command_execution",
1200 toolCallId: "call-a",
1201 status: "inProgress",
1202 data: { command: "vp test run" },
1203 },
1204 }),
1205 makeActivity({
1206 id: "tool-b-progress",
1207 createdAt: "2026-02-23T00:00:02.000Z",
1208 turnId: "turn-1",
1209 kind: "tool.updated",
1210 summary: "Tool B",
1211 payload: {
1212 itemType: "command_execution",
1213 toolCallId: "call-b",
1214 status: "inProgress",
1215 data: { command: "vp lint" },
1216 },
1217 }),
1218 makeActivity({
1219 id: "tool-a-complete",
1220 createdAt: "2026-02-23T00:00:03.000Z",
1221 turnId: "turn-1",
1222 kind: "tool.completed",
1223 summary: "Tool A completed",
1224 payload: {
1225 itemType: "command_execution",
1226 toolCallId: "call-a",
1227 status: "completed",
1228 },
1229 }),
1230 makeActivity({
1231 id: "tool-b-complete",
1232 createdAt: "2026-02-23T00:00:04.000Z",
1233 turnId: "turn-1",
1234 kind: "tool.completed",
1235 summary: "Tool B completed",
1236 payload: {
1237 itemType: "command_execution",
1238 toolCallId: "call-b",
1239 status: "completed",
1240 },
1241 }),
1242 ];
1243
1244 expect(deriveWorkLogEntries(activities)).toMatchObject([
1245 {
1246 id: "tool-a-complete",
1247 command: "vp test run",
1248 toolCallId: "call-a",
1249 toolLifecycleStatus: "completed",
1250 },
1251 {
1252 id: "tool-b-complete",
1253 command: "vp lint",
1254 toolCallId: "call-b",
1255 toolLifecycleStatus: "completed",
1256 },
1257 ]);
1258 });
1259
1260 it("does not merge reused tool call ids across turns", () => {
1261 const activities: OrchestrationThreadActivity[] = [
1262 makeActivity({
1263 id: "turn-1-tool",
1264 createdAt: "2026-02-23T00:00:01.000Z",
1265 turnId: "turn-1",
1266 kind: "tool.updated",
1267 summary: "Tool",
1268 payload: {
1269 itemType: "command_execution",
1270 toolCallId: "reused-call",
1271 status: "inProgress",
1272 },
1273 }),
1274 makeActivity({
1275 id: "turn-2-tool",
1276 createdAt: "2026-02-23T00:00:02.000Z",
1277 turnId: "turn-2",
1278 kind: "tool.completed",
1279 summary: "Tool completed",
1280 payload: {
1281 itemType: "command_execution",
1282 toolCallId: "reused-call",
1283 status: "completed",
1284 },
1285 }),
1286 ];
1287
1288 expect(deriveWorkLogEntries(activities)).toHaveLength(2);
11871289 });
11881290
11891291 it("unwraps PowerShell command wrappers for displayed command text", () => {
......@@ -1850,6 +1952,20 @@ describe("deriveActiveWorkStartedAt", () => {
18501952 ).toBe("2026-02-27T21:11:00.000Z");
18511953 });
18521954
1955 it("falls back to the latest user message while a running turn is being acknowledged", () => {
1956 expect(
1957 deriveActiveWorkStartedAt(
1958 latestTurn,
1959 {
1960 status: "running",
1961 activeTurnId: TurnId.make("turn-2"),
1962 },
1963 null,
1964 "2026-02-27T21:11:00.000Z",
1965 ),
1966 ).toBe("2026-02-27T21:11:00.000Z");
1967 });
1968
18531969 it("falls back to sendStartedAt once the latest turn is settled", () => {
18541970 expect(
18551971 deriveActiveWorkStartedAt(
apps/web/src/session-logic.ts+58-8
......@@ -65,6 +65,8 @@ export interface WorkLogEntry {
6565 id: string;
6666 createdAt: string;
6767 turnId?: TurnId | null;
68 /** Stable provider identity across in-progress and completed lifecycle updates. */
69 toolCallId?: string;
6870 label: string;
6971 detail?: string;
7072 command?: string;
......@@ -224,8 +226,10 @@ function toolDetailTextLooksLikeFailure(text: string): boolean {
224226 return false;
225227}
226228
227/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */
228export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
229function workEntryIndicatesToolFailureFromOutput(
230 entry: WorkLogEntry,
231 includeCommand: boolean,
232): boolean {
229233 if (entry.tone === "error") {
230234 return true;
231235 }
......@@ -240,7 +244,7 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
240244 if (entry.detail) {
241245 parts.push(entry.detail);
242246 }
243 if (entry.command) {
247 if (includeCommand && entry.command) {
244248 parts.push(entry.command);
245249 }
246250 const blob = parts.join("\n");
......@@ -250,6 +254,16 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
250254 return toolDetailTextLooksLikeFailure(blob);
251255}
252256
257/** True when a tool failed, including providers that put error output in `command`. */
258export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
259 return workEntryIndicatesToolFailureFromOutput(entry, true);
260}
261
262/** True when the rendered result indicates failure. The command itself is user intent, not output. */
263export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean {
264 return workEntryIndicatesToolFailureFromOutput(entry, false);
265}
266
253267/** Tool/command row completed without failure (blue check affordance). */
254268export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
255269 if (!workLogEntryIsToolLike(entry)) {
......@@ -338,13 +352,14 @@ export function deriveActiveWorkStartedAt(
338352 latestTurn: LatestTurnTiming | null,
339353 session: SessionActivityState | null,
340354 sendStartedAt: string | null,
355 latestUserMessageAt: string | null = null,
341356): string | null {
342357 const runningTurnId = session?.status === "running" ? session.activeTurnId : null;
343358 if (runningTurnId !== null) {
344359 if (latestTurn?.turnId === runningTurnId) {
345 return latestTurn.startedAt ?? sendStartedAt;
360 return latestTurn.startedAt ?? sendStartedAt ?? latestUserMessageAt;
346361 }
347 return sendStartedAt;
362 return sendStartedAt ?? latestUserMessageAt;
348363 }
349364 if (!isLatestTurnSettled(latestTurn, session)) {
350365 return latestTurn?.startedAt ?? sendStartedAt;
......@@ -1010,6 +1025,13 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
10101025 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
10111026}
10121027
1028function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined {
1029 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
1030 return undefined;
1031 }
1032 return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined;
1033}
1034
10131035function collapseDerivedWorkLogEntries(
10141036 entries: ReadonlyArray<DerivedWorkLogEntry>,
10151037): DerivedWorkLogEntry[] {
......@@ -1026,6 +1048,7 @@ function collapseDerivedWorkLogEntries(
10261048 // own turn splintered one batch into a stream of "Kicked off N subagents"
10271049 // rows (live-test finding, thread 7ac7ef05).
10281050 const groupKeyByTaskId = new Map<string, string>();
1051 const toolLifecycleRowIndex = new Map<string, number>();
10291052 for (const entry of entries) {
10301053 const isTaskRow =
10311054 entry.taskId !== undefined &&
......@@ -1070,12 +1093,36 @@ function collapseDerivedWorkLogEntries(
10701093 });
10711094 continue;
10721095 }
1096 const lifecycleKey = toolLifecycleCollapseMapKey(entry);
1097 if (lifecycleKey !== undefined) {
1098 const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
1099 const matchingEntry =
1100 matchingLifecycleIndex === undefined ? undefined : collapsed[matchingLifecycleIndex];
1101 if (
1102 matchingLifecycleIndex !== undefined &&
1103 matchingEntry &&
1104 shouldCollapseToolLifecycleEntries(matchingEntry, entry)
1105 ) {
1106 collapsed[matchingLifecycleIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
1107 continue;
1108 }
1109 toolLifecycleRowIndex.delete(lifecycleKey);
1110 }
10731111 const previous = collapsed.at(-1);
10741112 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
1075 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
1113 const previousIndex = collapsed.length - 1;
1114 const previousKey = toolLifecycleCollapseMapKey(previous);
1115 if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
1116 const merged = mergeDerivedWorkLogEntries(previous, entry);
1117 collapsed[previousIndex] = merged;
1118 const mergedKey = toolLifecycleCollapseMapKey(merged);
1119 if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex);
10761120 continue;
10771121 }
10781122 collapsed.push(entry);
1123 if (lifecycleKey !== undefined) {
1124 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1125 }
10791126 }
10801127 return collapsed;
10811128}
......@@ -1090,6 +1137,9 @@ function shouldCollapseToolLifecycleEntries(
10901137 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
10911138 return false;
10921139 }
1140 if (previous.turnId !== next.turnId) {
1141 return false;
1142 }
10931143 if (previous.activityKind === "tool.completed") {
10941144 return false;
10951145 }
......@@ -1161,7 +1211,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
11611211 return undefined;
11621212 }
11631213 if (entry.toolCallId) {
1164 return `tool:${entry.toolCallId}`;
1214 return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
11651215 }
11661216 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
11671217 const detail = entry.detail?.trim() ?? "";
......@@ -1393,7 +1443,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
13931443
13941444function extractToolCallId(payload: Record<string, unknown> | null): string | null {
13951445 const data = asRecord(payload?.data);
1396 return asTrimmedString(data?.toolCallId);
1446 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
13971447}
13981448
13991449function normalizeInlinePreview(value: string): string {