@agentprojectcontext/apx 1.70.0 → 1.72.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.
@@ -1,4 +1,5 @@
1
1
  import { useMemo } from "react";
2
+ import useSWR from "swr";
2
3
  import { useParams, Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
3
4
  import {
4
5
  Bot, Heart, Zap, Puzzle, Settings,
@@ -8,6 +9,8 @@ import {
8
9
  } from "lucide-react";
9
10
  import { useNavCollapse, type TabSection } from "../components/common/TabNav";
10
11
  import { TabLayout } from "../components/common/TabLayout";
12
+ import { Integrations } from "../lib/api";
13
+ import { ObsidianLogo } from "../components/integrations/BrandLogos";
11
14
  import { RobyEmpty } from "../components/Roby";
12
15
  import { Button } from "../components/ui/button";
13
16
  import { useProject } from "../hooks/useProjects";
@@ -50,7 +53,14 @@ export function ProjectScreen() {
50
53
  const { collapsed, toggle } = useNavCollapse(STORAGE.sidebarCollapsed + ".project");
51
54
 
52
55
  const isBase = String(pid) === "0";
56
+ // Obsidian-backed memory: when the vault integration is active, mark the
57
+ // Memories nav item so it's clear memory is mirrored through the vault.
58
+ const { data: catalog } = useSWR(`integrations-catalog-${pid}`, () => Integrations.catalog(pid), { shouldRetryOnError: false });
59
+ const obsidianEntry = catalog?.find((e) => e.slug === "obsidian");
60
+ const obsidianActive = obsidianEntry?.status?.status === "active" && !!obsidianEntry?.status?.is_enabled;
61
+
53
62
  const sections: TabSection[] = useMemo(() => {
63
+ const memoriesMark = obsidianActive ? <ObsidianLogo className="size-3 text-purple-400" /> : undefined;
54
64
  // One shared taxonomy for both Base and projects, in the same order, so the
55
65
  // two menus mirror each other. Base additionally gets a "General" admin
56
66
  // section (workspaces / engines / agent defaults) and drops "Content"
@@ -76,7 +86,7 @@ export function ProjectScreen() {
76
86
  { key: "", label: t("project.nav.overview"), icon: LayoutDashboard },
77
87
  ...(isCompany ? [{ key: "structure", label: t("project.nav.structure"), icon: Building2 }] : []),
78
88
  { key: "agents", label: t("project.nav.agents"), icon: Bot },
79
- { key: "memories", label: t("project.nav.memories"), icon: Brain },
89
+ { key: "memories", label: t("project.nav.memories"), icon: Brain, mark: memoriesMark },
80
90
  { key: "skills", label: t("skills_page.title"), icon: Sparkles },
81
91
  { key: "artifacts", label: t("project.nav.artifacts"), icon: FileCode2 },
82
92
  ],
@@ -120,7 +130,7 @@ export function ProjectScreen() {
120
130
  ];
121
131
 
122
132
  return out.filter(Boolean) as TabSection[];
123
- }, [isBase, project?.kind]);
133
+ }, [isBase, project?.kind, obsidianActive]);
124
134
 
125
135
  // First path segment after /p/:pid — so deep routes like agents/:slug still
126
136
  // highlight the "agents" nav item.
@@ -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,38 @@ 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));
53
56
 
