authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 19:31:49-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 19:31:49-03:00
logc0ddfb3a81abe553fea1e43c79cb723118959449
treee976b3bee12372d8d2bee9ed1bcf5a01bbfa460d
parentb0c6c3b2fd6079bd56ea8a101f1a3be244864bde
signature Signed by PGP key B5690EEEBB952194

feat(web): subagent spawns render as an expandable work row (#11433)


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 per3 * 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 });
11241124
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 launching665 // Workflows outlive their launching turn (dynamic spawns, background
665 // turn (dynamic spawns, background execution), and folding the CTA666 // 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 && !latestToolFailed984 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";
31import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";31import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
32import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";32import type {
33 AgentPanelModel,
34 RuntimeSubagent,
35} from "@t3tools/client-runtime/state/subagentRuntime";
33import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";36import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
34import {37import {
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";
3844
39const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();45const 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}
270278
...@@ -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 === workspaceRoot672 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
621 ? previous.projection673 ? 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({
23342392
2335function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {2393function 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);
23392406
...@@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => {...@@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => {
3657 }3724 }
3658};3725};
36593726
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-spawn3728const AgentSpawnRow = memo(function AgentSpawnRow(props: {
3662 * batch). Live status is derived from the shared agent panel model at render3729 workEntry: TimelineWorkEntry;
3663 * time — the row itself never re-renders a roster; the Agents panel is the3730 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 */
3667const 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);
36743741
3675 const memberIds = new Set(spawn.agentTaskIds);3742 const memberIds = new Set(spawn.agentTaskIds);
3676 const workflowGroup = spawn.workflowId3743 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 into3759 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});
37033799
3704 const dotClass = {3800const 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
3811function 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 };
37123856
3713 return (3857 return (
3714 <button3858 <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}
37353915
3736const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {3916const 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 <PlainWorkEntryRow3935 <PlainWorkEntryRow
apps/web/src/session-logic.test.ts+99-4
...@@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => {
2125});2125});
21262126
2127describe("deriveWorkLogEntries quiet-timeline guarantee", () => {2127describe("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 }
21692210
...@@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {...@@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
2210 );2251 );
2211 });2252 });
22122253
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 });
22232318
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 batch86 * 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 N87 * direct spawns. The row ("Kicked off N subagents") derives its live
88 * subagents") whose live status is derived from the agent panel model at88 * 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. */
401function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {401function 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 background425 // Task rows classify by the server stamp: a subagent's own background
426 // shell (agentId + "background") is agent-internal, but a nested AGENT426 // shell (agentId + "background") is agent-internal, but a nested AGENT
427 // (agentId + "agent") stays visible so its rows can anchor a spawn CTA427 // (agentId + "agent") stays visible so its rows can anchor a spawn row
428 // (review finding: hiding on agentId alone removed nested agents and428 // (review finding: hiding on agentId alone removed nested agents and
429 // their anchors). Bypassed agent lifecycle rows also pass — collapse429 // their anchors). Bypassed agent lifecycle rows also pass — collapse
430 // folds every such row into its batch's single CTA row, which is how430 // 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 (or717 // 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 — a718 // 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 it719 // spawn row in the timeline — no matter how many agents it
685 // contains or how their progress rows interleave (quiet-timeline720 // 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>();