| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useState, useCallback, useMemo } from "react"; |
| 4 | |
| 5 | interface DeckNode { |
| 6 | agent_id: string; |
| 7 | parent_id: string | null; |
| 8 | label: string; |
| 9 | model: string; |
| 10 | status: string; |
| 11 | tokens: number; |
| 12 | host: string; |
| 13 | session: string; |
| 14 | spawned_at?: string; |
| 15 | last_activity: string; |
| 16 | current_tool: string | null; |
| 17 | task_count?: number; |
| 18 | } |
| 19 | |
| 20 | interface TreeSnapshot { |
| 21 | ts: string; |
| 22 | nodes: DeckNode[]; |
| 23 | active_count: number; |
| 24 | total_tokens: number; |
| 25 | } |
| 26 | |
| 27 | type UIStatus = "active" | "idle" | "done" | "error"; |
| 28 | |
| 29 | interface UINode { |
| 30 | id: string; |
| 31 | parentId: string | null; |
| 32 | label: string; |
| 33 | short: string; |
| 34 | model: string; |
| 35 | host: string; |
| 36 | session: string; |
| 37 | status: UIStatus; |
| 38 | rawStatus: string; |
| 39 | tokens: number; |
| 40 | tool: string | null; |
| 41 | lastActivity: string; |
| 42 | lastActivityRaw: string; |
| 43 | taskCount: number; |
| 44 | isRoot: boolean; |
| 45 | } |
| 46 | |
| 47 | const MOCK_DECK: DeckNode[] = [ |
| 48 | { agent_id: "root", parent_id: null, label: "DARWIN", model: "system", status: "running", tokens: 0, host: "ct215", session: "system", last_activity: new Date().toISOString(), current_tool: null, task_count: 4 }, |
| 49 | { agent_id: "claude", parent_id: "root", label: "Researching homelab architecture and indexing layers", model: "sonnet", status: "running", tokens: 44508, host: "ct215", session: "demo-1", last_activity: new Date().toISOString(), current_tool: "WebSearch", task_count: 2 }, |
| 50 | { agent_id: "codex", parent_id: "root", label: "Refactor the deck stream protocol", model: "codex", status: "done", tokens: 12044, host: "ct241", session: "demo-2", last_activity: new Date(Date.now() - 4 * 60_000).toISOString(), current_tool: null, task_count: 0 }, |
| 51 | { agent_id: "glm", parent_id: "root", label: "Maintain oversight site and blog", model: "glm", status: "running", tokens: 8800, host: "ct247", session: "demo-3", last_activity: new Date(Date.now() - 38_000).toISOString(), current_tool: "Edit", task_count: 1 }, |
| 52 | { agent_id: "claude-2", parent_id: "claude", label: "Map what indexing already exists in the homelab", model: "sonnet", status: "done", tokens: 58147, host: "ct215", session: "demo-1", last_activity: new Date(Date.now() - 2 * 3600_000).toISOString(), current_tool: null, task_count: 6 }, |
| 53 | { agent_id: "probe-1", parent_id: "glm", label: "Probe candidate vuln surface", model: "glm", status: "done", tokens: 3200, host: "ct247", session: "demo-3", last_activity: new Date(Date.now() - 60_000).toISOString(), current_tool: null, task_count: 0 }, |
| 54 | ]; |
| 55 | |
| 56 | const DECK_WS_URL = |
| 57 | process.env.NEXT_PUBLIC_DECK_URL || "ws://REDACTED-IP:9893/stream"; |
| 58 | const DECK_HTTP_URL = DECK_WS_URL.replace(/^ws/, "http").replace("/stream", ""); |
| 59 | |
| 60 | const ACTIVE_WINDOW_MS = 10 * 60_000; |
| 61 | |
| 62 | function relativeTime(iso: string): string { |
| 63 | try { |
| 64 | const diff = Date.now() - new Date(iso).getTime(); |
| 65 | const s = Math.floor(diff / 1000); |
| 66 | if (s < 5) return "now"; |
| 67 | if (s < 60) return `${s}s ago`; |
| 68 | const m = Math.floor(s / 60); |
| 69 | if (m < 60) return `${m}m ago`; |
| 70 | const h = Math.floor(m / 60); |
| 71 | if (h < 24) return `${h}h ago`; |
| 72 | return `${Math.floor(h / 24)}d ago`; |
| 73 | } catch { |
| 74 | return ""; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | function cleanLabel(raw: string): string { |
| 79 | return raw |
| 80 | .replace(/^#+\s*/g, "") |
| 81 | .replace(/[*_`>]/g, "") |
| 82 | .replace(/\s+/g, " ") |
| 83 | .trim(); |
| 84 | } |
| 85 | |
| 86 | function truncate(s: string, n: number): string { |
| 87 | return s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s; |
| 88 | } |
| 89 | |
| 90 | function toUINode(n: DeckNode): UINode { |
| 91 | const recent = |
| 92 | n.last_activity && |
| 93 | Date.now() - new Date(n.last_activity).getTime() < ACTIVE_WINDOW_MS; |
| 94 | let status: UIStatus; |
| 95 | if (n.status === "error") status = "error"; |
| 96 | else if (n.status === "running" && recent) status = "active"; |
| 97 | else if (n.status === "running") status = "idle"; |
| 98 | else status = "done"; |
| 99 | |
| 100 | const isRoot = n.parent_id === null; |
| 101 | const clean = cleanLabel(n.label); |
| 102 | return { |
| 103 | id: n.agent_id, |
| 104 | parentId: n.parent_id, |
| 105 | label: clean, |
| 106 | short: truncate(clean, isRoot ? 16 : 22), |
| 107 | model: n.model, |
| 108 | host: n.host, |
| 109 | session: n.session, |
| 110 | status, |
| 111 | rawStatus: n.status, |
| 112 | tokens: n.tokens ?? 0, |
| 113 | tool: n.current_tool, |
| 114 | lastActivity: n.last_activity ? relativeTime(n.last_activity) : "", |
| 115 | lastActivityRaw: n.last_activity, |
| 116 | taskCount: n.task_count ?? 0, |
| 117 | isRoot, |
| 118 | }; |
| 119 | } |
| 120 | |
| 121 | const STATUS_COLOR: Record<UIStatus, string> = { |
| 122 | active: "#16e0c8", |
| 123 | idle: "#0d9e8e", |
| 124 | done: "#3a4048", |
| 125 | error: "#ef4444", |
| 126 | }; |
| 127 | |
| 128 | const STATUS_TEXT: Record<UIStatus, string> = { |
| 129 | active: "text-teal-bio", |
| 130 | idle: "text-teal-dim", |
| 131 | done: "text-text-muted", |
| 132 | error: "text-red-400", |
| 133 | }; |
| 134 | |
| 135 | |
| 136 | interface Pos { |
| 137 | id: string; |
| 138 | x: number; |
| 139 | y: number; |
| 140 | node: UINode; |
| 141 | } |
| 142 | |
| 143 | const COL_W = 230; |
| 144 | const ROW_H = 46; |
| 145 | const PAD = 40; |
| 146 | |
| 147 | function layoutTree(nodes: UINode[]): { positions: Pos[]; width: number; height: number } { |
| 148 | const byId = new Map(nodes.map((n) => [n.id, n])); |
| 149 | const children = new Map<string | null, UINode[]>(); |
| 150 | for (const n of nodes) { |
| 151 | const list = children.get(n.parentId) ?? []; |
| 152 | list.push(n); |
| 153 | children.set(n.parentId, list); |
| 154 | } |
| 155 | for (const list of Array.from(children.values())) { |
| 156 | list.sort((a, b) => { |
| 157 | if (a.status === "active" && b.status !== "active") return -1; |
| 158 | if (b.status === "active" && a.status !== "active") return 1; |
| 159 | return (b.lastActivityRaw || "").localeCompare(a.lastActivityRaw || ""); |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | const positions: Pos[] = []; |
| 164 | let leafCursor = 0; |
| 165 | |
| 166 | function walk(id: string, depth: number): number { |
| 167 | const node = byId.get(id)!; |
| 168 | const kids = (children.get(id) ?? []).filter((k) => byId.has(k.id)); |
| 169 | let y: number; |
| 170 | if (kids.length === 0) { |
| 171 | y = leafCursor * ROW_H + PAD; |
| 172 | leafCursor += 1; |
| 173 | } else { |
| 174 | const ys = kids.map((k) => walk(k.id, depth + 1)); |
| 175 | y = (ys[0] + ys[ys.length - 1]) / 2; |
| 176 | } |
| 177 | positions.push({ id, x: depth * COL_W + PAD, y, node }); |
| 178 | return y; |
| 179 | } |
| 180 | |
| 181 | const roots = nodes.filter((n) => n.parentId === null || !byId.has(n.parentId)); |
| 182 | for (const r of roots) walk(r.id, 0); |
| 183 | |
| 184 | const width = Math.max(COL_W, ...positions.map((p) => p.x)) + COL_W; |
| 185 | const height = Math.max(ROW_H, ...positions.map((p) => p.y)) + PAD; |
| 186 | return { positions, width, height }; |
| 187 | } |
| 188 | |
| 189 | |
| 190 | function InspectPanel({ |
| 191 | node, |
| 192 | onClose, |
| 193 | onAttach, |
| 194 | }: { |
| 195 | node: UINode; |
| 196 | onClose: () => void; |
| 197 | onAttach: (n: UINode) => void; |
| 198 | }) { |
| 199 | const canAttach = node.status === "active" || node.status === "idle"; |
| 200 | const summary = node.tool |
| 201 | ? `Currently using ${node.tool}.` |
| 202 | : node.status === "active" |
| 203 | ? "Working - no tool call in flight." |
| 204 | : node.status === "idle" |
| 205 | ? "Session open but quiet." |
| 206 | : node.status === "error" |
| 207 | ? "Ended in an error state." |
| 208 | : "Finished its task."; |
| 209 | |
| 210 | return ( |
| 211 | <div |
| 212 | className="absolute inset-x-0 bottom-0 z-20 border-t border-teal-bio/25" |
| 213 | style={{ |
| 214 | background: "rgba(8,10,12,0.97)", |
| 215 | backdropFilter: "blur(6px)", |
| 216 | animation: "sheet-up 0.32s cubic-bezier(0.16,1,0.3,1)", |
| 217 | maxHeight: "62%", |
| 218 | overflowY: "auto", |
| 219 | }} |
| 220 | role="dialog" |
| 221 | aria-label={`Inspect ${node.label}`} |
| 222 | > |
| 223 | <div className="px-4 pt-3.5 pb-4"> |
| 224 | {} |
| 225 | <div className="flex items-start justify-between gap-3 mb-3"> |
| 226 | <div className="min-w-0"> |
| 227 | <div className="flex items-center gap-2 mb-1"> |
| 228 | <span |
| 229 | className="w-2 h-2 rounded-full shrink-0" |
| 230 | style={{ background: STATUS_COLOR[node.status], boxShadow: node.status === "active" ? `0 0 8px ${STATUS_COLOR[node.status]}` : "none" }} |
| 231 | /> |
| 232 | <span className={`font-mono text-label-sm uppercase tracking-widest ${STATUS_TEXT[node.status]}`}> |
| 233 | {node.status} |
| 234 | </span> |
| 235 | </div> |
| 236 | <p className="font-ui text-sm text-text-primary leading-snug"> |
| 237 | {node.label} |
| 238 | </p> |
| 239 | </div> |
| 240 | <button |
| 241 | onClick={onClose} |
| 242 | aria-label="Close inspector" |
| 243 | className="shrink-0 w-8 h-8 grid place-items-center text-text-muted hover:text-teal-bio transition-colors" |
| 244 | style={{ touchAction: "manipulation" }} |
| 245 | > |
| 246 | <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> |
| 247 | <path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" /> |
| 248 | </svg> |
| 249 | </button> |
| 250 | </div> |
| 251 | |
| 252 | {} |
| 253 | <div className="grid grid-cols-2 gap-x-4 gap-y-2.5 mb-3"> |
| 254 | <Meta k="Host" v={node.host} /> |
| 255 | <Meta k="Model" v={node.model} /> |
| 256 | <Meta k="Tokens" v={node.tokens > 0 ? node.tokens.toLocaleString() : "-"} mono /> |
| 257 | <Meta k="Last active" v={node.lastActivity || "-"} /> |
| 258 | <Meta k="Tool" v={node.tool || "-"} /> |
| 259 | <Meta k="Tasks" v={String(node.taskCount)} /> |
| 260 | </div> |
| 261 | |
| 262 | {} |
| 263 | <div className="border-l-2 border-teal-bio/40 pl-3 py-1.5 mb-3.5"> |
| 264 | <p className="font-ui text-xs text-text-secondary leading-relaxed">{summary}</p> |
| 265 | </div> |
| 266 | |
| 267 | {} |
| 268 | {canAttach ? ( |
| 269 | <button |
| 270 | onClick={() => onAttach(node)} |
| 271 | className="font-display font-bold uppercase w-full" |
| 272 | style={{ |
| 273 | height: 44, |
| 274 | fontSize: "0.78rem", |
| 275 | letterSpacing: "0.08em", |
| 276 | color: "#050607", |
| 277 | background: "#16e0c8", |
| 278 | border: "none", |
| 279 | transition: "filter 0.2s", |
| 280 | touchAction: "manipulation", |
| 281 | }} |
| 282 | onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.1)")} |
| 283 | onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")} |
| 284 | > |
| 285 | Attach in workspace ▸ |
| 286 | </button> |
| 287 | ) : ( |
| 288 | <div |
| 289 | className="font-mono uppercase text-center" |
| 290 | style={{ height: 44, lineHeight: "44px", fontSize: "0.66rem", letterSpacing: "0.08em", color: "#4a525e", border: "1px solid rgba(22,224,200,0.1)" }} |
| 291 | > |
| 292 | Session ended - read-only |
| 293 | </div> |
| 294 | )} |
| 295 | </div> |
| 296 | </div> |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | function Meta({ k, v, mono }: { k: string; v: string; mono?: boolean }) { |
| 301 | return ( |
| 302 | <div className="min-w-0"> |
| 303 | <p className="font-mono uppercase" style={{ fontSize: "0.5rem", letterSpacing: "0.12em", color: "#4a525e" }}> |
| 304 | {k} |
| 305 | </p> |
| 306 | <p className={`truncate text-text-secondary ${mono ? "font-mono" : "font-ui"}`} style={{ fontSize: "0.74rem" }} title={v}> |
| 307 | {v} |
| 308 | </p> |
| 309 | </div> |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | |
| 314 | type DeckMode = "live" | "mock" | "connecting"; |
| 315 | |
| 316 | export default function AgentTree() { |
| 317 | const [agents, setAgents] = useState<UINode[]>(() => MOCK_DECK.map(toUINode)); |
| 318 | const [mode, setMode] = useState<DeckMode>("connecting"); |
| 319 | const [totalTokens, setTotalTokens] = useState(0); |
| 320 | const [selected, setSelected] = useState<string | null>(null); |
| 321 | const [hovered, setHovered] = useState<string | null>(null); |
| 322 | |
| 323 | const wsRef = useRef<WebSocket | null>(null); |
| 324 | const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 325 | |
| 326 | const wrapRef = useRef<HTMLDivElement>(null); |
| 327 | const [view, setView] = useState({ x: 0, y: 0, k: 1 }); |
| 328 | const dragRef = useRef<{ x: number; y: number; vx: number; vy: number; moved: boolean } | null>(null); |
| 329 | const pinchRef = useRef<{ d: number; k: number } | null>(null); |
| 330 | const fittedRef = useRef(false); |
| 331 | |
| 332 | const applySnapshot = useCallback((snap: TreeSnapshot) => { |
| 333 | setAgents(snap.nodes.map(toUINode)); |
| 334 | setTotalTokens(snap.total_tokens ?? 0); |
| 335 | setMode("live"); |
| 336 | }, []); |
| 337 | |
| 338 | const refetch = useCallback(() => { |
| 339 | fetch(`${DECK_HTTP_URL}/tree`) |
| 340 | .then((r) => r.json()) |
| 341 | .then((snap: TreeSnapshot) => applySnapshot(snap)) |
| 342 | .catch(() => {}); |
| 343 | }, [applySnapshot]); |
| 344 | |
| 345 | const connectWs = useCallback(() => { |
| 346 | if (wsRef.current) { try { wsRef.current.close(); } catch {} } |
| 347 | let ws: WebSocket; |
| 348 | try { |
| 349 | ws = new WebSocket(DECK_WS_URL); |
| 350 | } catch { |
| 351 | setMode("mock"); |
| 352 | return; |
| 353 | } |
| 354 | wsRef.current = ws; |
| 355 | setMode("connecting"); |
| 356 | ws.onmessage = (e) => { |
| 357 | try { |
| 358 | const data = JSON.parse(e.data); |
| 359 | if (data.type === "snapshot" || data.nodes) applySnapshot(data as TreeSnapshot); |
| 360 | else if (data.type === "ping") { } |
| 361 | else refetch(); |
| 362 | } catch { } |
| 363 | }; |
| 364 | ws.onerror = () => { setMode("mock"); }; |
| 365 | ws.onclose = () => { |
| 366 | if (retryRef.current) clearTimeout(retryRef.current); |
| 367 | retryRef.current = setTimeout(connectWs, 5000); |
| 368 | }; |
| 369 | }, [applySnapshot, refetch]); |
| 370 | |
| 371 | useEffect(() => { |
| 372 | connectWs(); |
| 373 | refetch(); |
| 374 | return () => { |
| 375 | if (retryRef.current) clearTimeout(retryRef.current); |
| 376 | if (wsRef.current) try { wsRef.current.close(); } catch {} |
| 377 | }; |
| 378 | }, []); |
| 379 | |
| 380 | const { positions, width, height } = useMemo(() => layoutTree(agents), [agents]); |
| 381 | const posMap = useMemo(() => new Map(positions.map((p) => [p.id, p])), [positions]); |
| 382 | |
| 383 | const counts = useMemo(() => { |
| 384 | const c = { active: 0, idle: 0, done: 0, error: 0 }; |
| 385 | for (const a of agents) c[a.status] += 1; |
| 386 | return c; |
| 387 | }, [agents]); |
| 388 | |
| 389 | const fitToView = useCallback(() => { |
| 390 | const wrap = wrapRef.current; |
| 391 | if (!wrap || positions.length === 0) return; |
| 392 | const cw = wrap.clientWidth; |
| 393 | const ch = wrap.clientHeight; |
| 394 | if (cw === 0 || ch === 0) return; |
| 395 | const k = Math.min(cw / (width + PAD), ch / (height + PAD), 1.2); |
| 396 | const x = (cw - width * k) / 2; |
| 397 | const y = (ch - height * k) / 2; |
| 398 | setView({ x, y, k: Math.max(0.25, k) }); |
| 399 | }, [positions.length, width, height]); |
| 400 | |
| 401 | useEffect(() => { |
| 402 | if (!fittedRef.current && positions.length > 0) { |
| 403 | fittedRef.current = true; |
| 404 | requestAnimationFrame(fitToView); |
| 405 | } |
| 406 | }, [positions.length, fitToView]); |
| 407 | |
| 408 | useEffect(() => { |
| 409 | const onResize = () => fitToView(); |
| 410 | window.addEventListener("resize", onResize); |
| 411 | return () => window.removeEventListener("resize", onResize); |
| 412 | }, [fitToView]); |
| 413 | |
| 414 | const onPointerDown = (e: React.PointerEvent) => { |
| 415 | if (e.button !== 0 && e.pointerType === "mouse") return; |
| 416 | (e.target as Element).setPointerCapture?.(e.pointerId); |
| 417 | dragRef.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false }; |
| 418 | }; |
| 419 | const onPointerMove = (e: React.PointerEvent) => { |
| 420 | const d = dragRef.current; |
| 421 | if (!d) return; |
| 422 | const dx = e.clientX - d.x; |
| 423 | const dy = e.clientY - d.y; |
| 424 | if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true; |
| 425 | setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy })); |
| 426 | }; |
| 427 | const onPointerUp = (e: React.PointerEvent) => { |
| 428 | (e.target as Element).releasePointerCapture?.(e.pointerId); |
| 429 | const d = dragRef.current; |
| 430 | dragRef.current = null; |
| 431 | if (d && !d.moved) { |
| 432 | const wrap = wrapRef.current; |
| 433 | if (!wrap) return; |
| 434 | const rect = wrap.getBoundingClientRect(); |
| 435 | const sx = e.clientX - rect.left; |
| 436 | const sy = e.clientY - rect.top; |
| 437 | const tx = (sx - view.x) / view.k; |
| 438 | const ty = (sy - view.y) / view.k; |
| 439 | let hit: string | null = null; |
| 440 | let best = Infinity; |
| 441 | for (const p of positions) { |
| 442 | const dist = Math.hypot(p.x - tx, p.y - ty); |
| 443 | const r = (p.node.isRoot ? 11 : 8) + 8; |
| 444 | if (dist < r && dist < best) { best = dist; hit = p.id; } |
| 445 | } |
| 446 | if (hit) setSelected((s) => (s === hit ? null : hit)); |
| 447 | } |
| 448 | }; |
| 449 | |
| 450 | const onWheel = (e: React.WheelEvent) => { |
| 451 | e.preventDefault(); |
| 452 | const wrap = wrapRef.current; |
| 453 | if (!wrap) return; |
| 454 | const rect = wrap.getBoundingClientRect(); |
| 455 | const cx = e.clientX - rect.left; |
| 456 | const cy = e.clientY - rect.top; |
| 457 | setView((v) => { |
| 458 | const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; |
| 459 | const k = Math.min(2.4, Math.max(0.25, v.k * factor)); |
| 460 | const ratio = k / v.k; |
| 461 | return { k, x: cx - (cx - v.x) * ratio, y: cy - (cy - v.y) * ratio }; |
| 462 | }); |
| 463 | }; |
| 464 | |
| 465 | const touchDist = (t: React.TouchList) => |
| 466 | Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); |
| 467 | const onTouchStart = (e: React.TouchEvent) => { |
| 468 | if (e.touches.length === 2) pinchRef.current = { d: touchDist(e.touches), k: view.k }; |
| 469 | }; |
| 470 | const onTouchMove = (e: React.TouchEvent) => { |
| 471 | if (e.touches.length === 2 && pinchRef.current) { |
| 472 | const nd = touchDist(e.touches); |
| 473 | const k = Math.min(2.4, Math.max(0.25, pinchRef.current.k * (nd / pinchRef.current.d))); |
| 474 | setView((v) => ({ ...v, k })); |
| 475 | } |
| 476 | }; |
| 477 | const onTouchEnd = (e: React.TouchEvent) => { |
| 478 | if (e.touches.length < 2) pinchRef.current = null; |
| 479 | }; |
| 480 | |
| 481 | const zoomBy = (factor: number) => { |
| 482 | const wrap = wrapRef.current; |
| 483 | if (!wrap) return; |
| 484 | const cx = wrap.clientWidth / 2; |
| 485 | const cy = wrap.clientHeight / 2; |
| 486 | setView((v) => { |
| 487 | const k = Math.min(2.4, Math.max(0.25, v.k * factor)); |
| 488 | const ratio = k / v.k; |
| 489 | return { k, x: cx - (cx - v.x) * ratio, y: cy - (cy - v.y) * ratio }; |
| 490 | }); |
| 491 | }; |
| 492 | |
| 493 | const onAttach = useCallback((n: UINode) => { |
| 494 | const params = new URLSearchParams({ |
| 495 | target: n.host || "ct215", |
| 496 | session: n.session, |
| 497 | agent: /codex/i.test(n.model) ? "codex" : /glm|opencode/i.test(n.model) ? "opencode" : "claude", |
| 498 | }); |
| 499 | window.location.href = `/workspace?${params.toString()}`; |
| 500 | }, []); |
| 501 | |
| 502 | const selectedNode = selected ? agents.find((a) => a.id === selected) ?? null : null; |
| 503 | |
| 504 | return ( |
| 505 | <div className="flex flex-col gap-4 h-full min-h-0"> |
| 506 | {} |
| 507 | <div> |
| 508 | <div className="flex items-center justify-between mb-3"> |
| 509 | <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest"> |
| 510 | Agent nervous system |
| 511 | </p> |
| 512 | <span |
| 513 | className={`font-mono text-label-sm px-1.5 py-0.5 rounded-sm ${ |
| 514 | mode === "live" |
| 515 | ? "text-teal-bio bg-teal-bio/10" |
| 516 | : mode === "connecting" |
| 517 | ? "text-yellow-400 bg-yellow-400/10" |
| 518 | : "text-text-muted bg-surface-2" |
| 519 | }`} |
| 520 | > |
| 521 | {mode === "live" ? "live" : mode === "connecting" ? "connecting…" : "mock"} |
| 522 | </span> |
| 523 | </div> |
| 524 | <div className="flex items-center gap-4 flex-wrap"> |
| 525 | <span className="flex items-center gap-2"> |
| 526 | <span className="w-2 h-2 rounded-full bg-teal-bio inline-block animate-pulse-slow" /> |
| 527 | <span className="font-mono text-label-sm text-teal-bio">{counts.active} active</span> |
| 528 | </span> |
| 529 | {counts.idle > 0 && ( |
| 530 | <span className="font-mono text-label-sm text-teal-dim">{counts.idle} idle</span> |
| 531 | )} |
| 532 | <span className="font-mono text-label-sm text-text-muted">{counts.done} done</span> |
| 533 | {counts.error > 0 && ( |
| 534 | <span className="font-mono text-label-sm text-red-400">{counts.error} error</span> |
| 535 | )} |
| 536 | {totalTokens > 0 && ( |
| 537 | <span className="font-mono text-label-sm text-text-muted ml-auto"> |
| 538 | {(totalTokens / 1_000_000).toFixed(1)}M tok |
| 539 | </span> |
| 540 | )} |
| 541 | </div> |
| 542 | </div> |
| 543 | |
| 544 | {} |
| 545 | <div |
| 546 | ref={wrapRef} |
| 547 | className="relative flex-1 min-h-[320px] overflow-hidden rounded-sm select-none" |
| 548 | style={{ |
| 549 | border: "1px solid rgba(22,224,200,0.1)", |
| 550 | background: "rgba(5,6,7,0.6)", |
| 551 | cursor: dragRef.current ? "grabbing" : "grab", |
| 552 | touchAction: "none", |
| 553 | }} |
| 554 | onPointerDown={onPointerDown} |
| 555 | onPointerMove={onPointerMove} |
| 556 | onPointerUp={onPointerUp} |
| 557 | onPointerLeave={onPointerUp} |
| 558 | onWheel={onWheel} |
| 559 | onTouchStart={onTouchStart} |
| 560 | onTouchMove={onTouchMove} |
| 561 | onTouchEnd={onTouchEnd} |
| 562 | aria-label="Agent tree - drag to pan, scroll or pinch to zoom, tap a node to inspect" |
| 563 | role="application" |
| 564 | > |
| 565 | <svg |
| 566 | width="100%" |
| 567 | height="100%" |
| 568 | aria-hidden="false" |
| 569 | role="img" |
| 570 | style={{ display: "block" }} |
| 571 | > |
| 572 | <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}> |
| 573 | {} |
| 574 | {agents.map((n) => { |
| 575 | if (!n.parentId) return null; |
| 576 | const from = posMap.get(n.parentId); |
| 577 | const to = posMap.get(n.id); |
| 578 | if (!from || !to) return null; |
| 579 | const live = n.status === "active"; |
| 580 | const midX = (from.x + to.x) / 2; |
| 581 | return ( |
| 582 | <path |
| 583 | key={`e-${n.id}`} |
| 584 | d={`M${from.x},${from.y} C${midX},${from.y} ${midX},${to.y} ${to.x},${to.y}`} |
| 585 | fill="none" |
| 586 | stroke={live ? "#16e0c8" : "#1e242b"} |
| 587 | strokeWidth={live ? 1.4 : 1} |
| 588 | opacity={live ? 0.5 : 0.5} |
| 589 | /> |
| 590 | ); |
| 591 | })} |
| 592 | |
| 593 | {} |
| 594 | {positions.map(({ id, x, y, node }) => { |
| 595 | const color = STATUS_COLOR[node.status]; |
| 596 | const r = node.isRoot ? 11 : node.status === "active" ? 8 : 6; |
| 597 | const isSel = selected === id; |
| 598 | const isHov = hovered === id; |
| 599 | const labelText = isHov ? truncate(node.label, 40) : node.short; |
| 600 | return ( |
| 601 | <g |
| 602 | key={id} |
| 603 | transform={`translate(${x},${y})`} |
| 604 | style={{ cursor: "pointer" }} |
| 605 | onMouseEnter={() => setHovered(id)} |
| 606 | onMouseLeave={() => setHovered((h) => (h === id ? null : h))} |
| 607 | role="button" |
| 608 | aria-label={`${node.label} - ${node.status}`} |
| 609 | > |
| 610 | {} |
| 611 | {(isSel || node.status === "active") && ( |
| 612 | <circle |
| 613 | r={r + 5} |
| 614 | fill="none" |
| 615 | stroke={isSel ? "#16e0c8" : color} |
| 616 | strokeWidth={isSel ? 1.4 : 0.8} |
| 617 | opacity={isSel ? 0.9 : 0.35} |
| 618 | className={node.status === "active" && !isSel ? "animate-pulse-slow" : ""} |
| 619 | /> |
| 620 | )} |
| 621 | <circle r={r} fill={color} fillOpacity={node.status === "done" ? 0.55 : 0.95} /> |
| 622 | {node.taskCount > 0 && r >= 8 && ( |
| 623 | <text textAnchor="middle" dominantBaseline="central" fontSize={8} fill="#050607" fontFamily="monospace" fontWeight={700}> |
| 624 | {node.taskCount > 99 ? "99" : node.taskCount} |
| 625 | </text> |
| 626 | )} |
| 627 | {} |
| 628 | <text |
| 629 | x={r + 8} |
| 630 | y={-1} |
| 631 | dominantBaseline="middle" |
| 632 | fontSize={node.isRoot ? 12 : 11} |
| 633 | fill={node.status === "active" ? "#e8eaed" : node.status === "done" ? "#5a626e" : "#9aa3af"} |
| 634 | fontFamily={node.isRoot ? "'Space Grotesk', sans-serif" : "'JetBrains Mono', monospace"} |
| 635 | fontWeight={node.isRoot ? 700 : 400} |
| 636 | style={{ pointerEvents: "none" }} |
| 637 | > |
| 638 | {labelText} |
| 639 | </text> |
| 640 | <text |
| 641 | x={r + 8} |
| 642 | y={12} |
| 643 | dominantBaseline="middle" |
| 644 | fontSize={8} |
| 645 | fill={node.status === "active" ? "#16e0c8" : "#3a4048"} |
| 646 | fontFamily="'JetBrains Mono', monospace" |
| 647 | style={{ pointerEvents: "none" }} |
| 648 | > |
| 649 | {node.host} · {node.lastActivity} |
| 650 | </text> |
| 651 | </g> |
| 652 | ); |
| 653 | })} |
| 654 | </g> |
| 655 | </svg> |
| 656 | |
| 657 | {} |
| 658 | <div className="absolute top-2 right-2 flex flex-col gap-1.5"> |
| 659 | <TreeBtn label="Zoom in" onClick={() => zoomBy(1.2)}>+</TreeBtn> |
| 660 | <TreeBtn label="Zoom out" onClick={() => zoomBy(1 / 1.2)}>−</TreeBtn> |
| 661 | <TreeBtn label="Fit to view" onClick={fitToView}> |
| 662 | <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"> |
| 663 | <path d="M1 4V1h3M11 4V1H8M1 8v3h3M11 8v3H8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="square" /> |
| 664 | </svg> |
| 665 | </TreeBtn> |
| 666 | </div> |
| 667 | |
| 668 | {} |
| 669 | <div className="absolute bottom-2 left-2 font-mono pointer-events-none" style={{ fontSize: "0.5rem", letterSpacing: "0.06em", color: "#3a4048" }}> |
| 670 | DRAG · SCROLL/PINCH · TAP NODE |
| 671 | </div> |
| 672 | |
| 673 | {} |
| 674 | {selectedNode && ( |
| 675 | <InspectPanel node={selectedNode} onClose={() => setSelected(null)} onAttach={onAttach} /> |
| 676 | )} |
| 677 | </div> |
| 678 | |
| 679 | {} |
| 680 | <div className="flex gap-3.5 flex-wrap pt-1 border-t border-surface-2"> |
| 681 | {(["active", "idle", "done", "error"] as UIStatus[]).map((s) => ( |
| 682 | <span key={s} className={`flex items-center gap-1.5 font-mono text-label-sm ${STATUS_TEXT[s]}`}> |
| 683 | <span className="w-2 h-2 rounded-full inline-block" style={{ background: STATUS_COLOR[s] }} /> |
| 684 | {s} |
| 685 | </span> |
| 686 | ))} |
| 687 | </div> |
| 688 | </div> |
| 689 | ); |
| 690 | } |
| 691 | |
| 692 | function TreeBtn({ children, onClick, label }: { children: React.ReactNode; onClick: () => void; label: string }) { |
| 693 | return ( |
| 694 | <button |
| 695 | onClick={onClick} |
| 696 | aria-label={label} |
| 697 | title={label} |
| 698 | className="grid place-items-center font-mono text-teal-dim hover:text-teal-bio transition-colors" |
| 699 | style={{ |
| 700 | width: 30, |
| 701 | height: 30, |
| 702 | background: "rgba(8,10,12,0.92)", |
| 703 | border: "1px solid rgba(22,224,200,0.16)", |
| 704 | fontSize: "1rem", |
| 705 | lineHeight: 1, |
| 706 | touchAction: "manipulation", |
| 707 | }} |
| 708 | > |
| 709 | {children} |
| 710 | </button> |
| 711 | ); |
| 712 | } |