54
57
  export function BrainGraph({
55
- nodes, edges, height = 460, onNodeClick,
58
+ nodes, edges, height = 520, onNodeClick, toolbar,
56
59
  }: {
57
60
  nodes: BrainNode[];
58
61
  edges: BrainEdge[];
59
62
  height?: number;
60
63
  onNodeClick?: (n: BrainNode) => void;
64
+ toolbar?: React.ReactNode; // extra controls (e.g. an Expand toggle)
61
65
  }) {
62
- const W = 760, H = height;
63
- const CX = W / 2, CY = H / 2;
66
+ const W = 1000, H = Math.round((W * height) / 760); // keep a wide-ish canvas
64
67
 
68
+ const wrapRef = useRef<HTMLDivElement | null>(null);
65
69
  const svgRef = useRef<SVGSVGElement | null>(null);
66
70
  const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
67
71
  const nodesRef = useRef<SimNode[]>([]);
68
72
  const linksRef = useRef<SimLink[]>([]);
69
73
  const dragRef = useRef<SimNode | null>(null);
74
+ const panRef = useRef<{ x: number; y: number } | null>(null);
75
+ const viewRef = useRef({ tx: 0, ty: 0, k: 1 });
76
+ const fitRef = useRef<() => void>(() => {});
70
77
  const [, setVersion] = useState(0);
71
78
  const [selected, setSelected] = useState<BrainNode | null>(null);
79
+ const [fs, setFs] = useState(false);
72
80
 
81
+ const CX = W / 2, CY = H / 2;
82
+ const bump = () => setVersion((v) => v + 1);
73
83
  const hideLeafLabels = nodes.length > 44;
74
84
 
85
+ // ── Layout simulation ──────────────────────────────────────────────────────
75
86
  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
87
  const coreR = Math.min(W, H) * 0.30;
79
88
  const hubs = nodes.filter((n) => roleOf(n) === "hub");
80
89
  const hubAngle = new Map<string, number>();
@@ -94,176 +103,266 @@ export function BrainGraph({
94
103
 
95
104
  nodesRef.current = simNodes;
96
105
  linksRef.current = links;
97
- setVersion((v) => v + 1); // commit the seeded layout even if the tick timer is throttled
106
+ bump();
98
107
 
99
108
  const linkDist = (l: SimLink) => {
100
109
  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;
110
+ if (ra === "core" || rb === "core") return 170;
111
+ if (ra === "hub" && rb === "hub") return 130;
112
+ return 58;
104
113
  };
105
114
  const charge = (n: SimNode) => {
106
115
  const r = roleOf(n);
107
- return r === "core" ? -620 : r === "hub" ? -320 : -110;
116
+ return r === "core" ? -700 : r === "hub" ? -360 : -90;
108
117
  };
109
118
 
110
119
  const sim = forceSimulation<SimNode>(simNodes)
111
- .force("link", forceLink<SimNode, SimLink>(links).distance(linkDist).strength(0.55))
120
+ .force("link", forceLink<SimNode, SimLink>(links).distance(linkDist).strength(0.5))
112
121
  .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))
122
+ .force("center", forceCenter(CX, CY).strength(0.03))
123
+ .force("x", forceX(CX).strength(0.02))
124
+ .force("y", forceY(CY).strength(0.02))
116
125
  .force("collide", forceCollide<SimNode>((n) => RADIUS[roleOf(n)] + 8))
117
- .alphaDecay(0.028)
118
- .on("tick", () => setVersion((v) => v + 1));
126
+ .alphaDecay(0.025)
127
+ .on("tick", bump);
119
128
  simRef.current = sim;
120
- return () => { sim.stop(); };
129
+ // Auto-fit once the layout has cooled a little.
130
+ const fit = setTimeout(() => fitRef.current(), 1400);
131
+ return () => { clearTimeout(fit); sim.stop(); };
121
132
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
133
  }, [nodes, edges, height]);
123
134
 
