diff --git a/apps/web/src/components/AgentsPanelV2.test.tsx b/apps/web/src/components/AgentsPanelV2.test.tsx new file mode 100644 index 00000000000..ffccf945cde --- /dev/null +++ b/apps/web/src/components/AgentsPanelV2.test.tsx @@ -0,0 +1,156 @@ +import { + NodeId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2Subagent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { AgentsPanelV2 } from "./AgentsPanelV2"; +import { makeThreadProjectionFixture } from "../test-fixtures"; + +const now = DateTime.makeUnsafe("2026-07-26T00:00:00.000Z"); +const workflowId = NodeId.make("workflow-1"); + +const agent = (id: string, input: Partial = {}): OrchestrationV2Subagent => + ({ + id: NodeId.make(id), + threadId: ThreadId.make("thread-test"), + runId: RunId.make("run-test"), + parentNodeId: NodeId.make("root-node"), + origin: "provider_native", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Work", + title: id, + model: "claude-opus-4-1", + kind: "subagent", + role: { name: "general-purpose", source: "provider" }, + status: "running", + result: null, + usage: null, + currentActivationId: null, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], + startedAt: now, + completedAt: null, + updatedAt: now, + ...input, + }) as OrchestrationV2Subagent; + +const renderPanel = (subagents: ReadonlyArray) => + renderToStaticMarkup( + , + ); + +describe("AgentsPanelV2", () => { + it("renders a workflow coordinator as a card before its members exist", () => { + // The Claude-only workflow path. A coordinator arrives before any member + // does, so rendering it as a bare heading left the panel showing a title, + // no rows, and "0 active" while the workflow was running. + const markup = renderPanel([ + agent("workflow-1", { + kind: "workflow", + title: "Refactor sweep", + role: { name: "workflow-coordinator", source: "app_default" }, + status: "running", + usage: { totalTokens: 5000 }, + workflow: { phases: [{ index: 0, title: "Research" }] }, + }), + ]); + + expect(markup).toContain("Refactor sweep"); + expect(markup).toContain("workflow-coordinator"); + // Its own model and usage only appear if it rendered as a card. + expect(markup).toContain("claude-opus-4-1"); + expect(markup).toContain("5k tokens"); + expect(markup).toContain("1 active"); + expect(markup).toContain("Research"); + }); + + it("shows the final result instead of stale progress for a settled agent", () => { + const markup = renderPanel([ + agent("settled-agent", { + status: "idle", + progress: "Still working on the old step", + result: "Final answer from the agent", + }), + ]); + + expect(markup).toContain("Final answer from the agent"); + expect(markup).not.toContain("Still working on the old step"); + }); + + it("renders workflow members under their phase", () => { + const markup = renderPanel([ + agent("workflow-1", { + kind: "workflow", + title: "Refactor sweep", + status: "running", + workflow: { + phases: [ + { index: 0, title: "Research" }, + { index: 1, title: "Implement" }, + ], + }, + }), + agent("researcher", { + kind: "workflow_agent", + title: "researcher", + status: "completed", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: 0, + attempt: 1, + }, + }), + agent("implementer", { + kind: "workflow_agent", + title: "implementer", + status: "running", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: 1, + attempt: 1, + }, + }), + ]); + + expect(markup).toContain("Research"); + expect(markup).toContain("Implement"); + expect(markup).toContain("researcher"); + expect(markup).toContain("implementer"); + // Members represent the coordinator's work, so only they are tallied. + expect(markup).toContain("1 active"); + expect(markup).toContain("1 settled"); + }); + + it("keeps a member visible when its coordinator is missing", () => { + const markup = renderPanel([ + agent("orphan", { + kind: "workflow_agent", + title: "orphaned-worker", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: 0, + attempt: 1, + }, + }), + ]); + + expect(markup).toContain("orphaned-worker"); + }); +}); diff --git a/apps/web/src/components/AgentsPanelV2.tsx b/apps/web/src/components/AgentsPanelV2.tsx new file mode 100644 index 00000000000..12c6a0e4ec8 --- /dev/null +++ b/apps/web/src/components/AgentsPanelV2.tsx @@ -0,0 +1,260 @@ +import { + deriveOrchestrationV2SubagentPanelState, + formatSubagentTokenCount, + isSettledOrchestrationV2Subagent, +} from "@t3tools/client-runtime/state/orchestration-v2-subagents"; +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { + ActivityIcon, + BotIcon, + ChevronDownIcon, + CircleDotIcon, + CoinsIcon, + GitBranchIcon, + WorkflowIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import * as DateTime from "effect/DateTime"; + +import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; +import { ScrollArea } from "./ui/scroll-area"; + +const statusTone = (status: OrchestrationV2Subagent["status"]) => + status === "failed" + ? "bg-destructive" + : status === "running" + ? "bg-emerald-500" + : status === "pending" || status === "waiting" + ? "bg-amber-500" + : status === "idle" + ? "bg-sky-500" + : "bg-muted-foreground/50"; + +const usageSummary = (agent: OrchestrationV2Subagent) => { + if (agent.usage === null) return null; + return [ + `${formatSubagentTokenCount(agent.usage.totalTokens)} tokens`, + agent.usage.toolUses === undefined ? null : `${agent.usage.toolUses} tools`, + ] + .filter((value): value is string => value !== null) + .join(" · "); +}; + +const activationLabel = (activation: OrchestrationV2SubagentActivation) => { + const usage = + activation.usage === null + ? null + : `${formatSubagentTokenCount(activation.usage.totalTokens)} tokens`; + return [`Run ${activation.ordinal}`, activation.status, usage] + .filter((value): value is string => value !== null) + .join(" · "); +}; + +const keyedActivities = (agent: OrchestrationV2Subagent) => { + const occurrences = new Map(); + return agent.recentActivity.map((activity) => { + const base = `${DateTime.formatIso(activity.at)}:${activity.summary}`; + const occurrence = (occurrences.get(base) ?? 0) + 1; + occurrences.set(base, occurrence); + return { activity, key: `${base}:${occurrence}` }; + }); +}; + +function AgentCard(props: { + agent: OrchestrationV2Subagent; + activations: ReadonlyArray; +}) { + const { agent } = props; + const detail = isSettledOrchestrationV2Subagent(agent) + ? agent.result?.trim() || agent.progress?.trim() || agent.prompt.trim() + : agent.progress?.trim() || agent.result?.trim() || agent.prompt.trim(); + const usage = usageSummary(agent); + const activities = keyedActivities(agent); + return ( +
+
+ +
+
+ +

+ {agent.title?.trim() || agent.role.name} +

+ + {agent.status} + +
+
+ + {agent.role.name} + + {agent.role.source === "app_default" ? ( + + default role + + ) : null} + {agent.model === null ? null : ( + + {agent.model} + + )} +
+ {detail.length > 0 ? ( +

+ {detail} +

+ ) : null} +
+ {usage === null ? null : ( + + + {usage} + + )} + + + {agent.activationCount} {agent.activationCount === 1 ? "run" : "runs"} + +
+
+
+ {agent.recentActivity.length > 0 || props.activations.length > 0 ? ( +
+ + + Activity and runs + +
+ {activities.map(({ activity, key }) => ( +
+ + {activity.summary} +
+ ))} + {props.activations.map((activation) => ( +
+ + {activationLabel(activation)} +
+ ))} +
+
+ ) : null} +
+ ); +} + +export function AgentsPanelV2(props: { projection: OrchestrationV2ThreadProjection | null }) { + const subagents = props.projection?.subagents ?? null; + const activations = props.projection?.subagentActivations ?? null; + const panel = useMemo( + () => + subagents === null || activations === null + ? null + : deriveOrchestrationV2SubagentPanelState({ + subagents, + activations, + }), + [activations, subagents], + ); + + if (panel === null || panel.groups.length === 0) { + return ( +
+
+ +

No agents yet

+

+ Provider-native and delegated agents will appear here with their usage, roles, runs, and + recent activity. +

+
+
+ ); + } + + return ( +
+
+
+ +

Agents

+ + {panel.totalTokens === null + ? "Usage unavailable" + : `${formatSubagentTokenCount(panel.totalTokens)} tokens`} + +
+
+ {panel.activeCount} active + {panel.waitingCount} waiting + {panel.settledCount} settled +
+
+ +
+ {panel.groups.map((group, groupIndex) => ( +
+ {group.workflow === null ? null : ( + <> +
+ +

+ {group.workflow.title?.trim() || "Workflow"} +

+
+ {/* The coordinator is an agent too: it has its own model, + usage and activations, and is the only row present at all + before its members are spawned. */} + + + )} + {group.phases.map((phase) => ( +
+
+ {phase.index + 1} + {phase.title} + {phase.status} +
+ {phase.agents.map((agent) => ( + + ))} +
+ ))} + {group.agents.map((agent) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ffd707879bf..3b9f5f6438d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -414,6 +414,9 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const AgentsPanelV2 = lazy(() => + import("./AgentsPanelV2").then((module) => ({ default: module.AgentsPanelV2 })), +); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -3152,6 +3155,10 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, planSidebarOpen, ]); + const addAgentsSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "agents"); + }, [activeThreadRef]); const openChangesFromThreadPanel = useCallback(() => { addDiffSurface(); }, [addDiffSurface]); @@ -5892,6 +5899,10 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "agents" ? ( + + + ) : activeRightPanelSurface?.kind === "plan" ? ( ); - const threadPanelHeaderControl = ( -
- -
- ); const panelLayoutControls = (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( - - ) : null} {panelToggleControls}
); + const rightPanelLayoutControls = ( +
+ + +
+ ); return (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
- {inlineRightPanelOwnsTitleBar - ? threadPanelHeaderControl - : !rightPanelOpen - ? panelLayoutControls - : null} + {!inlineRightPanelOwnsTitleBar && !rightPanelOpen ? panelLayoutControls : null} void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; + onAddAgents: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -90,6 +100,7 @@ function SurfaceMenuItem(props: { function RightPanelEmptyState(props: { onAddBrowser: () => void; + onAddAgents: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -98,6 +109,14 @@ function RightPanelEmptyState(props: { filesAvailable: boolean; }) { const actions = [ + { + label: "Agents", + description: "Inspect agent roles, runs, usage, and activity.", + icon: BotIcon, + available: true, + disabledReason: null, + onClick: props.onAddAgents, + }, { label: "Browser", description: "Open a local app or URL.", @@ -194,6 +213,8 @@ function surfaceTitle( terminalLabelsById: ReadonlyMap, ): string { switch (surface.kind) { + case "agents": + return "Agents"; case "diff": return "Diff"; case "files": @@ -246,6 +267,8 @@ function SurfaceIcon({ theme: "light" | "dark"; }) { switch (surface.kind) { + case "agents": + return ; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; @@ -446,6 +469,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { + + + Agents + , ): RightPanelSurface => { switch (kind) { + case "agents": + return { id: "agents", kind }; case "diff": return { id: "diff", kind }; case "files": diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d6dd2120307..d4a82e25962 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -79,6 +79,10 @@ "types": "./src/state/orchestrationV2Projection.ts", "default": "./src/state/orchestrationV2Projection.ts" }, + "./state/orchestration-v2-subagents": { + "types": "./src/state/orchestrationV2Subagents.ts", + "default": "./src/state/orchestrationV2Subagents.ts" + }, "./state/presentation": { "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts new file mode 100644 index 00000000000..ae692f53941 --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts @@ -0,0 +1,271 @@ +import { + NodeId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + SubagentActivationId, + ThreadId, + type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + deriveOrchestrationV2SubagentPanelState, + formatSubagentTokenCount, +} from "./orchestrationV2Subagents.ts"; + +const now = DateTime.makeUnsafe("2026-07-26T00:00:00.000Z"); +const threadId = ThreadId.make("thread-agents"); +const runId = RunId.make("run-agents"); +const workflowId = NodeId.make("workflow-1"); + +const agent = ( + id: string, + input: Partial = {}, +): OrchestrationV2Subagent => ({ + id: NodeId.make(id), + threadId, + runId, + parentNodeId: NodeId.make("root-node"), + origin: "provider_native", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Work", + title: id, + model: "claude-opus-4-1", + kind: "subagent", + role: { name: "general-purpose", source: "provider" }, + status: "running", + result: null, + usage: null, + currentActivationId: null, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], + startedAt: now, + completedAt: null, + updatedAt: now, + ...input, +}); + +const activation = ( + id: string, + subagentId: OrchestrationV2Subagent["id"], + ordinal: number, +): OrchestrationV2SubagentActivation => ({ + id: SubagentActivationId.make(id), + threadId, + subagentId, + runId, + providerTurnId: null, + ordinal, + status: "completed", + usage: { totalTokens: ordinal * 100 }, + startedAt: now, + completedAt: now, + updatedAt: now, +}); + +describe("deriveOrchestrationV2SubagentPanelState", () => { + it("groups workflow agents by phase without double-counting workflow usage", () => { + const workflow = agent("workflow-1", { + kind: "workflow", + role: { name: "workflow-coordinator", source: "app_default" }, + status: "completed", + usage: { totalTokens: 900 }, + workflow: { + phases: [ + { index: 0, title: "Research" }, + { index: 1, title: "Implement" }, + ], + }, + }); + const researcher = agent("researcher", { + kind: "workflow_agent", + usage: { totalTokens: 400 }, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 1, + phaseIndex: 0, + attempt: 1, + }, + }); + const auditor = agent("auditor", { + kind: "workflow_agent", + status: "completed", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: 0, + attempt: 1, + }, + }); + const implementer = agent("implementer", { + kind: "workflow_agent", + status: "idle", + usage: { totalTokens: 500 }, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 1, + phaseIndex: 1, + attempt: 1, + }, + }); + const activations = [ + activation("activation-researcher-1", researcher.id, 1), + activation("activation-researcher-2", researcher.id, 2), + ]; + + const result = deriveOrchestrationV2SubagentPanelState({ + subagents: [workflow, researcher, auditor, implementer], + activations, + }); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.phases.map((phase) => phase.title)).toEqual(["Research", "Implement"]); + expect(result.groups[0]?.phases[0]?.agents).toEqual([auditor, researcher]); + expect(result.activeCount).toBe(1); + expect(result.settledCount).toBe(2); + expect(result.totalTokens).toBe(900); + expect(result.activationsBySubagentId.get(researcher.id)).toEqual(activations); + }); + + it("keeps future all-pending workflow phases pending", () => { + const workflow = agent("workflow-1", { + kind: "workflow", + workflow: { + phases: [ + { index: 0, title: "Empty" }, + { index: 1, title: "Queued" }, + { index: 2, title: "Active" }, + { index: 3, title: "Settled" }, + ], + }, + }); + const member = (id: string, phaseIndex: number, status: OrchestrationV2Subagent["status"]) => + agent(id, { + kind: "workflow_agent", + status, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: phaseIndex, + phaseIndex, + attempt: 1, + }, + }); + + const result = deriveOrchestrationV2SubagentPanelState({ + subagents: [ + workflow, + member("queued-a", 1, "pending"), + member("queued-b", 1, "pending"), + member("active", 2, "waiting"), + member("settled", 3, "completed"), + ], + activations: [], + }); + + expect(result.groups[0]?.phases.map((phase) => phase.status)).toEqual([ + "pending", + "pending", + "running", + "done", + ]); + }); + + it("distinguishes unavailable usage from a reported zero", () => { + const unavailable = deriveOrchestrationV2SubagentPanelState({ + subagents: [agent("unreported")], + activations: [], + }); + const reportedZero = deriveOrchestrationV2SubagentPanelState({ + subagents: [agent("reported-zero", { usage: { totalTokens: 0 } })], + activations: [], + }); + + expect(unavailable.totalTokens).toBeNull(); + expect(reportedZero.totalTokens).toBe(0); + }); + + it("keeps workflow usage the members did not account for", () => { + // A provider can report the workflow's own usage while omitting per-agent + // tokens. Excluding the coordinator outright then erased the entire + // workflow from the total and the header read "Usage unavailable". + const workflow = agent("workflow-1", { + kind: "workflow", + status: "completed", + usage: { totalTokens: 5000 }, + workflow: { phases: [] }, + }); + const silentMember = agent("silent-member", { + kind: "workflow_agent", + status: "completed", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: null, + attempt: 1, + }, + }); + const partialMember = agent("partial-member", { + kind: "workflow_agent", + status: "completed", + usage: { totalTokens: 200 }, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 1, + phaseIndex: null, + attempt: 1, + }, + }); + + expect( + deriveOrchestrationV2SubagentPanelState({ + subagents: [workflow, silentMember], + activations: [], + }).totalTokens, + ).toBe(5000); + expect( + deriveOrchestrationV2SubagentPanelState({ + subagents: [workflow, silentMember, partialMember], + activations: [], + }).totalTokens, + ).toBe(5000); + }); + + it("counts a workflow that has no members yet", () => { + // Before its members are spawned the coordinator is the only row on the + // thread. Excluding it reported "0 active" while a workflow was visibly + // running, with nothing listed to explain the tokens in the header. + const running = deriveOrchestrationV2SubagentPanelState({ + subagents: [ + agent("workflow-1", { + kind: "workflow", + status: "running", + usage: { totalTokens: 5000 }, + workflow: { phases: [{ index: 0, title: "Research" }] }, + }), + ], + activations: [], + }); + + expect(running.activeCount).toBe(1); + expect(running.settledCount).toBe(0); + expect(running.totalTokens).toBe(5000); + expect(running.groups[0]?.workflow?.id).toBe(workflowId); + }); + + it("formats compact token counts", () => { + expect(formatSubagentTokenCount(999)).toBe("999"); + expect(formatSubagentTokenCount(1_200)).toBe("1.2k"); + expect(formatSubagentTokenCount(999_999)).toBe("1M"); + expect(formatSubagentTokenCount(2_000_000)).toBe("2M"); + }); +}); diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.ts new file mode 100644 index 00000000000..c8006fcc04b --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.ts @@ -0,0 +1,149 @@ +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, +} from "@t3tools/contracts"; + +export interface OrchestrationV2SubagentPhaseGroup { + readonly index: number; + readonly title: string; + readonly status: "pending" | "running" | "done"; + readonly agents: ReadonlyArray; +} + +export interface OrchestrationV2SubagentGroup { + readonly workflow: OrchestrationV2Subagent | null; + readonly phases: ReadonlyArray; + readonly agents: ReadonlyArray; +} + +export interface OrchestrationV2SubagentPanelState { + readonly groups: ReadonlyArray; + readonly activationsBySubagentId: ReadonlyMap< + string, + ReadonlyArray + >; + readonly activeCount: number; + readonly waitingCount: number; + readonly settledCount: number; + readonly totalTokens: number | null; +} + +export const isSettledOrchestrationV2Subagent = (agent: OrchestrationV2Subagent) => + agent.status === "idle" || + agent.status === "completed" || + agent.status === "failed" || + agent.status === "cancelled" || + agent.status === "interrupted"; + +const phaseStatus = (agents: ReadonlyArray) => + agents.length === 0 + ? ("pending" as const) + : agents.every(isSettledOrchestrationV2Subagent) + ? ("done" as const) + : agents.every((agent) => agent.status === "pending") + ? ("pending" as const) + : ("running" as const); + +export function deriveOrchestrationV2SubagentPanelState(input: { + readonly subagents: ReadonlyArray; + readonly activations: ReadonlyArray; +}): OrchestrationV2SubagentPanelState { + const activationsBySubagentId = new Map(); + for (const activation of input.activations) { + const current = activationsBySubagentId.get(activation.subagentId) ?? []; + current.push(activation); + activationsBySubagentId.set(activation.subagentId, current); + } + for (const activations of activationsBySubagentId.values()) { + activations.sort((left, right) => left.ordinal - right.ordinal); + } + + const workflows = input.subagents.filter((agent) => agent.kind === "workflow"); + const membersByWorkflow = new Map(); + const direct: OrchestrationV2Subagent[] = []; + for (const agent of input.subagents) { + if (agent.kind === "workflow") continue; + const workflowId = agent.workflowMembership?.workflowSubagentId; + if (workflowId === undefined) { + direct.push(agent); + continue; + } + const members = membersByWorkflow.get(workflowId) ?? []; + members.push(agent); + membersByWorkflow.set(workflowId, members); + } + + const groups: OrchestrationV2SubagentGroup[] = workflows.map((workflow) => { + const members = membersByWorkflow.get(workflow.id) ?? []; + membersByWorkflow.delete(workflow.id); + const phases = (workflow.workflow?.phases ?? []).map((phase) => { + const agents = members + .filter((agent) => agent.workflowMembership?.phaseIndex === phase.index) + .toSorted( + (left, right) => + (left.workflowMembership?.agentIndex ?? 0) - + (right.workflowMembership?.agentIndex ?? 0), + ); + return { ...phase, status: phaseStatus(agents), agents }; + }); + const phasedIds = new Set(phases.flatMap((phase) => phase.agents.map((agent) => agent.id))); + return { + workflow, + phases, + agents: members.filter((agent) => !phasedIds.has(agent.id)), + }; + }); + for (const orphaned of membersByWorkflow.values()) direct.push(...orphaned); + if (direct.length > 0) groups.push({ workflow: null, phases: [], agents: direct }); + + const workers = input.subagents.filter((agent) => agent.kind !== "workflow"); + // A coordinator's usage already includes its members', so counting both + // double-reports the thread. Only drop it when the members actually account + // for it: a provider that reports workflow usage but omits per-agent tokens + // would otherwise erase the whole workflow from the total. + const memberTotalsByWorkflow = new Map(); + for (const agent of workers) { + const workflowId = agent.workflowMembership?.workflowSubagentId; + if (workflowId === undefined) continue; + memberTotalsByWorkflow.set( + workflowId, + (memberTotalsByWorkflow.get(workflowId) ?? 0) + (agent.usage?.totalTokens ?? 0), + ); + } + const reportedUsage = input.subagents.flatMap((agent) => { + if (agent.usage === null) return []; + if (agent.kind !== "workflow") return [agent.usage.totalTokens]; + const memberTotal = memberTotalsByWorkflow.get(agent.id); + if (memberTotal === undefined) return [agent.usage.totalTokens]; + // Keep whatever the members did not account for. Dropping the coordinator + // outright erases the whole workflow whenever a provider reports its usage + // but omits per-agent tokens. + return memberTotal >= agent.usage.totalTokens ? [] : [agent.usage.totalTokens - memberTotal]; + }); + // Count the workers, plus any coordinator that has no members yet. A + // coordinator with members is represented by them and would double-count; + // one without is the only row on the thread, and omitting it reported + // "0 active" while a workflow was visibly running. + const counted = input.subagents.filter( + (agent) => agent.kind !== "workflow" || !memberTotalsByWorkflow.has(agent.id), + ); + return { + groups, + activationsBySubagentId, + activeCount: counted.filter((agent) => agent.status === "pending" || agent.status === "running") + .length, + waitingCount: counted.filter((agent) => agent.status === "waiting").length, + settledCount: counted.filter(isSettledOrchestrationV2Subagent).length, + totalTokens: + reportedUsage.length === 0 + ? null + : reportedUsage.reduce((total, tokens) => total + tokens, 0), + }; +} + +export const formatSubagentTokenCount = (totalTokens: number) => + totalTokens >= 999_500 + ? `${(totalTokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M` + : totalTokens >= 1_000 + ? `${(totalTokens / 1_000).toFixed(1).replace(/\.0$/, "")}k` + : String(totalTokens);