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: {...@@ -561,8 +561,10 @@ function useLocalDispatchState(input: {
561 threadError: string | null | undefined;561 threadError: string | null | undefined;
562}) {562}) {
563 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);563 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
564 const latestUserMessageId =564 const latestUserMessage = input.activeThread?.messages.findLast(
565 input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null;565 (message) => message.role === "user",
566 );
567 const latestUserMessageId = latestUserMessage?.id ?? null;
566568
567 const resetLocalDispatch = useCallback(() => {569 const resetLocalDispatch = useCallback(() => {
568 setLocalDispatch(null);570 setLocalDispatch(null);
...@@ -612,6 +614,7 @@ function useLocalDispatchState(input: {...@@ -612,6 +614,7 @@ function useLocalDispatchState(input: {
612 beginLocalDispatch,614 beginLocalDispatch,
613 resetLocalDispatch,615 resetLocalDispatch,
614 localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null,616 localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null,
617 latestUserMessageAt: latestUserMessage?.createdAt ?? null,
615 isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,618 isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
616 isSendBusy: activeLocalDispatch !== null,619 isSendBusy: activeLocalDispatch !== null,
617 };620 };
...@@ -2325,6 +2328,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2325,6 +2328,7 @@ function ChatViewContent(props: ChatViewProps) {
2325 beginLocalDispatch,2328 beginLocalDispatch,
2326 resetLocalDispatch,2329 resetLocalDispatch,
2327 localDispatchStartedAt,2330 localDispatchStartedAt,
2331 latestUserMessageAt,
2328 isPreparingWorktree,2332 isPreparingWorktree,
2329 isSendBusy,2333 isSendBusy,
2330 } = useLocalDispatchState({2334 } = useLocalDispatchState({
...@@ -2340,6 +2344,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2340,6 +2344,7 @@ function ChatViewContent(props: ChatViewProps) {
2340 activeLatestTurn,2344 activeLatestTurn,
2341 activeThread?.session ?? null,2345 activeThread?.session ?? null,
2342 localDispatchStartedAt,2346 localDispatchStartedAt,
2347 latestUserMessageAt,
2343 );2348 );
2344 useEffect(() => {2349 useEffect(() => {
2345 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;2350 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
...@@ -6389,7 +6394,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6389,7 +6394,6 @@ function ChatViewContent(props: ChatViewProps) {
6389 key={activeThread.id}6394 key={activeThread.id}
6390 isWorking={isWorking}6395 isWorking={isWorking}
6391 workingStepLabel={workingStepLabel}6396 workingStepLabel={workingStepLabel}
6392 activeTurnInProgress={isWorking || !latestTurnSettled}
6393 activeTurnStartedAt={activeWorkStartedAt}6397 activeTurnStartedAt={activeWorkStartedAt}
6394 listRef={legendListRef}6398 listRef={legendListRef}
6395 timelineEntries={timelineEntries}6399 timelineEntries={timelineEntries}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+358-7
...@@ -545,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -545,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => {
545 "user-entry",545 "user-entry",
546 "turn-fold:turn-1",546 "turn-fold:turn-1",
547 "assistant-thought-entry",547 "assistant-thought-entry",
548 "work-entry-1",548 "work-toggle:work-entry-1",
549 "assistant-final-entry",549 "assistant-final-entry",
550 ]);550 ]);
551 expect(551 expect(
...@@ -800,12 +800,362 @@ describe("deriveMessagesTimelineRows", () => {...@@ -800,12 +800,362 @@ describe("deriveMessagesTimelineRows", () => {
800800
801 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);801 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
802 expect(rows.map((row) => row.id)).toEqual([802 expect(rows.map((row) => row.id)).toEqual([
803 "assistant-thought-entry",
804 "work-entry-1",
805 "working-indicator-row",803 "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",
806 ]);1012 ]);
807 });1013 });
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
809 it("does not fold the session's running turn when latestTurn regresses", () => {1159 it("does not fold the session's running turn when latestTurn regresses", () => {
810 const rows = deriveMessagesTimelineRows({1160 const rows = deriveMessagesTimelineRows({
811 timelineEntries: [1161 timelineEntries: [
...@@ -864,7 +1214,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -864,7 +1214,7 @@ describe("deriveMessagesTimelineRows", () => {
864 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([1214 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
865 "turn-1",1215 "turn-1",
866 ]);1216 ]);
867 expect(rows.map((row) => row.id)).toContain("running-work-entry");1217 expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry");
868 });1218 });
8691219
870 it("only shows assistant metadata on the terminal assistant message", () => {1220 it("only shows assistant metadata on the terminal assistant message", () => {
...@@ -1006,18 +1356,19 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1006,18 +1356,19 @@ describe("deriveMessagesTimelineRows", () => {
1006 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),1356 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
1007 });1357 });
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"]);
1010 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1360 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1011 groupId: "work-group:work-entry-1",1361 groupId: "work-group:work-entry-1",
1012 hiddenCount: 2,1362 hiddenCount: 3,
1013 expanded: false,1363 expanded: false,
1014 onlyToolEntries: true,1364 onlyToolEntries: true,
1365 summary: "Used 3 tools",
1015 });1366 });
1016 expect(expandedRows.map((row) => row.id)).toEqual([1367 expect(expandedRows.map((row) => row.id)).toEqual([
1368 "work-toggle:work-entry-1",
1017 "work-1",1369 "work-1",
1018 "work-2",1370 "work-2",
1019 "work-3",1371 "work-3",
1020 "work-toggle:work-entry-1",
1021 ]);1372 ]);
1022 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({1373 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
1023 expanded: true,1374 expanded: true,
apps/web/src/components/chat/MessagesTimeline.logic.ts+436-24
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import {2import {
3 formatDuration,3 formatDuration,
4 workEntryDisplayIndicatesToolFailure,
4 workEntryIndicatesToolNeutralStatus,5 workEntryIndicatesToolNeutralStatus,
5 workLogEntryIsToolLike,6 workLogEntryIsToolLike,
6 type TimelineEntry,7 type TimelineEntry,
...@@ -17,6 +18,18 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";...@@ -17,6 +18,18 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
17export const TIMELINE_CONTENT_MAX_WIDTH = 768;18export const TIMELINE_CONTENT_MAX_WIDTH = 768;
18export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;19export 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
20export interface TimelineEndState {33export interface TimelineEndState {
21 readonly isAtEnd?: boolean;34 readonly isAtEnd?: boolean;
22 readonly contentLength?: number;35 readonly contentLength?: number;
...@@ -170,6 +183,17 @@ export type MessagesTimelineRow =...@@ -170,6 +183,17 @@ export type MessagesTimelineRow =
170 id: string;183 id: string;
171 createdAt: string;184 createdAt: string;
172 groupedEntries: WorkLogEntry[];185 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;
173 }197 }
174 | {198 | {
175 kind: "work-toggle";199 kind: "work-toggle";
...@@ -179,6 +203,9 @@ export type MessagesTimelineRow =...@@ -179,6 +203,9 @@ export type MessagesTimelineRow =
179 hiddenCount: number;203 hiddenCount: number;
180 expanded: boolean;204 expanded: boolean;
181 onlyToolEntries: boolean;205 onlyToolEntries: boolean;
206 summary: string | null;
207 summaryKind: ToolGroupSummaryKind | null;
208 hasFailure: boolean;
182 }209 }
183 | {210 | {
184 kind: "turn-fold";211 kind: "turn-fold";
...@@ -212,7 +239,12 @@ export type MessagesTimelineRow =...@@ -212,7 +239,12 @@ export type MessagesTimelineRow =
212 createdAt: string;239 createdAt: string;
213 turnPlan: TurnPlanEntry;240 turnPlan: TurnPlanEntry;
214 }241 }
215 | { kind: "working"; id: string; createdAt: string | null };242 | {
243 kind: "working";
244 id: string;
245 createdAt: string | null;
246 showThinking: boolean;
247 };
216248
217export interface StableMessagesTimelineRowsState {249export interface StableMessagesTimelineRowsState {
218 byId: Map<string, MessagesTimelineRow>;250 byId: Map<string, MessagesTimelineRow>;
...@@ -242,6 +274,161 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -242,6 +274,161 @@ export function normalizeCompactToolLabel(value: string): string {
242 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();274 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
243}275}
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
245export function resolveAssistantMessageCopyState({432export function resolveAssistantMessageCopyState({
246 text,433 text,
247 showCopyButton,434 showCopyButton,
...@@ -314,6 +501,25 @@ function deriveUnsettledTurnId(...@@ -314,6 +501,25 @@ function deriveUnsettledTurnId(
314 return isSettled ? null : latestTurn.turnId;501 return isSettled ? null : latestTurn.turnId;
315}502}
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
317/**523/**
318 * Settled turns fold their commentary and tool activity behind a524 * Settled turns fold their commentary and tool activity behind a
319 * "Worked for ..." row anchored at the turn's first foldable entry; the525 * "Worked for ..." row anchored at the turn's first foldable entry; the
...@@ -481,21 +687,132 @@ export function deriveMessagesTimelineRows(input: {...@@ -481,21 +687,132 @@ export function deriveMessagesTimelineRows(input: {
481 }687 }
482 }688 }
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
484 for (let index = 0; index < input.timelineEntries.length; index += 1) {793 for (let index = 0; index < input.timelineEntries.length; index += 1) {
485 const timelineEntry = input.timelineEntries[index];794 const timelineEntry = input.timelineEntries[index];
486 if (!timelineEntry) {795 if (!timelineEntry) {
487 continue;796 continue;
488 }797 }
489798
490 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);799 if (input.isWorking && index === activeTurnHeaderIndex) {
491 if (turnFold) {800 appendWorkingRow();
801 }
802
803 if (timelineEntry.id === activeWorkPlacementEntryId) {
804 appendActiveWorkRows();
805 }
806
807 const anchoredTurnFold = foldsByAnchorEntryId.get(timelineEntry.id);
808 if (anchoredTurnFold) {
492 nextRows.push({809 nextRows.push({
493 kind: "turn-fold",810 kind: "turn-fold",
494 id: `turn-fold:${turnFold.turnId}`,811 id: `turn-fold:${anchoredTurnFold.turnId}`,
495 createdAt: turnFold.createdAt,812 createdAt: anchoredTurnFold.createdAt,
496 turnId: turnFold.turnId,813 turnId: anchoredTurnFold.turnId,
497 label: turnFold.label,814 label: anchoredTurnFold.label,
498 expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,815 expanded: input.expandedTurnIds?.has(anchoredTurnFold.turnId) ?? false,
499 });816 });
500 }817 }
501818
...@@ -503,6 +820,10 @@ export function deriveMessagesTimelineRows(input: {...@@ -503,6 +820,10 @@ export function deriveMessagesTimelineRows(input: {
503 continue;820 continue;
504 }821 }
505822
823 if (activeWorkEntryIds.has(timelineEntry.id)) {
824 continue;
825 }
826
506 if (timelineEntry.kind === "work") {827 if (timelineEntry.kind === "work") {
507 const groupedEntries = [timelineEntry.entry];828 const groupedEntries = [timelineEntry.entry];
508 let cursor = index + 1;829 let cursor = index + 1;
...@@ -511,6 +832,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -511,6 +832,7 @@ export function deriveMessagesTimelineRows(input: {
511 if (832 if (
512 !nextEntry ||833 !nextEntry ||
513 nextEntry.kind !== "work" ||834 nextEntry.kind !== "work" ||
835 activeWorkEntryIds.has(nextEntry.id) ||
514 collapsedEntryIds.has(nextEntry.id) ||836 collapsedEntryIds.has(nextEntry.id) ||
515 foldsByAnchorEntryId.has(nextEntry.id)837 foldsByAnchorEntryId.has(nextEntry.id)
516 ) {838 ) {
...@@ -519,19 +841,86 @@ export function deriveMessagesTimelineRows(input: {...@@ -519,19 +841,86 @@ export function deriveMessagesTimelineRows(input: {
519 groupedEntries.push(nextEntry.entry);841 groupedEntries.push(nextEntry.entry);
520 cursor += 1;842 cursor += 1;
521 }843 }
522 const visibleGroupedEntries = groupedEntries.filter(844 const visibleGroupedEntries = omitSupersededLifecycleMarkers(
523 (entry) => !workEntryIndicatesToolNeutralStatus(entry),845 groupedEntries.filter((entry) =>
846 workEntryIsVisibleInGroup(entry, workEntryIsInActiveRun(entry)),
847 ),
848 (entry) => entry,
524 );849 );
525 if (visibleGroupedEntries.length > 0) {850 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) {
527 nextRows.push({914 nextRows.push({
528 kind: "work",915 kind: "work",
529 id: timelineEntry.id,916 id: timelineEntry.id,
530 createdAt: timelineEntry.createdAt,917 createdAt: timelineEntry.createdAt,
531 groupedEntries: visibleGroupedEntries,918 groupedEntries: visibleGroupedEntries,
919 isExpandedToolGroupEntry: false,
920 isLastExpandedToolGroupEntry: false,
532 });921 });
533 } else {922 } else {
534 const groupId = `work-group:${timelineEntry.id}`;923 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
535 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;924 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
536 // Agent-spawn CTA rows are always visible: a running fleet must925 // Agent-spawn CTA rows are always visible: a running fleet must
537 // never hide behind a "+N tool calls" toggle. Selection is by926 // never hide behind a "+N tool calls" toggle. Selection is by
...@@ -555,6 +944,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -555,6 +944,8 @@ export function deriveMessagesTimelineRows(input: {
555 id: workEntry.id,944 id: workEntry.id,
556 createdAt: workEntry.createdAt,945 createdAt: workEntry.createdAt,
557 groupedEntries: [workEntry],946 groupedEntries: [workEntry],
947 isExpandedToolGroupEntry: false,
948 isLastExpandedToolGroupEntry: false,
558 });949 });
559 }950 }
560951
...@@ -566,8 +957,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -566,8 +957,11 @@ export function deriveMessagesTimelineRows(input: {
566 groupId,957 groupId,
567 hiddenCount: hiddenEntries.length,958 hiddenCount: hiddenEntries.length,
568 expanded,959 expanded,
569 onlyToolEntries: visibleGroupedEntries.every((entry) =>960 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
570 workLogEntryIsToolLike(entry),961 summary: null,
962 summaryKind: null,
963 hasFailure: hiddenEntries.some((entry) =>
964 workEntryDisplayIndicatesToolFailure(entry),
571 ),965 ),
572 });966 });
573 }967 }
...@@ -633,12 +1027,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -633,12 +1027,8 @@ export function deriveMessagesTimelineRows(input: {
633 });1027 });
634 }1028 }
6351029
636 if (input.isWorking) {1030 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
637 nextRows.push({1031 appendWorkingRow();
638 kind: "working",
639 id: "working-indicator-row",
640 createdAt: input.activeTurnStartedAt,
641 });
642 }1032 }
6431033
644 return nextRows;1034 return nextRows;
...@@ -670,7 +1060,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -670,7 +1060,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
6701060
671 switch (a.kind) {1061 switch (a.kind) {
672 case "working":1062 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
675 case "turn-fold": {1067 case "turn-fold": {
676 const bf = b as typeof a;1068 const bf = b as typeof a;
...@@ -687,8 +1079,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -687,8 +1079,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
687 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;1079 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
688 }1080 }
6891081
690 case "work":1082 case "work": {
691 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);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
693 case "work-toggle": {1102 case "work-toggle": {
694 const bw = b as typeof a;1103 const bw = b as typeof a;
...@@ -697,7 +1106,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -697,7 +1106,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
697 a.groupId === bw.groupId &&1106 a.groupId === bw.groupId &&
698 a.hiddenCount === bw.hiddenCount &&1107 a.hiddenCount === bw.hiddenCount &&
699 a.expanded === bw.expanded &&1108 a.expanded === bw.expanded &&
700 a.onlyToolEntries === bw.onlyToolEntries1109 a.onlyToolEntries === bw.onlyToolEntries &&
1110 a.summary === bw.summary &&
1111 a.summaryKind === bw.summaryKind &&
1112 a.hasFailure === bw.hasFailure
701 );1113 );
702 }1114 }
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";...@@ -176,7 +176,6 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
176function buildProps() {176function buildProps() {
177 return {177 return {
178 isWorking: false,178 isWorking: false,
179 activeTurnInProgress: false,
180 activeTurnStartedAt: null,179 activeTurnStartedAt: null,
181 listRef: createRef<LegendListRef | null>(),180 listRef: createRef<LegendListRef | null>(),
182 latestTurn: null,181 latestTurn: null,
...@@ -237,6 +236,44 @@ function buildAssistantTimelineEntry(text: string) {...@@ -237,6 +236,44 @@ function buildAssistantTimelineEntry(text: string) {
237}236}
238237
239describe("MessagesTimeline", () => {238describe("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
240 it("uses the larger leading inset only when the top fade is enabled", () => {277 it("uses the larger leading inset only when the top fade is enabled", () => {
241 const timelineEntries = [buildUserTimelineEntry("Hello")];278 const timelineEntries = [buildUserTimelineEntry("Hello")];
242279
...@@ -700,7 +737,7 @@ describe("MessagesTimeline", () => {...@@ -700,7 +737,7 @@ describe("MessagesTimeline", () => {
700 expect(markup).toContain("Work Log");737 expect(markup).toContain("Work Log");
701 });738 });
702739
703 it("formats changed file paths from the workspace root", () => {740 it("summarizes changed files in one line", () => {
704 const markup = renderToStaticMarkup(741 const markup = renderToStaticMarkup(
705 <MessagesTimeline742 <MessagesTimeline
706 {...buildProps()}743 {...buildProps()}
...@@ -722,10 +759,159 @@ describe("MessagesTimeline", () => {...@@ -722,10 +759,159 @@ describe("MessagesTimeline", () => {
722 />,759 />,
723 );760 );
724761
725 expect(markup).toContain("t3code/apps/web/src/session-logic.ts");762 expect(markup).toContain("Changed 1 file");
726 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");763 expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
727 });764 });
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
729 it("renders review comment contexts as structured cards instead of raw tags", () => {915 it("renders review comment contexts as structured cards instead of raw tags", () => {
730 const markup = renderToStaticMarkup(916 const markup = renderToStaticMarkup(
731 <MessagesTimeline917 <MessagesTimeline
apps/web/src/components/chat/MessagesTimeline.tsx+472-142
...@@ -33,9 +33,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";...@@ -33,9 +33,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";
33import { FileDiff } from "@pierre/diffs/react";33import { FileDiff } from "@pierre/diffs/react";
34import {34import {
35 deriveTimelineEntries,35 deriveTimelineEntries,
36 workEntryIndicatesToolFailure,36 workEntryDisplayIndicatesToolFailure,
37 workEntryIndicatesToolNeutralStatus,
38 workEntryIndicatesToolSuccess,
39 workLogEntryIsToolLike,37 workLogEntryIsToolLike,
40} from "../../session-logic";38} from "../../session-logic";
41import { type TurnDiffSummary } from "../../types";39import { type TurnDiffSummary } from "../../types";
...@@ -57,7 +55,7 @@ import {...@@ -57,7 +55,7 @@ import {
57 MessageCircleIcon,55 MessageCircleIcon,
58 MousePointerClickIcon,56 MousePointerClickIcon,
59 PaintbrushIcon,57 PaintbrushIcon,
60 MinusIcon,58 SearchIcon,
61 SquarePenIcon,59 SquarePenIcon,
62 TerminalIcon,60 TerminalIcon,
63 Undo2Icon,61 Undo2Icon,
...@@ -84,6 +82,8 @@ import {...@@ -84,6 +82,8 @@ import {
84 resolveTimelineMinimapInteractiveWidth,82 resolveTimelineMinimapInteractiveWidth,
85 resolveTimelineMinimapTopPercent,83 resolveTimelineMinimapTopPercent,
86 shouldPreserveAssistantLineBreaks,84 shouldPreserveAssistantLineBreaks,
85 toolGroupAction,
86 workEntryIsVisibleInGroup,
87 type StableMessagesTimelineRowsState,87 type StableMessagesTimelineRowsState,
88 type MessagesTimelineRow,88 type MessagesTimelineRow,
89 TIMELINE_MINIMAP_MIN_ITEMS,89 TIMELINE_MINIMAP_MIN_ITEMS,
...@@ -150,7 +150,6 @@ interface TimelineRowSharedState {...@@ -150,7 +150,6 @@ interface TimelineRowSharedState {
150interface TimelineRowActivityState {150interface TimelineRowActivityState {
151 isWorking: boolean;151 isWorking: boolean;
152 isRevertingCheckpoint: boolean;152 isRevertingCheckpoint: boolean;
153 activeTurnInProgress: boolean;
154 latestTurnId: TurnId | null;153 latestTurnId: TurnId | null;
155 /** Current plan step label for the working row, when the turn has a plan. */154 /** Current plan step label for the working row, when the turn has a plan. */
156 workingStepLabel: string | null;155 workingStepLabel: string | null;
...@@ -207,7 +206,6 @@ interface MessagesTimelineProps {...@@ -207,7 +206,6 @@ interface MessagesTimelineProps {
207 onOpenAgents?: () => void;206 onOpenAgents?: () => void;
208 isWorking: boolean;207 isWorking: boolean;
209 workingStepLabel?: string | null;208 workingStepLabel?: string | null;
210 activeTurnInProgress: boolean;
211 activeTurnStartedAt: string | null;209 activeTurnStartedAt: string | null;
212 listRef: React.RefObject<LegendListRef | null>;210 listRef: React.RefObject<LegendListRef | null>;
213 timelineEntries: ReturnType<typeof deriveTimelineEntries>;211 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
...@@ -251,7 +249,6 @@ interface MessagesTimelineProps {...@@ -251,7 +249,6 @@ interface MessagesTimelineProps {
251export const MessagesTimeline = memo(function MessagesTimeline({249export const MessagesTimeline = memo(function MessagesTimeline({
252 isWorking,250 isWorking,
253 workingStepLabel = null,251 workingStepLabel = null,
254 activeTurnInProgress,
255 activeTurnStartedAt,252 activeTurnStartedAt,
256 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,253 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
257 onOpenAgents = NOOP_OPEN_AGENTS,254 onOpenAgents = NOOP_OPEN_AGENTS,
...@@ -540,11 +537,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -540,11 +537,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
540 () => ({537 () => ({
541 isWorking,538 isWorking,
542 isRevertingCheckpoint,539 isRevertingCheckpoint,
543 activeTurnInProgress,
544 latestTurnId: latestTurn?.turnId ?? null,540 latestTurnId: latestTurn?.turnId ?? null,
545 workingStepLabel,541 workingStepLabel,
546 }),542 }),
547 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],543 [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
548 );544 );
549545
550 // Stable renderItem — no closure deps. Row components read shared state546 // Stable renderItem — no closure deps. Row components read shared state
...@@ -921,17 +917,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped...@@ -921,17 +917,34 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
921type TimelineRow = MessagesTimelineRow;917type TimelineRow = MessagesTimelineRow;
922918
923const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {919const 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
924 return (926 return (
925 <div927 <div
926 className={cn(928 className={cn(
927 // Commentary (non-terminal assistant) rows carry no metadata row, so929 // Commentary (non-terminal assistant) rows carry no metadata row, so
928 // they sit closer to the work that follows them.930 // they sit closer to the work that follows them.
929 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||931 isExpandedToolGroupEntry
930 row.kind === "work" ||932 ? isLastExpandedToolGroupEntry
931 row.kind === "work-toggle" ||933 ? "pb-1"
932 row.kind === "turn-plan"934 : "pb-0"
933 ? "pb-2"935 : isExpandedToolGroupHeader
934 : "pb-4",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",
935 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,948 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
936 )}949 )}
937 data-timeline-row-id={row.id}950 data-timeline-row-id={row.id}
...@@ -939,7 +952,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -939,7 +952,13 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
939 data-message-id={row.kind === "message" ? row.message.id : undefined}952 data-message-id={row.kind === "message" ? row.message.id : undefined}
940 data-message-role={row.kind === "message" ? row.message.role : undefined}953 data-message-role={row.kind === "message" ? row.message.role : undefined}
941 >954 >
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}
943 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}962 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
944 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}963 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
945 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}964 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
...@@ -1093,7 +1112,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-...@@ -1093,7 +1112,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
1093 aria-expanded={row.expanded}1112 aria-expanded={row.expanded}
1094 data-scroll-anchor-ignore1113 data-scroll-anchor-ignore
1095 onClick={() => ctx.onToggleTurnFold(row.turnId)}1114 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"
1097 >1116 >
1098 <span>{row.label}</span>1117 <span>{row.label}</span>
1099 <Icon className="size-3.5" />1118 <Icon className="size-3.5" />
...@@ -1282,14 +1301,9 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1282,14 +1301,9 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1282function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1301function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1283 const { workingStepLabel } = use(TimelineRowActivityCtx);1302 const { workingStepLabel } = use(TimelineRowActivityCtx);
1284 return (1303 return (
1285 <div className="py-0.5 pl-1.5">1304 <div>
1286 <div className="flex min-w-0 items-center gap-2 pt-1 text-secondary-label text-[11px] tabular-nums">1305 <div className="border-b border-border/60 pb-2 pt-1">
1287 <span className="inline-flex items-center gap-[3px]">1306 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
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">
1293 {row.createdAt ? (1307 {row.createdAt ? (
1294 <>1308 <>
1295 Working for <WorkingTimer createdAt={row.createdAt} />1309 Working for <WorkingTimer createdAt={row.createdAt} />
...@@ -1297,11 +1311,16 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1297,11 +1311,16 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1297 ) : (1311 ) : (
1298 "Working..."1312 "Working..."
1299 )}1313 )}
1300 </span>1314 {workingStepLabel ? (
1301 {workingStepLabel ? (1315 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>
1302 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>1316 ) : null}
1303 ) : null}1317 </div>
1304 </div>1318 </div>
1319 {row.showThinking ? (
1320 <div className="mt-1">
1321 <ThinkingActivityRow />
1322 </div>
1323 ) : null}
1305 </div>1324 </div>
1306 );1325 );
1307}1326}
...@@ -1342,13 +1361,16 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {...@@ -1342,13 +1361,16 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
1342/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */1361/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */
1343const WorkGroupSection = memo(function WorkGroupSection({1362const WorkGroupSection = memo(function WorkGroupSection({
1344 groupedEntries,1363 groupedEntries,
1364 isExpandedToolGroupEntry,
1345}: {1365}: {
1346 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];1366 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1367 isExpandedToolGroupEntry: boolean;
1347}) {1368}) {
1348 const { workspaceRoot } = use(TimelineRowCtx);1369 const { workspaceRoot } = use(TimelineRowCtx);
1349 const nonEmptyEntries = useMemo(1370 const nonEmptyEntries = useMemo(
1350 () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)),1371 () =>
1351 [groupedEntries],1372 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
1373 [groupedEntries, isExpandedToolGroupEntry],
1352 );1374 );
1353 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));1375 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
1354 const groupLabel = onlyToolEntries1376 const groupLabel = onlyToolEntries
...@@ -1356,11 +1378,15 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1356,11 +1378,15 @@ const WorkGroupSection = memo(function WorkGroupSection({
1356 ? "1 tool call"1378 ? "1 tool call"
1357 : `${nonEmptyEntries.length} tool calls`1379 : `${nonEmptyEntries.length} tool calls`
1358 : "Work Log";1380 : "Work Log";
1381 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
13591382
1360 if (nonEmptyEntries.length === 0) return null;1383 if (nonEmptyEntries.length === 0) return null;
13611384
1362 return (1385 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 >
1364 {!onlyToolEntries && (1390 {!onlyToolEntries && (
1365 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>1391 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1366 )}1392 )}
...@@ -1370,19 +1396,170 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1370,19 +1396,170 @@ const WorkGroupSection = memo(function WorkGroupSection({
1370 key={workEntry.id}1396 key={workEntry.id}
1371 workEntry={workEntry}1397 workEntry={workEntry}
1372 workspaceRoot={workspaceRoot}1398 workspaceRoot={workspaceRoot}
1399 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
1373 />1400 />
1374 ))}1401 ))}
1375 </div>1402 </div>
1376 </section>1403 </GroupContainer>
1377 );1404 );
1378});1405});
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
1380function WorkGroupToggleTimelineRow({1531function WorkGroupToggleTimelineRow({
1381 row,1532 row,
1382}: {1533}: {
1383 row: Extract<TimelineRow, { kind: "work-toggle" }>;1534 row: Extract<TimelineRow, { kind: "work-toggle" }>;
1384}) {1535}) {
1385 const ctx = use(TimelineRowCtx);1536 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 }
1386 const labelNoun = row.onlyToolEntries1563 const labelNoun = row.onlyToolEntries
1387 ? row.hiddenCount === 11564 ? row.hiddenCount === 1
1388 ? "tool call"1565 ? "tool call"
...@@ -1390,21 +1567,33 @@ function WorkGroupToggleTimelineRow({...@@ -1390,21 +1567,33 @@ function WorkGroupToggleTimelineRow({
1390 : row.hiddenCount === 11567 : row.hiddenCount === 1
1391 ? "log entry"1568 ? "log entry"
1392 : "log entries";1569 : "log entries";
1570 const showHiddenFailure = row.hasFailure && !row.expanded;
13931571
1394 return (1572 return (
1395 <button1573 <button
1396 type="button"1574 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"
1398 aria-expanded={row.expanded}1576 aria-expanded={row.expanded}
1399 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1577 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1400 >1578 >
1401 <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">1579 <span
1402 <ChevronDownIcon1580 className={cn(
1403 className={cn(1581 "flex size-6 shrink-0 items-center justify-center",
1404 "size-3.5 shrink-0 opacity-70 transition-transform duration-200",1582 showHiddenFailure ? "text-destructive" : "text-icon-muted",
1405 row.expanded && "rotate-180",1583 )}
1406 )}1584 role={showHiddenFailure ? "img" : undefined}
1407 />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 )}
1408 </span>1597 </span>
1409 {row.expanded ? (1598 {row.expanded ? (
1410 <span className="font-medium text-foreground">1599 <span className="font-medium text-foreground">
...@@ -1947,6 +2136,7 @@ type WorkEntryIconName =...@@ -1947,6 +2136,7 @@ type WorkEntryIconName =
1947 | "globe"2136 | "globe"
1948 | "hammer"2137 | "hammer"
1949 | "message-circle"2138 | "message-circle"
2139 | "search"
1950 | "square-pen"2140 | "square-pen"
1951 | "terminal"2141 | "terminal"
1952 | "wrench"2142 | "wrench"
...@@ -1969,6 +2159,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN...@@ -1969,6 +2159,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN
1969 return <HammerIcon className={className} aria-hidden />;2159 return <HammerIcon className={className} aria-hidden />;
1970 case "message-circle":2160 case "message-circle":
1971 return <MessageCircleIcon className={className} aria-hidden />;2161 return <MessageCircleIcon className={className} aria-hidden />;
2162 case "search":
2163 return <SearchIcon className={className} aria-hidden />;
1972 case "square-pen":2164 case "square-pen":
1973 return <SquarePenIcon className={className} aria-hidden />;2165 return <SquarePenIcon className={className} aria-hidden />;
1974 case "terminal":2166 case "terminal":
...@@ -2035,6 +2227,192 @@ function workEntryRawCommand(...@@ -2035,6 +2227,192 @@ function workEntryRawCommand(
2035 return rawCommand === workEntry.command.trim() ? null : rawCommand;2227 return rawCommand === workEntry.command.trim() ? null : rawCommand;
2036}2228}
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
2038function buildToolCallExpandedBody(2416function buildToolCallExpandedBody(
2039 workEntry: TimelineWorkEntry,2417 workEntry: TimelineWorkEntry,
2040 workspaceRoot: string | undefined,2418 workspaceRoot: string | undefined,
...@@ -2073,18 +2451,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {...@@ -2073,18 +2451,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
2073 ) {2451 ) {
2074 return "message-circle";2452 return "message-circle";
2075 }2453 }
2076 if (workEntry.requestKind === "command") return "terminal";2454 const action = toolGroupAction(workEntry);
2077 if (workEntry.requestKind === "file-read") return "eye";2455 if (action !== "other") return toolGroupSummaryIconName(action);
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";
20882456
2089 switch (workEntry.itemType) {2457 switch (workEntry.itemType) {
2090 case "mcp_tool_call":2458 case "mcp_tool_call":
...@@ -2217,43 +2585,43 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -2217,43 +2585,43 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
2217const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {2585const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
2218 workEntry: TimelineWorkEntry;2586 workEntry: TimelineWorkEntry;
2219 workspaceRoot: string | undefined;2587 workspaceRoot: string | undefined;
2588 isExpandedToolGroupEntry: boolean;
2220}) {2589}) {
2221 const { workEntry, workspaceRoot } = props;2590 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2222 // Before any hooks: spawn CTA rows render their own component.2591 // Before any hooks: spawn CTA rows render their own component.
2223 if (workEntry.agentSpawn) {2592 if (workEntry.agentSpawn) {
2224 return <AgentSpawnCtaRow workEntry={workEntry} />;2593 return <AgentSpawnCtaRow workEntry={workEntry} />;
2225 }2594 }
2226 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;2595 return (
2596 <PlainWorkEntryRow
2597 workEntry={workEntry}
2598 workspaceRoot={workspaceRoot}
2599 isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2600 />
2601 );
2227});2602});
22282603
2229const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {2604const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2230 workEntry: TimelineWorkEntry;2605 workEntry: TimelineWorkEntry;
2231 workspaceRoot: string | undefined;2606 workspaceRoot: string | undefined;
2607 isExpandedToolGroupEntry: boolean;
2232}) {2608}) {
2233 const { workEntry, workspaceRoot } = props;2609 const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props;
2234 const activity = use(TimelineRowActivityCtx);
2235 const [expanded, setExpanded] = useState(false);2610 const [expanded, setExpanded] = useState(false);
2236 const iconConfig = workToneIcon(workEntry.tone);2611 const iconConfig = workToneIcon(workEntry.tone);
2237 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";2612 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
2238 const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry);2613 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
2239 const heading = toolWorkEntryHeading(workEntry);2614 const entryIconName =
2240 const rawPreview = workEntryPreview(workEntry, workspaceRoot);2615 showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry);
2241 const preview =2616 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2242 rawPreview &&
2243 normalizeCompactToolLabel(rawPreview).toLowerCase() ===
2244 normalizeCompactToolLabel(heading).toLowerCase()
2245 ? null
2246 : rawPreview;
2247 const displayText = preview ? `${heading} - ${preview}` : heading;
2248 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);2617 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
2249 const canExpand = expandedBody !== null;2618 const canExpand = expandedBody !== null;
2250 const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2251 const showDestructiveRowStyle =2619 const showDestructiveRowStyle =
2252 showFailedIndicator &&2620 showFailedIndicator &&
2253 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));2621 (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
2254 const iconWrapperClass = cn(2622 const iconWrapperClass = cn(
2255 "flex size-5 shrink-0 items-center justify-center",2623 "flex size-6 shrink-0 items-center justify-center",
2256 showWarningIndicator2624 showWarningIndicator || showFailedIndicator
2257 ? "text-destructive"2625 ? "text-destructive"
2258 : showDestructiveRowStyle2626 : showDestructiveRowStyle
2259 ? "text-destructive"2627 ? "text-destructive"
...@@ -2265,17 +2633,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2265,17 +2633,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2265 ? "font-medium text-warning"2633 ? "font-medium text-warning"
2266 : showDestructiveRowStyle2634 : showDestructiveRowStyle
2267 ? "font-medium text-destructive"2635 ? "font-medium text-destructive"
2268 : "font-medium text-foreground";2636 : workLogEntryIsToolLike(workEntry)
2269 const turnSettled = !activity.activeTurnInProgress;2637 ? "text-secondary-label"
2270 const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry);2638 : "text-foreground/80";
2271 const showSuccessIndicator =2639 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
2272 workEntryIndicatesToolSuccess(workEntry) ||2640 const accessibleDisplayText = showFailedIndicator
2273 (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry));2641 ? `${displayText}, tool call failed`
2642 : displayText;
2274 const rowToggleProps = canExpand2643 const rowToggleProps = canExpand
2275 ? {2644 ? {
2276 role: "button" as const,2645 role: "button" as const,
2277 tabIndex: 0 as const,2646 tabIndex: 0 as const,
2278 "aria-label": displayText,2647 "aria-label": accessibleDisplayText,
2648 "aria-expanded": expanded,
2279 onClick: () => setExpanded((v) => !v),2649 onClick: () => setExpanded((v) => !v),
2280 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {2650 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
2281 if (e.key === "Enter" || e.key === " ") {2651 if (e.key === "Enter" || e.key === " ") {
...@@ -2289,85 +2659,45 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -2289,85 +2659,45 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2289 return (2659 return (
2290 <div2660 <div
2291 className={cn(2661 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",
2293 canExpand &&2664 canExpand &&
2294 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",2665 "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
2295 )}2666 )}
2296 {...rowToggleProps}2667 {...rowToggleProps}
2297 >2668 >
2298 <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">2669 <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 >
2300 <WorkEntryIconSvg2676 <WorkEntryIconSvg
2301 name={entryIconName}2677 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"
2303 />2679 />
2304 </span>2680 </span>
2305 <div className="flex min-w-0 flex-1 items-center gap-1.5">2681 <div className="flex min-w-0 flex-1 items-center gap-1.5">
2306 <div className="min-w-0 flex-1 overflow-hidden">2682 <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">2683 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
2308 <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>2684 <span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
2309 {preview && (
2310 <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
2311 )}
2312 </p>2685 </p>
2313 </div>2686 </div>
2314 <div className="flex shrink-0 items-center gap-px text-icon-muted">2687 <span
2315 <span2688 className={cn(
2316 className="flex size-4 shrink-0 items-center justify-center"2689 "flex size-4 shrink-0 items-center justify-center",
2317 aria-hidden={!canExpand}2690 !canExpand && "invisible",
2318 >2691 )}
2319 {canExpand ? (2692 aria-hidden
2320 <ChevronDownIcon2693 >
2321 className={cn(2694 <ChevronDownIcon
2322 "size-3 shrink-0 opacity-70 transition-transform duration-200",2695 className={cn(
2323 expanded && "rotate-180",2696 "size-3 shrink-0 text-icon-muted opacity-70 transition-transform duration-200",
2324 )}2697 expanded && "rotate-180",
2325 aria-hidden2698 )}
2326 />2699 />
2327 ) : null}2700 </span>
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>
2371 </div>2701 </div>
2372 </div>2702 </div>
2373 {expanded && canExpand && expandedBody ? (2703 {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...@@ -431,6 +431,80 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
431 }431 }
432}432}
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
434@layer base {508@layer base {
435 :root {509 :root {
436 /* Keep the original T3 Code artwork palettes as the defaults. Built-in510 /* 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", () => {...@@ -1184,6 +1184,108 @@ describe("deriveWorkLogEntries", () => {
11841184
1185 const [entry] = deriveWorkLogEntries(activities);1185 const [entry] = deriveWorkLogEntries(activities);
1186 expect(entry?.toolData).toEqual(item);1186 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);
1187 });1289 });
11881290
1189 it("unwraps PowerShell command wrappers for displayed command text", () => {1291 it("unwraps PowerShell command wrappers for displayed command text", () => {
...@@ -1850,6 +1952,20 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1850,6 +1952,20 @@ describe("deriveActiveWorkStartedAt", () => {
1850 ).toBe("2026-02-27T21:11:00.000Z");1952 ).toBe("2026-02-27T21:11:00.000Z");
1851 });1953 });
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
1853 it("falls back to sendStartedAt once the latest turn is settled", () => {1969 it("falls back to sendStartedAt once the latest turn is settled", () => {
1854 expect(1970 expect(
1855 deriveActiveWorkStartedAt(1971 deriveActiveWorkStartedAt(
apps/web/src/session-logic.ts+58-8
...@@ -65,6 +65,8 @@ export interface WorkLogEntry {...@@ -65,6 +65,8 @@ export interface WorkLogEntry {
65 id: string;65 id: string;
66 createdAt: string;66 createdAt: string;
67 turnId?: TurnId | null;67 turnId?: TurnId | null;
68 /** Stable provider identity across in-progress and completed lifecycle updates. */
69 toolCallId?: string;
68 label: string;70 label: string;
69 detail?: string;71 detail?: string;
70 command?: string;72 command?: string;
...@@ -224,8 +226,10 @@ function toolDetailTextLooksLikeFailure(text: string): boolean {...@@ -224,8 +226,10 @@ function toolDetailTextLooksLikeFailure(text: string): boolean {
224 return false;226 return false;
225}227}
226228
227/** True when the row should show a failure affordance (explicit status/tone or error-shaped tool output). */229function workEntryIndicatesToolFailureFromOutput(
228export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {230 entry: WorkLogEntry,
231 includeCommand: boolean,
232): boolean {
229 if (entry.tone === "error") {233 if (entry.tone === "error") {
230 return true;234 return true;
231 }235 }
...@@ -240,7 +244,7 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {...@@ -240,7 +244,7 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
240 if (entry.detail) {244 if (entry.detail) {
241 parts.push(entry.detail);245 parts.push(entry.detail);
242 }246 }
243 if (entry.command) {247 if (includeCommand && entry.command) {
244 parts.push(entry.command);248 parts.push(entry.command);
245 }249 }
246 const blob = parts.join("\n");250 const blob = parts.join("\n");
...@@ -250,6 +254,16 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {...@@ -250,6 +254,16 @@ export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
250 return toolDetailTextLooksLikeFailure(blob);254 return toolDetailTextLooksLikeFailure(blob);
251}255}
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
253/** Tool/command row completed without failure (blue check affordance). */267/** Tool/command row completed without failure (blue check affordance). */
254export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {268export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
255 if (!workLogEntryIsToolLike(entry)) {269 if (!workLogEntryIsToolLike(entry)) {
...@@ -338,13 +352,14 @@ export function deriveActiveWorkStartedAt(...@@ -338,13 +352,14 @@ export function deriveActiveWorkStartedAt(
338 latestTurn: LatestTurnTiming | null,352 latestTurn: LatestTurnTiming | null,
339 session: SessionActivityState | null,353 session: SessionActivityState | null,
340 sendStartedAt: string | null,354 sendStartedAt: string | null,
355 latestUserMessageAt: string | null = null,
341): string | null {356): string | null {
342 const runningTurnId = session?.status === "running" ? session.activeTurnId : null;357 const runningTurnId = session?.status === "running" ? session.activeTurnId : null;
343 if (runningTurnId !== null) {358 if (runningTurnId !== null) {
344 if (latestTurn?.turnId === runningTurnId) {359 if (latestTurn?.turnId === runningTurnId) {
345 return latestTurn.startedAt ?? sendStartedAt;360 return latestTurn.startedAt ?? sendStartedAt ?? latestUserMessageAt;
346 }361 }
347 return sendStartedAt;362 return sendStartedAt ?? latestUserMessageAt;
348 }363 }
349 if (!isLatestTurnSettled(latestTurn, session)) {364 if (!isLatestTurnSettled(latestTurn, session)) {
350 return latestTurn?.startedAt ?? sendStartedAt;365 return latestTurn?.startedAt ?? sendStartedAt;
...@@ -1010,6 +1025,13 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {...@@ -1010,6 +1025,13 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
1010 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;1025 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
1011}1026}
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
1013function collapseDerivedWorkLogEntries(1035function collapseDerivedWorkLogEntries(
1014 entries: ReadonlyArray<DerivedWorkLogEntry>,1036 entries: ReadonlyArray<DerivedWorkLogEntry>,
1015): DerivedWorkLogEntry[] {1037): DerivedWorkLogEntry[] {
...@@ -1026,6 +1048,7 @@ function collapseDerivedWorkLogEntries(...@@ -1026,6 +1048,7 @@ function collapseDerivedWorkLogEntries(
1026 // own turn splintered one batch into a stream of "Kicked off N subagents"1048 // own turn splintered one batch into a stream of "Kicked off N subagents"
1027 // rows (live-test finding, thread 7ac7ef05).1049 // rows (live-test finding, thread 7ac7ef05).
1028 const groupKeyByTaskId = new Map<string, string>();1050 const groupKeyByTaskId = new Map<string, string>();
1051 const toolLifecycleRowIndex = new Map<string, number>();
1029 for (const entry of entries) {1052 for (const entry of entries) {
1030 const isTaskRow =1053 const isTaskRow =
1031 entry.taskId !== undefined &&1054 entry.taskId !== undefined &&
...@@ -1070,12 +1093,36 @@ function collapseDerivedWorkLogEntries(...@@ -1070,12 +1093,36 @@ function collapseDerivedWorkLogEntries(
1070 });1093 });
1071 continue;1094 continue;
1072 }1095 }
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 }
1073 const previous = collapsed.at(-1);1111 const previous = collapsed.at(-1);
1074 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {1112 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);
1076 continue;1120 continue;
1077 }1121 }
1078 collapsed.push(entry);1122 collapsed.push(entry);
1123 if (lifecycleKey !== undefined) {
1124 toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
1125 }
1079 }1126 }
1080 return collapsed;1127 return collapsed;
1081}1128}
...@@ -1090,6 +1137,9 @@ function shouldCollapseToolLifecycleEntries(...@@ -1090,6 +1137,9 @@ function shouldCollapseToolLifecycleEntries(
1090 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {1137 if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
1091 return false;1138 return false;
1092 }1139 }
1140 if (previous.turnId !== next.turnId) {
1141 return false;
1142 }
1093 if (previous.activityKind === "tool.completed") {1143 if (previous.activityKind === "tool.completed") {
1094 return false;1144 return false;
1095 }1145 }
...@@ -1161,7 +1211,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un...@@ -1161,7 +1211,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
1161 return undefined;1211 return undefined;
1162 }1212 }
1163 if (entry.toolCallId) {1213 if (entry.toolCallId) {
1164 return `tool:${entry.toolCallId}`;1214 return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
1165 }1215 }
1166 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);1216 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
1167 const detail = entry.detail?.trim() ?? "";1217 const detail = entry.detail?.trim() ?? "";
...@@ -1393,7 +1443,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul...@@ -1393,7 +1443,7 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
13931443
1394function extractToolCallId(payload: Record<string, unknown> | null): string | null {1444function extractToolCallId(payload: Record<string, unknown> | null): string | null {
1395 const data = asRecord(payload?.data);1445 const data = asRecord(payload?.data);
1396 return asTrimmedString(data?.toolCallId);1446 return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
1397}1447}
13981448
1399function normalizeInlinePreview(value: string): string {1449function normalizeInlinePreview(value: string): string {