| 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 | 1 | /** |
| 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 | |
| 4 | * spawn batch). | |
| 2 | * Agents right-panel surface: the fleet view over the native subagent fold. | |
| 3 | * The chat carries one expandable row per spawn batch and links here. | |
| 5 | 4 | * |
| 6 | 5 | * Visualization rules (from live-test feedback): |
| 7 | 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 | 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 | 1260 | it("only enables assistant copy for the terminal assistant message in a turn", () => { |
| 1126 | 1261 | const rows = deriveMessagesTimelineRows({ |
| 1127 | 1262 | timelineEntries: [ |
| ... | ... | @@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 2286 | 2421 | it.each([ |
| 2287 | 2422 | [undefined, true], |
| 2288 | 2423 | ["inProgress", true], |
| 2289 | ["completed", false], | |
| 2424 | ["completed", true], | |
| 2290 | 2425 | ["failed", null], |
| 2291 | 2426 | ["declined", false], |
| 2292 | 2427 | ["stopped", false], |
| ... | ... | @@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 2329 | 2464 | expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); |
| 2330 | 2465 | } else { |
| 2331 | 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 | 577 | terminalAssistantMessageIds: ReadonlySet<string>; |
| 578 | 578 | latestTurn: TimelineLatestTurn | null; |
| 579 | 579 | unfoldedTurnIds: ReadonlySet<TurnId>; |
| 580 | liveAgentTaskIds: ReadonlySet<string> | undefined; | |
| 580 | 581 | }): ReadonlyMap<string, TurnFold> { |
| 581 | 582 | interface TurnGroup { |
| 582 | 583 | entries: Array<TimelineEntry>; |
| ... | ... | @@ -661,11 +662,22 @@ function deriveTurnFolds(input: { |
| 661 | 662 | if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { |
| 662 | 663 | continue; |
| 663 | 664 | } |
| 664 | // Agent-spawn CTA rows never fold: workflows outlive their launching | |
| 665 | // turn (dynamic spawns, background execution), and folding the CTA | |
| 666 | // when the turn settles makes a still-running fleet invisible. | |
| 665 | // Workflows outlive their launching turn (dynamic spawns, background | |
| 666 | // execution), so a spawn row with a live member or coordinator stays | |
| 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 | 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 | 682 | hiddenEntryIds.add(entry.id); |
| 671 | 683 | } |
| ... | ... | @@ -858,6 +870,13 @@ export function deriveMessagesTimelineRows(input: { |
| 858 | 870 | activeTurnStartedAt: string | null; |
| 859 | 871 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; |
| 860 | 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 | 880 | }): MessagesTimelineRow[] { |
| 862 | 881 | const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>(); |
| 863 | 882 | for (const summary of input.turnDiffSummaries) { |
| ... | ... | @@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: { |
| 892 | 911 | terminalAssistantMessageIds, |
| 893 | 912 | latestTurn: input.latestTurn ?? null, |
| 894 | 913 | unfoldedTurnIds: activeVisualResponseTurnIds, |
| 914 | liveAgentTaskIds: input.liveAgentTaskIds, | |
| 895 | 915 | }); |
| 896 | 916 | const collapsedEntryIds = new Set<string>(); |
| 897 | 917 | for (const fold of foldsByAnchorEntryId.values()) { |
| 898 | 918 | if (!input.expandedTurnIds?.has(fold.turnId)) { |
| 899 | 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 | 923 | collapsedEntryIds.add(entryId); |
| 901 | 924 | } |
| 902 | 925 | } |
| ... | ... | @@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: { |
| 922 | 945 | if ( |
| 923 | 946 | !entryBelongsToActiveTurn(entry, index) || |
| 924 | 947 | entry.kind !== "work" || |
| 925 | entry.entry.agentSpawn !== undefined || | |
| 926 | 948 | entry.entry.questionAnswer !== undefined || |
| 927 | 949 | entry.entry.sourceActivityKind === "context-compaction" || |
| 928 | 950 | entry.entry.tone === "error" |
| ... | ... | @@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: { |
| 937 | 959 | ); |
| 938 | 960 | const activeWorkAnchor = activeToolEntries[0]; |
| 939 | 961 | const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); |
| 940 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => | |
| 941 | workEntryIsActiveTurnActivity(entry.entry), | |
| 942 | ); | |
| 962 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => { | |
| 963 | const spawn = entry.entry.agentSpawn; | |
| 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 | 970 | const latestToolFailed = |
| 944 | 971 | latestRunningToolEntry === undefined && |
| 945 | 972 | latestVisibleToolEntry !== undefined && |
| ... | ... | @@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: { |
| 948 | 975 | const latestToolKeepsActivityLive = |
| 949 | 976 | latestRunningToolEntry !== undefined || |
| 950 | 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 | 982 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 953 | 983 | const activeWorkRow = |
| 954 | 984 | activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed |
| ... | ... | @@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: { |
| 990 | 1020 | if (activeWorkRow === null) return; |
| 991 | 1021 | nextRows.push(activeWorkRow); |
| 992 | 1022 | hasActivityRow ||= activeWorkRow.active; |
| 993 | if (!activeWorkRow.expanded) return; | |
| 1023 | if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return; | |
| 994 | 1024 | nextRows.push( |
| 995 | 1025 | expandedWorkGroupRow( |
| 996 | 1026 | activeWorkRow.groupId, |
| ... | ... | @@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: { |
| 1053 | 1083 | timelineEntry.entry.questionAnswer !== undefined || |
| 1054 | 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 | 1092 | nextRows.push({ |
| 1057 | 1093 | kind: "work", |
| 1058 | 1094 | id: timelineEntry.id, |
apps/web/src/components/chat/MessagesTimeline.tsx+236-50| ... | ... | @@ -29,11 +29,17 @@ import { |
| 29 | 29 | workEntryViewedImagePath, |
| 30 | 30 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 31 | 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 | 36 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 34 | 37 | import { |
| 35 | 38 | emptyAgentPanelModel, |
| 39 | formatSubagentModelLabel, | |
| 36 | 40 | formatSubagentTokenCount, |
| 41 | isActiveSubagentStatus, | |
| 42 | isTerminalSubagentStatus, | |
| 37 | 43 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 38 | 44 | |
| 39 | 45 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| ... | ... | @@ -263,8 +269,10 @@ interface TimelineRowSharedState { |
| 263 | 269 | onToggleTurnFold: (turnId: TurnId) => void; |
| 264 | 270 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 265 | 271 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; |
| 272 | onToggleSpawnRow: (entryId: string, expanded: boolean) => void; | |
| 266 | 273 | workGroupViewState: WorkGroupViewState; |
| 267 | 274 | agentPanelModel: AgentPanelModel; |
| 275 | expandedSpawnEntryIds: ReadonlySet<string>; | |
| 268 | 276 | onOpenAgents: () => void; |
| 269 | 277 | } |
| 270 | 278 | |
| ... | ... | @@ -420,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 420 | 428 | isPreparingWorktree = false, |
| 421 | 429 | isCompacting = false, |
| 422 | 430 | activeTurnStartedAt, |
| 423 | agentPanelModel = EMPTY_AGENT_PANEL_MODEL, | |
| 431 | agentPanelModel, | |
| 424 | 432 | onOpenAgents = NOOP_OPEN_AGENTS, |
| 425 | 433 | listRef, |
| 426 | 434 | timelineEntries, |
| ... | ... | @@ -457,19 +465,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 457 | 465 | }: MessagesTimelineProps) { |
| 458 | 466 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 459 | 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 | 473 | const listIdentityKey = displayThreadKey ?? routeThreadKey; |
| 461 | 474 | const listIdentityRef = useRef(listIdentityKey); |
| 462 | 475 | const previousLatestTurnRef = useRef(latestTurn); |
| 463 | 476 | let paintedExpandedTurnIds = expandedTurnIds; |
| 464 | 477 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; |
| 478 | let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; | |
| 465 | 479 | if (listIdentityRef.current !== listIdentityKey) { |
| 466 | 480 | listIdentityRef.current = listIdentityKey; |
| 467 | 481 | previousLatestTurnRef.current = latestTurn; |
| 468 | 482 | paintedExpandedTurnIds = new Set(); |
| 469 | 483 | paintedExpandedWorkGroupIds = new Set(); |
| 484 | paintedExpandedSpawnEntryIds = new Set(); | |
| 470 | 485 | setExpandedTurnIds(paintedExpandedTurnIds); |
| 471 | 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 | 498 | const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); |
| 474 | 499 | const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); |
| 475 | 500 | const expandCitedTurn = useCallback((turnId: TurnId) => { |
| ... | ... | @@ -603,6 +628,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 603 | 628 | workspaceRoot: string | undefined; |
| 604 | 629 | projection: MessagesTimelineRowsProjection; |
| 605 | 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 | 656 | const rawRows = useMemo(() => { |
| 607 | 657 | const previous = rowsProjectionRef.current; |
| 608 | 658 | const projection = deriveMessagesTimelineRowsWithState( |
| ... | ... | @@ -616,6 +666,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 616 | 666 | activeTurnStartedAt, |
| 617 | 667 | turnDiffSummaries, |
| 618 | 668 | supportsConversationRollback, |
| 669 | liveAgentTaskIds, | |
| 670 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, | |
| 619 | 671 | }, |
| 620 | 672 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 621 | 673 | ? previous.projection |
| ... | ... | @@ -636,6 +688,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 636 | 688 | activeTurnStartedAt, |
| 637 | 689 | turnDiffSummaries, |
| 638 | 690 | supportsConversationRollback, |
| 691 | liveAgentTaskIds, | |
| 692 | paintedExpandedSpawnEntryIds, | |
| 639 | 693 | ]); |
| 640 | 694 | const rows = useStableRows(rawRows, listIdentityKey); |
| 641 | 695 | const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); |
| ... | ... | @@ -823,8 +877,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 823 | 877 | onToggleTurnFold, |
| 824 | 878 | onToggleWorkGroup, |
| 825 | 879 | onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, |
| 880 | onToggleSpawnRow, | |
| 826 | 881 | workGroupViewState, |
| 827 | agentPanelModel, | |
| 882 | agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL, | |
| 883 | expandedSpawnEntryIds: paintedExpandedSpawnEntryIds, | |
| 828 | 884 | onOpenAgents, |
| 829 | 885 | }), |
| 830 | 886 | [ |
| ... | ... | @@ -848,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 848 | 904 | onToggleTurnFold, |
| 849 | 905 | onToggleWorkGroup, |
| 850 | 906 | suspendEndScrollMaintenanceForDisclosure, |
| 907 | onToggleSpawnRow, | |
| 851 | 908 | workGroupViewState, |
| 852 | 909 | agentPanelModel, |
| 910 | paintedExpandedSpawnEntryIds, | |
| 853 | 911 | onOpenAgents, |
| 854 | 912 | ], |
| 855 | 913 | ); |
| ... | ... | @@ -2334,6 +2392,15 @@ function LiveActivityContent({ |
| 2334 | 2392 | |
| 2335 | 2393 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { |
| 2336 | 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 | 2404 | const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); |
| 2338 | 2405 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 2339 | 2406 | |
| ... | ... | @@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => { |
| 3657 | 3724 | } |
| 3658 | 3725 | }; |
| 3659 | 3726 | |
| 3660 | /** | |
| 3661 | * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn | |
| 3662 | * batch). Live status is derived from the shared agent panel model at render | |
| 3663 | * time — the row itself never re-renders a roster; the Agents panel is the | |
| 3664 | * only roster. Freezes to past tense when every member settles. Static dot, | |
| 3665 | * no animation. | |
| 3666 | */ | |
| 3667 | const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { | |
| 3727 | /** One tool row per batch, with member results available on expansion. */ | |
| 3728 | const AgentSpawnRow = memo(function AgentSpawnRow(props: { | |
| 3729 | workEntry: TimelineWorkEntry; | |
| 3730 | active?: boolean | undefined; | |
| 3731 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; | |
| 3732 | }) { | |
| 3668 | 3733 | const { workEntry } = props; |
| 3669 | const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); | |
| 3734 | const { agentPanelModel, expandedSpawnEntryIds, onToggleSpawnRow, onOpenAgents } = | |
| 3735 | use(TimelineRowCtx); | |
| 3670 | 3736 | const spawn = workEntry.agentSpawn; |
| 3671 | 3737 | if (!spawn) { |
| 3672 | 3738 | return null; |
| 3673 | 3739 | } |
| 3740 | const expanded = expandedSpawnEntryIds.has(workEntry.id); | |
| 3674 | 3741 | |
| 3675 | 3742 | const memberIds = new Set(spawn.agentTaskIds); |
| 3676 | 3743 | const workflowGroup = spawn.workflowId |
| ... | ... | @@ -3683,55 +3750,168 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time |
| 3683 | 3750 | agents.length, |
| 3684 | 3751 | Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), |
| 3685 | 3752 | ); |
| 3686 | ||
| 3687 | 3753 | const summary = deriveAgentSpawnSummary({ |
| 3688 | 3754 | agents, |
| 3689 | 3755 | agentCount, |
| 3690 | 3756 | coordinatorStatus: workflowGroup?.workflow.status, |
| 3691 | 3757 | }); |
| 3692 | 3758 | const { live, lead } = summary; |
| 3693 | // Same rule as the panel footer: providers may aggregate member usage into | |
| 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"); | |
| 3759 | const failed = summary.tone === "failed"; | |
| 3701 | 3760 | const workflowName = |
| 3702 | 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 = { | |
| 3705 | working: "bg-info", | |
| 3706 | failed: "bg-destructive", | |
| 3707 | completed: "bg-success", | |
| 3708 | inactive: "bg-muted-foreground/50", | |
| 3709 | }[summary.tone]; | |
| 3710 | const status = | |
| 3711 | live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; | |
| 3800 | const AGENT_MEMBER_STATUS_LABEL: Record<RuntimeSubagent["status"], string> = { | |
| 3801 | pending: "Working", | |
| 3802 | running: "Working", | |
| 3803 | waiting: "Working", | |
| 3804 | idle: "Idle", | |
| 3805 | completed: "Completed", | |
| 3806 | failed: "Failed", | |
| 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 | 3857 | return ( |
| 3714 | <button | |
| 3715 | type="button" | |
| 3716 | onClick={onOpenAgents} | |
| 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" | |
| 3858 | <div | |
| 3859 | role={canExpand ? "button" : undefined} | |
| 3860 | tabIndex={canExpand ? 0 : undefined} | |
| 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)} /> | |
| 3720 | <WorkEntryIcon name="bot" className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 3721 | <span className="min-w-0 truncate"> | |
| 3722 | <span className="font-medium">{lead}</span> | |
| 3723 | {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null} | |
| 3724 | </span> | |
| 3725 | <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground"> | |
| 3726 | <span>{status}</span> | |
| 3727 | {totalTokens > 0 ? ( | |
| 3728 | <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span> | |
| 3729 | ) : null} | |
| 3730 | <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span> | |
| 3731 | </span> | |
| 3732 | </button> | |
| 3880 | <div className="flex select-none items-center gap-1.5"> | |
| 3881 | <p className="flex min-w-0 flex-1 items-baseline gap-1.5 text-sm leading-relaxed"> | |
| 3882 | <span | |
| 3883 | className={cn( | |
| 3884 | "min-w-0 truncate", | |
| 3885 | agent.status === "failed" ? failedToolIconClassName : "text-foreground/80", | |
| 3886 | )} | |
| 3887 | > | |
| 3888 | {agent.title} | |
| 3889 | </span> | |
| 3890 | {role ? ( | |
| 3891 | <span className="max-w-28 shrink-0 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground"> | |
| 3892 | {role} | |
| 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 | 3916 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3737 | 3917 | workEntry: TimelineWorkEntry; |
| ... | ... | @@ -3741,9 +3921,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 3741 | 3921 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; |
| 3742 | 3922 | }) { |
| 3743 | 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 | 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 | 3934 | return ( |
| 3749 | 3935 | <PlainWorkEntryRow |
apps/web/src/session-logic.test.ts+99-4| ... | ... | @@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => { |
| 2125 | 2125 | }); |
| 2126 | 2126 | |
| 2127 | 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 | 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 | 2147 | for (let agent = 0; agent < 5; agent += 1) { |
| 2131 | 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 | 2165 | // Progress ticks (several per agent) + attributed tool rows. |
| 2133 | 2166 | for (let tick = 0; tick < 4; tick += 1) { |
| 2134 | 2167 | activities.push( |
| ... | ... | @@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2136 | 2169 | kind: "task.progress", |
| 2137 | 2170 | summary: `agent ${agent} tick ${tick}`, |
| 2138 | 2171 | tone: "info", |
| 2139 | payload: { taskId, summary: `working ${tick}`, role: "explorer" }, | |
| 2172 | payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" }, | |
| 2140 | 2173 | turnId: "turn-batch", |
| 2141 | 2174 | sequence: agent * 20 + tick, |
| 2142 | 2175 | }), |
| ... | ... | @@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2157 | 2190 | tone: "info", |
| 2158 | 2191 | payload: { |
| 2159 | 2192 | taskId, |
| 2193 | toolUseId, | |
| 2160 | 2194 | status: "completed", |
| 2161 | 2195 | summary: `agent ${agent} done`, |
| 2162 | 2196 | role: "explorer", |
| ... | ... | @@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { |
| 2164 | 2198 | turnId: "turn-batch", |
| 2165 | 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 | 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 | 2255 | const entries = deriveWorkLogEntries([ |
| 2215 | 2256 | makeActivity({ |
| 2216 | 2257 | kind: "tool.completed", |
| 2217 | 2258 | summary: "Bash", |
| 2218 | 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 | 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 | 83 | /** Agent role (subagent_type) for labeled timeline rows. */ |
| 84 | 84 | agentRole?: string; |
| 85 | 85 | /** |
| 86 | * Present on agent-spawn CTA rows: one per workflow run or per-turn batch | |
| 87 | * of direct spawns. The row renders as a call-to-action ("Kicked off N | |
| 88 | * subagents") whose live status is derived from the agent panel model at | |
| 89 | * render time; clicking opens the Agents panel. | |
| 86 | * Present on agent-spawn rows: one per workflow run or per-turn batch of | |
| 87 | * direct spawns. The row ("Kicked off N subagents") derives its live | |
| 88 | * status and member list from the agent panel model at render time. | |
| 90 | 89 | */ |
| 91 | 90 | agentSpawn?: { |
| 92 | 91 | /** Workflow coordinator taskId, or null for a direct-spawn batch. */ |
| ... | ... | @@ -395,7 +394,8 @@ export function hasActionableProposedPlan( |
| 395 | 394 | * - tool rows attributed to an owning agent (payload.agentId) are re-homed; |
| 396 | 395 | * - task.progress ticks collapse into one row per taskId; |
| 397 | 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 | 400 | /** Agent (non-background) task.started rows seed spawn CTA batches. */ |
| 401 | 401 | function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { |
| ... | ... | @@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean |
| 424 | 424 | activity.kind === "task.completed"; |
| 425 | 425 | // Task rows classify by the server stamp: a subagent's own background |
| 426 | 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 | 428 | // (review finding: hiding on agentId alone removed nested agents and |
| 429 | 429 | // their anchors). Bypassed agent lifecycle rows also pass — collapse |
| 430 | 430 | // folds every such row into its batch's single CTA row, which is how |
| ... | ... | @@ -452,6 +452,20 @@ export function deriveWorkLogEntries( |
| 452 | 452 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 453 | 453 | ): WorkLogEntry[] { |
| 454 | 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 | 469 | const entries: DerivedWorkLogEntry[] = []; |
| 456 | 470 | for (const activity of foldUserInputActivities(ordered)) { |
| 457 | 471 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; |
| ... | ... | @@ -469,7 +483,28 @@ export function deriveWorkLogEntries( |
| 469 | 483 | if (isNoContentRuntimeWarning(activity)) continue; |
| 470 | 484 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 471 | 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 | 509 | return collapseDerivedWorkLogEntries(entries); |
| 475 | 510 | } |
| ... | ... | @@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries( |
| 681 | 716 | const collapsed: DerivedWorkLogEntry[] = []; |
| 682 | 717 | // Subagent rows collapse by spawn group, not adjacency: a workflow run (or |
| 683 | 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 | 720 | // contains or how their progress rows interleave (quiet-timeline |
| 686 | 721 | // guarantee). |
| 687 | 722 | const spawnRowIndex = new Map<string, number>(); |