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 @@
11/**
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.
54 *
65 * Visualization rules (from live-test feedback):
76 * - 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", () => {
11221122 ]);
11231123 });
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
11251260 it("only enables assistant copy for the terminal assistant message in a turn", () => {
11261261 const rows = deriveMessagesTimelineRows({
11271262 timelineEntries: [
......@@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => {
22862421 it.each([
22872422 [undefined, true],
22882423 ["inProgress", true],
2289 ["completed", false],
2424 ["completed", true],
22902425 ["failed", null],
22912426 ["declined", false],
22922427 ["stopped", false],
......@@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => {
23292464 expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
23302465 } else {
23312466 expect(workLiveRow).toMatchObject({ active });
2467 if (active) expect(rows.some((row) => row.kind === "thinking")).toBe(false);
23322468 }
23332469 },
23342470 );
apps/web/src/components/chat/MessagesTimeline.logic.ts+46-10
......@@ -577,6 +577,7 @@ function deriveTurnFolds(input: {
577577 terminalAssistantMessageIds: ReadonlySet<string>;
578578 latestTurn: TimelineLatestTurn | null;
579579 unfoldedTurnIds: ReadonlySet<TurnId>;
580 liveAgentTaskIds: ReadonlySet<string> | undefined;
580581}): ReadonlyMap<string, TurnFold> {
581582 interface TurnGroup {
582583 entries: Array<TimelineEntry>;
......@@ -661,11 +662,22 @@ function deriveTurnFolds(input: {
661662 if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
662663 continue;
663664 }
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.
667671 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 }
669681 }
670682 hiddenEntryIds.add(entry.id);
671683 }
......@@ -858,6 +870,13 @@ export function deriveMessagesTimelineRows(input: {
858870 activeTurnStartedAt: string | null;
859871 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
860872 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;
861880}): MessagesTimelineRow[] {
862881 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
863882 for (const summary of input.turnDiffSummaries) {
......@@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: {
892911 terminalAssistantMessageIds,
893912 latestTurn: input.latestTurn ?? null,
894913 unfoldedTurnIds: activeVisualResponseTurnIds,
914 liveAgentTaskIds: input.liveAgentTaskIds,
895915 });
896916 const collapsedEntryIds = new Set<string>();
897917 for (const fold of foldsByAnchorEntryId.values()) {
898918 if (!input.expandedTurnIds?.has(fold.turnId)) {
899919 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;
900923 collapsedEntryIds.add(entryId);
901924 }
902925 }
......@@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: {
922945 if (
923946 !entryBelongsToActiveTurn(entry, index) ||
924947 entry.kind !== "work" ||
925 entry.entry.agentSpawn !== undefined ||
926948 entry.entry.questionAnswer !== undefined ||
927949 entry.entry.sourceActivityKind === "context-compaction" ||
928950 entry.entry.tone === "error"
......@@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: {
937959 );
938960 const activeWorkAnchor = activeToolEntries[0];
939961 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 });
943970 const latestToolFailed =
944971 latestRunningToolEntry === undefined &&
945972 latestVisibleToolEntry !== undefined &&
......@@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: {
948975 const latestToolKeepsActivityLive =
949976 latestRunningToolEntry !== undefined ||
950977 (latestVisibleToolEntry !== undefined &&
951 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
978 latestVisibleToolEntry.entry.agentSpawn === undefined &&
979 (workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry) ||
980 (latestVisibleToolEntry.entry.toolLifecycleStatus === "completed" &&
981 !workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry))));
952982 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
953983 const activeWorkRow =
954984 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
......@@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: {
9901020 if (activeWorkRow === null) return;
9911021 nextRows.push(activeWorkRow);
9921022 hasActivityRow ||= activeWorkRow.active;
993 if (!activeWorkRow.expanded) return;
1023 if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return;
9941024 nextRows.push(
9951025 expandedWorkGroupRow(
9961026 activeWorkRow.groupId,
......@@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: {
10531083 timelineEntry.entry.questionAnswer !== undefined ||
10541084 timelineEntry.entry.tone === "error"
10551085 ) {
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 }
10561092 nextRows.push({
10571093 kind: "work",
10581094 id: timelineEntry.id,
apps/web/src/components/chat/MessagesTimeline.tsx+236-50
......@@ -29,11 +29,17 @@ import {
2929 workEntryViewedImagePath,
3030} from "@t3tools/client-runtime/work-log/presentation";
3131import { 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";
3336import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
3437import {
3538 emptyAgentPanelModel,
39 formatSubagentModelLabel,
3640 formatSubagentTokenCount,
41 isActiveSubagentStatus,
42 isTerminalSubagentStatus,
3743} from "@t3tools/client-runtime/state/subagentRuntime";
3844
3945const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
......@@ -263,8 +269,10 @@ interface TimelineRowSharedState {
263269 onToggleTurnFold: (turnId: TurnId) => void;
264270 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
265271 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
272 onToggleSpawnRow: (entryId: string, expanded: boolean) => void;
266273 workGroupViewState: WorkGroupViewState;
267274 agentPanelModel: AgentPanelModel;
275 expandedSpawnEntryIds: ReadonlySet<string>;
268276 onOpenAgents: () => void;
269277}
270278
......@@ -420,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
420428 isPreparingWorktree = false,
421429 isCompacting = false,
422430 activeTurnStartedAt,
423 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
431 agentPanelModel,
424432 onOpenAgents = NOOP_OPEN_AGENTS,
425433 listRef,
426434 timelineEntries,
......@@ -457,19 +465,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({
457465}: MessagesTimelineProps) {
458466 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
459467 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 );
460473 const listIdentityKey = displayThreadKey ?? routeThreadKey;
461474 const listIdentityRef = useRef(listIdentityKey);
462475 const previousLatestTurnRef = useRef(latestTurn);
463476 let paintedExpandedTurnIds = expandedTurnIds;
464477 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
478 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;
465479 if (listIdentityRef.current !== listIdentityKey) {
466480 listIdentityRef.current = listIdentityKey;
467481 previousLatestTurnRef.current = latestTurn;
468482 paintedExpandedTurnIds = new Set();
469483 paintedExpandedWorkGroupIds = new Set();
484 paintedExpandedSpawnEntryIds = new Set();
470485 setExpandedTurnIds(paintedExpandedTurnIds);
471486 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
487 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);
472488 }
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 }, []);
473498 const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
474499 const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined);
475500 const expandCitedTurn = useCallback((turnId: TurnId) => {
......@@ -603,6 +628,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({
603628 workspaceRoot: string | undefined;
604629 projection: MessagesTimelineRowsProjection;
605630 } | 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 );
606656 const rawRows = useMemo(() => {
607657 const previous = rowsProjectionRef.current;
608658 const projection = deriveMessagesTimelineRowsWithState(
......@@ -616,6 +666,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
616666 activeTurnStartedAt,
617667 turnDiffSummaries,
618668 supportsConversationRollback,
669 liveAgentTaskIds,
670 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
619671 },
620672 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
621673 ? previous.projection
......@@ -636,6 +688,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
636688 activeTurnStartedAt,
637689 turnDiffSummaries,
638690 supportsConversationRollback,
691 liveAgentTaskIds,
692 paintedExpandedSpawnEntryIds,
639693 ]);
640694 const rows = useStableRows(rawRows, listIdentityKey);
641695 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
......@@ -823,8 +877,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
823877 onToggleTurnFold,
824878 onToggleWorkGroup,
825879 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
880 onToggleSpawnRow,
826881 workGroupViewState,
827 agentPanelModel,
882 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,
883 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
828884 onOpenAgents,
829885 }),
830886 [
......@@ -848,8 +904,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
848904 onToggleTurnFold,
849905 onToggleWorkGroup,
850906 suspendEndScrollMaintenanceForDisclosure,
907 onToggleSpawnRow,
851908 workGroupViewState,
852909 agentPanelModel,
910 paintedExpandedSpawnEntryIds,
853911 onOpenAgents,
854912 ],
855913 );
......@@ -2334,6 +2392,15 @@ function LiveActivityContent({
23342392
23352393function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
23362394 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 }
23372404 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
23382405 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
23392406
......@@ -3657,20 +3724,20 @@ const stopRowToggleWhileSelectingText = (e: MouseEvent<HTMLElement>) => {
36573724 }
36583725};
36593726
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 */
3667const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) {
3727/** One tool row per batch, with member results available on expansion. */
3728const AgentSpawnRow = memo(function AgentSpawnRow(props: {
3729 workEntry: TimelineWorkEntry;
3730 active?: boolean | undefined;
3731 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
3732}) {
36683733 const { workEntry } = props;
3669 const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx);
3734 const { agentPanelModel, expandedSpawnEntryIds, onToggleSpawnRow, onOpenAgents } =
3735 use(TimelineRowCtx);
36703736 const spawn = workEntry.agentSpawn;
36713737 if (!spawn) {
36723738 return null;
36733739 }
3740 const expanded = expandedSpawnEntryIds.has(workEntry.id);
36743741
36753742 const memberIds = new Set(spawn.agentTaskIds);
36763743 const workflowGroup = spawn.workflowId
......@@ -3683,55 +3750,168 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
36833750 agents.length,
36843751 Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0),
36853752 );
3686
36873753 const summary = deriveAgentSpawnSummary({
36883754 agents,
36893755 agentCount,
36903756 coordinatorStatus: workflowGroup?.workflow.status,
36913757 });
36923758 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";
37013760 const workflowName =
37023761 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 = {
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;
3800const 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
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
37133857 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 )}
37183879 >
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>
37333913 );
3734});
3914}
37353915
37363916const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
37373917 workEntry: TimelineWorkEntry;
......@@ -3741,9 +3921,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
37413921 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
37423922}) {
37433923 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.
37453925 if (workEntry.agentSpawn) {
3746 return <AgentSpawnCtaRow workEntry={workEntry} />;
3926 return (
3927 <AgentSpawnRow
3928 workEntry={workEntry}
3929 active={!isExpandedToolGroupEntry}
3930 onToggleEntry={props.onToggleEntry}
3931 />
3932 );
37473933 }
37483934 return (
37493935 <PlainWorkEntryRow
apps/web/src/session-logic.test.ts+99-4
......@@ -2125,10 +2125,43 @@ describe("deriveActiveWorkStartedAt", () => {
21252125});
21262126
21272127describe("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", () => {
21292129 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 }
21302147 for (let agent = 0; agent < 5; agent += 1) {
21312148 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);
21322165 // Progress ticks (several per agent) + attributed tool rows.
21332166 for (let tick = 0; tick < 4; tick += 1) {
21342167 activities.push(
......@@ -2136,7 +2169,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
21362169 kind: "task.progress",
21372170 summary: `agent ${agent} tick ${tick}`,
21382171 tone: "info",
2139 payload: { taskId, summary: `working ${tick}`, role: "explorer" },
2172 payload: { taskId, toolUseId, summary: `working ${tick}`, role: "explorer" },
21402173 turnId: "turn-batch",
21412174 sequence: agent * 20 + tick,
21422175 }),
......@@ -2157,6 +2190,7 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
21572190 tone: "info",
21582191 payload: {
21592192 taskId,
2193 toolUseId,
21602194 status: "completed",
21612195 summary: `agent ${agent} done`,
21622196 role: "explorer",
......@@ -2164,6 +2198,13 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
21642198 turnId: "turn-batch",
21652199 sequence: agent * 20 + 19,
21662200 }),
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 }),
21672208 );
21682209 }
21692210
......@@ -2210,15 +2251,69 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
22102251 );
22112252 });
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", () => {
22142255 const entries = deriveWorkLogEntries([
22152256 makeActivity({
22162257 kind: "tool.completed",
22172258 summary: "Bash",
22182259 payload: { itemType: "command_execution", command: "ls" },
22192260 }),
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 }),
22202312 ]);
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 );
22222317 });
22232318
22242319 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 {
8383 /** Agent role (subagent_type) for labeled timeline rows. */
8484 agentRole?: string;
8585 /**
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.
9089 */
9190 agentSpawn?: {
9291 /** Workflow coordinator taskId, or null for a direct-spawn batch. */
......@@ -395,7 +394,8 @@ export function hasActionableProposedPlan(
395394 * - tool rows attributed to an owning agent (payload.agentId) are re-homed;
396395 * - task.progress ticks collapse into one row per taskId;
397396 * - 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.
399399 */
400400/** Agent (non-background) task.started rows seed spawn CTA batches. */
401401function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
......@@ -424,7 +424,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
424424 activity.kind === "task.completed";
425425 // Task rows classify by the server stamp: a subagent's own background
426426 // 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
428428 // (review finding: hiding on agentId alone removed nested agents and
429429 // their anchors). Bypassed agent lifecycle rows also pass — collapse
430430 // folds every such row into its batch's single CTA row, which is how
......@@ -452,6 +452,20 @@ export function deriveWorkLogEntries(
452452 activities: ReadonlyArray<OrchestrationThreadActivity>,
453453): WorkLogEntry[] {
454454 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 }
455469 const entries: DerivedWorkLogEntry[] = [];
456470 for (const activity of foldUserInputActivities(ordered)) {
457471 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
......@@ -469,7 +483,28 @@ export function deriveWorkLogEntries(
469483 if (isNoContentRuntimeWarning(activity)) continue;
470484 if (isPlanBoundaryToolActivity(activity)) continue;
471485 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);
473508 }
474509 return collapseDerivedWorkLogEntries(entries);
475510}
......@@ -681,7 +716,7 @@ function collapseDerivedWorkLogEntries(
681716 const collapsed: DerivedWorkLogEntry[] = [];
682717 // Subagent rows collapse by spawn group, not adjacency: a workflow run (or
683718 // 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
685720 // contains or how their progress rows interleave (quiet-timeline
686721 // guarantee).
687722 const spawnRowIndex = new Map<string, number>();