● Zion Boggan
repos/Darwin/face/src/components/surface/AgentTree.tsx
zionboggan.com ↗
712 lines · tsx
History for this file →
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
}