| author | |
| committer | |
| log | c0ddfb3a81abe553fea1e43c79cb723118959449 |
| tree | e976b3bee12372d8d2bee9ed1bcf5a01bbfa460d |
| parent | b0c6c3b2fd6079bd56ea8a101f1a3be244864bde |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 563 insertions(+), 76 deletions(-)
apps/web/src/components/AgentsPanel.tsx+2-3| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | /** | 1 | /** |
| 2 | * Agents right-panel surface: the fleet view over the native subagent fold, | 2 | * Agents right-panel surface: the fleet view over the native subagent fold. |
| 3 | * and the ONLY place the roster renders (the chat carries one CTA row per | 3 | * The chat carries one expandable row per spawn batch and links here. |
| 4 | * spawn batch). | ||
| 5 | * | 4 | * |
| 6 | * Visualization rules (from live-test feedback): | 5 | * Visualization rules (from live-test feedback): |
| 7 | * - Spawn order is stable. Activity and completion update rows in place. | 6 | * - Spawn order is stable. Activity and completion update rows in place. |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+137-1| ... | @@ -1122,6 +1122,141 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -1122,6 +1122,141 @@ describe("deriveMessagesTimelineRows", () => { |
| 1122 | ]); | 1122 | ]); |
| 1123 | }); | 1123 | }); |
| 1124 | 1124 | ||
| 1125 | it("folds a settled subagent spawn row and keeps a live one outside the fold", () => { | ||
| 1126 | const firstMessage: ChatMessage = { | ||
| 1127 | id: MessageId.make("assistant-first-entry"), | ||
| 1128 | role: "assistant", | ||
| 1129 | text: "Fanning out.", | ||
| 1130 | turnId: TurnId.make("turn-1"), | ||
| 1131 | createdAt: "2026-01-01T00:00:01Z", | ||
| 1132 | updatedAt: "2026-01-01T00:00:02Z", | ||
| 1133 | streaming: false, | ||
| 1134 | }; | ||
| 1135 | const entriesWith = (agentSpawn: { workflowId: string | null; agentTaskIds: string[] }) => | ||
| 1136 | deriveTimelineEntries( | ||
| 1137 | [ | ||
| 1138 | firstMessage, | ||
| 1139 | { | ||
| 1140 | ...firstMessage, | ||
| 1141 | id: MessageId.make("assistant-final-entry"), | ||
| 1142 | text: "Done.", | ||
| 1143 | createdAt: "2026-01-01T00:00:05Z", | ||
| 1144 | updatedAt: "2026-01-01T00:00:06Z", | ||
| 1145 | }, | ||
| 1146 | ], | ||
| 1147 | [], | ||
| 1148 | [ | ||
| 1149 | { | ||
| 1150 | id: "spawn-entry", | ||
| 1151 | createdAt: "2026-01-01T00:00:03Z", | ||
| 1152 | turnId: firstMessage.turnId, | ||
| 1153 | label: "Ran 2 subagents", | ||
| 1154 | tone: "tool", | ||
| 1155 | agentSpawn, | ||
| 1156 | }, | ||
| 1157 | ], | ||
| 1158 | ); | ||
| 1159 | const direct = entriesWith({ workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }); | ||
| 1160 | const workflow = entriesWith({ workflowId: "wf-1", agentTaskIds: ["wf-1", "agent-a"] }); | ||
| 1161 | const derive = ( | ||
| 1162 | timelineEntries: typeof direct, | ||
| 1163 | liveAgentTaskIds: ReadonlySet<string> | undefined, | ||
| 1164 | expandedSpawnEntryIds?: ReadonlySet<string>, | ||
| 1165 | expandedTurnIds?: ReadonlySet<TurnId>, | ||
| 1166 | ) => | ||
| 1167 | deriveMessagesTimelineRows({ | ||
| 1168 | timelineEntries, | ||
| 1169 | isWorking: false, | ||
| 1170 | activeTurnStartedAt: null, | ||
| 1171 | turnDiffSummaries: [], | ||
| 1172 | supportsConversationRollback: false, | ||
| 1173 | liveAgentTaskIds, | ||
| 1174 | expandedSpawnEntryIds, | ||
| 1175 | ...(expandedTurnIds ? { expandedTurnIds } : {}), | ||
| 1176 | }).map((row) => row.id); | ||
| 1177 | const folded = ["turn-fold:turn-1", "assistant-final-entry"]; | ||
| 1178 | const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"]; | ||
| 1179 | |||
| 1180 | const activeRows = ( | ||
| 1181 | timelineEntries: typeof direct, | ||
| 1182 | liveAgentTaskIds: ReadonlySet<string>, | ||
| 1183 | runningTurnId = "turn-1", | ||
| 1184 | ) => | ||
| 1185 | deriveMessagesTimelineRows({ | ||
| 1186 | timelineEntries: timelineEntries.slice(0, 2), | ||
| 1187 | isWorking: true, | ||
| 1188 | runningTurnId: runningTurnId as TurnId, | ||
| 1189 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | ||
| 1190 | turnDiffSummaries: [], | ||
| 1191 | supportsConversationRollback: false, | ||
| 1192 | liveAgentTaskIds, | ||
| 1193 | }).map((row) => row.kind); | ||
| 1194 | expect(activeRows(direct, new Set(["agent-b"]))).not.toContain("thinking"); | ||
| 1195 | expect(activeRows(workflow, new Set(["wf-1"]))).not.toContain("thinking"); | ||
| 1196 | expect(activeRows(direct, new Set())).toContain("thinking"); | ||
| 1197 | expect(activeRows(direct, new Set(["agent-b"]), "turn-2")).toContain("thinking"); | ||
| 1198 | |||
| 1199 | for (const [toolLifecycleStatus, tone, sourceActivityKind] of [ | ||
| 1200 | ["inProgress", "tool", "tool.updated"], | ||
| 1201 | ["completed", "tool", "tool.completed"], | ||
| 1202 | // Claude background Bash completions arrive without a command or item type. | ||
| 1203 | ["completed", "info", "task.completed"], | ||
| 1204 | ] as const) { | ||
| 1205 | const laterTool = { | ||
| 1206 | id: "later-tool", | ||
| 1207 | kind: "work" as const, | ||
| 1208 | createdAt: "2026-01-01T00:00:04Z", | ||
| 1209 | entry: { | ||
| 1210 | id: "later-tool", | ||
| 1211 | turnId: "turn-1" as TurnId, | ||
| 1212 | createdAt: "2026-01-01T00:00:04Z", | ||
| 1213 | label: "Read file", | ||
| 1214 | tone, | ||
| 1215 | sourceActivityKind, | ||
| 1216 | toolLifecycleStatus, | ||
| 1217 | }, | ||
| 1218 | }; | ||
| 1219 | for (const trailingEntries of [[], [laterTool]]) { | ||
| 1220 | const rows = deriveMessagesTimelineRows({ | ||
| 1221 | timelineEntries: [...direct.slice(0, 2), ...trailingEntries], | ||
| 1222 | isWorking: true, | ||
| 1223 | runningTurnId: "turn-1" as TurnId, | ||
| 1224 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | ||
| 1225 | turnDiffSummaries: [], | ||
| 1226 | supportsConversationRollback: false, | ||
| 1227 | liveAgentTaskIds: new Set(["agent-b"]), | ||
| 1228 | }); | ||
| 1229 | expect(rows.filter((row) => row.kind === "work-live")).toMatchObject([ | ||
| 1230 | { | ||
| 1231 | id: "live-activity-row", | ||
| 1232 | entry: { id: trailingEntries.length ? "later-tool" : "spawn-entry" }, | ||
| 1233 | active: true, | ||
| 1234 | }, | ||
| 1235 | ]); | ||
| 1236 | expect(rows.some((row) => row.kind === "work" || row.kind === "thinking")).toBe(false); | ||
| 1237 | } | ||
| 1238 | } | ||
| 1239 | |||
| 1240 | expect(derive(direct, new Set())).toEqual(folded); | ||
| 1241 | expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded); | ||
| 1242 | // A workflow coordinator between phases keeps its batch out of the fold. | ||
| 1243 | expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded); | ||
| 1244 | expect(derive(workflow, new Set())).toEqual(folded); | ||
| 1245 | // No live set is known. | ||
| 1246 | expect(derive(direct, undefined)).toEqual(unfolded); | ||
| 1247 | // The user has it open: it stays visible under the collapsed fold and | ||
| 1248 | // keeps its place when the fold is expanded. | ||
| 1249 | expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded); | ||
| 1250 | expect( | ||
| 1251 | derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])), | ||
| 1252 | ).toEqual([ | ||
| 1253 | "turn-fold:turn-1", | ||
| 1254 | "assistant-first-entry", | ||
| 1255 | "spawn-entry", | ||
| 1256 | "assistant-final-entry", | ||
| 1257 | ]); | ||
| 1258 | }); | ||
| 1259 | |||
| 1125 | it("only enables assistant copy for the terminal assistant message in a turn", () => { | 1260 | it("only enables assistant copy for the terminal assistant message in a turn", () => { |
| 1126 | const rows = deriveMessagesTimelineRows({ | 1261 | const rows = deriveMessagesTimelineRows({ |
| 1127 | timelineEntries: [ | 1262 | timelineEntries: [ |
| ... | @@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 2286 | it.each([ | 2421 | it.each([ |
| 2287 | [undefined, true], | 2422 | [undefined, true], |
| 2288 | ["inProgress", true], | 2423 | ["inProgress", true], |
| 2289 | ["completed", false], | 2424 | ["completed", true], |
| 2290 | ["failed", null], | 2425 | ["failed", null], |
| 2291 | ["declined", false], | 2426 | ["declined", false], |
| 2292 | ["stopped", false], | 2427 | ["stopped", false], |
| ... | @@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 2329 | expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); | 2464 | expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); |
| 2330 | } else { | 2465 | } else { |
| 2331 | expect(workLiveRow).toMatchObject({ active }); | 2466 | expect(workLiveRow).toMatchObject({ active }); |
| 2467 | if (active) expect(rows.some((row) => row.kind === "thinking")).toBe(false); | ||
| 2332 | } | 2468 | } |
| 2333 | }, | 2469 | }, |
| 2334 | ); | 2470 | ); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+46-10| ... | @@ -577,6 +577,7 @@ function deriveTurnFolds(input: { | ... | @@ -577,6 +577,7 @@ function deriveTurnFolds(input: { |
| 577 | terminalAssistantMessageIds: ReadonlySet<string>; | 577 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 578 | latestTurn: TimelineLatestTurn | null; | 578 | latestTurn: TimelineLatestTurn | null; |
| 579 | unfoldedTurnIds: ReadonlySet<TurnId>; | 579 | unfoldedTurnIds: ReadonlySet<TurnId>; |
| 580 | liveAgentTaskIds: ReadonlySet<string> | undefined; | ||
| 580 | }): ReadonlyMap<string, TurnFold> { | 581 | }): ReadonlyMap<string, TurnFold> { |
| 581 | interface TurnGroup { | 582 | interface TurnGroup { |
| 582 | entries: Array<TimelineEntry>; | 583 | entries: Array<TimelineEntry>; |
| ... | @@ -661,11 +662,22 @@ function deriveTurnFolds(input: { | ... | @@ -661,11 +662,22 @@ function deriveTurnFolds(input: { |
| 661 | if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { | 662 | if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { |
| 662 | continue; | 663 | continue; |
| 663 | } | 664 | } |
| 664 | // Agent-spawn CTA rows never fold: workflows outlive their launching | 665 | // Workflows outlive their launching turn (dynamic spawns, background |
| 665 | // turn (dynamic spawns, background execution), and folding the CTA | 666 | // execution), so a spawn row with a live member or coordinator stays |
| 666 | // when the turn settles makes a still-running fleet invisible. | 667 | // outside the fold instead of hiding a still-running fleet. Settled |
| 668 | // spawns fold with the rest of the turn. Without a live set (no agent | ||
| 669 | // panel model, as in the held paint during a thread switch) every | ||
| 670 | // spawn row stays out. | ||
| 667 | if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { | 671 | if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { |
| 668 | continue; | 672 | const live = input.liveAgentTaskIds; |
| 673 | const { workflowId, agentTaskIds } = entry.entry.agentSpawn; | ||
| 674 | if ( | ||
| 675 | live === undefined || | ||
| 676 | (workflowId !== null && live.has(workflowId)) || | ||
| 677 | agentTaskIds.some((taskId) => live.has(taskId)) | ||
| 678 | ) { | ||
| 679 | continue; | ||
| 680 | } | ||
| 669 | } | 681 | } |
| 670 | hiddenEntryIds.add(entry.id); | 682 | hiddenEntryIds.add(entry.id); |
| 671 | } | 683 | } |
| ... | @@ -858,6 +870,13 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -858,6 +870,13 @@ export function deriveMessagesTimelineRows(input: { |
| 858 | activeTurnStartedAt: string | null; | 870 | activeTurnStartedAt: string | null; |
| 859 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; | 871 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; |
| 860 | supportsConversationRollback: boolean; | 872 | supportsConversationRollback: boolean; |
| 873 | /** | ||
| 874 | * Task ids of subagents still working; their spawn row stays outside turn | ||
| 875 | * folds. Undefined means unknown, which keeps every spawn row out. | ||
| 876 | */ | ||
| 877 | liveAgentTaskIds?: ReadonlySet<string> | undefined; | ||
| 878 | /** Spawn rows the user opened stay visible while their turn fold is collapsed. */ | ||
| 879 | expandedSpawnEntryIds?: ReadonlySet<string> | undefined; | ||
| 861 | }): MessagesTimelineRow[] { | 880 | }): MessagesTimelineRow[] { |
| 862 | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>(); | 881 | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>(); |
| 863 | for (const summary of input.turnDiffSummaries) { | 882 | for (const summary of input.turnDiffSummaries) { |
| ... | @@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: { |
| 892 | terminalAssistantMessageIds, | 911 | terminalAssistantMessageIds, |
| 893 | latestTurn: input.latestTurn ?? null, | 912 | latestTurn: input.latestTurn ?? null, |
| 894 | unfoldedTurnIds: activeVisualResponseTurnIds, | 913 | unfoldedTurnIds: activeVisualResponseTurnIds, |
| 914 | liveAgentTaskIds: input.liveAgentTaskIds, | ||
| 895 | }); | 915 | }); |
| 896 | const collapsedEntryIds = new Set<string>(); | 916 | const collapsedEntryIds = new Set<string>(); |
| 897 | for (const fold of foldsByAnchorEntryId.values()) { | 917 | for (const fold of foldsByAnchorEntryId.values()) { |
| 898 | if (!input.expandedTurnIds?.has(fold.turnId)) { | 918 | if (!input.expandedTurnIds?.has(fold.turnId)) { |
| 899 | for (const entryId of fold.hiddenEntryIds) { | 919 | for (const entryId of fold.hiddenEntryIds) { |
| 920 | // An opened spawn row keeps its fold membership but is not pulled | ||
| 921 | // away mid-read when its last member settles. | ||
| 922 | if (input.expandedSpawnEntryIds?.has(entryId)) continue; | ||
| 900 | collapsedEntryIds.add(entryId); | 923 | collapsedEntryIds.add(entryId); |
| 901 | } | 924 | } |
| 902 | } | 925 | } |
| ... | @@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: { |
| 922 | if ( | 945 | if ( |
| 923 | !entryBelongsToActiveTurn(entry, index) || | 946 | !entryBelongsToActiveTurn(entry, index) || |
| 924 | entry.kind !== "work" || | 947 | entry.kind !== "work" || |
| 925 | entry.entry.agentSpawn !== undefined || | ||
| 926 | entry.entry.questionAnswer !== undefined || | 948 | entry.entry.questionAnswer !== undefined || |
| 927 | entry.entry.sourceActivityKind === "context-compaction" || | 949 | entry.entry.sourceActivityKind === "context-compaction" || |
| 928 | entry.entry.tone === "error" | 950 | entry.entry.tone === "error" |
| ... | @@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: { |
| 937 | ); | 959 | ); |
| 938 | const activeWorkAnchor = activeToolEntries[0]; | 960 | const activeWorkAnchor = activeToolEntries[0]; |
| 939 | const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); | 961 | const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); |
| 940 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => | 962 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => { |
| 941 | workEntryIsActiveTurnActivity(entry.entry), | 963 | const spawn = entry.entry.agentSpawn; |
| 942 | ); | 964 | return spawn |
| 965 | ? entry === latestVisibleToolEntry && | ||
| 966 | ((spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) || | ||
| 967 | spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId))) | ||
| 968 | : workEntryIsActiveTurnActivity(entry.entry); | ||
| 969 | }); | ||
| 943 | const latestToolFailed = | 970 | const latestToolFailed = |
| 944 | latestRunningToolEntry === undefined && | 971 | latestRunningToolEntry === undefined && |
| 945 | latestVisibleToolEntry !== undefined && | 972 | latestVisibleToolEntry !== undefined && |
| ... | @@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: { |
| 948 | const latestToolKeepsActivityLive = | 975 | const latestToolKeepsActivityLive = |
| 949 | latestRunningToolEntry !== undefined || | 976 | latestRunningToolEntry !== undefined || |
| 950 | (latestVisibleToolEntry !== undefined && | 977 | (latestVisibleToolEntry !== undefined && |
| 951 | workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); | 978 | latestVisibleToolEntry.entry.agentSpawn === undefined && |
| 979 | (workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry) || | ||
| 980 | (latestVisibleToolEntry.entry.toolLifecycleStatus === "completed" && | ||
| 981 | !workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry)))); | ||
| 952 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; | 982 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 953 | const activeWorkRow = | 983 | const activeWorkRow = |
| 954 | activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed | 984 | activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed |
| ... | @@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: { |
| 990 | if (activeWorkRow === null) return; | 1020 | if (activeWorkRow === null) return; |
| 991 | nextRows.push(activeWorkRow); | 1021 | nextRows.push(activeWorkRow); |
| 992 | hasActivityRow ||= activeWorkRow.active; | 1022 | hasActivityRow ||= activeWorkRow.active; |
| 993 | if (!activeWorkRow.expanded) return; | 1023 | if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return; |
| 994 | nextRows.push( | 1024 | nextRows.push( |
| 995 | expandedWorkGroupRow( | 1025 | expandedWorkGroupRow( |
| 996 | activeWorkRow.groupId, | 1026 | activeWorkRow.groupId, |
| ... | @@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: { |
| 1053 | timelineEntry.entry.questionAnswer !== undefined || | 1083 | timelineEntry.entry.questionAnswer !== undefined || |
| 1054 | timelineEntry.entry.tone === "error" | 1084 | timelineEntry.entry.tone === "error" |
| 1055 | ) { | 1085 | ) { |
| 1086 | const spawn = timelineEntry.entry.agentSpawn; | ||
| 1087 | if (spawn && entryBelongsToActiveTurn(timelineEntry, index)) { | ||
| 1088 | hasActivityRow ||= | ||
| 1089 | (spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) || | ||
| 1090 | spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId)); | ||
| 1091 | } | ||
| 1056 | nextRows.push({ | 1092 | nextRows.push({ |
| 1057 | kind: "work", | 1093 | kind: "work", |
| 1058 | id: timelineEntry.id, | 1094 | id: timelineEntry.id, |
apps/web/src/components/chat/MessagesTimeline.tsx+236-50| ... | @@ -29,11 +29,17 @@ import { | ... | @@ -29,11 +29,17 @@ import { |
| 29 | workEntryViewedImagePath, | 29 | workEntryViewedImagePath, |
| 30 | } from "@t3tools/client-runtime/work-log/presentation"; | 30 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 31 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | 31 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; |
| 32 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; | 32 | import type { |
| 33 | AgentPanelModel, | ||
| 34 | RuntimeSubagent, | ||
| 35 | } from "@t3tools/client-runtime/state/subagentRuntime"; | ||
| 33 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 36 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 34 | import { | 37 | import { |
| 35 | emptyAgentPanelModel, | 38 | emptyAgentPanelModel, |
| 39 | formatSubagentModelLabel, | ||
| 36 | formatSubagentTokenCount, | 40 | formatSubagentTokenCount, |
| 41 | isActiveSubagentStatus, | ||
| 42 | isTerminalSubagentStatus, | ||
| 37 | } from "@t3tools/client-runtime/state/subagentRuntime"; | 43 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 38 | 44 | ||
| 39 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); | 45 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| ... | @@ -263,8 +269,10 @@ interface TimelineRowSharedState { | ... | @@ -263,8 +269,10 @@ interface TimelineRowSharedState { |
| 263 | onToggleTurnFold: (turnId: TurnId) => void; | 269 | onToggleTurnFold: (turnId: TurnId) => void; |
| 264 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; | 270 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 265 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; | 271 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; |
| 272 | onToggleSpawnRow: (entryId: string, expanded: boolean) => void; | ||
| 266 | workGroupViewState: WorkGroupViewState; | 273 | workGroupViewState: WorkGroupViewState; |
| 267 | agentPanelModel: AgentPanelModel; | 274 | agentPanelModel: AgentPanelModel; |
| 275 | expandedSpawnEntryIds: ReadonlySet<string>; | ||
| 268 | onOpenAgents: () => void; | 276 | onOpenAgents: () => void; |
| 269 | } | 277 | } |
| 270 | 278 | ||
| ... | @@ -420,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -420,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 420 | isPreparingWorktree = false, | 428 | isPreparingWorktree = false, |
| 421 | isCompacting = false, | 429 | isCompacting = false, |
| 422 | activeTurnStartedAt, | 430 | activeTurnStartedAt, |
| 423 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, | 431 | agentPanelModel, |
| 424 | onOpenAgents = NOOP_OPEN_AGENTS, | 432 | onOpenAgents = NOOP_OPEN_AGENTS, |
| 425 | listRef, | 433 | listRef, |
| 426 | timelineEntries, | 434 | timelineEntries, |
| ... | @@ -457,19 +465,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -457,19 +465,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 457 | }: MessagesTimelineProps) { | 465 | }: MessagesTimelineProps) { |
| 458 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); | 466 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 459 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); | 467 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); |
| 468 | // Expanded spawn rows outlive virtualization and stay visible while their | ||
| 469 | // turn fold is collapsed, so a settling fleet is not pulled away mid-read. | ||
| 470 | const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>( | ||
| 471 | new Set(), | ||
| 472 | ); | ||
| 460 | const listIdentityKey = displayThreadKey ?? routeThreadKey; | 473 | const listIdentityKey = displayThreadKey ?? routeThreadKey; |
| 461 | const listIdentityRef = useRef(listIdentityKey); | 474 | const listIdentityRef = useRef(listIdentityKey); |
| 462 | const previousLatestTurnRef = useRef(latestTurn); | 475 | const previousLatestTurnRef = useRef(latestTurn); |
| 463 | let paintedExpandedTurnIds = expandedTurnIds; | 476 | let paintedExpandedTurnIds = expandedTurnIds; |
| 464 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; | 477 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; |
| 478 | let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; | ||
| 465 | if (listIdentityRef.current !== listIdentityKey) { | 479 | if (listIdentityRef.current !== listIdentityKey) { |
| 466 | listIdentityRef.current = listIdentityKey; | 480 | listIdentityRef.current = listIdentityKey; |
| 467 | previousLatestTurnRef.current = latestTurn; | 481 | previousLatestTurnRef.current = latestTurn; |
| 468 | paintedExpandedTurnIds = new Set(); | 482 | paintedExpandedTurnIds = new Set(); |
| 469 | paintedExpandedWorkGroupIds = new Set(); | 483 | paintedExpandedWorkGroupIds = new Set(); |
| 484 | paintedExpandedSpawnEntryIds = new Set(); | ||
| 470 | setExpandedTurnIds(paintedExpandedTurnIds); | 485 | setExpandedTurnIds(paintedExpandedTurnIds); |
| 471 | setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); | 486 | setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); |
| 487 | setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds); | ||
| 472 | } | 488 | } |
| 489 | const onToggleSpawnRow = useCallback((entryId: string, expanded: boolean) => { | ||
| 490 | setExpandedSpawnEntryIds((current) => { | ||
| 491 | if (current.has(entryId) === expanded) return current; | ||
| 492 | const next = new Set(current); | ||
| 493 | if (expanded) next.add(entryId); | ||
| 494 | else next.delete(entryId); | ||
| 495 | return next; | ||
| 496 | }); | ||
| 497 | }, []); | ||
| 473 | const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); | 498 | const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); |
| 474 | const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); | 499 | const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); |
| 475 | const expandCitedTurn = useCallback((turnId: TurnId) => { | 500 | const expandCitedTurn = useCallback((turnId: TurnId) => { |
| ... | @@ -603,6 +628,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -603,6 +628,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 603 | workspaceRoot: string | undefined; | 628 | workspaceRoot: string | undefined; |
| 604 | projection: MessagesTimelineRowsProjection; | 629 | projection: MessagesTimelineRowsProjection; |
| 605 | } | null>(null); | 630 | } | null>(null); |
| 631 | // Subagents still working keep their spawn row outside the turn fold. Same | ||
| 632 | // liveness rule as the row header (deriveAgentSpawnSummary): members while | ||
| 633 | // active, workflow coordinators until terminal. Keyed by content so the | ||
| 634 | // projection input keeps its identity across unrelated panel updates. | ||
| 635 | const liveAgentTaskKey = useMemo(() => { | ||
| 636 | if (agentPanelModel === undefined) return undefined; | ||
| 637 | const ids: string[] = []; | ||
| 638 | const consider = (agent: { id: string; status: RuntimeSubagent["status"] }) => { | ||
| 639 | if (isActiveSubagentStatus(agent.status)) ids.push(agent.id); | ||
| 640 | }; | ||
| 641 | agentPanelModel.directAgents.forEach(consider); | ||
| 642 | for (const group of agentPanelModel.workflows) { | ||
| 643 | if (!isTerminalSubagentStatus(group.workflow.status)) ids.push(group.workflow.id); | ||
| 644 | group.unphasedMembers.forEach(consider); | ||
| 645 | group.phases.forEach((phase) => phase.members.forEach(consider)); | ||
| 646 | } | ||
| 647 | return ids.sort().join("\n"); | ||
| 648 | }, [agentPanelModel]); | ||
| 649 | const liveAgentTaskIds = useMemo( | ||
| 650 | () => | ||
| 651 | liveAgentTaskKey === undefined | ||
| 652 | ? undefined | ||
| 653 | : new Set(liveAgentTaskKey.length > 0 ? liveAgentTaskKey.split("\n") : []), | ||
| 654 | [liveAgentTaskKey], | ||
| 655 | ); | ||
| 606 | const rawRows = useMemo(() => { | 656 | const rawRows = useMemo(() => { |
| 607 | const previous = rowsProjectionRef.current; | 657 | const previous = rowsProjectionRef.current; |
| 608 | const projection = deriveMessagesTimelineRowsWithState( | 658 | const projection = deriveMessagesTimelineRowsWithState( |
| ... | @@ -616,6 +666,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -616,6 +666,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 616 | activeTurnStartedAt, | 666 | activeTurnStartedAt, |
| 617 | turnDiffSummaries, | 667 | turnDiffSummaries, |
| 618 | supportsConversationRollback, | 668 | supportsConversationRollback, |
| 669 | liveAgentTaskIds, | ||
| 670 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, | ||
| 619 | }, | 671 | }, |
| 620 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot | 672 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 621 | ? previous.projection | 673 | ? previous.projection |
| ... | @@ -636,6 +688,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -636,6 +688,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 636 | activeTurnStartedAt, | 688 | activeTurnStartedAt, |
| 637 | turnDiffSummaries, | 689 | turnDiffSummaries, |
| 638 | supportsConversationRollback, | 690 | supportsConversationRollback, |
| 691 | liveAgentTaskIds, | ||
| 692 | paintedExpandedSpawnEntryIds, | ||
| 639 | ]); | 693 | ]); |
| 640 | const rows = useStableRows(rawRows, listIdentityKey); | 694 | const rows = useStableRows(rawRows, listIdentityKey); |
| 641 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); | 695 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| ... | @@ -823,8 +877,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -823,8 +877,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 823 | onToggleTurnFold, | 877 | onToggleTurnFold, |
| 824 | onToggleWorkGroup, | 878 | onToggleWorkGroup, |
| 825 | onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, | 879 | onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, |
| 880 | onToggleSpawnRow, | ||
| 826 | workGroupViewState, | 881 | workGroupViewState, |
| 827 | agentPanelModel, | 882 | agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, |
| 883 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, | ||
| 828 | onOpenAgents, | 884 | onOpenAgents, |
| 829 | }), | 885 | }), |
| 830 | [ | 886 | [ |
| ... | @@ -848,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -848,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 848 | onToggleTurnFold, | 904 | onToggleTurnFold, |
| 849 | onToggleWorkGroup, | 905 | onToggleWorkGroup, |
| 850 | suspendEndScrollMaintenanceForDisclosure, | 906 | suspendEndScrollMaintenanceForDisclosure, |
| 907 | onToggleSpawnRow, | ||
| 851 | workGroupViewState, | 908 | workGroupViewState, |
| 852 | agentPanelModel, | 909 | agentPanelModel, |
| 910 | paintedExpandedSpawnEntryIds, | ||
| 853 | onOpenAgents, | 911 | onOpenAgents, |
| 854 | ], | 912 | ], |
| 855 | ); | 913 | ); |
| ... | @@ -2334,6 +2392,15 @@ function LiveActivityContent({ | ... | @@ -2334,6 +2392,15 @@ function LiveActivityContent({ |
| 2334 | 2392 | ||
| 2335 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { | 2393 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { |
| 2336 | const ctx = use(TimelineRowCtx); | 2394 | const ctx = use(TimelineRowCtx); |
| 2395 | if (row.entry.agentSpawn) { | ||
| 2396 | return ( | ||
| 2397 | <AgentSpawnRow | ||
| 2398 | workEntry={row.entry} | ||
| 2399 | active={row.active} | ||
| 2400 | onToggleEntry={(collapsed) => ctx.onToggleWorkEntry(row.id, collapsed)} | ||
| 2401 | /> | ||
| 2402 | ); | ||
| 2403 | } | ||
| 2337 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); | 2404 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); |
| 2338 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); | 2405 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 2339 | 2406 | ||
| ... | @@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => { | ... | @@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => { |
| 3657 | } | 3724 | } |
| 3658 | }; | 3725 | }; |
| 3659 | 3726 | ||
| 3660 | /** | 3727 | /** One tool row per batch, with member results available on expansion. */ |
| 3661 | * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn | 3728 | const AgentSpawnRow = memo(function AgentSpawnRow(props: { |
| 3662 | * batch). Live status is derived from the shared agent panel model at render | 3729 | workEntry: TimelineWorkEntry; |
| 3663 | * time — the row itself never re-renders a roster; the Agents panel is the | 3730 | active?: boolean | undefined; |
| 3664 | * only roster. Freezes to past tense when every member settles. Static dot, | 3731 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; |
| 3665 | * no animation. | 3732 | }) { |
| 3666 | */ | ||
| 3667 | const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { | ||
| 3668 | const { workEntry } = props; | 3733 | const { workEntry } = props; |
| 3669 | const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); | 3734 | const { agentPanelModel, expandedSpawnEntryIds, onToggleSpawnRow, onOpenAgents } = |
| 3735 | use(TimelineRowCtx); | ||
| 3670 | const spawn = workEntry.agentSpawn; | 3736 | const spawn = workEntry.agentSpawn; |
| 3671 | if (!spawn) { | 3737 | if (!spawn) { |
| 3672 | return null; | 3738 | return null; |
| 3673 | } | 3739 | } |
| 3740 | const expanded = expandedSpawnEntryIds.has(workEntry.id); | ||
| 3674 | 3741 | ||
| 3675 | const memberIds = new Set(spawn.agentTaskIds); | 3742 | const memberIds = new Set(spawn.agentTaskIds); |
| 3676 | const workflowGroup = spawn.workflowId | 3743 | const workflowGroup = spawn.workflowId |
| ... | @@ -3683,55 +3750,168 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time | ... | @@ -3683,55 +3750,168 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time |
| 3683 | agents.length, | 3750 | agents.length, |
| 3684 | Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), | 3751 | Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), |
| 3685 | ); | 3752 | ); |
| 3686 | |||
| 3687 | const summary = deriveAgentSpawnSummary({ | 3753 | const summary = deriveAgentSpawnSummary({ |
| 3688 | agents, | 3754 | agents, |
| 3689 | agentCount, | 3755 | agentCount, |
| 3690 | coordinatorStatus: workflowGroup?.workflow.status, | 3756 | coordinatorStatus: workflowGroup?.workflow.status, |
| 3691 | }); | 3757 | }); |
| 3692 | const { live, lead } = summary; | 3758 | const { live, lead } = summary; |
| 3693 | // Same rule as the panel footer: providers may aggregate member usage into | 3759 | const failed = summary.tone === "failed"; |
| 3694 | // the coordinator, so count the coordinator only when no members exist. | ||
| 3695 | const totalTokens = agents.reduce( | ||
| 3696 | (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), | ||
| 3697 | spawn.workflowId && agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, | ||
| 3698 | ); | ||
| 3699 | |||
| 3700 | const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); | ||
| 3701 | const workflowName = | 3760 | const workflowName = |
| 3702 | workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; | 3761 | workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; |
| 3762 | const toggleExpanded = () => { | ||
| 3763 | props.onToggleEntry?.(expanded); | ||
| 3764 | onToggleSpawnRow(workEntry.id, !expanded); | ||
| 3765 | }; | ||
| 3766 | |||
| 3767 | return ( | ||
| 3768 | <div className="flex flex-col"> | ||
| 3769 | <button | ||
| 3770 | type="button" | ||
| 3771 | aria-expanded={expanded} | ||
| 3772 | onClick={toggleExpanded} | ||
| 3773 | className="flex cursor-pointer select-none rounded-md text-left transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" | ||
| 3774 | > | ||
| 3775 | <LiveActivityRow | ||
| 3776 | label={workflowName ? `${lead} · ${workflowName}` : lead} | ||
| 3777 | iconName="bot" | ||
| 3778 | active={live && props.active !== false} | ||
| 3779 | failed={failed} | ||
| 3780 | /> | ||
| 3781 | </button> | ||
| 3782 | {expanded ? ( | ||
| 3783 | <div className="ms-7 mt-0.5 flex flex-col"> | ||
| 3784 | {agents.map((agent) => ( | ||
| 3785 | <AgentSpawnMemberRow key={agent.id} agent={agent} onToggleEntry={props.onToggleEntry} /> | ||
| 3786 | ))} | ||
| 3787 | <button | ||
| 3788 | type="button" | ||
| 3789 | onClick={onOpenAgents} | ||
| 3790 | className="mt-1 self-start rounded-sm px-1 text-xs text-muted-foreground hover:text-foreground" | ||
| 3791 | > | ||
| 3792 | Open Agents panel › | ||
| 3793 | </button> | ||
| 3794 | </div> | ||
| 3795 | ) : null} | ||
| 3796 | </div> | ||
| 3797 | ); | ||
| 3798 | }); | ||
| 3703 | 3799 | ||
| 3704 | const dotClass = { | 3800 | const AGENT_MEMBER_STATUS_LABEL: Record<RuntimeSubagent["status"], string> = { |
| 3705 | working: "bg-info", | 3801 | pending: "Working", |
| 3706 | failed: "bg-destructive", | 3802 | running: "Working", |
| 3707 | completed: "bg-success", | 3803 | waiting: "Working", |
| 3708 | inactive: "bg-muted-foreground/50", | 3804 | idle: "Idle", |
| 3709 | }[summary.tone]; | 3805 | completed: "Completed", |
| 3710 | const status = | 3806 | failed: "Failed", |
| 3711 | live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; | 3807 | cancelled: "Stopped", |
| 3808 | interrupted: "Stopped", | ||
| 3809 | }; | ||
| 3810 | |||
| 3811 | function AgentSpawnMemberRow({ | ||
| 3812 | agent, | ||
| 3813 | onToggleEntry, | ||
| 3814 | }: { | ||
| 3815 | agent: RuntimeSubagent; | ||
| 3816 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; | ||
| 3817 | }) { | ||
| 3818 | const [open, setOpen] = useState(false); | ||
| 3819 | const activeStatus = isActiveSubagentStatus(agent.status); | ||
| 3820 | const activity = activeStatus | ||
| 3821 | ? (agent.progress ?? (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)) | ||
| 3822 | : (agent.error ?? agent.result ?? agent.progress ?? null); | ||
| 3823 | const durationMs = | ||
| 3824 | agent.startedAt && agent.completedAt | ||
| 3825 | ? Date.parse(agent.completedAt) - Date.parse(agent.startedAt) | ||
| 3826 | : null; | ||
| 3827 | const meta = [ | ||
| 3828 | durationMs !== null && durationMs >= 0 ? formatDuration(durationMs) : null, | ||
| 3829 | agent.usage && agent.usage.totalTokens > 0 | ||
| 3830 | ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` | ||
| 3831 | : null, | ||
| 3832 | ] | ||
| 3833 | .filter(Boolean) | ||
| 3834 | .join(" · "); | ||
| 3835 | // Settled members show their metrics; anything other than success keeps | ||
| 3836 | // the status word so the outcome remains explicit. | ||
| 3837 | const statusLabel = | ||
| 3838 | activeStatus || !meta | ||
| 3839 | ? AGENT_MEMBER_STATUS_LABEL[agent.status] | ||
| 3840 | : agent.status === "completed" | ||
| 3841 | ? meta | ||
| 3842 | : `${AGENT_MEMBER_STATUS_LABEL[agent.status]} · ${meta}`; | ||
| 3843 | const role = | ||
| 3844 | agent.role && agent.role.trim().toLowerCase() !== agent.title.trim().toLowerCase() | ||
| 3845 | ? agent.role | ||
| 3846 | : null; | ||
| 3847 | const firstLine = activity?.split("\n").find((line) => line.trim().length > 0) ?? null; | ||
| 3848 | const body = [activity?.trim() || null, formatSubagentModelLabel(agent.model, agent.effort)] | ||
| 3849 | .filter(Boolean) | ||
| 3850 | .join("\n\n"); | ||
| 3851 | const canExpand = body.length > 0; | ||
| 3852 | const toggleOpen = () => { | ||
| 3853 | onToggleEntry?.(open); | ||
| 3854 | setOpen((value) => !value); | ||
| 3855 | }; | ||
| 3712 | 3856 | ||
| 3713 | return ( | 3857 | return ( |
| 3714 | <button | 3858 | <div |
| 3715 | type="button" | 3859 | role={canExpand ? "button" : undefined} |
| 3716 | onClick={onOpenAgents} | 3860 | tabIndex={canExpand ? 0 : undefined} |
| 3717 | className="flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[.8125rem] transition hover:bg-accent/50" | 3861 | aria-label={canExpand ? `${agent.title}, ${statusLabel}` : undefined} |
| 3862 | aria-expanded={canExpand ? open : undefined} | ||
| 3863 | onClick={canExpand ? toggleOpen : undefined} | ||
| 3864 | onKeyDown={ | ||
| 3865 | canExpand | ||
| 3866 | ? (e) => { | ||
| 3867 | if (e.key === "Enter" || e.key === " ") { | ||
| 3868 | e.preventDefault(); | ||
| 3869 | toggleOpen(); | ||
| 3870 | } | ||
| 3871 | } | ||
| 3872 | : undefined | ||
| 3873 | } | ||
| 3874 | className={cn( | ||
| 3875 | "flex flex-col rounded-md px-1 py-0.5 transition-colors", | ||
| 3876 | canExpand && | ||
| 3877 | "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | ||
| 3878 | )} | ||
| 3718 | > | 3879 | > |
| 3719 | <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} /> | 3880 | <div className="flex select-none items-center gap-1.5"> |
| 3720 | <WorkEntryIcon name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | 3881 | <p className="flex min-w-0 flex-1 items-baseline gap-1.5 text-sm leading-relaxed"> |
| 3721 | <span className="min-w-0 truncate"> | 3882 | <span |
| 3722 | <span className="font-medium">{lead}</span> | 3883 | className={cn( |
| 3723 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} | 3884 | "min-w-0 truncate", |
| 3724 | </span> | 3885 | agent.status === "failed" ? failedToolIconClassName : "text-foreground/80", |
| 3725 | <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground"> | 3886 | )} |
| 3726 | <span>{status}</span> | 3887 | > |
| 3727 | {totalTokens > 0 ? ( | 3888 | {agent.title} |
| 3728 | <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span> | 3889 | </span> |
| 3729 | ) : null} | 3890 | {role ? ( |
| 3730 | <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span> | 3891 | <span className="max-w-28 shrink-0 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground"> |
| 3731 | </span> | 3892 | {role} |
| 3732 | </button> | 3893 | </span> |
| 3894 | ) : null} | ||
| 3895 | </p> | ||
| 3896 | <span className="shrink-0 font-mono text-[.7rem] tabular-nums text-muted-foreground"> | ||
| 3897 | {statusLabel} | ||
| 3898 | </span> | ||
| 3899 | </div> | ||
| 3900 | {!open && firstLine ? ( | ||
| 3901 | <p className="truncate text-xs text-muted-foreground">{firstLine}</p> | ||
| 3902 | ) : null} | ||
| 3903 | {open ? ( | ||
| 3904 | <div | ||
| 3905 | className="mt-1 cursor-default rounded-md bg-muted/40 px-3 py-2" | ||
| 3906 | onClick={stopRowToggle} | ||
| 3907 | onPointerDown={stopRowToggle} | ||
| 3908 | > | ||
| 3909 | <pre className={toolCallExpandedBodyClassName}>{body}</pre> | ||
| 3910 | </div> | ||
| 3911 | ) : null} | ||
| 3912 | </div> | ||
| 3733 | ); | 3913 | ); |
| 3734 | }); | 3914 | } |
| 3735 | 3915 | ||
| 3736 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | 3916 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3737 | workEntry: TimelineWorkEntry; | 3917 | workEntry: TimelineWorkEntry; |
| ... | @@ -3741,9 +3921,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { | ... | @@ -3741,9 +3921,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3741 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; | 3921 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; |
| 3742 | }) { | 3922 | }) { |
| 3743 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; | 3923 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; |
| 3744 | // Before any hooks: spawn CTA rows render their own component. | 3924 | // Before any hooks: spawn rows render their own component. |
| 3745 | if (workEntry.agentSpawn) { | 3925 | if (workEntry.agentSpawn) { |
| 3746 | return <AgentSpawnCtaRow workEntry={workEntry} />; | 3926 | return ( |
| 3927 | <AgentSpawnRow | ||
| 3928 | workEntry={workEntry} | ||
| 3929 | active={!isExpandedToolGroupEntry} | ||
| 3930 | onToggleEntry={props.onToggleEntry} | ||
| 3931 | /> | ||
| 3932 | ); | ||
| 3747 | } | 3933 | } |
| 3748 | return ( | 3934 | return ( |
| 3749 | <PlainWorkEntryRow | 3935 | <PlainWorkEntryRow |
apps/web/src/session-logic.test.ts+99-4| ... | @@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => { |
| 2125 | }); | 2125 | }); |
| 2126 | 2126 | ||
| 2127 | describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | 2127 | describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2128 | it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { | 2128 | it("concurrent subagents replace their launch tools with one lifecycle row", () => { |
| 2129 | const activities: OrchestrationThreadActivity[] = []; | 2129 | const activities: OrchestrationThreadActivity[] = []; |
| 2130 | for (let agent = 0; agent < 5; agent += 1) { | ||
| 2131 | activities.push( | ||
| 2132 | makeActivity({ | ||
| 2133 | kind: "tool.updated", | ||
| 2134 | summary: "Subagent task", | ||
| 2135 | payload: { | ||
| 2136 | toolCallId: `launch-${agent}`, | ||
| 2137 | itemType: "collab_agent_tool_call", | ||
| 2138 | status: "inProgress", | ||
| 2139 | data: { toolName: agent % 2 === 0 ? "Agent" : "Task" }, | ||
| 2140 | }, | ||
| 2141 | turnId: "turn-batch", | ||
| 2142 | sequence: agent - 10, | ||
| 2143 | }), | ||
| 2144 | ); | ||
| 2145 | expect(deriveWorkLogEntries(activities)).toHaveLength(0); | ||
| 2146 | } | ||
| 2130 | for (let agent = 0; agent < 5; agent += 1) { | 2147 | for (let agent = 0; agent < 5; agent += 1) { |
| 2131 | const taskId = `task-${agent}`; | 2148 | const taskId = `task-${agent}`; |
| 2149 | const toolUseId = `launch-${agent}`; | ||
| 2150 | expect(deriveWorkLogEntries(activities)).toHaveLength(agent === 0 ? 0 : 1); | ||
| 2151 | activities.push( | ||
| 2152 | makeActivity({ | ||
| 2153 | id: `started-${agent}`, | ||
| 2154 | kind: "task.started", | ||
| 2155 | summary: "Task started", | ||
| 2156 | payload: { taskId, toolUseId, taskType: "local_agent" }, | ||
| 2157 | turnId: "turn-batch", | ||
| 2158 | sequence: agent * 20 - 1, | ||
| 2159 | }), | ||
| 2160 | ); | ||
| 2161 | const runningEntries = deriveWorkLogEntries(activities); | ||
| 2162 | expect(runningEntries).toHaveLength(1); | ||
| 2163 | expect(runningEntries[0]!.id).toBe("started-0"); | ||
| 2164 | expect(runningEntries[0]!.agentSpawn?.agentTaskIds).toHaveLength(agent + 1); | ||
| 2132 | // Progress ticks (several per agent) + attributed tool rows. | 2165 | // Progress ticks (several per agent) + attributed tool rows. |
| 2133 | for (let tick = 0; tick < 4; tick += 1) { | 2166 | for (let tick = 0; tick < 4; tick += 1) { |
| 2134 | activities.push( | 2167 | activities.push( |
| ... | @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | ... | @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2136 | kind: "task.progress", | 2169 | kind: "task.progress", |
| 2137 | summary: `agent ${agent} tick ${tick}`, | 2170 | summary: `agent ${agent} tick ${tick}`, |
| 2138 | tone: "info", | 2171 | tone: "info", |
| 2139 | payload: { taskId, summary: `working ${tick}`, role: "explorer" }, | 2172 | payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" }, |
| 2140 | turnId: "turn-batch", | 2173 | turnId: "turn-batch", |
| 2141 | sequence: agent * 20 + tick, | 2174 | sequence: agent * 20 + tick, |
| 2142 | }), | 2175 | }), |
| ... | @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | ... | @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2157 | tone: "info", | 2190 | tone: "info", |
| 2158 | payload: { | 2191 | payload: { |
| 2159 | taskId, | 2192 | taskId, |
| 2193 | toolUseId, | ||
| 2160 | status: "completed", | 2194 | status: "completed", |
| 2161 | summary: `agent ${agent} done`, | 2195 | summary: `agent ${agent} done`, |
| 2162 | role: "explorer", | 2196 | role: "explorer", |
| ... | @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | ... | @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2164 | turnId: "turn-batch", | 2198 | turnId: "turn-batch", |
| 2165 | sequence: agent * 20 + 19, | 2199 | sequence: agent * 20 + 19, |
| 2166 | }), | 2200 | }), |
| 2201 | makeActivity({ | ||
| 2202 | kind: "tool.completed", | ||
| 2203 | summary: "Subagent task", | ||
| 2204 | payload: { toolCallId: toolUseId, status: "completed" }, | ||
| 2205 | turnId: "turn-batch", | ||
| 2206 | sequence: agent * 20 + 19, | ||
| 2207 | }), | ||
| 2167 | ); | 2208 | ); |
| 2168 | } | 2209 | } |
| 2169 | 2210 | ||
| ... | @@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { | ... | @@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2210 | ); | 2251 | ); |
| 2211 | }); | 2252 | }); |
| 2212 | 2253 | ||
| 2213 | it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { | 2254 | it("keeps unrelated tools and failed launches, including failures after a task starts", () => { |
| 2214 | const entries = deriveWorkLogEntries([ | 2255 | const entries = deriveWorkLogEntries([ |
| 2215 | makeActivity({ | 2256 | makeActivity({ |
| 2216 | kind: "tool.completed", | 2257 | kind: "tool.completed", |
| 2217 | summary: "Bash", | 2258 | summary: "Bash", |
| 2218 | payload: { itemType: "command_execution", command: "ls" }, | 2259 | payload: { itemType: "command_execution", command: "ls" }, |
| 2219 | }), | 2260 | }), |
| 2261 | makeActivity({ | ||
| 2262 | id: "unlinked-failure", | ||
| 2263 | kind: "tool.completed", | ||
| 2264 | summary: "Subagent task", | ||
| 2265 | tone: "error", | ||
| 2266 | payload: { toolCallId: "unlinked", status: "failed" }, | ||
| 2267 | }), | ||
| 2268 | makeActivity({ | ||
| 2269 | id: "linked-task", | ||
| 2270 | kind: "task.started", | ||
| 2271 | summary: "Task started", | ||
| 2272 | payload: { taskId: "agent", toolUseId: "linked", taskType: "local_agent" }, | ||
| 2273 | }), | ||
| 2274 | makeActivity({ | ||
| 2275 | id: "linked-failure", | ||
| 2276 | kind: "tool.completed", | ||
| 2277 | summary: "Subagent task", | ||
| 2278 | payload: { toolCallId: "linked", status: "failed" }, | ||
| 2279 | }), | ||
| 2280 | makeActivity({ | ||
| 2281 | id: "orphan-completion", | ||
| 2282 | kind: "tool.completed", | ||
| 2283 | summary: "Subagent task", | ||
| 2284 | payload: { | ||
| 2285 | toolCallId: "orphan", | ||
| 2286 | itemType: "collab_agent_tool_call", | ||
| 2287 | status: "completed", | ||
| 2288 | data: { toolName: "Agent" }, | ||
| 2289 | }, | ||
| 2290 | }), | ||
| 2291 | makeActivity({ | ||
| 2292 | id: "send-input", | ||
| 2293 | kind: "tool.updated", | ||
| 2294 | payload: { | ||
| 2295 | toolCallId: "send-input", | ||
| 2296 | itemType: "collab_agent_tool_call", | ||
| 2297 | status: "inProgress", | ||
| 2298 | data: { toolName: "send_input" }, | ||
| 2299 | }, | ||
| 2300 | }), | ||
| 2301 | makeActivity({ | ||
| 2302 | id: "active-launch-error", | ||
| 2303 | kind: "tool.updated", | ||
| 2304 | tone: "error", | ||
| 2305 | payload: { | ||
| 2306 | toolCallId: "active-launch-error", | ||
| 2307 | itemType: "collab_agent_tool_call", | ||
| 2308 | status: "inProgress", | ||
| 2309 | data: { toolName: "Task" }, | ||
| 2310 | }, | ||
| 2311 | }), | ||
| 2220 | ]); | 2312 | ]); |
| 2221 | expect(entries).toHaveLength(1); | 2313 | expect(entries).toHaveLength(7); |
| 2314 | expect(entries.map((entry) => entry.id)).toEqual( | ||
| 2315 | expect.arrayContaining(["unlinked-failure", "linked-task", "linked-failure"]), | ||
| 2316 | ); | ||
| 2222 | }); | 2317 | }); |
| 2223 | 2318 | ||
| 2224 | it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { | 2319 | it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => { |
apps/web/src/session-logic.ts+43-8| ... | @@ -83,10 +83,9 @@ export interface WorkLogEntry { | ... | @@ -83,10 +83,9 @@ export interface WorkLogEntry { |
| 83 | /** Agent role (subagent_type) for labeled timeline rows. */ | 83 | /** Agent role (subagent_type) for labeled timeline rows. */ |
| 84 | agentRole?: string; | 84 | agentRole?: string; |
| 85 | /** | 85 | /** |
| 86 | * Present on agent-spawn CTA rows: one per workflow run or per-turn batch | 86 | * Present on agent-spawn rows: one per workflow run or per-turn batch of |
| 87 | * of direct spawns. The row renders as a call-to-action ("Kicked off N | 87 | * direct spawns. The row ("Kicked off N subagents") derives its live |
| 88 | * subagents") whose live status is derived from the agent panel model at | 88 | * status and member list from the agent panel model at render time. |
| 89 | * render time; clicking opens the Agents panel. | ||
| 90 | */ | 89 | */ |
| 91 | agentSpawn?: { | 90 | agentSpawn?: { |
| 92 | /** Workflow coordinator taskId, or null for a direct-spawn batch. */ | 91 | /** Workflow coordinator taskId, or null for a direct-spawn batch. */ |
| ... | @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( | ... | @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( |
| 395 | * - tool rows attributed to an owning agent (payload.agentId) are re-homed; | 394 | * - tool rows attributed to an owning agent (payload.agentId) are re-homed; |
| 396 | * - task.progress ticks collapse into one row per taskId; | 395 | * - task.progress ticks collapse into one row per taskId; |
| 397 | * - task.updated is fold input only (status patches are not narrative). | 396 | * - task.updated is fold input only (status patches are not narrative). |
| 398 | * Unattributed rows always stay: over-hiding loses the only terminal signal. | 397 | * Unattributed rows stay unless a linked agent row replaces their launch; |
| 398 | * failed launches stay so the only terminal signal cannot disappear. | ||
| 399 | */ | 399 | */ |
| 400 | /** Agent (non-background) task.started rows seed spawn CTA batches. */ | 400 | /** Agent (non-background) task.started rows seed spawn CTA batches. */ |
| 401 | function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { | 401 | function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { |
| ... | @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean | ... | @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean |
| 424 | activity.kind === "task.completed"; | 424 | activity.kind === "task.completed"; |
| 425 | // Task rows classify by the server stamp: a subagent's own background | 425 | // Task rows classify by the server stamp: a subagent's own background |
| 426 | // shell (agentId + "background") is agent-internal, but a nested AGENT | 426 | // shell (agentId + "background") is agent-internal, but a nested AGENT |
| 427 | // (agentId + "agent") stays visible so its rows can anchor a spawn CTA | 427 | // (agentId + "agent") stays visible so its rows can anchor a spawn row |
| 428 | // (review finding: hiding on agentId alone removed nested agents and | 428 | // (review finding: hiding on agentId alone removed nested agents and |
| 429 | // their anchors). Bypassed agent lifecycle rows also pass — collapse | 429 | // their anchors). Bypassed agent lifecycle rows also pass — collapse |
| 430 | // folds every such row into its batch's single CTA row, which is how | 430 | // folds every such row into its batch's single CTA row, which is how |
| ... | @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( | ... | @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( |
| 452 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 452 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 453 | ): WorkLogEntry[] { | 453 | ): WorkLogEntry[] { |
| 454 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | 454 | const ordered = [...activities].toSorted(compareActivitiesByOrder); |
| 455 | // A launch tool and its task lifecycle describe the same run. Only hide | ||
| 456 | // launch rows once their tool-use id has an agent row to replace them. | ||
| 457 | const agentLaunchToolIds = new Set<string>(); | ||
| 458 | for (const activity of ordered) { | ||
| 459 | if ( | ||
| 460 | (activity.kind === "task.started" || | ||
| 461 | activity.kind === "task.progress" || | ||
| 462 | activity.kind === "task.completed") && | ||
| 463 | isAgentTaskStartedActivity(activity) | ||
| 464 | ) { | ||
| 465 | const toolUseId = asTrimmedString(asRecord(activity.payload)?.toolUseId); | ||
| 466 | if (toolUseId) agentLaunchToolIds.add(toolUseId); | ||
| 467 | } | ||
| 468 | } | ||
| 455 | const entries: DerivedWorkLogEntry[] = []; | 469 | const entries: DerivedWorkLogEntry[] = []; |
| 456 | for (const activity of foldUserInputActivities(ordered)) { | 470 | for (const activity of foldUserInputActivities(ordered)) { |
| 457 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | 471 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; |
| ... | @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( | ... | @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( |
| 469 | if (isNoContentRuntimeWarning(activity)) continue; | 483 | if (isNoContentRuntimeWarning(activity)) continue; |
| 470 | if (isPlanBoundaryToolActivity(activity)) continue; | 484 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 471 | if (isAgentInternalActivity(activity)) continue; | 485 | if (isAgentInternalActivity(activity)) continue; |
| 472 | entries.push(toDerivedWorkLogEntry(activity)); | 486 | const entry = toDerivedWorkLogEntry(activity); |
| 487 | // Native agent launches get their visible row from task.started. Defer | ||
| 488 | // their active tool row so another launch cannot duplicate the batch. | ||
| 489 | if ( | ||
| 490 | activity.kind === "tool.updated" && | ||
| 491 | entry.itemType === "collab_agent_tool_call" && | ||
| 492 | entry.toolLifecycleStatus === "inProgress" && | ||
| 493 | entry.tone !== "error" | ||
| 494 | ) { | ||
| 495 | const toolName = asRecord(asRecord(activity.payload)?.data)?.toolName; | ||
| 496 | if (toolName === "Agent" || toolName === "Task") continue; | ||
| 497 | } | ||
| 498 | if ( | ||
| 499 | (activity.kind === "tool.updated" || activity.kind === "tool.completed") && | ||
| 500 | entry.toolCallId && | ||
| 501 | agentLaunchToolIds.has(entry.toolCallId) && | ||
| 502 | entry.tone !== "error" && | ||
| 503 | entry.toolLifecycleStatus !== "failed" | ||
| 504 | ) { | ||
| 505 | continue; | ||
| 506 | } | ||
| 507 | entries.push(entry); | ||
| 473 | } | 508 | } |
| 474 | return collapseDerivedWorkLogEntries(entries); | 509 | return collapseDerivedWorkLogEntries(entries); |
| 475 | } | 510 | } |
| ... | @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( | ... | @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( |
| 681 | const collapsed: DerivedWorkLogEntry[] = []; | 716 | const collapsed: DerivedWorkLogEntry[] = []; |
| 682 | // Subagent rows collapse by spawn group, not adjacency: a workflow run (or | 717 | // Subagent rows collapse by spawn group, not adjacency: a workflow run (or |
| 683 | // a turn's batch of direct spawns) is ONE narrative event in the chat — a | 718 | // a turn's batch of direct spawns) is ONE narrative event in the chat — a |
| 684 | // CTA row that opens the Agents panel — no matter how many agents it | 719 | // spawn row in the timeline — no matter how many agents it |
| 685 | // contains or how their progress rows interleave (quiet-timeline | 720 | // contains or how their progress rows interleave (quiet-timeline |
| 686 | // guarantee). | 721 | // guarantee). |
| 687 | const spawnRowIndex = new Map<string, number>(); | 722 | const spawnRowIndex = new Map<string, number>(); |