124
- const toSvg = (e: React.PointerEvent) => {
135
+ // ── View helpers (pan / zoom / fit) ────────────────────────────────────────
136
+ const svgPoint = (clientX: number, clientY: number) => {
125
137
  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,
138
+ return { x: ((clientX - rect.left) / rect.width) * W, y: ((clientY - rect.top) / rect.height) * H };
139
+ };
140
+ const zoomAround = (sx: number, sy: number, factor: number) => {
141
+ const v = viewRef.current;
142
+ const k = clamp(v.k * factor, 0.25, 8);
143
+ viewRef.current = { k, tx: sx - (sx - v.tx) * (k / v.k), ty: sy - (sy - v.ty) * (k / v.k) };
144
+ bump();
145
+ };
146
+ fitRef.current = () => {
147
+ const ns = nodesRef.current.filter((n) => n.x != null && n.y != null);
148
+ if (!ns.length) return;
149
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
150
+ 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!); }
151
+ const pad = 60;
152
+ const bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
153
+ const k = clamp(Math.min((W - pad * 2) / bw, (H - pad * 2) / bh), 0.25, 2.5);
154
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
155
+ viewRef.current = { k, tx: W / 2 - cx * k, ty: H / 2 - cy * k };
156
+ bump();
157
+ };
158
+
159
+ // Native non-passive wheel so we can preventDefault the page scroll.
160
+ useEffect(() => {
161
+ const el = svgRef.current;
162
+ if (!el) return;
163
+ const onWheel = (e: WheelEvent) => {
164
+ e.preventDefault();
165
+ const p = svgPoint(e.clientX, e.clientY);
166
+ zoomAround(p.x, p.y, e.deltaY > 0 ? 0.9 : 1.1);
129
167
  };
168
+ el.addEventListener("wheel", onWheel, { passive: false });
169
+ return () => el.removeEventListener("wheel", onWheel);
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, []);
172
+
173
+ // Escape exits fullscreen.
174
+ useEffect(() => {
175
+ if (!fs) return;
176
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFs(false); };
177
+ window.addEventListener("keydown", onKey);
178
+ return () => window.removeEventListener("keydown", onKey);
179
+ }, [fs]);
180
+
181
+ // ── Pointer interaction: node drag OR background pan ────────────────────────
182
+ const worldFromClient = (clientX: number, clientY: number) => {
183
+ const p = svgPoint(clientX, clientY);
184
+ const v = viewRef.current;
185
+ return { x: (p.x - v.tx) / v.k, y: (p.y - v.ty) / v.k };
130
186
  };
131
- const onDown = (n: SimNode) => (e: React.PointerEvent) => {
187
+ const onNodeDown = (n: SimNode) => (e: React.PointerEvent) => {
132
188
  if (roleOf(n) === "core") return;
189
+ e.stopPropagation();
133
190
  dragRef.current = n;
134
191
  (e.target as Element).setPointerCapture?.(e.pointerId);
135
192
  simRef.current?.alphaTarget(0.3).restart();
136
193
  };
194
+ const onBgDown = (e: React.PointerEvent) => {
195
+ panRef.current = { x: e.clientX, y: e.clientY };
196
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
197
+ };
137
198
  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;
199
+ if (dragRef.current) {
200
+ const w = worldFromClient(e.clientX, e.clientY);
201
+ dragRef.current.fx = w.x; dragRef.current.fy = w.y;
202
+ return;
203
+ }
204
+ if (panRef.current) {
205
+ const rect = svgRef.current!.getBoundingClientRect();
206
+ viewRef.current.tx += ((e.clientX - panRef.current.x) / rect.width) * W;
207
+ viewRef.current.ty += ((e.clientY - panRef.current.y) / rect.height) * H;
208
+ panRef.current = { x: e.clientX, y: e.clientY };
209
+ bump();
210
+ }
142
211
  };
143
212
  const onUp = () => {
144
213
  const n = dragRef.current;
145
214
  if (n) { n.fx = null; n.fy = null; }
146
215
  dragRef.current = null;
216
+ panRef.current = null;
147
217
  simRef.current?.alphaTarget(0);
148
218
  };
149
219
 
150
220
  const simNodes = nodesRef.current;
151
221
  const links = linksRef.current;
152
-
153
- // Legend: the item kinds actually present (exclude the core agent + structural hubs).
222
+ const v = viewRef.current;
154
223
  const legendKinds = [...new Set(nodes.map((n) => n.kind))].filter((k) => k !== "agent" && k !== "hub");
155
-
156
224
  const pick = (n: SimNode) => { setSelected(n); onNodeClick?.(n); };
157
225
 
