From 345f0c787b3067d7df6aa956c7b875cb79f752a3 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:17:48 +0530 Subject: [PATCH 1/2] feat(web): add the Agents panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the subagent data the previous PRs record. A new right-panel tab lists every subagent on the thread with its role, model, status, token usage, recent activity, and the runs it has been activated for. Grouping lives in client-runtime rather than the component so it stays testable: workflow coordinators own their phases, phase status derives from its members, and members orphaned by a missing coordinator fall back to the flat list rather than disappearing. A coordinator is an agent in its own right, so it renders as a card rather than a bare heading — it has a model, usage and activations, and before its members are spawned it is the only row on the thread. It is counted in the tallies for the same reason, but only while it has no members, since afterwards they represent the same work and counting both double-reports it. Without that, a thread running a workflow showed "0 active" with nothing listed and unexplained tokens in the header. Usage follows the same rule: the total keeps whatever the members did not account for. Dropping the coordinator outright erased the whole workflow whenever a provider reported workflow usage but omitted per-agent tokens, and the header read "Usage unavailable" for a workflow that had spent thousands. Read-only: this only renders what the projection already contains. --- .../web/src/components/AgentsPanelV2.test.tsx | 143 ++++++++++ apps/web/src/components/AgentsPanelV2.tsx | 257 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 45 +-- apps/web/src/components/RightPanelTabs.tsx | 33 ++- apps/web/src/rightPanelStore.ts | 13 +- packages/client-runtime/package.json | 4 + .../state/orchestrationV2Subagents.test.ts | 228 ++++++++++++++++ .../src/state/orchestrationV2Subagents.ts | 147 ++++++++++ 8 files changed, 845 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/AgentsPanelV2.test.tsx create mode 100644 apps/web/src/components/AgentsPanelV2.tsx create mode 100644 packages/client-runtime/src/state/orchestrationV2Subagents.test.ts create mode 100644 packages/client-runtime/src/state/orchestrationV2Subagents.ts diff --git a/apps/web/src/components/AgentsPanelV2.test.tsx b/apps/web/src/components/AgentsPanelV2.test.tsx new file mode 100644 index 00000000000..64f6888b59e --- /dev/null +++ b/apps/web/src/components/AgentsPanelV2.test.tsx @@ -0,0 +1,143 @@ +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("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..a14b31e360e --- /dev/null +++ b/apps/web/src/components/AgentsPanelV2.tsx @@ -0,0 +1,257 @@ +import { + deriveOrchestrationV2SubagentPanelState, + formatSubagentTokenCount, +} 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 = 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 48e8b6bc9c5..7e999466f29 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -411,6 +411,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 = [ @@ -3120,6 +3123,10 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, planSidebarOpen, ]); + const addAgentsSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "agents"); + }, [activeThreadRef]); const openChangesFromThreadPanel = useCallback(() => { addDiffSurface(); }, [addDiffSurface]); @@ -5807,6 +5814,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; @@ -360,8 +383,7 @@ 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..6e5404963eb --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts @@ -0,0 +1,228 @@ +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("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..cf18b805118 --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.ts @@ -0,0 +1,147 @@ +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) + : ("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); From 70d5111670c73f7f1d143d2da4fb1c2ecdbadd56 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:46:53 +0530 Subject: [PATCH 2/2] fix(web): correct agent phase and result state Co-Authored-By: Claude --- .../web/src/components/AgentsPanelV2.test.tsx | 13 ++++++ apps/web/src/components/AgentsPanelV2.tsx | 5 ++- .../state/orchestrationV2Subagents.test.ts | 43 +++++++++++++++++++ .../src/state/orchestrationV2Subagents.ts | 4 +- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AgentsPanelV2.test.tsx b/apps/web/src/components/AgentsPanelV2.test.tsx index 64f6888b59e..ffccf945cde 100644 --- a/apps/web/src/components/AgentsPanelV2.test.tsx +++ b/apps/web/src/components/AgentsPanelV2.test.tsx @@ -78,6 +78,19 @@ describe("AgentsPanelV2", () => { 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", { diff --git a/apps/web/src/components/AgentsPanelV2.tsx b/apps/web/src/components/AgentsPanelV2.tsx index a14b31e360e..12c6a0e4ec8 100644 --- a/apps/web/src/components/AgentsPanelV2.tsx +++ b/apps/web/src/components/AgentsPanelV2.tsx @@ -1,6 +1,7 @@ import { deriveOrchestrationV2SubagentPanelState, formatSubagentTokenCount, + isSettledOrchestrationV2Subagent, } from "@t3tools/client-runtime/state/orchestration-v2-subagents"; import type { OrchestrationV2Subagent, @@ -69,7 +70,9 @@ function AgentCard(props: { activations: ReadonlyArray; }) { const { agent } = props; - const detail = agent.progress?.trim() || agent.result?.trim() || agent.prompt.trim(); + 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 ( diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts index 6e5404963eb..ae692f53941 100644 --- a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts @@ -137,6 +137,49 @@ describe("deriveOrchestrationV2SubagentPanelState", () => { 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")], diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.ts index cf18b805118..c8006fcc04b 100644 --- a/packages/client-runtime/src/state/orchestrationV2Subagents.ts +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.ts @@ -40,7 +40,9 @@ const phaseStatus = (agents: ReadonlyArray) => ? ("pending" as const) : agents.every(isSettledOrchestrationV2Subagent) ? ("done" as const) - : ("running" as const); + : agents.every((agent) => agent.status === "pending") + ? ("pending" as const) + : ("running" as const); export function deriveOrchestrationV2SubagentPanelState(input: { readonly subagents: ReadonlyArray;