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