From 1e72407d7b93a89bbb2af768e5c7ce471cd5950d Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 13:50:27 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(coc):=20agents=20canvas=20=E2=80=94?= =?UTF-8?q?=20data=20+=20layout=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for a spatial "Agents" view of a chat's recursive sub-agent runs (ported from the coc-chat design). Pure, UI-free modules so the layout math and data adapter are unit-tested in isolation: - types.ts — AgentRunNode tree (orchestrator root → sub-agent runs). - layout.ts — tidy left→right tree layout (buildLayout), curved edge paths (edgePath), and per-depth spine colors (spineVars), ported verbatim from the prototype so spatial output matches. - buildAgentRunTree.ts — adapts real conversation turns into the tree: the orchestrator is the root, each `Task` tool call becomes a sub-agent child (name/role from args, status/timing from the call), deduped across toolCalls + timeline and ordered by start time. Tests cover layout geometry/edges/spine cycling and the adapter's field mapping, prompt-name fallback, dedup, root-status derivation, and ordering. No UI is wired yet. Co-Authored-By: Claude Opus 4.8 --- .../chat/agent-canvas/buildAgentRunTree.ts | 180 ++++++++++++++++++ .../features/chat/agent-canvas/layout.ts | 110 +++++++++++ .../react/features/chat/agent-canvas/types.ts | 29 +++ .../spa/client/agent-canvas-data.test.ts | 135 +++++++++++++ .../spa/client/agent-canvas-layout.test.ts | 94 +++++++++ 5 files changed, 548 insertions(+) create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/layout.ts create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts create mode 100644 packages/coc/test/server/spa/client/agent-canvas-data.test.ts create mode 100644 packages/coc/test/server/spa/client/agent-canvas-layout.test.ts diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts new file mode 100644 index 000000000..bbbf92fb8 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -0,0 +1,180 @@ +// Adapts the dashboard's real conversation data into the recursive +// AgentRunNode tree the canvas renders. The orchestrator (this conversation) +// is the root; each `Task` tool call it issued becomes a sub-agent child. +// +// Sub-agent `Task` calls are the faithful, already-loaded source for the agent +// tree — no extra fetch. The tree shape supports arbitrary depth, so deeper +// recursion (a sub-agent's own children) can be layered on later without +// touching the canvas. + +import type { ClientConversationTurn, ClientToolCall } from '../../../types/dashboard'; +import { normalizeToolName } from '../conversation/tool-calls/toolNormalization'; +import type { AgentRunNode, AgentRunStatus } from './types'; + +export interface AgentRunRootMeta { + /** Root node id (defaults to 'root'). */ + id?: string; + /** Root label (defaults to a generic orchestrator name). */ + title?: string; + /** Overall conversation/process status — drives the root node's status. */ + status?: string; +} + +function parseTime(v: unknown): number | undefined { + if (typeof v === 'number') { + return Number.isFinite(v) ? v : undefined; + } + if (typeof v === 'string') { + const ms = Date.parse(v); + return Number.isFinite(ms) ? ms : undefined; + } + return undefined; +} + +/** Tool-call status → run status. */ +function mapToolStatus(status: string | undefined): AgentRunStatus { + switch (status) { + case 'completed': return 'done'; + case 'failed': return 'failed'; + case 'pending': return 'queued'; + case 'running': + default: return 'running'; + } +} + +/** AIProcess status → run status (for the orchestrator root). */ +function mapRootStatus(status: string | undefined): AgentRunStatus { + switch (status) { + case 'completed': return 'done'; + case 'failed': + case 'cancelled': return 'failed'; + case 'queued': return 'queued'; + case 'running': + case 'cancelling': return 'running'; + default: return 'done'; + } +} + +function firstLine(text: string): string { + const line = text.split('\n').map((l) => l.trim()).find(Boolean) || ''; + return line.length > 120 ? `${line.slice(0, 117).trimEnd()}…` : line; +} + +// How "advanced" a status is — used to keep the best snapshot when the same +// tool-call id appears in both `turn.toolCalls` and the timeline. +const STATUS_RANK: Record = { pending: 0, running: 1, completed: 2, failed: 2 }; + +/** Collect every tool call across turns, deduped by id, preferring terminal state. */ +function collectToolCalls(turns: ClientConversationTurn[]): ClientToolCall[] { + const byId = new Map(); + const consider = (tc: ClientToolCall | undefined): void => { + if (!tc || !tc.id) { + return; + } + const existing = byId.get(tc.id); + if (!existing) { + byId.set(tc.id, tc); + return; + } + const keepNew = (STATUS_RANK[tc.status] ?? 0) >= (STATUS_RANK[existing.status] ?? 0); + const better = keepNew ? tc : existing; + const worse = keepNew ? existing : tc; + byId.set(tc.id, { + ...worse, + ...better, + startTime: better.startTime ?? worse.startTime, + endTime: better.endTime ?? worse.endTime, + result: better.result ?? worse.result, + error: better.error ?? worse.error, + }); + }; + for (const turn of turns) { + if (Array.isArray(turn.toolCalls)) { + for (const tc of turn.toolCalls) { + consider(tc); + } + } + for (const item of turn.timeline || []) { + consider(item.toolCall); + } + } + return Array.from(byId.values()); +} + +/** Stable sort by start time; runs with no known start time keep their order, last. */ +function byStartedAt(a: AgentRunNode, b: AgentRunNode): number { + if (a.startedAt === undefined && b.startedAt === undefined) { + return 0; + } + if (a.startedAt === undefined) { + return 1; + } + if (b.startedAt === undefined) { + return -1; + } + return a.startedAt - b.startedAt; +} + +function asRecord(v: unknown): Record { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; +} + +/** Build a sub-agent node from a normalized `Task` tool call. */ +function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { + const args = asRecord(tc.args); + const agentType = typeof args.agent_type === 'string' ? args.agent_type + : typeof args.subagent_type === 'string' ? args.subagent_type + : ''; + const description = typeof args.description === 'string' ? args.description.trim() : ''; + const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''; + const name = description + || (prompt ? (prompt.length > 48 ? `${prompt.slice(0, 45).trimEnd()}…` : prompt) : '') + || 'sub-agent'; + const summary = typeof tc.result === 'string' && tc.result.trim() + ? firstLine(tc.result) + : undefined; + return { + id: tc.id, + name, + role: agentType || 'agent', + status: mapToolStatus(tc.status), + startedAt: parseTime(tc.startTime), + completedAt: parseTime(tc.endTime), + summary, + children: [], + }; +} + +/** + * Build the agent-run tree from a conversation's turns. The root represents the + * orchestrator; its children are the `Task` sub-agents it spawned, ordered by + * start time. Returns a root with no children when the conversation issued none. + */ +export function buildAgentRunTreeFromTurns( + turns: ClientConversationTurn[] | undefined, + root?: AgentRunRootMeta, +): AgentRunNode { + const taskCalls = collectToolCalls(turns || []) + .filter((tc) => normalizeToolName(tc.toolName) === 'task'); + + const children = taskCalls.map(nodeFromTaskCall); + children.sort(byStartedAt); + + const rootStatus: AgentRunStatus = root?.status + ? mapRootStatus(root.status) + : (children.some((c) => c.status === 'running' || c.status === 'queued') ? 'running' : 'done'); + + return { + id: root?.id || 'root', + name: (root?.title && root.title.trim()) || 'CoC · orchestrator', + role: 'orchestrator', + status: rootStatus, + isRoot: true, + children, + }; +} + +/** Count every run in the tree, including the root. */ +export function countRuns(node: AgentRunNode): number { + return 1 + (node.children || []).reduce((sum, c) => sum + countRuns(c), 0); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/layout.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/layout.ts new file mode 100644 index 000000000..47e449249 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/layout.ts @@ -0,0 +1,110 @@ +// Pure layout math for the Agents canvas — a tidy left→right tree over the +// recursive agent-run tree. Ported verbatim from the design prototype +// (coc-chat/agent-canvas.jsx) so the spatial output matches pixel-for-pixel. + +import type { AgentRunNode } from './types'; + +// Geometry (px). COLW = horizontal stride per depth, ROWH = vertical stride per +// leaf, NODE* = node box size, PAD = world padding around the tree. +export const COLW = 250; +export const ROWH = 78; +export const NODEW = 202; +export const NODEH = 56; +export const PAD = 60; + +export interface PositionedNode { + x: number; + y: number; + depth: number; + node: AgentRunNode; +} + +export interface CanvasEdge { + from: string; + to: string; + depth: number; +} + +export interface CanvasLayout { + /** Positioned node keyed by node id. */ + pos: Record; + /** Node ids in render order (root-first, depth-first). */ + order: string[]; + /** Parent→child connectors. */ + edges: CanvasEdge[]; + /** Intrinsic world size for fit-to-view. */ + worldW: number; + worldH: number; +} + +/** + * Tidy left→right layout over the run tree (including the synthetic root). + * Each parent is vertically centered over its children; leaves stack one row + * apart. x is driven purely by depth, so columns line up across branches. + */ +export function buildLayout(root: AgentRunNode): CanvasLayout { + const pos: Record = {}; + const order: string[] = []; + const edges: CanvasEdge[] = []; + let cursorY = 0; + + function rec(node: AgentRunNode, depth: number, parentId: string | null): number { + const x = depth * COLW; + const kids = node.children || []; + let y: number; + if (kids.length) { + const ys = kids.map((k) => rec(k, depth + 1, node.id)); + y = (ys[0] + ys[ys.length - 1]) / 2; + } else { + y = cursorY; + cursorY += ROWH; + } + pos[node.id] = { x, y, depth, node }; + order.push(node.id); + if (parentId !== null) { + edges.push({ from: parentId, to: node.id, depth }); + } + return y; + } + rec(root, 0, null); + + let maxX = 0; + let maxY = 0; + for (const p of Object.values(pos)) { + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return { + pos, + order, + edges, + worldW: maxX + NODEW + PAD * 2, + worldH: maxY + NODEH + PAD * 2, + }; +} + +/** Curved connector from a parent's right-center to a child's left-center. */ +export function edgePath(a: PositionedNode, b: PositionedNode): string { + const x1 = a.x + NODEW + PAD; + const y1 = a.y + NODEH / 2 + PAD; + const x2 = b.x + PAD; + const y2 = b.y + NODEH / 2 + PAD; + const dx = Math.max(40, (x2 - x1) * 0.5); + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +// Per-depth spine hue, cycling a ring — mirrors the thread's depth-spine colors +// so a node's depth reads the same in both views. +const DEPTH_HUES = [252, 292, 162, 28, 200]; + +// CSS custom-property bag spread into a node's inline style. Built via bracket +// assignment because `--`-prefixed keys aren't valid camelCase identifiers. +export type SpineVars = Record; + +export function spineVars(depth: number): SpineVars { + const h = DEPTH_HUES[depth % DEPTH_HUES.length]; + const vars: SpineVars = {}; + vars['--spine'] = `oklch(0.55 0.16 ${h})`; + vars['--spine-soft'] = `oklch(0.95 0.04 ${h})`; + return vars; +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts new file mode 100644 index 000000000..909e6f9de --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts @@ -0,0 +1,29 @@ +// Recursive agent-run tree consumed by the "Agents" canvas view — a spatial +// map of the orchestrator and its (recursively spawned) sub-agent runs. +// +// Ported from the design prototype (coc-chat/agent-canvas.jsx), adapted to the +// dashboard's real conversation data: every node is either the synthetic +// orchestrator root or a sub-agent run derived from a `Task` tool call. + +/** Run lifecycle state, using the prototype's vocabulary so the CSS port maps 1:1. */ +export type AgentRunStatus = 'queued' | 'running' | 'done' | 'failed'; + +export interface AgentRunNode { + /** Stable id — the tool-call id for sub-agents, 'root' for the orchestrator. */ + id: string; + /** Display name: the sub-agent's description (or a truncated prompt). */ + name: string; + /** Role label: the sub-agent type (e.g. 'Explore'), or 'orchestrator' for the root. */ + role: string; + status: AgentRunStatus; + /** True only for the synthetic orchestrator root. */ + isRoot?: boolean; + /** Epoch ms when the run started, if known. */ + startedAt?: number; + /** Epoch ms when the run finished, if known. */ + completedAt?: number; + /** One-line summary / conclusion, if available. */ + summary?: string; + /** Recursively spawned child runs. */ + children: AgentRunNode[]; +} diff --git a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts new file mode 100644 index 000000000..d28f3e441 --- /dev/null +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { + buildAgentRunTreeFromTurns, + countRuns, +} from '../../../../src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree'; +import type { AgentRunNode } from '../../../../src/server/spa/client/react/features/chat/agent-canvas/types'; +import type { ClientConversationTurn, ClientToolCall } from '../../../../src/server/spa/client/react/types/dashboard'; + +function tc(partial: Partial & { id: string }): ClientToolCall { + return { toolName: 'Task', args: {}, status: 'completed', ...partial }; +} + +function assistantTurn( + toolCalls: ClientToolCall[], + timeline: ClientConversationTurn['timeline'] = [], +): ClientConversationTurn { + return { role: 'assistant', content: '', timeline, toolCalls }; +} + +describe('buildAgentRunTreeFromTurns', () => { + it('returns a lone orchestrator root when there are no sub-agents', () => { + const root = buildAgentRunTreeFromTurns([], { title: 'Dark mode work', status: 'completed' }); + expect(root).toMatchObject({ + id: 'root', + isRoot: true, + role: 'orchestrator', + name: 'Dark mode work', + status: 'done', + }); + expect(root.children).toEqual([]); + expect(countRuns(root)).toBe(1); + }); + + it('maps Task tool calls into sub-agent children with name/role/status/timing', () => { + const turns = [assistantTurn([ + tc({ + id: 't1', + args: { agent_type: 'Explore', description: 'map data model' }, + status: 'running', + startTime: '2026-06-13T10:00:00.000Z', + endTime: undefined, + }), + ])]; + const root = buildAgentRunTreeFromTurns(turns, { status: 'running' }); + expect(root.status).toBe('running'); + expect(root.children).toHaveLength(1); + expect(root.children[0]).toMatchObject({ + id: 't1', + name: 'map data model', + role: 'Explore', + status: 'running', + startedAt: Date.parse('2026-06-13T10:00:00.000Z'), + }); + expect(root.children[0].completedAt).toBeUndefined(); + }); + + it('falls back to a truncated prompt when no description is present', () => { + const longPrompt = 'investigate the entire conversation timeline rendering pipeline end to end'; + const turns = [assistantTurn([ + tc({ id: 't1', args: { agent_type: 'general-purpose', prompt: longPrompt } }), + ])]; + const root = buildAgentRunTreeFromTurns(turns); + expect(root.children[0].name).toBe('investigate the entire conversation timeline…'); + expect(root.children[0].role).toBe('general-purpose'); + }); + + it('also reads subagent_type as the role', () => { + const turns = [assistantTurn([ + tc({ id: 't1', args: { subagent_type: 'rust-code-reviewer', description: 'review' } }), + ])]; + expect(buildAgentRunTreeFromTurns(turns).children[0].role).toBe('rust-code-reviewer'); + }); + + it('ignores non-Task tool calls', () => { + const turns = [assistantTurn([ + tc({ id: 'r1', toolName: 'Read', args: { file_path: '/a.ts' } }), + tc({ id: 'b1', toolName: 'Bash', args: { command: 'ls' } }), + ])]; + expect(buildAgentRunTreeFromTurns(turns).children).toEqual([]); + }); + + it('dedupes a tool call seen in both toolCalls and the timeline, preferring terminal state', () => { + const turns = [assistantTurn( + [tc({ id: 't1', args: { description: 'x' }, status: 'running' })], + [{ + type: 'tool-complete', + timestamp: '2026-06-13T10:00:00.000Z', + toolCall: tc({ id: 't1', args: { description: 'x' }, status: 'completed', result: 'all green\nmore' }), + }], + )]; + const root = buildAgentRunTreeFromTurns(turns); + expect(root.children).toHaveLength(1); + expect(root.children[0].status).toBe('done'); + expect(root.children[0].summary).toBe('all green'); + }); + + it('derives root status from children when no explicit status is given', () => { + const running = [assistantTurn([tc({ id: 't1', args: { description: 'x' }, status: 'running' })])]; + expect(buildAgentRunTreeFromTurns(running).status).toBe('running'); + + const allDone = [assistantTurn([tc({ id: 't1', args: { description: 'x' }, status: 'completed' })])]; + expect(buildAgentRunTreeFromTurns(allDone).status).toBe('done'); + }); + + it('maps failed/cancelled root status to failed and queued to queued', () => { + expect(buildAgentRunTreeFromTurns([], { status: 'failed' }).status).toBe('failed'); + expect(buildAgentRunTreeFromTurns([], { status: 'cancelled' }).status).toBe('failed'); + expect(buildAgentRunTreeFromTurns([], { status: 'queued' }).status).toBe('queued'); + }); + + it('orders children by start time, placing unknown start times last', () => { + const turns = [assistantTurn([ + tc({ id: 'late', args: { description: 'late' }, startTime: '2026-06-13T10:05:00.000Z' }), + tc({ id: 'early', args: { description: 'early' }, startTime: '2026-06-13T10:01:00.000Z' }), + tc({ id: 'unknown', args: { description: 'unknown' } }), + ])]; + const ids = buildAgentRunTreeFromTurns(turns).children.map((c) => c.id); + expect(ids).toEqual(['early', 'late', 'unknown']); + }); +}); + +describe('countRuns', () => { + it('counts every run including the root', () => { + const tree: AgentRunNode = { + id: 'root', name: 'r', role: 'orchestrator', status: 'done', + children: [ + { id: 'a', name: 'a', role: 'agent', status: 'done', children: [ + { id: 'a1', name: 'a1', role: 'agent', status: 'done', children: [] }, + ] }, + { id: 'b', name: 'b', role: 'agent', status: 'done', children: [] }, + ], + }; + expect(countRuns(tree)).toBe(4); + }); +}); diff --git a/packages/coc/test/server/spa/client/agent-canvas-layout.test.ts b/packages/coc/test/server/spa/client/agent-canvas-layout.test.ts new file mode 100644 index 000000000..be7bf3770 --- /dev/null +++ b/packages/coc/test/server/spa/client/agent-canvas-layout.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + buildLayout, + edgePath, + spineVars, + COLW, + ROWH, + NODEW, + NODEH, + PAD, +} from '../../../../src/server/spa/client/react/features/chat/agent-canvas/layout'; +import type { AgentRunNode } from '../../../../src/server/spa/client/react/features/chat/agent-canvas/types'; + +function node(id: string, children: AgentRunNode[] = []): AgentRunNode { + return { id, name: id, role: 'agent', status: 'done', children }; +} + +describe('buildLayout', () => { + it('lays out a lone root at the origin with padded world size', () => { + const layout = buildLayout(node('root')); + expect(layout.order).toEqual(['root']); + expect(layout.edges).toEqual([]); + expect(layout.pos.root).toMatchObject({ x: 0, y: 0, depth: 0 }); + expect(layout.worldW).toBe(NODEW + PAD * 2); + expect(layout.worldH).toBe(NODEH + PAD * 2); + }); + + it('stacks leaf children one row apart and centers the parent over them', () => { + const root = node('root', [node('a'), node('b')]); + const layout = buildLayout(root); + + // children sit at depth 1 (x = COLW), stacked ROWH apart + expect(layout.pos.a).toMatchObject({ x: COLW, y: 0, depth: 1 }); + expect(layout.pos.b).toMatchObject({ x: COLW, y: ROWH, depth: 1 }); + // parent is vertically centered over its children + expect(layout.pos.root).toMatchObject({ x: 0, y: ROWH / 2, depth: 0 }); + + // post-order: children before parent + expect(layout.order).toEqual(['a', 'b', 'root']); + expect(layout.edges).toEqual([ + { from: 'root', to: 'a', depth: 1 }, + { from: 'root', to: 'b', depth: 1 }, + ]); + expect(layout.worldW).toBe(COLW + NODEW + PAD * 2); + expect(layout.worldH).toBe(ROWH + NODEH + PAD * 2); + }); + + it('handles arbitrary nesting depth', () => { + const root = node('root', [node('a', [node('a1'), node('a2')])]); + const layout = buildLayout(root); + + expect(layout.pos.a1).toMatchObject({ x: COLW * 2, y: 0, depth: 2 }); + expect(layout.pos.a2).toMatchObject({ x: COLW * 2, y: ROWH, depth: 2 }); + expect(layout.pos.a).toMatchObject({ x: COLW, y: ROWH / 2, depth: 1 }); + expect(layout.pos.root).toMatchObject({ x: 0, y: ROWH / 2, depth: 0 }); + + expect(layout.order).toEqual(['a1', 'a2', 'a', 'root']); + expect(layout.edges).toContainEqual({ from: 'a', to: 'a1', depth: 2 }); + expect(layout.edges).toContainEqual({ from: 'a', to: 'a2', depth: 2 }); + expect(layout.edges).toContainEqual({ from: 'root', to: 'a', depth: 1 }); + expect(layout.worldW).toBe(COLW * 2 + NODEW + PAD * 2); + }); +}); + +describe('edgePath', () => { + it('draws a cubic bezier from parent right-center to child left-center', () => { + const a = { x: 0, y: ROWH / 2, depth: 0, node: node('root') }; + const b = { x: COLW, y: 0, depth: 1, node: node('a') }; + // x1 = 0+202+60=262, y1 = 39+28+60=127, x2 = 250+60=310, y2 = 0+28+60=88, dx=max(40,24)=40 + expect(edgePath(a, b)).toBe('M 262 127 C 302 127, 270 88, 310 88'); + }); + + it('uses a minimum horizontal control offset of 40', () => { + const a = { x: 0, y: 0, depth: 0, node: node('root') }; + const b = { x: 0, y: 0, depth: 1, node: node('a') }; + // dx clamps to 40 even when columns overlap: x1=262, x2=60 → controls 302 and 20 + expect(edgePath(a, b)).toBe('M 262 88 C 302 88, 20 88, 60 88'); + }); +}); + +describe('spineVars', () => { + it('returns oklch spine colors keyed off depth', () => { + expect(spineVars(0)).toEqual({ + '--spine': 'oklch(0.55 0.16 252)', + '--spine-soft': 'oklch(0.95 0.04 252)', + }); + expect(spineVars(1)['--spine']).toBe('oklch(0.55 0.16 292)'); + }); + + it('cycles the hue ring (5 hues) for deep nesting', () => { + expect(spineVars(5)).toEqual(spineVars(0)); + expect(spineVars(6)).toEqual(spineVars(1)); + }); +}); From b1e8540e38950842472408f8bd95166502510983 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 13:59:01 -0700 Subject: [PATCH 02/11] =?UTF-8?q?feat(coc):=20agents=20canvas=20=E2=80=94?= =?UTF-8?q?=20AgentCanvas=20component=20+=20scoped=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pannable/zoomable spatial tree view, ported from the coc-chat design and adapted to the app: - AgentCanvas.tsx — renders the run tree from buildLayout: curved SVG edges (animated for running, dashed for queued) + absolutely-placed HTML node cards. Each card shows a role glyph, name, role · live elapsed, a spawn-count pill, a status dot, and a progress bar. Pan/ zoom is delegated to the shared useZoomPan hook; auto-fit on mount and on resize until the user takes over, with a re-arming Fit button. A live 1s clock ticks running nodes' elapsed time; an empty state shows when the chat spawned no sub-agents. - icons.tsx — line-icon set (roles, spawn, zoom, view nav) ported from the design, plus a keyword→role glyph picker for free-form agent types. - agent-canvas.css — design's canvas styles, namespaced under `.agent-canvas` with light/dark token sets (dark via `.dark`). - index.ts — barrel for the feature. Render tests cover node rendering, the spawn pill, empty state, selection highlight, onSelect, the zoom toolbar, and status flags. Not wired into the chat yet. Co-Authored-By: Claude Opus 4.8 --- .../chat/agent-canvas/AgentCanvas.tsx | 229 +++++++++++ .../chat/agent-canvas/agent-canvas.css | 361 ++++++++++++++++++ .../features/chat/agent-canvas/icons.tsx | 74 ++++ .../react/features/chat/agent-canvas/index.ts | 5 + .../coc/test/spa/react/AgentCanvas.test.tsx | 64 ++++ 5 files changed, 733 insertions(+) create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts create mode 100644 packages/coc/test/spa/react/AgentCanvas.test.tsx diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx new file mode 100644 index 000000000..8a1882cb0 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx @@ -0,0 +1,229 @@ +// AgentCanvas — a pannable / zoomable spatial map of a chat's recursive +// sub-agent runs. The orchestrator root branches left→right into its +// sub-agents, recursively. Driven by live run status; clicking a node calls +// onSelect (the host scrolls the thread to the matching turn). +// +// Ported from the coc-chat design (agent-canvas.jsx), with pan/zoom delegated +// to the repo's shared useZoomPan hook and the prototype's clock scrubber +// dropped (the real app is live-streaming, not replayable). + +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { useZoomPan } from '../../../hooks/ui/useZoomPan'; +import { buildLayout, edgePath, spineVars, PAD, type PositionedNode } from './layout'; +import type { AgentRunNode } from './types'; +import { AcIcons, roleIcon } from './icons'; +import './agent-canvas.css'; + +export interface AgentCanvasProps { + /** The orchestrator root whose subtree is the agent run tree. */ + root: AgentRunNode; + /** Currently selected run id (highlighted), or null. */ + selectedId?: string | null; + /** Called when a node is clicked. */ + onSelect?: (node: AgentRunNode) => void; +} + +function fmtDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, '0')}`; +} + +function anyRunning(node: AgentRunNode): boolean { + if (node.status === 'running') { + return true; + } + return (node.children || []).some(anyRunning); +} + +/** Short status/elapsed label shown under a node's name. */ +function nodeTimeText(node: AgentRunNode, now: number): string { + if (node.isRoot) { + if (node.status === 'running') { + return 'live'; + } + return node.status === 'failed' ? 'failed' : 'done'; + } + switch (node.status) { + case 'running': + return node.startedAt ? fmtDuration((now || node.startedAt) - node.startedAt) : 'running'; + case 'done': + return node.startedAt && node.completedAt ? fmtDuration(node.completedAt - node.startedAt) : 'done'; + case 'failed': + return 'failed'; + case 'queued': + return 'queued'; + default: + return ''; + } +} + +function CanvasNode({ entry, selected, onSelect, now }: { + entry: PositionedNode; + selected: boolean; + onSelect?: (node: AgentRunNode) => void; + now: number; +}) { + const { node, depth } = entry; + const isRoot = !!node.isRoot; + const status = node.status; + const RoleIcon = isRoot ? AcIcons.Orchestr : roleIcon(node.role); + const kids = node.children || []; + const pct = status === 'queued' ? 0 : 100; + const styleVars = spineVars(depth) as CSSProperties; + + return ( + + ); +} + +export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) { + const layout = useMemo(() => buildLayout(root), [root]); + + const { containerRef, state, zoomIn, zoomOut, fitToView, zoomLabel } = useZoomPan({ + contentWidth: layout.worldW, + contentHeight: layout.worldH, + minZoom: 0.25, + maxZoom: 2.2, + }); + + // Auto-fit on mount and whenever the tree resizes — until the user takes + // over the view (wheel/drag/zoom). The Fit button re-arms auto-fit. + const interactedRef = useRef(false); + + useLayoutEffect(() => { + if (!interactedRef.current) { + fitToView(); + } + }, [layout.worldW, layout.worldH, fitToView]); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + const markInteracted = () => { interactedRef.current = true; }; + el.addEventListener('wheel', markInteracted, { passive: true }); + el.addEventListener('pointerdown', markInteracted); + const ro = new ResizeObserver(() => { + if (!interactedRef.current) { + fitToView(); + } + }); + ro.observe(el); + return () => { + el.removeEventListener('wheel', markInteracted); + el.removeEventListener('pointerdown', markInteracted); + ro.disconnect(); + }; + }, [containerRef, fitToView]); + + // Live clock so running nodes' elapsed time ticks; idle when nothing runs. + const hasRunning = useMemo(() => anyRunning(root), [root]); + const [now, setNow] = useState(0); + useEffect(() => { + if (!hasRunning) { + return; + } + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [hasRunning]); + + const worldTransform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`; + + return ( +
+
+ + {layout.edges.map((e) => { + const a = layout.pos[e.from]; + const b = layout.pos[e.to]; + const childStatus = b.node.status; + const active = childStatus === 'running'; + const queued = childStatus === 'queued'; + const spine = spineVars(e.depth)['--spine']; + return ( + + ); + })} + + {layout.order.map((id) => ( + + ))} +
+ + {root.children.length === 0 && ( +
+ No sub-agent runs + Agents this chat spawns will appear here as a tree. +
+ )} + +
+ + {zoomLabel} + + + +
+ +
+ running + done + queued + drag to pan · scroll to zoom +
+
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css new file mode 100644 index 000000000..b4ae5318b --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css @@ -0,0 +1,361 @@ +/* ========================================================= + Agents canvas — spatial tree map of a chat's sub-agent runs. + Ported from the coc-chat design (coc-chat-styles.css). Every rule is + namespaced under `.agent-canvas`, and the design's theme tokens are + scoped there too (light by default, dark under `.dark`), so nothing + leaks into the surrounding app. + ========================================================= */ + +.agent-canvas { + /* theme tokens (light) */ + --bg: #f6f6f6; + --bg-2: #efefef; + --panel: #ffffff; + --text: #1f1f1f; + --muted: #6b6b6b; + --faint: #8c8c8c; + --border: #e2e2e2; + --border-strong: #c6c6c6; + --hover: #f0f0f0; + --accent: oklch(0.55 0.18 252); + --success: oklch(0.60 0.13 155); + --danger: oklch(0.58 0.19 24); + --radius: 10px; + --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + /* spine defaults; per-node values come from inline style (spineVars). */ + --spine: var(--accent); + --spine-soft: oklch(0.96 0.04 252); + + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + cursor: grab; + background-color: var(--bg); + background-image: radial-gradient(circle, color-mix(in oklab, var(--border-strong) 60%, transparent) 1px, transparent 1.4px); + background-size: 22px 22px; + user-select: none; +} + +.dark .agent-canvas { + --bg: #1e1e1e; + --bg-2: #2a2a2b; + --panel: #252526; + --text: #cccccc; + --muted: #9d9d9d; + --faint: #6e6e6e; + --border: #3a3a3c; + --border-strong: #565659; + --hover: #2a2d2e; + --accent: oklch(0.68 0.16 252); + --success: oklch(0.72 0.16 152); + --danger: oklch(0.68 0.19 22); + --spine-soft: oklch(0.32 0.07 252); +} + +.agent-canvas .world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; +} + +.agent-canvas .canvas-edges { + position: absolute; + top: 0; + left: 0; + overflow: visible; + pointer-events: none; +} + +.agent-canvas .edge-active { + animation: ac-edgeflow 0.7s linear infinite; +} + +@keyframes ac-edgeflow { + to { stroke-dashoffset: -11; } +} + +.agent-canvas .cnode { + position: absolute; + width: 202px; + height: 56px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 1px 2px rgb(20 30 60 / 0.06); + cursor: pointer; + text-align: left; + color: var(--text); + transition: box-shadow 0.14s, border-color 0.14s, transform 0.14s; + animation: ac-nodeIn 0.42s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes ac-nodeIn { + from { transform: scale(0.9); } + to { transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .agent-canvas .cnode { animation: none; } +} + +.agent-canvas .cnode:hover { + border-color: var(--border-strong); + box-shadow: 0 3px 10px rgb(20 30 60 / 0.12); + transform: translateY(-1px); +} + +.agent-canvas .cnode.sel { + border-color: var(--spine); + box-shadow: 0 0 0 3px var(--spine-soft), 0 3px 10px rgb(20 30 60 / 0.12); +} + +.agent-canvas .cnode.root { + background: linear-gradient(180deg, var(--panel), var(--bg-2)); + border-color: var(--border-strong); +} + +.agent-canvas .cnode[data-status="running"] { + border-color: color-mix(in oklab, var(--spine) 45%, var(--border)); +} + +.agent-canvas .cnode[data-status="queued"] { + border-style: dashed; + opacity: 0.72; +} + +.agent-canvas .cn-badge { + width: 28px; + height: 28px; + border-radius: 7px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--spine-soft); + color: var(--spine); +} + +.agent-canvas .cn-body { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.agent-canvas .cn-name { + font-size: 12.5px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-canvas .cn-sub { + font-size: 10px; + font-family: var(--font-mono); + color: var(--faint); + display: flex; + align-items: center; + gap: 4px; +} + +.agent-canvas .cn-sub .cn-role { + color: var(--spine); + font-weight: 500; +} + +.agent-canvas .cn-spawn { + position: absolute; + top: -8px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 9.5px; + font-family: var(--font-mono); + color: var(--muted); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 6px; +} + +.agent-canvas .cn-spawn svg { + width: 10px; + height: 10px; + opacity: 0.8; +} + +.agent-canvas .cn-state { + position: absolute; + top: 9px; + right: 10px; + width: 7px; + height: 7px; + border-radius: 999px; + flex-shrink: 0; +} + +.agent-canvas .cn-state[data-status="running"] { + background: var(--spine); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--spine) 22%, transparent); + animation: ac-nodepulse 1.6s infinite; +} + +.agent-canvas .cn-state[data-status="done"] { background: var(--success); } +.agent-canvas .cn-state[data-status="failed"] { background: var(--danger); } +.agent-canvas .cn-state[data-status="queued"] { background: var(--faint); } + +@keyframes ac-nodepulse { + 0%, 100% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--spine) 22%, transparent); } + 50% { box-shadow: 0 0 0 6px color-mix(in oklab, var(--spine) 6%, transparent); } +} + +.agent-canvas .cn-bar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + border-radius: 0 0 var(--radius) var(--radius); + background: var(--hover); + overflow: hidden; +} + +.agent-canvas .cn-bar > i { + display: block; + height: 100%; + background: var(--spine); + transition: width 0.4s ease; +} + +.agent-canvas .cnode[data-status="running"] .cn-bar > i { + animation: ac-barpulse 1.6s ease-in-out infinite; +} + +.agent-canvas .cnode[data-status="done"] .cn-bar > i { background: var(--success); } + +@keyframes ac-barpulse { + 0%, 100% { opacity: 0.65; } + 50% { opacity: 1; } +} + +.agent-canvas .canvas-toolbar { + position: absolute; + left: 16px; + bottom: 16px; + display: flex; + align-items: center; + gap: 2px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 9px; + padding: 4px; + box-shadow: 0 2px 8px rgb(20 30 60 / 0.10); +} + +.agent-canvas .canvas-toolbar button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 9px; + border: none; + background: transparent; + color: var(--muted); + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; +} + +.agent-canvas .canvas-toolbar button:hover { + background: var(--hover); + color: var(--text); +} + +.agent-canvas .canvas-toolbar .cz { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + width: 40px; + text-align: center; +} + +.agent-canvas .canvas-toolbar .cz-sep { + width: 1px; + height: 18px; + background: var(--border); + margin: 0 3px; +} + +.agent-canvas .canvas-legend { + position: absolute; + right: 16px; + top: 16px; + display: flex; + align-items: center; + gap: 12px; + background: color-mix(in oklab, var(--panel) 88%, transparent); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 9px; + padding: 7px 12px; + font-size: 11px; + color: var(--muted); +} + +.agent-canvas .canvas-legend .cl-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.agent-canvas .canvas-legend .cl-dot { + width: 8px; + height: 8px; + border-radius: 999px; +} + +.agent-canvas .canvas-legend .cl-dot[data-status="running"] { background: var(--accent); } +.agent-canvas .canvas-legend .cl-dot[data-status="done"] { background: var(--success); } +.agent-canvas .canvas-legend .cl-dot[data-status="queued"] { + background: var(--faint); + border: 1px dashed var(--border-strong); +} + +.agent-canvas .canvas-legend .cl-hint { + color: var(--faint); + font-family: var(--font-mono); + font-size: 10px; + border-left: 1px solid var(--border); + padding-left: 12px; +} + +/* Empty state — no sub-agents in this conversation. */ +.agent-canvas .canvas-empty { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--faint); + font-size: 13px; + pointer-events: none; +} + +.agent-canvas .canvas-empty .ce-title { + color: var(--muted); + font-weight: 600; +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx new file mode 100644 index 000000000..4379b16e4 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx @@ -0,0 +1,74 @@ +// Line-icon set for the Agents canvas — agent roles, spawn, and view nav. +// Ported from the coc-chat design (chat-icons.jsx) so glyphs match the mock. + +import type { ReactNode, SVGProps } from 'react'; + +export interface AcIconProps extends Omit, 'stroke'> { + size?: number; + /** Stroke width (the icons are stroked, not filled). */ + stroke?: number; +} + +function mk(paths: ReactNode, viewBox = '0 0 16 16') { + return function Icon({ size = 16, stroke = 1.5, ...rest }: AcIconProps) { + return ( + + {paths} + + ); + }; +} + +export const AcIcons = { + // ── roles ── + Orchestr: mk(<>), + Explorer: mk(<>), + Refactor: mk(<>), + Tester: mk(<>), + Reviewer: mk(<>), + Planner: mk(<>), + Doc: mk(<>), + Agent: mk(<>), + + // ── canvas controls / nav ── + Spawn: mk(<>), + Expand: mk(<>), + Collapse: mk(<>), + Replay: mk(<>), + Thread: mk(<>), + Tree: mk(<>), +}; + +// Keyword → role glyph, matched in order. Real sub-agent types are free-form +// strings (e.g. 'Explore', 'general-purpose', 'rust-code-reviewer'), so we map +// by substring rather than an exact enum. +const ROLE_ICON_RULES: Array<[string[], (p: AcIconProps) => ReactNode]> = [ + [['orchestr'], AcIcons.Orchestr], + [['explor', 'research', 'search'], AcIcons.Explorer], + [['review'], AcIcons.Reviewer], + [['test'], AcIcons.Tester], + [['plan'], AcIcons.Planner], + [['refactor', 'fix', 'impl', 'edit', 'code'], AcIcons.Refactor], + [['doc', 'write'], AcIcons.Doc], +]; + +/** Pick a role glyph by keyword, falling back to a generic agent icon. */ +export function roleIcon(role: string | undefined): (p: AcIconProps) => ReactNode { + const r = (role || '').toLowerCase(); + for (const [keywords, icon] of ROLE_ICON_RULES) { + if (keywords.some((k) => r.includes(k))) { + return icon; + } + } + return AcIcons.Agent; +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts new file mode 100644 index 000000000..4b937f158 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts @@ -0,0 +1,5 @@ +export { AgentCanvas } from './AgentCanvas'; +export type { AgentCanvasProps } from './AgentCanvas'; +export { buildAgentRunTreeFromTurns, countRuns } from './buildAgentRunTree'; +export type { AgentRunRootMeta } from './buildAgentRunTree'; +export type { AgentRunNode, AgentRunStatus } from './types'; diff --git a/packages/coc/test/spa/react/AgentCanvas.test.tsx b/packages/coc/test/spa/react/AgentCanvas.test.tsx new file mode 100644 index 000000000..82f858a4f --- /dev/null +++ b/packages/coc/test/spa/react/AgentCanvas.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentCanvas } from '../../../src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas'; +import type { AgentRunNode } from '../../../src/server/spa/client/react/features/chat/agent-canvas/types'; + +function tree(children: AgentRunNode[] = []): AgentRunNode { + return { id: 'root', name: 'CoC · orchestrator', role: 'orchestrator', status: 'running', isRoot: true, children }; +} + +function sub(id: string, overrides: Partial = {}): AgentRunNode { + return { id, name: id, role: 'Explore', status: 'done', children: [], ...overrides }; +} + +describe('AgentCanvas', () => { + it('renders the orchestrator root and every sub-agent node', () => { + render(); + expect(screen.getByTestId('agent-canvas')).toBeTruthy(); + expect(screen.getByTestId('agent-canvas-node-root')).toBeTruthy(); + expect(screen.getByTestId('agent-canvas-node-explore')).toBeTruthy(); + expect(screen.getByTestId('agent-canvas-node-review')).toBeTruthy(); + expect(screen.getByText('CoC · orchestrator')).toBeTruthy(); + expect(screen.getByText('explore')).toBeTruthy(); + }); + + it('shows a spawn-count pill on the root reflecting its children', () => { + render(); + const rootNode = screen.getByTestId('agent-canvas-node-root'); + expect(rootNode.querySelector('.cn-spawn')?.textContent).toContain('3'); + }); + + it('renders the empty-state hint when there are no sub-agents', () => { + render(); + expect(screen.getByText('No sub-agent runs')).toBeTruthy(); + // the root node still renders + expect(screen.getByTestId('agent-canvas-node-root')).toBeTruthy(); + }); + + it('calls onSelect with the clicked node', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('agent-canvas-node-explore')); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0][0]).toMatchObject({ id: 'explore' }); + }); + + it('marks the selected node with the sel class', () => { + render(); + expect(screen.getByTestId('agent-canvas-node-explore').className).toContain('sel'); + expect(screen.getByTestId('agent-canvas-node-root').className).not.toContain('sel'); + }); + + it('renders the zoom toolbar with a percentage label', () => { + render(); + const canvas = screen.getByTestId('agent-canvas'); + expect(canvas.querySelector('.canvas-toolbar')).toBeTruthy(); + expect(canvas.querySelector('.cz')?.textContent).toMatch(/%$/); + }); + + it('flags running children edges/nodes via data-status', () => { + render(); + expect(screen.getByTestId('agent-canvas-node-busy').getAttribute('data-status')).toBe('running'); + }); +}); From 8a60d64a1cbce7ac20d8cb06498cd27aeb88c070 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 14:11:08 -0700 Subject: [PATCH 03/11] feat(coc): wire Thread/Agents view toggle into the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Thread | Agents segmented toggle to the chat top bar and renders the sub-agent canvas as a second view, leaving the thread untouched. - ChatViewToggle — segmented control (Thread / Agents) styled with the app's light/dark tokens; exposed through a new optional `viewToggle` slot on ChatHeader. - ChatDetail — owns `view` state (resets to thread on chat switch). Builds the agent tree from the loaded turns via buildAgentRunTreeFromTurns and, in Agents mode, swaps the ConversationArea inner row for , keeping the composer/ scratchpad and hiding thread-only flow cards (Ralph start, Implement plan). The toggle is hidden in the floating variant and while loading/pending. - Node click → findTurnIndexForRun maps the run back to its issuing turn, switches to the thread, and scrolls to it. Tests: findTurnIndexForRun (toolCalls/timeline/turnIndex/missing) and ChatViewToggle (render, aria-pressed, onChange). build:client passes; no new tsc/lint errors. Knowledge doc (dashboard-spa.md) updated. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 26 +++++++++ .../client/react/features/chat/ChatDetail.tsx | 58 +++++++++++++++++-- .../client/react/features/chat/ChatHeader.tsx | 7 ++- .../chat/agent-canvas/ChatViewToggle.tsx | 55 ++++++++++++++++++ .../chat/agent-canvas/buildAgentRunTree.ts | 23 ++++++++ .../react/features/chat/agent-canvas/index.ts | 4 +- .../spa/client/agent-canvas-data.test.ts | 33 +++++++++++ .../test/spa/react/ChatViewToggle.test.tsx | 25 ++++++++ 8 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 packages/coc/src/server/spa/client/react/features/chat/agent-canvas/ChatViewToggle.tsx create mode 100644 packages/coc/test/spa/react/ChatViewToggle.test.tsx diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index f209b99c8..cad815075 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -296,6 +296,32 @@ server/Forge layer); cells without a displayable USD value show explicit `USD pricing unavailable` copy instead of silently leaving cost blank. The UI does not render Copilot premium request units. +### Agents view (sub-agent canvas) + +`ChatHeader` exposes a `Thread | Agents` segmented toggle (`ChatViewToggle`, +under `features/chat/agent-canvas/`) via its `viewToggle` slot. `ChatDetail` +owns the `view` state and, in `agents` mode, swaps the `ConversationArea` inner +row for `AgentCanvas` — a pannable/zoomable spatial tree of the chat's +recursive sub-agent runs — while keeping the composer/scratchpad and hiding the +thread-only flow cards (Ralph start, Implement-plan). The toggle is hidden in +the `floating` variant and while loading/pending; `view` resets to `thread` on +chat switch. + +`buildAgentRunTreeFromTurns(turns, root)` derives the tree with no extra fetch: +the orchestrator (this process) is the root and each `Task` tool call becomes a +sub-agent child (name/role from args, status/timing from the call), deduped +across `toolCalls`+timeline and ordered by start time. The `AgentRunNode` tree +supports arbitrary depth, so deeper recursion can be layered on later. +`AgentCanvas` reuses the shared `useZoomPan` hook (auto-fit until the user takes +over; a Fit button re-arms it), renders curved SVG edges + node cards (role +glyph, name, live elapsed, spawn-count pill, status dot, progress bar), and a +live 1s clock for running nodes. Clicking a node calls `onSelect`; `ChatDetail` +maps it back to the issuing turn via `findTurnIndexForRun`, switches to the +thread, and scrolls there. Styles live in scoped `agent-canvas.css` +(`.agent-canvas`, light/dark via `.dark`); there is no clock scrubber (the +prototype's replay control is dropped — the real view is live). Distinct from +the co-edited `CanvasPanel` side panel. + ## Tool Call Rendering Inside `WhisperCollapsedGroup`, tool calls render as compact "whisper-row" variant: diff --git a/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx b/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx index 5c02e4139..f762f55db 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx @@ -44,6 +44,8 @@ import { buildEffortOptionsForModel } from './EffortPillSelector'; import type { EffortLevel } from './EffortPillSelector'; import type { RichTextInputHandle } from '../../shared/RichTextInput'; import { ConversationMiniMap } from './conversation/ConversationMiniMap'; +import { AgentCanvas, ChatViewToggle, buildAgentRunTreeFromTurns, findTurnIndexForRun } from './agent-canvas'; +import type { AgentRunNode, ChatView } from './agent-canvas'; import { useConversationSelection } from './hooks/useConversationSelection'; import { snapshotConversation } from '../../utils/snapshot-copy-utils'; import { copyHtmlToClipboard } from '../../utils/format'; @@ -179,6 +181,10 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari const [activeCanvasId, setActiveCanvasId] = useState(null); const [canvasLiveEvent, setCanvasLiveEvent] = useState(null); const [canvasPanelClosed, setCanvasPanelClosed] = useState(false); + // Thread vs. Agents (spatial sub-agent run tree) view. + const [view, setView] = useState('thread'); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [pendingScrollTurn, setPendingScrollTurn] = useState(null); const [noteEdits, setNoteEdits] = useState scanTurnsForCreatedFiles(turns), [turns]); + // ── Agents view: spatial tree of this chat's recursive sub-agent runs ── + const agentRoot = useMemo(() => buildAgentRunTreeFromTurns(turns, { + id: 'root', + title: (task?.customTitle as string | undefined) || title || task?.title || task?.displayName, + status: effectiveStatus, + }), [turns, task?.customTitle, task?.title, task?.displayName, title, effectiveStatus]); + + // Clicking a canvas node jumps to the matching turn in the thread. + const handleAgentSelect = useCallback((node: AgentRunNode) => { + setSelectedAgentId(node.id); + const idx = node.isRoot + ? (turnsRef.current[0]?.turnIndex ?? 0) + : findTurnIndexForRun(turnsRef.current, node.id); + if (idx != null) { + setView('thread'); + setPendingScrollTurn(idx); + } + }, []); + + // After switching to the thread, scroll to the turn a canvas node pointed at. + useEffect(() => { + if (pendingScrollTurn == null || view !== 'thread') return; + const container = conversationContainerRef.current; + const target = container?.querySelector(`[data-turn-index="${pendingScrollTurn}"]`); + if (target) { + target.scrollIntoView({ block: 'start', behavior: 'smooth' }); + setPendingScrollTurn(null); + } + }, [pendingScrollTurn, view, turns]); + // Compute the follow-up mode pill set, optionally appending Ralph when // the chat is eligible for in-place promotion. Eligibility: // - completed (no in-flight turn or queued follow-ups) @@ -427,6 +463,9 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari modelOverrideMountedRef.current = false; setEffortOverride(null); setInvalidScratchpadPaths(new Set()); + setView('thread'); + setSelectedAgentId(null); + setPendingScrollTurn(null); }, [taskId]); // ── Resolve existing implementation runs from task metadata ───────── @@ -1609,6 +1648,9 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari onRenameTitle={processId ? () => setRenameOpen(true) : undefined} onStartFreshSameContext={onStartFreshSameContext} startingFreshSameContext={startingFreshSameContext} + viewToggle={!loading && !isPending && variant !== 'floating' + ? + : undefined} /> {loopPanelOpen && isLoopsEnabled() && (
@@ -1635,8 +1677,12 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari : { flex: '1 1 auto', minHeight: 0 } } > - {/* Inner row: ConversationArea + MiniMap side by side */} + {/* Inner row: ConversationArea + MiniMap, or the Agents canvas */}
+ {view === 'agents' ? ( + + ) : ( + <> )} + + )}
- {/* Ralph grilling complete — show Start Ralph panel */} - {(() => { + {/* Ralph grilling complete — show Start Ralph panel (thread view only) */} + {view === 'thread' && (() => { const ralphCtx = getRalphContext(task); const goalPath = detectedGoalFile || (task?.metadata?.goalFilePath as string | undefined) || ''; // Path 1: traditional grilling-phase → start @@ -1733,8 +1781,8 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari } return null; })()} - {/* Plan file complete — offer one-click handoff to autopilot */} - {isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && ( + {/* Plan file complete — offer one-click handoff to autopilot (thread view only) */} + {view === 'thread' && isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && ( Promise | boolean | void; /** True while the lens chat fresh-context operation is in progress. */ startingFreshSameContext?: boolean; + /** Optional control rendered at the start of the right-side action area (e.g. the Thread/Agents view toggle). */ + viewToggle?: ReactNode; } /** Build overflow menu items based on what's hidden at the current container tier */ @@ -346,6 +348,7 @@ export function ChatHeader({ onRenameTitle, onStartFreshSameContext, startingFreshSameContext = false, + viewToggle, }: ChatHeaderProps) { const { isMobile } = useBreakpoint(); const { isFloating } = useFloatingChats(); @@ -494,6 +497,8 @@ export function ChatHeader({ {/* Right side */}
+ {/* View toggle (Thread / Agents), when provided by the host. */} + {viewToggle} {/* Vertical divider visually separates the identity/status area from the action group, matching the redesign mockup's diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/ChatViewToggle.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/ChatViewToggle.tsx new file mode 100644 index 000000000..a678975bb --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/ChatViewToggle.tsx @@ -0,0 +1,55 @@ +// Segmented Thread / Agents toggle for the chat top bar. Switches between the +// linear transcript and the spatial agent-run canvas. Ported from the +// coc-chat design's `.view-seg`, styled with the app's light/dark tokens. + +import { cn } from '../../../ui/cn'; +import { AcIcons } from './icons'; + +export type ChatView = 'thread' | 'agents'; + +interface ChatViewToggleProps { + view: ChatView; + onChange: (view: ChatView) => void; +} + +function SegButton({ active, onClick, testid, children }: { + active: boolean; + onClick: () => void; + testid: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function ChatViewToggle({ view, onChange }: ChatViewToggleProps) { + return ( +
+ onChange('thread')} testid="chat-view-thread"> + Thread + + onChange('agents')} testid="chat-view-agents"> + Agents + +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts index bbbf92fb8..ace1a1c21 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -178,3 +178,26 @@ export function buildAgentRunTreeFromTurns( export function countRuns(node: AgentRunNode): number { return 1 + (node.children || []).reduce((sum, c) => sum + countRuns(c), 0); } + +/** + * Find the `data-turn-index` of the turn that issued a given run (its `Task` + * tool-call id), so a canvas node click can scroll the thread to that turn. + * Mirrors ConversationArea's `turn.turnIndex ?? arrayIndex`. + */ +export function findTurnIndexForRun( + turns: ClientConversationTurn[] | undefined, + runId: string, +): number | null { + if (!turns) { + return null; + } + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + const inToolCalls = Array.isArray(turn.toolCalls) && turn.toolCalls.some((tc) => tc.id === runId); + const inTimeline = (turn.timeline || []).some((item) => item.toolCall?.id === runId); + if (inToolCalls || inTimeline) { + return turn.turnIndex ?? i; + } + } + return null; +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts index 4b937f158..bc604ba76 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts @@ -1,5 +1,7 @@ export { AgentCanvas } from './AgentCanvas'; export type { AgentCanvasProps } from './AgentCanvas'; -export { buildAgentRunTreeFromTurns, countRuns } from './buildAgentRunTree'; +export { ChatViewToggle } from './ChatViewToggle'; +export type { ChatView } from './ChatViewToggle'; +export { buildAgentRunTreeFromTurns, countRuns, findTurnIndexForRun } from './buildAgentRunTree'; export type { AgentRunRootMeta } from './buildAgentRunTree'; export type { AgentRunNode, AgentRunStatus } from './types'; diff --git a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts index d28f3e441..a21d5b2fa 100644 --- a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { buildAgentRunTreeFromTurns, countRuns, + findTurnIndexForRun, } from '../../../../src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree'; import type { AgentRunNode } from '../../../../src/server/spa/client/react/features/chat/agent-canvas/types'; import type { ClientConversationTurn, ClientToolCall } from '../../../../src/server/spa/client/react/types/dashboard'; @@ -119,6 +120,38 @@ describe('buildAgentRunTreeFromTurns', () => { }); }); +describe('findTurnIndexForRun', () => { + it('returns the data-turn-index of the turn that issued the run', () => { + const turns = [ + { role: 'user' as const, content: 'go', timeline: [], turnIndex: 0 }, + assistantTurn([tc({ id: 't1', args: { description: 'x' } })], []), + ]; + // second turn has no explicit turnIndex → falls back to array index 1 + expect(findTurnIndexForRun(turns, 't1')).toBe(1); + }); + + it('prefers an explicit turn.turnIndex over the array index', () => { + const turns = [ + { ...assistantTurn([tc({ id: 't1', args: { description: 'x' } })]), turnIndex: 7 }, + ]; + expect(findTurnIndexForRun(turns, 't1')).toBe(7); + }); + + it('matches a run found only in the timeline', () => { + const turns = [assistantTurn([], [{ + type: 'tool-complete', + timestamp: '2026-06-13T10:00:00.000Z', + toolCall: tc({ id: 't9', args: {}, status: 'completed' }), + }])]; + expect(findTurnIndexForRun(turns, 't9')).toBe(0); + }); + + it('returns null when the run is not present', () => { + expect(findTurnIndexForRun([assistantTurn([tc({ id: 't1', args: {} })])], 'missing')).toBeNull(); + expect(findTurnIndexForRun(undefined, 't1')).toBeNull(); + }); +}); + describe('countRuns', () => { it('counts every run including the root', () => { const tree: AgentRunNode = { diff --git a/packages/coc/test/spa/react/ChatViewToggle.test.tsx b/packages/coc/test/spa/react/ChatViewToggle.test.tsx new file mode 100644 index 000000000..bf9db8e0e --- /dev/null +++ b/packages/coc/test/spa/react/ChatViewToggle.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChatViewToggle } from '../../../src/server/spa/client/react/features/chat/agent-canvas/ChatViewToggle'; + +describe('ChatViewToggle', () => { + it('renders Thread and Agents segments', () => { + render(); + expect(screen.getByTestId('chat-view-thread')).toBeTruthy(); + expect(screen.getByTestId('chat-view-agents')).toBeTruthy(); + }); + + it('reflects the active view via aria-pressed', () => { + render(); + expect(screen.getByTestId('chat-view-agents').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('chat-view-thread').getAttribute('aria-pressed')).toBe('false'); + }); + + it('calls onChange when a segment is clicked', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('chat-view-agents')); + expect(onChange).toHaveBeenCalledWith('agents'); + }); +}); From d4f270109448676b089ed7f6599a32922687856b Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 14:31:37 -0700 Subject: [PATCH 04/11] fix(coc): keep agents canvas sub-agents after a chat completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live (SSE) tool calls carry `toolName`, but the persisted forge read model (loaded by refreshConversation after completion) carries `name` (and may use `parameters` instead of `args`). The agent-tree adapter read `tc.toolName` directly, so once a chat finished and its turns were refreshed from the server, `Task` calls were no longer recognized and every sub-agent vanished from the canvas — leaving just the root. Detect the tool name via `toolName ?? name` and read args via `args ?? parameters`, so sub-agents render identically mid-run and after completion. Tests cover the persisted (`name`) shape and `parameters` fallback. Co-Authored-By: Claude Opus 4.8 --- .../chat/agent-canvas/buildAgentRunTree.ts | 15 +++++++++++-- .../spa/client/agent-canvas-data.test.ts | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts index ace1a1c21..ee40b6134 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -119,9 +119,20 @@ function asRecord(v: unknown): Record { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; } +// Live (SSE) tool calls carry `toolName` + `args`; persisted ones (forge's +// ToolCall read model) carry `name` + `args`/`parameters`. Read both so a +// sub-agent is detected the same way mid-run and after the chat completes. +function rawToolName(tc: ClientToolCall): string | undefined { + return tc.toolName ?? (tc as { name?: string }).name; +} + +function rawArgs(tc: ClientToolCall): unknown { + return tc.args ?? (tc as { parameters?: unknown }).parameters; +} + /** Build a sub-agent node from a normalized `Task` tool call. */ function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { - const args = asRecord(tc.args); + const args = asRecord(rawArgs(tc)); const agentType = typeof args.agent_type === 'string' ? args.agent_type : typeof args.subagent_type === 'string' ? args.subagent_type : ''; @@ -155,7 +166,7 @@ export function buildAgentRunTreeFromTurns( root?: AgentRunRootMeta, ): AgentRunNode { const taskCalls = collectToolCalls(turns || []) - .filter((tc) => normalizeToolName(tc.toolName) === 'task'); + .filter((tc) => normalizeToolName(rawToolName(tc)) === 'task'); const children = taskCalls.map(nodeFromTaskCall); children.sort(byStartedAt); diff --git a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts index a21d5b2fa..7b7b6a4b2 100644 --- a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -80,6 +80,27 @@ describe('buildAgentRunTreeFromTurns', () => { expect(buildAgentRunTreeFromTurns(turns).children).toEqual([]); }); + it('detects persisted Task calls that use `name` instead of `toolName`', () => { + // forge's persisted ToolCall read model carries `name`, not `toolName`, + // so sub-agents must still be found after the chat completes + refreshes. + const persisted = { + id: 't1', name: 'Task', status: 'completed', result: 'done', + args: { agent_type: 'Explore', description: 'map data model' }, + } as unknown as ClientToolCall; + const root = buildAgentRunTreeFromTurns([assistantTurn([persisted])]); + expect(root.children).toHaveLength(1); + expect(root.children[0]).toMatchObject({ id: 't1', name: 'map data model', role: 'Explore', status: 'done' }); + }); + + it('reads sub-agent args from `parameters` when `args` is absent', () => { + const persisted = { + id: 't1', name: 'Task', status: 'running', + parameters: { agent_type: 'general-purpose', description: 'research' }, + } as unknown as ClientToolCall; + const root = buildAgentRunTreeFromTurns([assistantTurn([persisted])]); + expect(root.children[0]).toMatchObject({ id: 't1', name: 'research', role: 'general-purpose', status: 'running' }); + }); + it('dedupes a tool call seen in both toolCalls and the timeline, preferring terminal state', () => { const turns = [assistantTurn( [tc({ id: 't1', args: { description: 'x' }, status: 'running' })], From 9aa284cfa2b780c2b97bc5cf2853b991b4b5b15e Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 14:33:34 -0700 Subject: [PATCH 05/11] feat(coc): agents canvas defaults to 100% zoom, centered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas opened auto-fit (scaled down to fit the whole tree). Default to 100% zoom with the content centered in the viewport instead, so nodes render at full size on open and the user pans to explore. - useZoomPan: add `centerContent(scale = 1)` — sets the given scale and centers content in the container (additive; existing consumers unaffected). - AgentCanvas: use centerContent(1) for the default/auto view (mount, tree growth, resize) until the user takes over. The Fit button still zooms to fit the whole tree and now sticks (counts as an interaction) rather than re-arming auto-center. Tests cover centerContent (centering math, no-container no-op, scale clamping). Co-Authored-By: Claude Opus 4.8 --- .../chat/agent-canvas/AgentCanvas.tsx | 17 ++++++----- .../spa/client/react/hooks/ui/useZoomPan.ts | 17 ++++++++++- .../test/spa/react/hooks/useZoomPan.test.ts | 30 +++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx index 8a1882cb0..a916a80a0 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx @@ -104,22 +104,23 @@ function CanvasNode({ entry, selected, onSelect, now }: { export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) { const layout = useMemo(() => buildLayout(root), [root]); - const { containerRef, state, zoomIn, zoomOut, fitToView, zoomLabel } = useZoomPan({ + const { containerRef, state, zoomIn, zoomOut, fitToView, centerContent, zoomLabel } = useZoomPan({ contentWidth: layout.worldW, contentHeight: layout.worldH, minZoom: 0.25, maxZoom: 2.2, }); - // Auto-fit on mount and whenever the tree resizes — until the user takes - // over the view (wheel/drag/zoom). The Fit button re-arms auto-fit. + // Default view: 100% zoom, content centered in the viewport. Re-centers on + // mount, tree growth, and container resize — until the user takes over + // (wheel/drag/zoom or the Fit button). const interactedRef = useRef(false); useLayoutEffect(() => { if (!interactedRef.current) { - fitToView(); + centerContent(1); } - }, [layout.worldW, layout.worldH, fitToView]); + }, [layout.worldW, layout.worldH, centerContent]); useEffect(() => { const el = containerRef.current; @@ -131,7 +132,7 @@ export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) { el.addEventListener('pointerdown', markInteracted); const ro = new ResizeObserver(() => { if (!interactedRef.current) { - fitToView(); + centerContent(1); } }); ro.observe(el); @@ -140,7 +141,7 @@ export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) { el.removeEventListener('pointerdown', markInteracted); ro.disconnect(); }; - }, [containerRef, fitToView]); + }, [containerRef, centerContent]); // Live clock so running nodes' elapsed time ticks; idle when nothing runs. const hasRunning = useMemo(() => anyRunning(root), [root]); @@ -211,7 +212,7 @@ export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) { - {zoomLabel} + + + {zoomMenuOpen && ( +
+ {ZOOM_PRESETS.map((p) => ( + + ))} + + +
+ )} +
)} + {selectedNode && ( + setSelectedId(null)} + onSelectChild={(child) => setSelectedId(child.id)} + onOpenInThread={onOpenInThread} + /> + )} +
@@ -273,12 +305,14 @@ export function AgentCanvas({ root, selectedId, onSelect }: AgentCanvasProps) {
-
- running - done - queued - drag to pan · scroll to zoom -
+ {!selectedNode && ( +
+ running + done + queued + drag to pan · scroll to zoom +
+ )}
); } diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx new file mode 100644 index 000000000..7aac1e465 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx @@ -0,0 +1,124 @@ +// AgentInspector — a right-side detail panel for a selected run on the Agents +// canvas. Shows the run's role/status/timing, the task it was handed, its +// result/conclusion, and its spawned children (clickable to drill in). + +import { AcIcons, roleIcon } from './icons'; +import type { AgentRunNode, AgentRunStatus } from './types'; + +const STATUS_LABEL: Record = { + running: 'Running', + done: 'Done', + failed: 'Failed', + queued: 'Queued', +}; + +function fmtDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, '0')}`; +} + +function durationText(node: AgentRunNode, now: number): string | null { + if (node.status === 'running' && node.startedAt !== undefined) { + return fmtDuration((now || node.startedAt) - node.startedAt); + } + if (node.startedAt !== undefined && node.completedAt !== undefined) { + return fmtDuration(node.completedAt - node.startedAt); + } + return null; +} + +function resultText(node: AgentRunNode): string { + if (node.result) { + return node.result; + } + if (node.status === 'running') { + return 'Running…'; + } + if (node.status === 'queued') { + return 'Queued — waiting for a worker.'; + } + return 'No result recorded.'; +} + +export interface AgentInspectorProps { + node: AgentRunNode; + /** Live clock (epoch ms) so a running node's elapsed time ticks. */ + now: number; + onClose: () => void; + /** Select a child run (drill in). */ + onSelectChild?: (node: AgentRunNode) => void; + /** Jump to this run's turn in the linear thread. */ + onOpenInThread?: (node: AgentRunNode) => void; +} + +export function AgentInspector({ node, now, onClose, onSelectChild, onOpenInThread }: AgentInspectorProps) { + const isRoot = !!node.isRoot; + const RoleIcon = isRoot ? AcIcons.Orchestr : roleIcon(node.role); + const kids = node.children || []; + const dur = durationText(node, now); + const terminal = node.status === 'done' || node.status === 'failed'; + + return ( + + ); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css index 082ffccc7..0708826c7 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css @@ -423,3 +423,233 @@ color: var(--muted); font-weight: 600; } + +/* Inspector — right-side detail panel for a selected run. */ +.agent-canvas .agent-inspector { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 340px; + max-width: 86%; + display: flex; + flex-direction: column; + background: var(--panel); + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px rgb(20 30 60 / 0.10); + overflow-y: auto; + z-index: 6; + cursor: default; +} + +.agent-canvas .agent-inspector .ai-head { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 14px 12px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--panel); +} + +.agent-canvas .agent-inspector .ai-badge { + width: 30px; + height: 30px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--spine-soft); + color: var(--accent); +} + +.agent-canvas .agent-inspector .ai-title { + min-width: 0; + flex: 1; +} + +.agent-canvas .agent-inspector .ai-name { + font-size: 13.5px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-canvas .agent-inspector .ai-role { + font-size: 10.5px; + font-family: var(--font-mono); + color: var(--accent); + margin-top: 1px; +} + +.agent-canvas .agent-inspector .ai-close { + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.agent-canvas .agent-inspector .ai-close:hover { + background: var(--hover); + color: var(--text); +} + +.agent-canvas .agent-inspector .ai-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; +} + +.agent-canvas .agent-inspector .ai-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + padding: 3px 9px; + border-radius: 999px; + background: var(--hover); + color: var(--muted); +} + +.agent-canvas .agent-inspector .ai-dot { + width: 7px; + height: 7px; + border-radius: 999px; +} + +.agent-canvas .agent-inspector .ai-dot[data-status="running"] { background: var(--accent); } +.agent-canvas .agent-inspector .ai-dot[data-status="done"] { background: var(--success); } +.agent-canvas .agent-inspector .ai-dot[data-status="failed"] { background: var(--danger); } +.agent-canvas .agent-inspector .ai-dot[data-status="queued"] { background: var(--faint); } + +.agent-canvas .agent-inspector .ai-stat { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-family: var(--font-mono); + color: var(--muted); +} + +.agent-canvas .agent-inspector .ai-stat svg { opacity: 0.8; } + +.agent-canvas .agent-inspector .ai-section { + padding: 2px 14px 14px; + border-top: 1px solid var(--border); +} + +.agent-canvas .agent-inspector .ai-section h4 { + margin: 12px 0 6px; + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); +} + +.agent-canvas .agent-inspector .ai-text { + font-size: 12.5px; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.agent-canvas .agent-inspector .ai-children { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.agent-canvas .agent-inspector .ai-children button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + border: 1px solid var(--border); + background: var(--panel); + border-radius: 8px; + padding: 7px 9px; + cursor: pointer; +} + +.agent-canvas .agent-inspector .ai-children button:hover { + background: var(--hover); + border-color: var(--border-strong); +} + +.agent-canvas .agent-inspector .ai-cbadge { + width: 20px; + height: 20px; + border-radius: 5px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--spine-soft); + color: var(--accent); +} + +.agent-canvas .agent-inspector .ai-cname { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-canvas .agent-inspector .ai-cstate { + width: 7px; + height: 7px; + border-radius: 999px; + flex-shrink: 0; +} + +.agent-canvas .agent-inspector .ai-cstate[data-status="running"] { background: var(--accent); } +.agent-canvas .agent-inspector .ai-cstate[data-status="done"] { background: var(--success); } +.agent-canvas .agent-inspector .ai-cstate[data-status="failed"] { background: var(--danger); } +.agent-canvas .agent-inspector .ai-cstate[data-status="queued"] { background: var(--faint); } + +.agent-canvas .agent-inspector .ai-open-thread { + margin: 4px 14px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + border-radius: 8px; + padding: 9px; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.agent-canvas .agent-inspector .ai-open-thread:hover { + background: var(--hover); + border-color: var(--border-strong); +} diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts index ee40b6134..08f264048 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -141,9 +141,7 @@ function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { const name = description || (prompt ? (prompt.length > 48 ? `${prompt.slice(0, 45).trimEnd()}…` : prompt) : '') || 'sub-agent'; - const summary = typeof tc.result === 'string' && tc.result.trim() - ? firstLine(tc.result) - : undefined; + const result = typeof tc.result === 'string' && tc.result.trim() ? tc.result.trim() : undefined; return { id: tc.id, name, @@ -151,7 +149,9 @@ function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { status: mapToolStatus(tc.status), startedAt: parseTime(tc.startTime), completedAt: parseTime(tc.endTime), - summary, + summary: result ? firstLine(result) : undefined, + prompt: prompt || undefined, + result, children: [], }; } diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx index 4379b16e4..849b35aa7 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx @@ -42,6 +42,8 @@ export const AcIcons = { // ── canvas controls / nav ── Spawn: mk(<>), + Clock: mk(<>), + X: mk(<>), Expand: mk(<>), Collapse: mk(<>), Replay: mk(<>), diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts index 909e6f9de..483f88a77 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts @@ -24,6 +24,10 @@ export interface AgentRunNode { completedAt?: number; /** One-line summary / conclusion, if available. */ summary?: string; + /** Full task/instruction handed to this sub-agent (the Task tool's prompt). */ + prompt?: string; + /** Full result/output the run produced, if available. */ + result?: string; /** Recursively spawned child runs. */ children: AgentRunNode[]; } diff --git a/packages/coc/test/spa/react/AgentCanvas.test.tsx b/packages/coc/test/spa/react/AgentCanvas.test.tsx index c487d6f98..d4b84b543 100644 --- a/packages/coc/test/spa/react/AgentCanvas.test.tsx +++ b/packages/coc/test/spa/react/AgentCanvas.test.tsx @@ -36,18 +36,39 @@ describe('AgentCanvas', () => { expect(screen.getByTestId('agent-canvas-node-root')).toBeTruthy(); }); - it('calls onSelect with the clicked node', () => { - const onSelect = vi.fn(); - render(); + it('opens the inspector with the clicked sub-agent details and highlights the node', () => { + render(); + expect(screen.queryByTestId('agent-inspector')).toBeNull(); fireEvent.click(screen.getByTestId('agent-canvas-node-explore')); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect.mock.calls[0][0]).toMatchObject({ id: 'explore' }); + const inspector = screen.getByTestId('agent-inspector'); + expect(within(inspector).getByText('map data')).toBeTruthy(); + expect(within(inspector).getByText('go map it')).toBeTruthy(); + expect(within(inspector).getByText('mapped ok')).toBeTruthy(); + expect(screen.getByTestId('agent-canvas-node-explore').className).toContain('sel'); }); - it('marks the selected node with the sel class', () => { - render(); - expect(screen.getByTestId('agent-canvas-node-explore').className).toContain('sel'); - expect(screen.getByTestId('agent-canvas-node-root').className).not.toContain('sel'); + it('closes the inspector when the root node is clicked', () => { + render(); + fireEvent.click(screen.getByTestId('agent-canvas-node-explore')); + expect(screen.getByTestId('agent-inspector')).toBeTruthy(); + fireEvent.click(screen.getByTestId('agent-canvas-node-root')); + expect(screen.queryByTestId('agent-inspector')).toBeNull(); + }); + + it('closes the inspector via the close button', () => { + render(); + fireEvent.click(screen.getByTestId('agent-canvas-node-explore')); + fireEvent.click(within(screen.getByTestId('agent-inspector')).getByLabelText('Close inspector')); + expect(screen.queryByTestId('agent-inspector')).toBeNull(); + }); + + it('calls onOpenInThread from the inspector', () => { + const onOpenInThread = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('agent-canvas-node-explore')); + fireEvent.click(screen.getByTestId('agent-inspector-open-thread')); + expect(onOpenInThread).toHaveBeenCalledTimes(1); + expect(onOpenInThread.mock.calls[0][0]).toMatchObject({ id: 'explore' }); }); it('renders the zoom toolbar with a percentage label', () => { diff --git a/packages/coc/test/spa/react/AgentInspector.test.tsx b/packages/coc/test/spa/react/AgentInspector.test.tsx new file mode 100644 index 000000000..deabab6e2 --- /dev/null +++ b/packages/coc/test/spa/react/AgentInspector.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentInspector } from '../../../src/server/spa/client/react/features/chat/agent-canvas/AgentInspector'; +import type { AgentRunNode } from '../../../src/server/spa/client/react/features/chat/agent-canvas/types'; + +function node(overrides: Partial = {}): AgentRunNode { + return { id: 'r1', name: 'explore-thing', role: 'Explore', status: 'done', children: [], ...overrides }; +} + +describe('AgentInspector', () => { + it('renders name, role, status, duration, task and result for a finished run', () => { + render(); + expect(screen.getByText('explore-thing')).toBeTruthy(); + expect(screen.getByText('Explore')).toBeTruthy(); + expect(screen.getByText('Done')).toBeTruthy(); + expect(screen.getByText('0:09')).toBeTruthy(); // (9000-0)ms + expect(screen.getByText('do the thing')).toBeTruthy(); + expect(screen.getByText('did it')).toBeTruthy(); + }); + + it('shows a running placeholder when there is no result yet', () => { + render(); + expect(screen.getByText('Running…')).toBeTruthy(); + }); + + it('shows a queued message for queued runs', () => { + render(); + expect(screen.getByText(/waiting for a worker/)).toBeTruthy(); + }); + + it('lists children and drills into one when clicked', () => { + const onSelectChild = vi.fn(); + const root: AgentRunNode = { + id: 'root', name: 'CoC', role: 'orchestrator', status: 'running', isRoot: true, + children: [node({ id: 'a', name: 'child-a' }), node({ id: 'b', name: 'child-b' })], + }; + render(); + expect(screen.getByText('child-a')).toBeTruthy(); + fireEvent.click(screen.getByTestId('agent-inspector-child-b')); + expect(onSelectChild).toHaveBeenCalledTimes(1); + expect(onSelectChild.mock.calls[0][0]).toMatchObject({ id: 'b' }); + }); + + it('calls onClose from the close button', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('Close inspector')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('hides the result section and Open-in-thread for the orchestrator root', () => { + const onOpenInThread = vi.fn(); + const root: AgentRunNode = { + id: 'root', name: 'CoC', role: 'orchestrator', status: 'running', isRoot: true, children: [], + }; + render(); + expect(screen.queryByTestId('agent-inspector-open-thread')).toBeNull(); + expect(screen.queryByText('Result')).toBeNull(); + }); +}); From 634b13573d1059ba25ca4ed5a700e21cffc60bdb Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 15:53:54 -0700 Subject: [PATCH 09/11] feat(coc): show sub-agent name/type/model/mode in the inspector The Task tool's args carry richer fields than were surfaced. Capture and display them: - buildAgentRunTree: read `name` (the agent's name) as the node title (falling back to description/prompt), plus `model`, `mode`, and keep `description` when it adds something beyond the name. - AgentInspector: add a details list (Model / Mode / Summary) under the status meta. Type is the role shown in the header, name is the title. So a node like { name: "time-agent-1", agent_type: "explore", model: "claude-sonnet-4.6", mode: "background", description: "Query current time" } now reads clearly in the panel. Tests cover the new arg capture (name title, model/mode/description) and the inspector rendering them. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 15 ++++---- .../chat/agent-canvas/AgentInspector.tsx | 8 +++++ .../chat/agent-canvas/agent-canvas.css | 25 +++++++++++++ .../chat/agent-canvas/buildAgentRunTree.ts | 24 +++++++++---- .../react/features/chat/agent-canvas/types.ts | 8 ++++- .../spa/client/agent-canvas-data.test.ts | 35 +++++++++++++++++++ .../test/spa/react/AgentInspector.test.tsx | 13 +++++++ 7 files changed, 115 insertions(+), 13 deletions(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 7fb39b3a8..eb3fd223e 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -314,8 +314,11 @@ strips the `?query` so the param never corrupts the taskId. `view` resets to `buildAgentRunTreeFromTurns(turns, root)` derives the tree with no extra fetch: the orchestrator (this process) is the root and each `Task` tool call becomes a -sub-agent child (name/role from args, status/timing from the call), deduped -across `toolCalls`+timeline and ordered by start time. Tool name/args are read +sub-agent child. From the call's args it captures the agent name (`args.name`, +falling back to `description`/`prompt`), type (`agent_type`/`subagent_type`), +`model`, `mode`, `description`, and `prompt`; status/timing come from the call. +Children are deduped across `toolCalls`+timeline and ordered by start time. +Tool name/args are read via `toolName ?? name` and `args ?? parameters` so sub-agents are detected in both the live (SSE) shape and the persisted forge read model — they stay on the canvas after the chat completes and turns refresh. The `AgentRunNode` tree @@ -328,13 +331,13 @@ takes over. The toolbar's % is a dropdown of preset levels It renders curved SVG edges + node cards (role glyph, name, live elapsed, spawn-count pill, status dot, progress bar) and a live 1s clock for running nodes. Clicking a sub-agent node opens `AgentInspector` — a right-side panel -with the run's role/status/elapsed, the task prompt, its result, and its -children (clickable to drill in); clicking the orchestrator root closes it. +with the run's name/type/status/elapsed, a details list (model, mode, summary), +the task prompt, its result, and its children (clickable to drill in); clicking +the orchestrator root closes it. `AgentCanvas` owns the selection; the inspector's "Open in thread" button calls `onOpenInThread`, which `ChatDetail` maps back to the issuing turn via `findTurnIndexForRun`, switching to the thread and scrolling there. -`buildAgentRunTreeFromTurns` also captures each run's `prompt` and `result` for -the inspector. Styles live in scoped `agent-canvas.css` (`.agent-canvas`, +Styles live in scoped `agent-canvas.css` (`.agent-canvas`, light/dark via `.dark`); there is no clock scrubber (the prototype's replay control is dropped — the real view is live). Distinct from the co-edited `CanvasPanel` side panel. diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx index 7aac1e465..5a3e3302f 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx @@ -80,6 +80,14 @@ export function AgentInspector({ node, now, onClose, onSelectChild, onOpenInThre {kids.length > 0 && {kids.length} spawned} + {(node.model || node.mode || node.description) && ( +
+ {node.model && (<>
Model
{node.model}
)} + {node.mode && (<>
Mode
{node.mode}
)} + {node.description && (<>
Summary
{node.description}
)} +
+ )} + {node.prompt && (

Task

diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css index 0708826c7..6085a947f 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css @@ -548,6 +548,31 @@ .agent-canvas .agent-inspector .ai-stat svg { opacity: 0.8; } +.agent-canvas .agent-inspector .ai-fields { + margin: 0; + padding: 0 14px 12px; + display: grid; + grid-template-columns: auto 1fr; + gap: 5px 12px; + align-items: baseline; +} + +.agent-canvas .agent-inspector .ai-fields dt { + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); +} + +.agent-canvas .agent-inspector .ai-fields dd { + margin: 0; + font-size: 12px; + color: var(--text); + font-family: var(--font-mono); + word-break: break-word; +} + .agent-canvas .agent-inspector .ai-section { padding: 2px 14px 14px; border-top: 1px solid var(--border); diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts index 08f264048..a6678a926 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -130,15 +130,23 @@ function rawArgs(tc: ClientToolCall): unknown { return tc.args ?? (tc as { parameters?: unknown }).parameters; } +function asString(v: unknown): string { + return typeof v === 'string' ? v.trim() : ''; +} + /** Build a sub-agent node from a normalized `Task` tool call. */ function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { const args = asRecord(rawArgs(tc)); - const agentType = typeof args.agent_type === 'string' ? args.agent_type - : typeof args.subagent_type === 'string' ? args.subagent_type - : ''; - const description = typeof args.description === 'string' ? args.description.trim() : ''; - const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''; - const name = description + const agentType = asString(args.agent_type) || asString(args.subagent_type); + const agentName = asString(args.name); + const description = asString(args.description); + const prompt = asString(args.prompt); + const model = asString(args.model); + const mode = asString(args.mode); + // Prefer the explicit agent name; fall back to the description, then a + // truncated prompt. + const name = agentName + || description || (prompt ? (prompt.length > 48 ? `${prompt.slice(0, 45).trimEnd()}…` : prompt) : '') || 'sub-agent'; const result = typeof tc.result === 'string' && tc.result.trim() ? tc.result.trim() : undefined; @@ -146,6 +154,10 @@ function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { id: tc.id, name, role: agentType || 'agent', + // Keep the description only when it adds something beyond the name. + description: description && description !== name ? description : undefined, + model: model || undefined, + mode: mode || undefined, status: mapToolStatus(tc.status), startedAt: parseTime(tc.startTime), completedAt: parseTime(tc.endTime), diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts index 483f88a77..cc4326e15 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts @@ -13,8 +13,14 @@ export interface AgentRunNode { id: string; /** Display name: the sub-agent's description (or a truncated prompt). */ name: string; - /** Role label: the sub-agent type (e.g. 'Explore'), or 'orchestrator' for the root. */ + /** Role label: the sub-agent type (e.g. 'explore'), or 'orchestrator' for the root. */ role: string; + /** Short description of the run's task, when distinct from the name. */ + description?: string; + /** Model the sub-agent runs on (e.g. 'claude-sonnet-4.6'), if specified. */ + model?: string; + /** Execution mode (e.g. 'background'), if specified. */ + mode?: string; status: AgentRunStatus; /** True only for the synthetic orchestrator root. */ isRoot?: boolean; diff --git a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts index 7b7b6a4b2..997da19b4 100644 --- a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -72,6 +72,41 @@ describe('buildAgentRunTreeFromTurns', () => { expect(buildAgentRunTreeFromTurns(turns).children[0].role).toBe('rust-code-reviewer'); }); + it('captures name, type, model, mode and description from Task args', () => { + const turns = [assistantTurn([tc({ + id: 't1', + status: 'running', + args: { + agent_type: 'explore', + name: 'time-agent-1', + description: 'Query current time', + model: 'claude-sonnet-4.6', + mode: 'background', + prompt: 'Query the current date and time.', + }, + })])]; + expect(buildAgentRunTreeFromTurns(turns).children[0]).toMatchObject({ + id: 't1', + name: 'time-agent-1', + role: 'explore', + description: 'Query current time', + model: 'claude-sonnet-4.6', + mode: 'background', + prompt: 'Query the current date and time.', + }); + }); + + it('uses the agent name as the title and drops a redundant description', () => { + // No explicit name → title falls back to description, which is then cleared + // so the inspector does not show it twice. + const child = buildAgentRunTreeFromTurns([ + assistantTurn([tc({ id: 't1', args: { agent_type: 'explore', description: 'map data' } })]), + ]).children[0]; + expect(child.name).toBe('map data'); + expect(child.description).toBeUndefined(); + expect(child.model).toBeUndefined(); + }); + it('ignores non-Task tool calls', () => { const turns = [assistantTurn([ tc({ id: 'r1', toolName: 'Read', args: { file_path: '/a.ts' } }), diff --git a/packages/coc/test/spa/react/AgentInspector.test.tsx b/packages/coc/test/spa/react/AgentInspector.test.tsx index deabab6e2..22e702c77 100644 --- a/packages/coc/test/spa/react/AgentInspector.test.tsx +++ b/packages/coc/test/spa/react/AgentInspector.test.tsx @@ -19,6 +19,19 @@ describe('AgentInspector', () => { expect(screen.getByText('did it')).toBeTruthy(); }); + it('shows the agent type, model, mode and summary', () => { + render(); + expect(screen.getByText('time-agent-1')).toBeTruthy(); + expect(screen.getByText('explore')).toBeTruthy(); // type, shown as the role in the head + expect(screen.getByText('claude-sonnet-4.6')).toBeTruthy(); + expect(screen.getByText('background')).toBeTruthy(); + expect(screen.getByText('Query current time')).toBeTruthy(); + }); + it('shows a running placeholder when there is no result yet', () => { render(); expect(screen.getByText('Running…')).toBeTruthy(); From f531fd2eade36177d301426f4c19163c6bca8433 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 16:25:43 -0700 Subject: [PATCH 10/11] fix(coc): keep sub-agent name/model/type when tool-complete has empty args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Task call appears three times in a completed conversation: toolCalls (full args), timeline tool-start (full args), and timeline tool-complete (same terminal status, but EMPTY args). The dedup preferred the latest equal-rank snapshot — the empty-args tool-complete — so every node fell back to "sub-agent"/"agent" with no model/mode. collectToolCalls now keeps whichever snapshot has non-empty args (nonEmptyArgs helper), so the name/type/model/mode survive. Verified against the live "3 subagents" chat: nodes now resolve to time-agent-1/2/3 · explore · claude-sonnet-4.6 · background. Regression test replicates the three-snapshot shape. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 4 +++- .../chat/agent-canvas/buildAgentRunTree.ts | 13 ++++++++++++ .../spa/client/agent-canvas-data.test.ts | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index eb3fd223e..f7bdbdb93 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -317,7 +317,9 @@ the orchestrator (this process) is the root and each `Task` tool call becomes a sub-agent child. From the call's args it captures the agent name (`args.name`, falling back to `description`/`prompt`), type (`agent_type`/`subagent_type`), `model`, `mode`, `description`, and `prompt`; status/timing come from the call. -Children are deduped across `toolCalls`+timeline and ordered by start time. +Children are deduped across `toolCalls`+timeline — keeping the snapshot with +non-empty args, since a terminal `tool-complete` often carries empty args while +an earlier snapshot holds the full invocation — and ordered by start time. Tool name/args are read via `toolName ?? name` and `args ?? parameters` so sub-agents are detected in both the live (SSE) shape and the persisted forge read model — they stay on the diff --git a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts index a6678a926..0a723eeb5 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -79,9 +79,14 @@ function collectToolCalls(turns: ClientConversationTurn[]): ClientToolCall[] { const keepNew = (STATUS_RANK[tc.status] ?? 0) >= (STATUS_RANK[existing.status] ?? 0); const better = keepNew ? tc : existing; const worse = keepNew ? existing : tc; + // The terminal snapshot (e.g. a timeline `tool-complete`) often carries + // EMPTY args while an earlier snapshot has the full invocation args — + // keep whichever args are non-empty so name/model/type survive. + const mergedArgs = nonEmptyArgs(better) ?? nonEmptyArgs(worse); byId.set(tc.id, { ...worse, ...better, + ...(mergedArgs ? { args: mergedArgs } : {}), startTime: better.startTime ?? worse.startTime, endTime: better.endTime ?? worse.endTime, result: better.result ?? worse.result, @@ -130,6 +135,14 @@ function rawArgs(tc: ClientToolCall): unknown { return tc.args ?? (tc as { parameters?: unknown }).parameters; } +/** The tool call's args (or parameters) only when it's a non-empty object. */ +function nonEmptyArgs(tc: ClientToolCall): Record | undefined { + const a = rawArgs(tc); + return a && typeof a === 'object' && !Array.isArray(a) && Object.keys(a).length > 0 + ? (a as Record) + : undefined; +} + function asString(v: unknown): string { return typeof v === 'string' ? v.trim() : ''; } diff --git a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts index 997da19b4..cc3d90a7a 100644 --- a/packages/coc/test/server/spa/client/agent-canvas-data.test.ts +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -151,6 +151,27 @@ describe('buildAgentRunTreeFromTurns', () => { expect(root.children[0].summary).toBe('all green'); }); + it('keeps full args when a later tool-complete snapshot has empty args', () => { + // Real shape: toolCalls + timeline tool-start carry full args, but the + // timeline tool-complete (same id, same terminal status) has empty args. + const fullArgs = { + agent_type: 'explore', name: 'time-agent-1', + model: 'claude-sonnet-4.6', mode: 'background', description: 'Query current time', + }; + const turns = [assistantTurn( + [tc({ id: 't1', args: fullArgs, status: 'completed', result: 'ok' })], + [ + { type: 'tool-start', timestamp: '2026-06-13T21:18:04.000Z', toolCall: tc({ id: 't1', args: fullArgs, status: 'running' }) }, + { type: 'tool-complete', timestamp: '2026-06-13T21:18:09.000Z', toolCall: tc({ id: 't1', args: {}, status: 'completed', result: 'ok' }) }, + ], + )]; + const child = buildAgentRunTreeFromTurns(turns).children[0]; + expect(child).toMatchObject({ + id: 't1', name: 'time-agent-1', role: 'explore', + model: 'claude-sonnet-4.6', mode: 'background', status: 'done', + }); + }); + it('derives root status from children when no explicit status is given', () => { const running = [assistantTurn([tc({ id: 't1', args: { description: 'x' }, status: 'running' })])]; expect(buildAgentRunTreeFromTurns(running).status).toBe('running'); From 2f33236e7e196c6fa465b8209f12731e1f9cf02e Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 18:58:04 -0700 Subject: [PATCH 11/11] fix(coc): strip ?query from the hash before routing so deep-links work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Router's hashchange handler split the raw hash into path parts without removing a trailing `?query`, so a deep-link like `#repos//activity/?view=agents` parsed the task id as `?view=agents` — the wrong id, which broke landing directly on the Agents view (and any other `?`-carrying chat deep-link). Strip the query once where the handler reads the hash, so every routed segment (repoId, sub-tab, taskId) is clean. The query stays on `location.hash`, so components that read it directly (ChatDetail's `?view`, TaskPreview's `?mode`) still work. Mirrors the existing `parseActivityDeepLink` query-strip (already tested). Co-Authored-By: Claude Opus 4.8 --- packages/coc/src/server/spa/client/react/layout/Router.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coc/src/server/spa/client/react/layout/Router.tsx b/packages/coc/src/server/spa/client/react/layout/Router.tsx index 2ff13bcad..d2175af1a 100644 --- a/packages/coc/src/server/spa/client/react/layout/Router.tsx +++ b/packages/coc/src/server/spa/client/react/layout/Router.tsx @@ -508,7 +508,10 @@ export function Router() { // blank-page flash when navigating directly to a deep-link (e.g. on refresh). useLayoutEffect(() => { const handleHash = () => { - const hash = location.hash.replace(/^#/, ''); + // Strip any `?query` (e.g. `?view=agents`) before parsing the path — + // it's metadata for components (which read it from location.hash + // directly), never part of the routed path or a task id. + const hash = location.hash.replace(/^#/, '').split('?')[0]; const tab = tabFromHash('#' + hash); if (tab) { dispatch({ type: 'SET_ACTIVE_TAB', tab });