226
+ const CtrlBtn = ({ onClick, title, children }: { onClick: () => void; title: string; children: React.ReactNode }) => (
227
+ <button type="button" title={title} onClick={onClick}
228
+ className="grid size-7 place-items-center rounded-md border border-border bg-card/80 text-muted-fg backdrop-blur hover:text-foreground">
229
+ {children}
230
+ </button>
231
+ );
232
+
158
233
  return (
159
234
  <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>
235
+ <div
236
+ ref={wrapRef}
237
+ className={fs
238
+ ? "fixed inset-0 z-[60] flex flex-col gap-3 bg-background p-4"
239
+ : "relative"}
240
+ >
241
+ <div className="relative min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-gradient-to-b from-background to-muted/20">
242
+ {/* Controls */}
243
+ <div className="absolute right-2 top-2 z-10 flex items-center gap-1.5">
244
+ {toolbar}
245
+ <CtrlBtn onClick={() => zoomAround(CX, CY, 1.2)} title={t("agents_ui.brain_zoom_in")}><Plus size={14} /></CtrlBtn>
246
+ <CtrlBtn onClick={() => zoomAround(CX, CY, 0.83)} title={t("agents_ui.brain_zoom_out")}><Minus size={14} /></CtrlBtn>
247
+ <CtrlBtn onClick={() => fitRef.current()} title={t("agents_ui.brain_fit")}><Frame size={14} /></CtrlBtn>
248
+ <CtrlBtn onClick={() => setFs((f) => !f)} title={t(fs ? "agents_ui.brain_exit_fs" : "agents_ui.brain_fullscreen")}>
249
+ {fs ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
250
+ </CtrlBtn>
251
+ </div>
185
252
 
186
- <rect x={0} y={0} width={W} height={H} fill="url(#brain-bg)" />
253
+ <svg
254
+ ref={svgRef}
255
+ viewBox={`0 0 ${W} ${H}`}
256
+ preserveAspectRatio="xMidYMid meet"
257
+ style={fs ? { height: "100%", width: "100%" } : { height }}
258
+ className="w-full touch-none select-none"
259
+ onPointerMove={onMove}
260
+ onPointerUp={onUp}
261
+ onPointerLeave={onUp}
262
+ >
263
+ <defs>
264
+ <filter id="brain-glow" x="-80%" y="-80%" width="260%" height="260%">
265
+ <feGaussianBlur stdDeviation="3" result="blur" />
266
+ <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
267
+ </filter>
268
+ <radialGradient id="brain-core" cx="50%" cy="50%" r="50%">
269
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.9" />
270
+ <stop offset="55%" stopColor={KIND_COLOR.agent} stopOpacity="0.35" />
271
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
272
+ </radialGradient>
273
+ <radialGradient id="brain-bg" cx="50%" cy="50%" r="60%">
274
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.10" />
275
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
276
+ </radialGradient>
277
+ </defs>
187
278
 
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
- })}
279
+ {/* Background also the pan surface */}
280
+ <rect x={0} y={0} width={W} height={H} fill="url(#brain-bg)" onPointerDown={onBgDown} className="cursor-grab active:cursor-grabbing" />
204
281
 
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>
282
+ <g transform={`translate(${v.tx},${v.ty}) scale(${v.k})`}>
283
+ {/* Edges: faint spine + a bead of energy flowing along it */}
284
+ {links.map((l, i) => {
285
+ const color = KIND_COLOR[l.target.kind === "hub" ? l.source.kind : l.target.kind];
286
+ const dur = 1.4 + (i % 5) * 0.35;
287
+ return (
288
+ <g key={i}>
289
+ <line x1={l.source.x} y1={l.source.y} x2={l.target.x} y2={l.target.y}
290
+ stroke={color} strokeOpacity={0.16} strokeWidth={1.4} />
291
+ <line x1={l.target.x} y1={l.target.y} x2={l.source.x} y2={l.source.y}
292
+ stroke={color} strokeOpacity={0.5} strokeWidth={2}
293
+ strokeLinecap="round" strokeDasharray="1 14">
294
+ <animate attributeName="stroke-dashoffset" values="15;0" dur={`${dur}s`} repeatCount="indefinite" />
295
+ </line>
296
+ </g>
297
+ );
298
+ })}
299
+
300
+ {/* Nodes */}
301
+ {simNodes.map((n, idx) => {
302
+ const role = roleOf(n);
303
+ const color = KIND_COLOR[n.kind];
304
+ if (role === "core") {
305
+ const display = (n.emoji && n.emoji.trim()) || n.label.slice(0, 2).toUpperCase();
306
+ return (
307
+ <g key={n.id} transform={`translate(${n.x},${n.y})`}>
308
+ <circle r={54} fill="url(#brain-core)">
309
+ <animate attributeName="r" values="50;58;50" dur="4s" repeatCount="indefinite" />
310
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
311
+ </circle>
312
+ <circle r={26} fill="none" stroke={KIND_COLOR.agent} strokeWidth={1.5} opacity={0.5}>
313
+ <animate attributeName="r" values="26;48" dur="3.2s" repeatCount="indefinite" />
314
+ <animate attributeName="opacity" values="0.5;0" dur="3.2s" repeatCount="indefinite" />
315
+ </circle>
316
+ <circle r={24} fill={KIND_COLOR.agent} filter="url(#brain-glow)" />
317
+ <circle r={24} fill="none" stroke="#ffffff" strokeOpacity={0.35} strokeWidth={1} />
318
+ <text textAnchor="middle" dominantBaseline="central" fontSize={display.length <= 2 ? 20 : 11} fontWeight={700} fill="#1a1030">
319
+ {display.length > 8 ? display.slice(0, 8) : display}
320
+ </text>
321
+ </g>
322
+ );
323
+ }
324
+ const isSel = selected?.id === n.id;
325
+ const r = RADIUS[role] + (isSel ? 3 : 0);
326
+ const beat = 2.4 + (idx % 6) * 0.4;
327
+ const begin = `${(idx % 6) * 0.3}s`;
328
+ const isHub = role === "hub";
329
+ const showLabel = isHub || isSel || !hideLeafLabels;
330
+ return (
331
+ <g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
332
+ onPointerDown={onNodeDown(n)} onClick={() => pick(n)}>
333
+ <circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
334
+ <animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
335
+ <animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
336
+ </circle>
337
+ <circle r={r} fill={color} fillOpacity={isSel ? 1 : 0.95}
338
+ stroke={isSel ? "#fff" : "#ffffff"} strokeOpacity={isSel ? 1 : 0.25} strokeWidth={isSel ? 2 : 1} />
339
+ {n.emoji && isHub && (
340
+ <text textAnchor="middle" dominantBaseline="central" fontSize={11} style={{ pointerEvents: "none" }}>{n.emoji}</text>
341
+ )}
342
+ {showLabel && (
343
+ <text x={r + 4} y={4} fontSize={isHub ? 11 : 10}
344
+ className={isHub ? "fill-foreground font-medium" : "fill-foreground/80"} style={{ pointerEvents: "none" }}>
345
+ {n.label.length > 22 ? `${n.label.slice(0, 22)}…` : n.label}
346
+ </text>
347
+ )}
348
+ </g>
349
+ );
350
+ })}
351
+ </g>
352
+ </svg>
353
+ </div>
258
354
 
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>
355
+ {/* Legend + hint (kept inside the fullscreen container too) */}
356
+ <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-fg">
357
+ {legendKinds.map((k) => (
358
+ <span key={k} className="inline-flex items-center gap-1">
359
+ <span className="size-2 rounded-full" style={{ background: KIND_COLOR[k] }} /> {kindLabel(k)}
360
+ </span>
361
+ ))}
362
+ <span className="ml-auto">{t("agents_ui.brain_pan_hint")} · {t("agents_ui.nodes_drag_hint", { n: String(nodes.length) })}</span>
363
+ </div>
266
364
  </div>
365
+
267
366
  {selected && (
268
367
  <div className="rounded-lg border border-border bg-card p-3 text-xs">
269
368
  <div className="flex items-center gap-2">