diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index f209b99c8..f7bdbdb93 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -296,6 +296,54 @@ 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. In the main inline context +the view is deep-linked: a `?view=agents` query param on the chat hash +(`#repos///?view=agents`) is read on mount (so a +shared/bookmarked URL reopens straight into the canvas) and written via +`history.replaceState` on toggle (`chatViewHash.ts`). `parseActivityDeepLink` +strips the `?query` so the param never corrupts the taskId. `view` resets to +`thread` on chat switch (honoring a deep-linked view on first mount). + +`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. 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 — 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 +canvas after the chat completes and turns refresh. The `AgentRunNode` tree +supports arbitrary depth, so deeper recursion can be layered on later. +`AgentCanvas` reuses the shared `useZoomPan` hook — it opens at 100% zoom, +centered (`centerContent`), re-centering on mount/growth/resize until the user +takes over. The toolbar's % is a dropdown of preset levels +(25/50/75/100/150/200% + Fit) backed by `useZoomPan`'s `zoomTo(scale)` +(zooms about the viewport center); the Fit button zooms to fit the whole tree. +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 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. +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..41c5f480c 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, readChatViewFromHash, applyChatViewToHash } 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,12 @@ 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. In the main inline + // context the view is deep-linked via a `?view=agents` hash param, so a + // shared/bookmarked URL reopens straight into the canvas. + const hashViewSync = variant === 'inline' && !standalone; + const [view, setView] = useState(() => (hashViewSync ? (readChatViewFromHash(window.location.hash) ?? 'thread') : 'thread')); + 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]); + + // The inspector's "Open in thread" action: switch to the thread and scroll + // to the run's turn. + const openAgentInThread = useCallback((node: AgentRunNode) => { + 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) @@ -429,6 +467,29 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari setInvalidScratchpadPaths(new Set()); }, [taskId]); + // Reset the Agents view when switching chats, but honor a deep-linked view + // on first mount (the useState initializer already read it from the hash). + const viewResetMountRef = useRef(true); + useEffect(() => { + if (viewResetMountRef.current) { + viewResetMountRef.current = false; + return; + } + setView(hashViewSync ? (readChatViewFromHash(window.location.hash) ?? 'thread') : 'thread'); + setPendingScrollTurn(null); + }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Mirror the active view into the chat hash (`?view=agents`) so it can be + // shared/bookmarked. replaceState avoids history spam and doesn't refire + // the router's hashchange handler. + useEffect(() => { + if (!hashViewSync || !window.location.hash) return; + const next = applyChatViewToHash(window.location.hash, view); + if (next !== window.location.hash) { + window.history.replaceState(null, '', next); + } + }, [view, hashViewSync]); + // ── Resolve existing implementation runs from task metadata ───────── const rawImplementations: ImplementationRecord[] = useMemo(() => { const impls = task?.metadata?.implementations; @@ -1609,6 +1670,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 +1699,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 +1803,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/AgentCanvas.tsx b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx new file mode 100644 index 000000000..195c9f7c6 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentCanvas.tsx @@ -0,0 +1,318 @@ +// 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 opens an +// inspector with that run's details (clicking the root closes it). +// +// 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 { useCallback, 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 { AgentInspector } from './AgentInspector'; +import './agent-canvas.css'; + +export interface AgentCanvasProps { + /** The orchestrator root whose subtree is the agent run tree. */ + root: AgentRunNode; + /** Jump to a run's turn in the linear thread (the inspector's "Open in thread"). */ + onOpenInThread?: (node: AgentRunNode) => void; +} + +/** Depth-first lookup of a node by id within the run tree. */ +function findNode(node: AgentRunNode, id: string): AgentRunNode | null { + if (node.id === id) { + return node; + } + for (const child of node.children || []) { + const found = findNode(child, id); + if (found) { + return found; + } + } + return null; +} + +// Preset zoom levels offered by the % menu (within useZoomPan's 25%–220% range). +const ZOOM_PRESETS = [25, 50, 75, 100, 150, 200]; + +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 !== undefined ? fmtDuration((now || node.startedAt) - node.startedAt) : 'running'; + case 'done': + return node.startedAt !== undefined && node.completedAt !== undefined ? 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, onOpenInThread }: AgentCanvasProps) { + const layout = useMemo(() => buildLayout(root), [root]); + + // Selected run drives the inspector. Resolve from the live tree so a node + // that disappears (e.g. tree changes) simply closes the panel. + const [selectedId, setSelectedId] = useState(null); + const selectedNode = useMemo(() => (selectedId ? findNode(root, selectedId) : null), [root, selectedId]); + const handleNodeClick = useCallback((node: AgentRunNode) => { + // The root (orchestrator) closes the inspector; a sub-agent opens it. + setSelectedId(node.isRoot ? null : node.id); + }, []); + + const { containerRef, state, zoomIn, zoomOut, fitToView, centerContent, zoomTo, zoomLabel } = useZoomPan({ + contentWidth: layout.worldW, + contentHeight: layout.worldH, + minZoom: 0.25, + maxZoom: 2.2, + }); + + const [zoomMenuOpen, setZoomMenuOpen] = useState(false); + useEffect(() => { + if (!zoomMenuOpen) { + return; + } + const close = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.cz-wrap')) { + setZoomMenuOpen(false); + } + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [zoomMenuOpen]); + const currentPct = Math.round(state.scale * 100); + + // 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) { + centerContent(1); + } + }, [layout.worldW, layout.worldH, centerContent]); + + 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) { + centerContent(1); + } + }); + ro.observe(el); + return () => { + el.removeEventListener('wheel', markInteracted); + el.removeEventListener('pointerdown', markInteracted); + ro.disconnect(); + }; + }, [containerRef, centerContent]); + + // 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. +
+ )} + + {selectedNode && ( + setSelectedId(null)} + onSelectChild={(child) => setSelectedId(child.id)} + onOpenInThread={onOpenInThread} + /> + )} + +
+ + + + {zoomMenuOpen && ( +
+ {ZOOM_PRESETS.map((p) => ( + + ))} + + +
+ )} +
+ + + +
+ + {!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..5a3e3302f --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/AgentInspector.tsx @@ -0,0 +1,132 @@ +// 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/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/agent-canvas.css b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css new file mode 100644 index 000000000..6085a947f --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/agent-canvas.css @@ -0,0 +1,680 @@ +/* ========================================================= + 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-wrap { + position: relative; + display: inline-flex; +} + +.agent-canvas .canvas-toolbar .cz { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + min-width: 46px; + text-align: center; + border: none; + background: transparent; + border-radius: 6px; + padding: 4px 6px; + cursor: pointer; +} + +.agent-canvas .canvas-toolbar .cz:hover { + background: var(--hover); + color: var(--text); +} + +.agent-canvas .canvas-zoom-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 1px; + min-width: 116px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 9px; + padding: 4px; + box-shadow: 0 6px 20px rgb(20 30 60 / 0.16); + z-index: 5; +} + +.agent-canvas .canvas-zoom-menu button { + display: flex; + align-items: center; + justify-content: flex-start; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 12px; + font-weight: 500; + font-family: var(--font-mono); + padding: 5px 9px; + border-radius: 6px; + text-align: left; +} + +.agent-canvas .canvas-zoom-menu button:hover { + background: var(--hover); + color: var(--text); +} + +.agent-canvas .canvas-zoom-menu button.on { + color: var(--accent); + font-weight: 600; +} + +.agent-canvas .canvas-zoom-menu .czm-sep { + height: 1px; + background: var(--border); + margin: 3px 4px; +} + +.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; +} + +/* 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-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); +} + +.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 new file mode 100644 index 000000000..0a723eeb5 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/buildAgentRunTree.ts @@ -0,0 +1,239 @@ +// 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; + // 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, + 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) : {}; +} + +// 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; +} + +/** 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() : ''; +} + +/** Build a sub-agent node from a normalized `Task` tool call. */ +function nodeFromTaskCall(tc: ClientToolCall): AgentRunNode { + const args = asRecord(rawArgs(tc)); + 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; + return { + 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), + summary: result ? firstLine(result) : undefined, + prompt: prompt || undefined, + result, + 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(rawToolName(tc)) === '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); +} + +/** + * 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/chatViewHash.ts b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/chatViewHash.ts new file mode 100644 index 000000000..a399d52d8 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/chatViewHash.ts @@ -0,0 +1,43 @@ +// Deep-link helpers for the chat's Thread/Agents view. The view rides as a +// `?view=agents` query param on the existing chat hash +// (`#repos///`), read on mount and written on toggle. Pure +// string functions so they're unit-testable and SSR/embed-safe. + +import type { ChatView } from './ChatViewToggle'; + +/** Read the chat view from a raw `location.hash` string, or null if unset/invalid. */ +export function readChatViewFromHash(rawHash: string): ChatView | null { + const qIndex = rawHash.indexOf('?'); + if (qIndex < 0) { + return null; + } + const view = new URLSearchParams(rawHash.slice(qIndex + 1)).get('view'); + if (view === 'agents') { + return 'agents'; + } + if (view === 'thread') { + return 'thread'; + } + return null; +} + +/** + * Return `rawHash` with the `view` param set for the agents view and removed + * for thread (the default). Preserves the path, leading `#`, and any other + * query params. Returns a value directly comparable to `location.hash`. + */ +export function applyChatViewToHash(rawHash: string, view: ChatView): string { + const hasLeadingHash = rawHash.startsWith('#'); + const body = hasLeadingHash ? rawHash.slice(1) : rawHash; + const qIndex = body.indexOf('?'); + const path = qIndex < 0 ? body : body.slice(0, qIndex); + const params = new URLSearchParams(qIndex < 0 ? '' : body.slice(qIndex + 1)); + if (view === 'agents') { + params.set('view', 'agents'); + } else { + params.delete('view'); + } + const query = params.toString(); + const result = query ? `${path}?${query}` : path; + return (hasLeadingHash ? '#' : '') + result; +} 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..849b35aa7 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/icons.tsx @@ -0,0 +1,76 @@ +// 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(<>), + Clock: mk(<>), + X: 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..592aa8afc --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/index.ts @@ -0,0 +1,8 @@ +export { AgentCanvas } from './AgentCanvas'; +export type { AgentCanvasProps } from './AgentCanvas'; +export { ChatViewToggle } from './ChatViewToggle'; +export type { ChatView } from './ChatViewToggle'; +export { readChatViewFromHash, applyChatViewToHash } from './chatViewHash'; +export { buildAgentRunTreeFromTurns, countRuns, findTurnIndexForRun } from './buildAgentRunTree'; +export type { AgentRunRootMeta } from './buildAgentRunTree'; +export type { AgentRunNode, AgentRunStatus } from './types'; 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..cc4326e15 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/chat/agent-canvas/types.ts @@ -0,0 +1,39 @@ +// 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; + /** 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; + /** 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; + /** 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/src/server/spa/client/react/hooks/ui/useZoomPan.ts b/packages/coc/src/server/spa/client/react/hooks/ui/useZoomPan.ts index b62df2137..7446fd6a1 100644 --- a/packages/coc/src/server/spa/client/react/hooks/ui/useZoomPan.ts +++ b/packages/coc/src/server/spa/client/react/hooks/ui/useZoomPan.ts @@ -45,6 +45,10 @@ export interface UseZoomPanReturn { reset: () => void; /** Auto-fit: calculate scale so all content fits the container. */ fitToView: () => void; + /** Center the content in the container at a fixed scale (default 1 = 100%). */ + centerContent: (scale?: number) => void; + /** Zoom to a specific scale, keeping the viewport center fixed. */ + zoomTo: (scale: number) => void; /** Formatted zoom percentage string, e.g. `"125%"`. */ zoomLabel: string; } @@ -196,5 +200,33 @@ export function useZoomPan(options: UseZoomPanOptions): UseZoomPanReturn { setState({ scale: fitScale, translateX: tx, translateY: ty, isDragging: false }); }, [contentWidth, contentHeight, clampScale]); - return { containerRef, state, svgTransform, zoomIn, zoomOut, reset, fitToView, zoomLabel }; + const centerContent = useCallback((targetScale = 1) => { + const el = containerRef.current; + if (!el || contentWidth <= 0 || contentHeight <= 0) return; + const rect = el.getBoundingClientRect(); + const s = clampScale(targetScale); + setState({ + scale: s, + translateX: (rect.width - contentWidth * s) / 2, + translateY: (rect.height - contentHeight * s) / 2, + isDragging: false, + }); + }, [contentWidth, contentHeight, clampScale]); + + const zoomTo = useCallback((targetScale: number) => { + const s = clampScale(targetScale); + setState(prev => { + const el = containerRef.current; + if (!el) return { ...prev, scale: s }; + const rect = el.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + // Keep the world point under the viewport center fixed while scaling. + const px = (cx - prev.translateX) / prev.scale; + const py = (cy - prev.translateY) / prev.scale; + return { ...prev, scale: s, translateX: cx - px * s, translateY: cy - py * s }; + }); + }, [clampScale]); + + return { containerRef, state, svgTransform, zoomIn, zoomOut, reset, fitToView, centerContent, zoomTo, zoomLabel }; } 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 5586f38d9..d2175af1a 100644 --- a/packages/coc/src/server/spa/client/react/layout/Router.tsx +++ b/packages/coc/src/server/spa/client/react/layout/Router.tsx @@ -180,7 +180,8 @@ export function parsePrDetailTab(hash: string): PrDetailTab { } export function parseActivityDeepLink(hash: string): string | null { - const cleaned = hash.replace(/^#/, ''); + // Strip any `?query` (e.g. `?view=agents`) so it never bleeds into the taskId. + const cleaned = hash.replace(/^#/, '').split('?')[0]; const parts = cleaned.split('/'); if (parts[0] === 'repos' && parts[1] && (parts[2] === 'chats' || parts[2] === 'activity') && parts[3]) { if (parts[3] === 'ralph' || parts[3] === 'for-each' || parts[3] === 'map-reduce') return null; @@ -507,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 }); 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..cc3d90a7a --- /dev/null +++ b/packages/coc/test/server/spa/client/agent-canvas-data.test.ts @@ -0,0 +1,245 @@ +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'; + +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('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' } }), + tc({ id: 'b1', toolName: 'Bash', args: { command: 'ls' } }), + ])]; + 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' })], + [{ + 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('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'); + + 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('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 = { + 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)); + }); +}); diff --git a/packages/coc/test/server/spa/client/chat-view-hash.test.ts b/packages/coc/test/server/spa/client/chat-view-hash.test.ts new file mode 100644 index 000000000..9a165f2ff --- /dev/null +++ b/packages/coc/test/server/spa/client/chat-view-hash.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + readChatViewFromHash, + applyChatViewToHash, +} from '../../../../src/server/spa/client/react/features/chat/agent-canvas/chatViewHash'; + +const BASE = '#repos/ws-1/activity/task-9'; + +describe('readChatViewFromHash', () => { + it('returns null when there is no query', () => { + expect(readChatViewFromHash(BASE)).toBeNull(); + expect(readChatViewFromHash('')).toBeNull(); + }); + + it('reads view=agents and view=thread', () => { + expect(readChatViewFromHash(`${BASE}?view=agents`)).toBe('agents'); + expect(readChatViewFromHash(`${BASE}?view=thread`)).toBe('thread'); + }); + + it('returns null for unknown or unrelated params', () => { + expect(readChatViewFromHash(`${BASE}?view=bogus`)).toBeNull(); + expect(readChatViewFromHash(`${BASE}?mode=source`)).toBeNull(); + }); + + it('reads view alongside other params', () => { + expect(readChatViewFromHash(`${BASE}?mode=source&view=agents`)).toBe('agents'); + }); +}); + +describe('applyChatViewToHash', () => { + it('adds the param for the agents view', () => { + expect(applyChatViewToHash(BASE, 'agents')).toBe(`${BASE}?view=agents`); + }); + + it('removes the param for the (default) thread view', () => { + expect(applyChatViewToHash(`${BASE}?view=agents`, 'thread')).toBe(BASE); + }); + + it('is a no-op when thread view has no param', () => { + expect(applyChatViewToHash(BASE, 'thread')).toBe(BASE); + }); + + it('preserves the path, leading #, and other query params', () => { + expect(applyChatViewToHash(`${BASE}?mode=source`, 'agents')).toBe(`${BASE}?mode=source&view=agents`); + expect(applyChatViewToHash(`${BASE}?mode=source&view=agents`, 'thread')).toBe(`${BASE}?mode=source`); + }); + + it('handles a hash with no leading #', () => { + expect(applyChatViewToHash('repos/ws/activity/x', 'agents')).toBe('repos/ws/activity/x?view=agents'); + }); + + it('round-trips with readChatViewFromHash', () => { + const withAgents = applyChatViewToHash(BASE, 'agents'); + expect(readChatViewFromHash(withAgents)).toBe('agents'); + const backToThread = applyChatViewToHash(withAgents, 'thread'); + expect(readChatViewFromHash(backToThread)).toBeNull(); + }); +}); 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..d4b84b543 --- /dev/null +++ b/packages/coc/test/spa/react/AgentCanvas.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, within } 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('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')); + 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('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', () => { + 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'); + }); + + it('opens a zoom preset menu from the % label', () => { + render(); + expect(screen.queryByTestId('agent-canvas-zoom-menu')).toBeNull(); + fireEvent.click(screen.getByTestId('agent-canvas-zoom-label')); + const menu = screen.getByTestId('agent-canvas-zoom-menu'); + expect(within(menu).getByText('50%')).toBeTruthy(); + expect(within(menu).getByText('200%')).toBeTruthy(); + expect(within(menu).getByText('Fit to screen')).toBeTruthy(); + }); + + it('closes the zoom menu after picking a preset', () => { + render(); + fireEvent.click(screen.getByTestId('agent-canvas-zoom-label')); + fireEvent.click(within(screen.getByTestId('agent-canvas-zoom-menu')).getByText('50%')); + expect(screen.queryByTestId('agent-canvas-zoom-menu')).toBeNull(); + }); +}); 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..22e702c77 --- /dev/null +++ b/packages/coc/test/spa/react/AgentInspector.test.tsx @@ -0,0 +1,74 @@ +// @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 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(); + }); + + 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(); + }); +}); 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'); + }); +}); diff --git a/packages/coc/test/spa/react/Router.test.ts b/packages/coc/test/spa/react/Router.test.ts index 195b2c078..807f30f7d 100644 --- a/packages/coc/test/spa/react/Router.test.ts +++ b/packages/coc/test/spa/react/Router.test.ts @@ -781,6 +781,11 @@ describe('parseActivityDeepLink', () => { expect(parseActivityDeepLink('#repos/my-repo/activity/task%2F1')).toBe('task/1'); }); + it('strips a trailing ?query (e.g. ?view=agents) from the task ID', () => { + expect(parseActivityDeepLink('#repos/my-repo/activity/task-1?view=agents')).toBe('task-1'); + expect(parseActivityDeepLink('#repos/my-repo/chats/task-1?view=thread')).toBe('task-1'); + }); + it('returns null when task ID is missing', () => { expect(parseActivityDeepLink('#repos/my-repo/activity')).toBeNull(); }); diff --git a/packages/coc/test/spa/react/hooks/useZoomPan.test.ts b/packages/coc/test/spa/react/hooks/useZoomPan.test.ts index 392a708ee..0a7b00617 100644 --- a/packages/coc/test/spa/react/hooks/useZoomPan.test.ts +++ b/packages/coc/test/spa/react/hooks/useZoomPan.test.ts @@ -129,4 +129,55 @@ describe('useZoomPan', () => { act(() => result.current.zoomOut()); expect(result.current.state.scale).toBe(1); }); + + it('centerContent centers content at 100% using the container rect', () => { + const { result } = renderHook(() => useZoomPan(defaultOptions)); // content 400x200 + act(() => { + (result.current.containerRef as { current: unknown }).current = { + getBoundingClientRect: () => ({ width: 800, height: 600 }), + }; + result.current.centerContent(1); + }); + expect(result.current.state.scale).toBe(1); + expect(result.current.state.translateX).toBe(200); // (800 - 400) / 2 + expect(result.current.state.translateY).toBe(200); // (600 - 200) / 2 + }); + + it('centerContent no-ops when there is no container', () => { + const { result } = renderHook(() => useZoomPan(defaultOptions)); + act(() => result.current.centerContent(1)); + expect(result.current.state).toEqual({ scale: 1, translateX: 0, translateY: 0, isDragging: false }); + }); + + it('centerContent clamps the scale to the configured bounds', () => { + const { result } = renderHook(() => useZoomPan({ ...defaultOptions, maxZoom: 2 })); + act(() => { + (result.current.containerRef as { current: unknown }).current = { + getBoundingClientRect: () => ({ width: 800, height: 600 }), + }; + result.current.centerContent(5); // above maxZoom + }); + expect(result.current.state.scale).toBe(2); + }); + + it('zoomTo zooms about the viewport center, keeping that point fixed', () => { + const { result } = renderHook(() => useZoomPan(defaultOptions)); + act(() => { + (result.current.containerRef as { current: unknown }).current = { + getBoundingClientRect: () => ({ width: 800, height: 600 }), + }; + result.current.zoomTo(2); + }); + expect(result.current.state.scale).toBe(2); + // center (400,300) under world point (400,300); tx = 400 - 400*2, ty = 300 - 300*2 + expect(result.current.state.translateX).toBe(-400); + expect(result.current.state.translateY).toBe(-300); + }); + + it('zoomTo clamps the scale and works without a container', () => { + const { result } = renderHook(() => useZoomPan({ ...defaultOptions, maxZoom: 2 })); + act(() => result.current.zoomTo(5)); + expect(result.current.state.scale).toBe(2); + expect(result.current.state.translateX).toBe(0); + }); });