@agentprojectcontext/apx 1.71.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-CX15mZXM.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-CDz9OwCP.css">
21
+ <script type="module" crossorigin src="/assets/index-D2b7Sqvg.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-CUeIhw7z.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -1487,6 +1487,17 @@ export const en = {
1487
1487
  stat_tasks: "Tasks",
1488
1488
  stat_heartbeats: "Heartbeats",
1489
1489
  uncategorized: "Uncategorized",
1490
+ brain_zoom_in: "Zoom in",
1491
+ brain_zoom_out: "Zoom out",
1492
+ brain_fit: "Fit to view",
1493
+ brain_fullscreen: "Fullscreen",
1494
+ brain_exit_fs: "Exit fullscreen",
1495
+ brain_pan_hint: "scroll to zoom · drag background to pan",
1496
+ brain_expand: "Expand brains",
1497
+ brain_collapse: "Collapse",
1498
+ brain_open: "Open",
1499
+ brain_part_of: "Part of",
1500
+ brain_branches: "Branches",
1490
1501
  config_def_desc: "definition (frontmatter + system prompt).",
1491
1502
  memory_durable_desc: "durable facts the agent remembers.",
1492
1503
  running: "running",
@@ -1485,6 +1485,17 @@ export const es = {
1485
1485
  stat_tasks: "Tasks",
1486
1486
  stat_heartbeats: "Heartbeats",
1487
1487
  uncategorized: "Sin categoría",
1488
+ brain_zoom_in: "Acercar",
1489
+ brain_zoom_out: "Alejar",
1490
+ brain_fit: "Ajustar a la vista",
1491
+ brain_fullscreen: "Pantalla completa",
1492
+ brain_exit_fs: "Salir de pantalla completa",
1493
+ brain_pan_hint: "scroll para zoom · arrastrá el fondo para mover",
1494
+ brain_expand: "Expandir cerebros",
1495
+ brain_collapse: "Colapsar",
1496
+ brain_open: "Abrir",
1497
+ brain_part_of: "Parte de",
1498
+ brain_branches: "Ramas",
1488
1499
  config_def_desc: "definición (frontmatter + system prompt).",
1489
1500
  memory_durable_desc: "hechos durables que el agente recuerda.",
1490
1501
  running: "running",
@@ -3,16 +3,18 @@ import {
3
3
  forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY,
4
4
  type Simulation,
5
5
  } from "d3-force";
6
+ import { Maximize2, Minimize2, Plus, Minus, Frame } from "lucide-react";
6
7
  import { t } from "../../i18n";
7
8
 
8
9
  // Generic animated "brain" graph (d3-force + SVG). It takes an explicit node +
9
10
  // edge set so callers can model *any* topology — a single agent's hubbed brain
10
11
  // (Memory / Threads / Tasks / Routines / Team + cross-links) or the whole
11
- // project's agent map (orchestrators with their specialists as satellites).
12
+ // project's agent map (each agent expanded into its own connected sub-brain).
12
13
  //
13
14
  // Motion is SMIL/CSS driven (breathing halos, a pulsing core, energy beads
14
15
  // flowing down the edges) so the graph stays alive without React re-renders;
15
- // d3 only drives layout.
16
+ // d3 only drives layout. The view supports wheel-zoom, background pan, a
17
+ // fit-to-content button and fullscreen.
16
18
 
17
19
  export type BrainKind =
18
20
  | "agent" | "memory" | "thread" | "task" | "routine" | "agentlink" | "hub";
@@ -50,31 +52,44 @@ function kindLabel(k: BrainKind): string {
50
52
 
51
53
  const RADIUS: Record<BrainRole, number> = { core: 24, hub: 12, leaf: 6 };
52
54
  const roleOf = (n: BrainNode): BrainRole => n.role ?? "leaf";
55
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
56
+ const uniqById = <T extends { id: string }>(arr: T[]): T[] => {
57
+ const m = new Map<string, T>();
58
+ for (const n of arr) m.set(n.id, n);
59
+ return [...m.values()];
60
+ };
61
+ const clip = (s: string, n = 26) => (s.length > n ? `${s.slice(0, n)}…` : s);
53
62
 
54
63
  export function BrainGraph({
55
- nodes, edges, height = 460, onNodeClick,
64
+ nodes, edges, height = 520, onNodeClick, toolbar,
56
65
  }: {
57
66
  nodes: BrainNode[];
58
67
  edges: BrainEdge[];
59
68
  height?: number;
60
69
  onNodeClick?: (n: BrainNode) => void;
70
+ toolbar?: React.ReactNode; // extra controls (e.g. an Expand toggle)
61
71
  }) {
62
- const W = 760, H = height;
63
- const CX = W / 2, CY = H / 2;
72
+ const W = 1000, H = Math.round((W * height) / 760); // keep a wide-ish canvas
64
73
 
74
+ const wrapRef = useRef<HTMLDivElement | null>(null);
65
75
  const svgRef = useRef<SVGSVGElement | null>(null);
66
76
  const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
67
77
  const nodesRef = useRef<SimNode[]>([]);
68
78
  const linksRef = useRef<SimLink[]>([]);
69
79
  const dragRef = useRef<SimNode | null>(null);
80
+ const panRef = useRef<{ x: number; y: number } | null>(null);
81
+ const viewRef = useRef({ tx: 0, ty: 0, k: 1 });
82
+ const fitRef = useRef<() => void>(() => {});
70
83
  const [, setVersion] = useState(0);
71
84
  const [selected, setSelected] = useState<BrainNode | null>(null);
85
+ const [fs, setFs] = useState(false);
72
86
 
87
+ const CX = W / 2, CY = H / 2;
88
+ const bump = () => setVersion((v) => v + 1);
73
89
  const hideLeafLabels = nodes.length > 44;
74
90
 
91
+ // ── Layout simulation ──────────────────────────────────────────────────────
75
92
  useEffect(() => {
76
- // Seed positions so the first paint already looks like a graph (no blank
77
- // flash, and a sane starting layout for d3).
78
93
  const coreR = Math.min(W, H) * 0.30;
79
94
  const hubs = nodes.filter((n) => roleOf(n) === "hub");
80
95
  const hubAngle = new Map<string, number>();
@@ -94,187 +109,331 @@ export function BrainGraph({
94
109
 
95
110
  nodesRef.current = simNodes;
96
111
  linksRef.current = links;
97
- setVersion((v) => v + 1); // commit the seeded layout even if the tick timer is throttled
112
+ bump();
98
113
 
99
114
  const linkDist = (l: SimLink) => {
100
115
  const ra = roleOf(l.source), rb = roleOf(l.target);
101
- if (ra === "core" || rb === "core") return 150;
102
- if (ra === "hub" && rb === "hub") return 120;
103
- return 62;
116
+ if (ra === "core" || rb === "core") return 170;
117
+ if (ra === "hub" && rb === "hub") return 130;
118
+ return 58;
104
119
  };
105
120
  const charge = (n: SimNode) => {
106
121
  const r = roleOf(n);
107
- return r === "core" ? -620 : r === "hub" ? -320 : -110;
122
+ return r === "core" ? -700 : r === "hub" ? -360 : -90;
108
123
  };
109
124
 
110
125
  const sim = forceSimulation<SimNode>(simNodes)
111
- .force("link", forceLink<SimNode, SimLink>(links).distance(linkDist).strength(0.55))
126
+ .force("link", forceLink<SimNode, SimLink>(links).distance(linkDist).strength(0.5))
112
127
  .force("charge", forceManyBody<SimNode>().strength(charge))
113
- .force("center", forceCenter(CX, CY).strength(0.04))
114
- .force("x", forceX(CX).strength(0.03))
115
- .force("y", forceY(CY).strength(0.03))
128
+ .force("center", forceCenter(CX, CY).strength(0.03))
129
+ .force("x", forceX(CX).strength(0.02))
130
+ .force("y", forceY(CY).strength(0.02))
116
131
  .force("collide", forceCollide<SimNode>((n) => RADIUS[roleOf(n)] + 8))
117
- .alphaDecay(0.028)
118
- .on("tick", () => setVersion((v) => v + 1));
132
+ .alphaDecay(0.025)
133
+ .on("tick", bump);
119
134
  simRef.current = sim;
120
- return () => { sim.stop(); };
135
+ // Auto-fit once the layout has cooled a little.
136
+ const fit = setTimeout(() => fitRef.current(), 1400);
137
+ return () => { clearTimeout(fit); sim.stop(); };
121
138
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
139
  }, [nodes, edges, height]);
123
140
 
124
- const toSvg = (e: React.PointerEvent) => {
141
+ // ── View helpers (pan / zoom / fit) ────────────────────────────────────────
142
+ const svgPoint = (clientX: number, clientY: number) => {
125
143
  const rect = svgRef.current!.getBoundingClientRect();
126
- return {
127
- x: ((e.clientX - rect.left) / rect.width) * W,
128
- y: ((e.clientY - rect.top) / rect.height) * H,
144
+ return { x: ((clientX - rect.left) / rect.width) * W, y: ((clientY - rect.top) / rect.height) * H };
145
+ };
146
+ const zoomAround = (sx: number, sy: number, factor: number) => {
147
+ const v = viewRef.current;
148
+ const k = clamp(v.k * factor, 0.25, 8);
149
+ viewRef.current = { k, tx: sx - (sx - v.tx) * (k / v.k), ty: sy - (sy - v.ty) * (k / v.k) };
150
+ bump();
151
+ };
152
+ fitRef.current = () => {
153
+ const ns = nodesRef.current.filter((n) => n.x != null && n.y != null);
154
+ if (!ns.length) return;
155
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
156
+ for (const n of ns) { minX = Math.min(minX, n.x!); minY = Math.min(minY, n.y!); maxX = Math.max(maxX, n.x!); maxY = Math.max(maxY, n.y!); }
157
+ const pad = 60;
158
+ const bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
159
+ const k = clamp(Math.min((W - pad * 2) / bw, (H - pad * 2) / bh), 0.25, 2.5);
160
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
161
+ viewRef.current = { k, tx: W / 2 - cx * k, ty: H / 2 - cy * k };
162
+ bump();
163
+ };
164
+
165
+ // Native non-passive wheel so we can preventDefault the page scroll.
166
+ useEffect(() => {
167
+ const el = svgRef.current;
168
+ if (!el) return;
169
+ const onWheel = (e: WheelEvent) => {
170
+ e.preventDefault();
171
+ const p = svgPoint(e.clientX, e.clientY);
172
+ zoomAround(p.x, p.y, e.deltaY > 0 ? 0.9 : 1.1);
129
173
  };
174
+ el.addEventListener("wheel", onWheel, { passive: false });
175
+ return () => el.removeEventListener("wheel", onWheel);
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, []);
178
+
179
+ // Escape exits fullscreen.
180
+ useEffect(() => {
181
+ if (!fs) return;
182
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFs(false); };
183
+ window.addEventListener("keydown", onKey);
184
+ return () => window.removeEventListener("keydown", onKey);
185
+ }, [fs]);
186
+
187
+ // ── Pointer interaction: node drag OR background pan ────────────────────────
188
+ const worldFromClient = (clientX: number, clientY: number) => {
189
+ const p = svgPoint(clientX, clientY);
190
+ const v = viewRef.current;
191
+ return { x: (p.x - v.tx) / v.k, y: (p.y - v.ty) / v.k };
130
192
  };
131
- const onDown = (n: SimNode) => (e: React.PointerEvent) => {
193
+ const onNodeDown = (n: SimNode) => (e: React.PointerEvent) => {
132
194
  if (roleOf(n) === "core") return;
195
+ e.stopPropagation();
133
196
  dragRef.current = n;
134
197
  (e.target as Element).setPointerCapture?.(e.pointerId);
135
198
  simRef.current?.alphaTarget(0.3).restart();
136
199
  };
200
+ const onBgDown = (e: React.PointerEvent) => {
201
+ panRef.current = { x: e.clientX, y: e.clientY };
202
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
203
+ };
137
204
  const onMove = (e: React.PointerEvent) => {
138
- const n = dragRef.current;
139
- if (!n) return;
140
- const { x, y } = toSvg(e);
141
- n.fx = x; n.fy = y;
205
+ if (dragRef.current) {
206
+ const w = worldFromClient(e.clientX, e.clientY);
207
+ dragRef.current.fx = w.x; dragRef.current.fy = w.y;
208
+ return;
209
+ }
210
+ if (panRef.current) {
211
+ const rect = svgRef.current!.getBoundingClientRect();
212
+ viewRef.current.tx += ((e.clientX - panRef.current.x) / rect.width) * W;
213
+ viewRef.current.ty += ((e.clientY - panRef.current.y) / rect.height) * H;
214
+ panRef.current = { x: e.clientX, y: e.clientY };
215
+ bump();
216
+ }
142
217
  };
143
218
  const onUp = () => {
144
219
  const n = dragRef.current;
145
220
  if (n) { n.fx = null; n.fy = null; }
146
221
  dragRef.current = null;
222
+ panRef.current = null;
147
223
  simRef.current?.alphaTarget(0);
148
224
  };
149
225
 
150
226
  const simNodes = nodesRef.current;
151
227
  const links = linksRef.current;
152
-
153
- // Legend: the item kinds actually present (exclude the core agent + structural hubs).
228
+ const v = viewRef.current;
154
229
  const legendKinds = [...new Set(nodes.map((n) => n.kind))].filter((k) => k !== "agent" && k !== "hub");
155
-
156
230
  const pick = (n: SimNode) => { setSelected(n); onNodeClick?.(n); };
157
231
 
232
+ // For the detail panel: what the selected node hangs off (parents) and the
233
+ // branches that hang off it (children), derived from the live edges.
234
+ const selParents = selected ? uniqById(links.filter((l) => l.target.id === selected.id).map((l) => l.source)) : [];
235
+ const selChildren = selected ? uniqById(links.filter((l) => l.source.id === selected.id).map((l) => l.target)) : [];
236
+ // The detail line is only meaningful when it adds something beyond the title.
237
+ const selDetail = selected?.detail && selected.detail.trim() !== selected.label.trim() ? selected.detail : null;
238
+
239
+ const CtrlBtn = ({ onClick, title, children }: { onClick: () => void; title: string; children: React.ReactNode }) => (
240
+ <button type="button" title={title} onClick={onClick}
241
+ className="grid size-7 place-items-center rounded-md border border-border bg-card/80 text-muted-fg backdrop-blur hover:text-foreground">
242
+ {children}
243
+ </button>
244
+ );
245
+
158
246
  return (
159
247
  <div className="space-y-3">
160
- <div className="overflow-hidden rounded-xl border border-border bg-gradient-to-b from-background to-muted/20">
161
- <svg
162
- ref={svgRef}
163
- viewBox={`0 0 ${W} ${H}`}
164
- style={{ height }}
165
- className="w-full touch-none select-none"
166
- onPointerMove={onMove}
167
- onPointerUp={onUp}
168
- onPointerLeave={onUp}
169
- >
170
- <defs>
171
- <filter id="brain-glow" x="-80%" y="-80%" width="260%" height="260%">
172
- <feGaussianBlur stdDeviation="3" result="blur" />
173
- <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
174
- </filter>
175
- <radialGradient id="brain-core" cx="50%" cy="50%" r="50%">
176
- <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.9" />
177
- <stop offset="55%" stopColor={KIND_COLOR.agent} stopOpacity="0.35" />
178
- <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
179
- </radialGradient>
180
- <radialGradient id="brain-bg" cx="50%" cy="50%" r="60%">
181
- <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.10" />
182
- <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
183
- </radialGradient>
184
- </defs>
185
-
186
- <rect x={0} y={0} width={W} height={H} fill="url(#brain-bg)" />
187
-
188
- {/* Edges: faint spine + a bead of energy flowing along it */}
189
- {links.map((l, i) => {
190
- const color = KIND_COLOR[l.target.kind === "hub" ? l.source.kind : l.target.kind];
191
- const dur = 1.4 + (i % 5) * 0.35;
192
- return (
193
- <g key={i}>
194
- <line x1={l.source.x} y1={l.source.y} x2={l.target.x} y2={l.target.y}
195
- stroke={color} strokeOpacity={0.16} strokeWidth={1.4} />
196
- <line x1={l.target.x} y1={l.target.y} x2={l.source.x} y2={l.source.y}
197
- stroke={color} strokeOpacity={0.5} strokeWidth={2}
198
- strokeLinecap="round" strokeDasharray="1 14">
199
- <animate attributeName="stroke-dashoffset" values="15;0" dur={`${dur}s`} repeatCount="indefinite" />
200
- </line>
201
- </g>
202
- );
203
- })}
204
-
205
- {/* Nodes */}
206
- {simNodes.map((n, idx) => {
207
- const role = roleOf(n);
208
- const color = KIND_COLOR[n.kind];
209
- if (role === "core") {
210
- const display = (n.emoji && n.emoji.trim()) || n.label.slice(0, 2).toUpperCase();
211
- return (
212
- <g key={n.id} transform={`translate(${n.x},${n.y})`}>
213
- <circle r={54} fill="url(#brain-core)">
214
- <animate attributeName="r" values="50;58;50" dur="4s" repeatCount="indefinite" />
215
- <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
216
- </circle>
217
- <circle r={26} fill="none" stroke={KIND_COLOR.agent} strokeWidth={1.5} opacity={0.5}>
218
- <animate attributeName="r" values="26;48" dur="3.2s" repeatCount="indefinite" />
219
- <animate attributeName="opacity" values="0.5;0" dur="3.2s" repeatCount="indefinite" />
220
- </circle>
221
- <circle r={24} fill={KIND_COLOR.agent} filter="url(#brain-glow)" />
222
- <circle r={24} fill="none" stroke="#ffffff" strokeOpacity={0.35} strokeWidth={1} />
223
- <text textAnchor="middle" dominantBaseline="central" fontSize={display.length <= 2 ? 20 : 11} fontWeight={700} fill="#1a1030">
224
- {display.length > 8 ? display.slice(0, 8) : display}
225
- </text>
226
- </g>
227
- );
228
- }
229
- const isSel = selected?.id === n.id;
230
- const r = RADIUS[role] + (isSel ? 3 : 0);
231
- const beat = 2.4 + (idx % 6) * 0.4;
232
- const begin = `${(idx % 6) * 0.3}s`;
233
- const isHub = role === "hub";
234
- const showLabel = isHub || isSel || !hideLeafLabels;
235
- return (
236
- <g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
237
- onPointerDown={onDown(n)} onClick={() => pick(n)}>
238
- <circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
239
- <animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
240
- <animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
241
- </circle>
242
- <circle r={r} fill={color} fillOpacity={isSel ? 1 : 0.95}
243
- stroke={isSel ? "#fff" : "#ffffff"} strokeOpacity={isSel ? 1 : 0.25} strokeWidth={isSel ? 2 : 1} />
244
- {n.emoji && isHub && (
245
- <text textAnchor="middle" dominantBaseline="central" fontSize={11} style={{ pointerEvents: "none" }}>{n.emoji}</text>
246
- )}
247
- {showLabel && (
248
- <text x={r + 4} y={4} fontSize={isHub ? 11 : 10}
249
- className={isHub ? "fill-foreground font-medium" : "fill-foreground/80"} style={{ pointerEvents: "none" }}>
250
- {n.label.length > 22 ? `${n.label.slice(0, 22)}…` : n.label}
251
- </text>
252
- )}
253
- </g>
254
- );
255
- })}
256
- </svg>
257
- </div>
248
+ <div
249
+ ref={wrapRef}
250
+ className={fs
251
+ ? "fixed inset-0 z-[60] flex flex-col gap-3 bg-background p-4"
252
+ : "relative"}
253
+ >
254
+ <div className="relative min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-gradient-to-b from-background to-muted/20">
255
+ {/* Controls */}
256
+ <div className="absolute right-2 top-2 z-10 flex items-center gap-1.5">
257
+ {toolbar}
258
+ <CtrlBtn onClick={() => zoomAround(CX, CY, 1.2)} title={t("agents_ui.brain_zoom_in")}><Plus size={14} /></CtrlBtn>
259
+ <CtrlBtn onClick={() => zoomAround(CX, CY, 0.83)} title={t("agents_ui.brain_zoom_out")}><Minus size={14} /></CtrlBtn>
260
+ <CtrlBtn onClick={() => fitRef.current()} title={t("agents_ui.brain_fit")}><Frame size={14} /></CtrlBtn>
261
+ <CtrlBtn onClick={() => setFs((f) => !f)} title={t(fs ? "agents_ui.brain_exit_fs" : "agents_ui.brain_fullscreen")}>
262
+ {fs ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
263
+ </CtrlBtn>
264
+ </div>
265
+
266
+ <svg
267
+ ref={svgRef}
268
+ viewBox={`0 0 ${W} ${H}`}
269
+ preserveAspectRatio="xMidYMid meet"
270
+ style={fs ? { height: "100%", width: "100%" } : { height }}
271
+ className="w-full touch-none select-none"
272
+ onPointerMove={onMove}
273
+ onPointerUp={onUp}
274
+ onPointerLeave={onUp}
275
+ >
276
+ <defs>
277
+ <filter id="brain-glow" x="-80%" y="-80%" width="260%" height="260%">
278
+ <feGaussianBlur stdDeviation="3" result="blur" />
279
+ <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
280
+ </filter>
281
+ <radialGradient id="brain-core" cx="50%" cy="50%" r="50%">
282
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.9" />
283
+ <stop offset="55%" stopColor={KIND_COLOR.agent} stopOpacity="0.35" />
284
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
285
+ </radialGradient>
286
+ <radialGradient id="brain-bg" cx="50%" cy="50%" r="60%">
287
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.10" />
288
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
289
+ </radialGradient>
290
+ </defs>
291
+
292
+ {/* Background — also the pan surface */}
293
+ <rect x={0} y={0} width={W} height={H} fill="url(#brain-bg)" onPointerDown={onBgDown} className="cursor-grab active:cursor-grabbing" />
258
294
 
259
- <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-fg">
260
- {legendKinds.map((k) => (
261
- <span key={k} className="inline-flex items-center gap-1">
262
- <span className="size-2 rounded-full" style={{ background: KIND_COLOR[k] }} /> {kindLabel(k)}
263
- </span>
264
- ))}
265
- <span className="ml-auto">{t("agents_ui.nodes_drag_hint", { n: String(nodes.length) })}</span>
295
+ <g transform={`translate(${v.tx},${v.ty}) scale(${v.k})`}>
296
+ {/* Edges: faint spine + a bead of energy flowing along it */}
297
+ {links.map((l, i) => {
298
+ const color = KIND_COLOR[l.target.kind === "hub" ? l.source.kind : l.target.kind];
299
+ const dur = 1.4 + (i % 5) * 0.35;
300
+ return (
301
+ <g key={i}>
302
+ <line x1={l.source.x} y1={l.source.y} x2={l.target.x} y2={l.target.y}
303
+ stroke={color} strokeOpacity={0.16} strokeWidth={1.4} />
304
+ <line x1={l.target.x} y1={l.target.y} x2={l.source.x} y2={l.source.y}
305
+ stroke={color} strokeOpacity={0.5} strokeWidth={2}
306
+ strokeLinecap="round" strokeDasharray="1 14">
307
+ <animate attributeName="stroke-dashoffset" values="15;0" dur={`${dur}s`} repeatCount="indefinite" />
308
+ </line>
309
+ </g>
310
+ );
311
+ })}
312
+
313
+ {/* Nodes */}
314
+ {simNodes.map((n, idx) => {
315
+ const role = roleOf(n);
316
+ const color = KIND_COLOR[n.kind];
317
+ if (role === "core") {
318
+ const display = (n.emoji && n.emoji.trim()) || n.label.slice(0, 2).toUpperCase();
319
+ return (
320
+ <g key={n.id} transform={`translate(${n.x},${n.y})`}>
321
+ <circle r={54} fill="url(#brain-core)">
322
+ <animate attributeName="r" values="50;58;50" dur="4s" repeatCount="indefinite" />
323
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
324
+ </circle>
325
+ <circle r={26} fill="none" stroke={KIND_COLOR.agent} strokeWidth={1.5} opacity={0.5}>
326
+ <animate attributeName="r" values="26;48" dur="3.2s" repeatCount="indefinite" />
327
+ <animate attributeName="opacity" values="0.5;0" dur="3.2s" repeatCount="indefinite" />
328
+ </circle>
329
+ <circle r={24} fill={KIND_COLOR.agent} filter="url(#brain-glow)" />
330
+ <circle r={24} fill="none" stroke="#ffffff" strokeOpacity={0.35} strokeWidth={1} />
331
+ <text textAnchor="middle" dominantBaseline="central" fontSize={display.length <= 2 ? 20 : 11} fontWeight={700} fill="#1a1030">
332
+ {display.length > 8 ? display.slice(0, 8) : display}
333
+ </text>
334
+ </g>
335
+ );
336
+ }
337
+ const isSel = selected?.id === n.id;
338
+ const r = RADIUS[role] + (isSel ? 3 : 0);
339
+ const beat = 2.4 + (idx % 6) * 0.4;
340
+ const begin = `${(idx % 6) * 0.3}s`;
341
+ const isHub = role === "hub";
342
+ const showLabel = isHub || isSel || !hideLeafLabels;
343
+ return (
344
+ <g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
345
+ onPointerDown={onNodeDown(n)} onClick={() => pick(n)}>
346
+ <circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
347
+ <animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
348
+ <animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
349
+ </circle>
350
+ <circle r={r} fill={color} fillOpacity={isSel ? 1 : 0.95}
351
+ stroke={isSel ? "#fff" : "#ffffff"} strokeOpacity={isSel ? 1 : 0.25} strokeWidth={isSel ? 2 : 1} />
352
+ {n.emoji && isHub && (
353
+ <text textAnchor="middle" dominantBaseline="central" fontSize={11} style={{ pointerEvents: "none" }}>{n.emoji}</text>
354
+ )}
355
+ {showLabel && (
356
+ <text x={r + 4} y={4} fontSize={isHub ? 11 : 10}
357
+ className={isHub ? "fill-foreground font-medium" : "fill-foreground/80"} style={{ pointerEvents: "none" }}>
358
+ {n.label.length > 22 ? `${n.label.slice(0, 22)}…` : n.label}
359
+ </text>
360
+ )}
361
+ </g>
362
+ );
363
+ })}
364
+ </g>
365
+ </svg>
366
+ </div>
367
+
368
+ {/* Legend + hint (kept inside the fullscreen container too) */}
369
+ <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-fg">
370
+ {legendKinds.map((k) => (
371
+ <span key={k} className="inline-flex items-center gap-1">
372
+ <span className="size-2 rounded-full" style={{ background: KIND_COLOR[k] }} /> {kindLabel(k)}
373
+ </span>
374
+ ))}
375
+ <span className="ml-auto">{t("agents_ui.brain_pan_hint")} · {t("agents_ui.nodes_drag_hint", { n: String(nodes.length) })}</span>
376
+ </div>
266
377
  </div>
378
+
267
379
  {selected && (
268
- <div className="rounded-lg border border-border bg-card p-3 text-xs">
269
- <div className="flex items-center gap-2">
270
- <span className="size-2 rounded-full" style={{ background: KIND_COLOR[selected.kind] }} />
271
- {selected.emoji && <span>{selected.emoji}</span>}
272
- <span className="font-medium">{selected.label}</span>
380
+ <div className="space-y-2.5 rounded-lg border border-border bg-card p-3 text-xs">
381
+ {/* The clicked node: title, type, relation */}
382
+ <div className="flex flex-wrap items-center gap-2">
383
+ <span className="size-2.5 rounded-full" style={{ background: KIND_COLOR[selected.kind] }} />
384
+ {selected.emoji && <span className="text-sm leading-none">{selected.emoji}</span>}
385
+ <span className="text-[13px] font-semibold">{selected.label}</span>
386
+ <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-fg">
387
+ {kindLabel(selected.kind)}
388
+ </span>
273
389
  {selected.relation && <span className="text-muted-fg">· {selected.relation}</span>}
390
+ <div className="ml-auto flex items-center gap-2">
391
+ {selected.slug && onNodeClick && (
392
+ <button type="button" onClick={() => onNodeClick(selected)} className="text-primary hover:underline">
393
+ {t("agents_ui.brain_open")}
394
+ </button>
395
+ )}
396
+ <button type="button" onClick={() => setSelected(null)} className="text-muted-fg hover:text-foreground">✕</button>
397
+ </div>
274
398
  </div>
275
- {selected.detail && <p className="mt-1 whitespace-pre-wrap text-muted-fg">{selected.detail}</p>}
399
+
400
+ {/* Internal info — only when it adds something beyond the title */}
401
+ {selDetail && <p className="whitespace-pre-wrap text-muted-fg">{selDetail}</p>}
402
+
403
+ {/* Where it hangs from */}
404
+ {selParents.length > 0 && (
405
+ <div className="flex flex-wrap items-center gap-1.5">
406
+ <span className="text-[10px] uppercase tracking-wide text-muted-fg/70">{t("agents_ui.brain_part_of")}</span>
407
+ {selParents.map((p) => <NeighborChip key={p.id} node={p} onClick={() => setSelected(p)} />)}
408
+ </div>
409
+ )}
410
+
411
+ {/* Branches that follow it */}
412
+ {selChildren.length > 0 && (
413
+ <div className="flex flex-wrap items-center gap-1.5">
414
+ <span className="text-[10px] uppercase tracking-wide text-muted-fg/70">
415
+ {t("agents_ui.brain_branches")} · {selChildren.length}
416
+ </span>
417
+ {selChildren.map((c) => <NeighborChip key={c.id} node={c} onClick={() => setSelected(c)} />)}
418
+ </div>
419
+ )}
276
420
  </div>
277
421
  )}
278
422
  </div>
279
423
  );
280
424
  }
425
+
426
+ // A clickable chip for a connected node in the detail panel.
427
+ function NeighborChip({ node, onClick }: { node: BrainNode; onClick: () => void }) {
428
+ return (
429
+ <button
430
+ type="button"
431
+ onClick={onClick}
432
+ className="inline-flex max-w-[220px] items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] hover:border-muted-fg/50 hover:bg-muted"
433
+ >
434
+ <span className="size-1.5 shrink-0 rounded-full" style={{ background: KIND_COLOR[node.kind] }} />
435
+ {node.emoji && <span className="leading-none">{node.emoji}</span>}
436
+ <span className="truncate">{clip(node.label, 28)}</span>
437
+ </button>
438
+ );
439
+ }