@brainpilot/web 0.0.3 → 0.0.5

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.
Files changed (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,1240 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Activity,
4
+ ArrowRight,
5
+ Bot,
6
+ EyeOff,
7
+ Filter,
8
+ Inbox,
9
+ MessageSquare,
10
+ Network,
11
+ Plus,
12
+ Send,
13
+ Webhook,
14
+ Wrench,
15
+ X,
16
+ } from "lucide-react";
17
+ import { AgentStatus, ChatMessage } from "../../contracts/backend";
18
+ import {
19
+ AgentEdge,
20
+ AgentEdgeMessage,
21
+ BUILTIN_AGENT_NAMES,
22
+ buildEdges,
23
+ countMessagesFor,
24
+ getAgentAccentVar,
25
+ getAgentIcon,
26
+ getAgentProfile,
27
+ msgTypeKind,
28
+ relativeTime,
29
+ statusKind,
30
+ } from "./agentNetworkShared";
31
+ import {
32
+ computeAgentActivity,
33
+ computeAllAgentActivities,
34
+ computeAgentActivityPercentages,
35
+ type AgentActivity,
36
+ type AgentActivityPercentages,
37
+ } from "./agentAnalytics";
38
+ import { NodeTooltip, NodeTooltipData } from "./NodeTooltip";
39
+ import { GlobalOverview } from "./GlobalOverview";
40
+ import { AnalyticsTab } from "./AnalyticsTab";
41
+ import { TimelineTab } from "./TimelineTab";
42
+ import { useT } from "../../i18n/useT";
43
+
44
+ type AgentTab = "detail" | "analytics" | "timeline";
45
+ const TAB_STORAGE_KEY = "agent-network-active-tab";
46
+
47
+ function loadActiveTab(): AgentTab {
48
+ try {
49
+ const v = localStorage.getItem(TAB_STORAGE_KEY);
50
+ if (v === "detail" || v === "analytics" || v === "timeline") return v;
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ return "detail";
55
+ }
56
+
57
+ type Selection =
58
+ | { kind: "node"; id: string }
59
+ | { kind: "edge"; key: string }
60
+ | null;
61
+
62
+ /* --------------------------------------------------------------------------
63
+ * Helpers
64
+ * ------------------------------------------------------------------------ */
65
+
66
+ const ACTIVE_EDGE_WINDOW_MS = 5_000;
67
+
68
+ /** Translation key for a `statusKind` value, used as a display label. */
69
+ function statusLabelKey(status: "running" | "idle" | "error" | "stopped"): string {
70
+ return `network.status.${status}`;
71
+ }
72
+
73
+ /* --------------------------------------------------------------------------
74
+ * Layout: deterministic concentric ring placement (no force layout, no jitter).
75
+ * ------------------------------------------------------------------------ */
76
+
77
+ interface PositionedNode {
78
+ name: string;
79
+ x: number;
80
+ y: number;
81
+ }
82
+
83
+ const VIEWBOX_WIDTH = 760;
84
+ const VIEWBOX_HEIGHT = 480;
85
+ const NODE_RADIUS = 30;
86
+
87
+ function layoutNodes(names: string[]): PositionedNode[] {
88
+ const cx = VIEWBOX_WIDTH / 2;
89
+ const cy = VIEWBOX_HEIGHT / 2;
90
+ if (names.length === 0) return [];
91
+
92
+ // Always anchor `principal` at the center if present.
93
+ const principalIdx = names.indexOf("principal");
94
+ const center = principalIdx >= 0 ? names[principalIdx] : null;
95
+ const ring = center ? names.filter((n) => n !== center) : [...names];
96
+
97
+ const positioned: PositionedNode[] = [];
98
+ if (center) {
99
+ positioned.push({ name: center, x: cx, y: cy });
100
+ }
101
+
102
+ const ringCount = ring.length;
103
+ if (ringCount === 0) {
104
+ if (!center) {
105
+ // Single non-principal node -> place at center
106
+ positioned.push({ name: names[0], x: cx, y: cy });
107
+ }
108
+ return positioned;
109
+ }
110
+
111
+ // One ring up to 6, otherwise split into inner/outer.
112
+ if (ringCount <= 6 || !center) {
113
+ const radius = Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.34;
114
+ const angleOffset = -Math.PI / 2; // start at top
115
+ ring.forEach((name, idx) => {
116
+ const angle = angleOffset + (idx * 2 * Math.PI) / ringCount;
117
+ positioned.push({
118
+ name,
119
+ x: cx + radius * Math.cos(angle),
120
+ y: cy + radius * Math.sin(angle),
121
+ });
122
+ });
123
+ return positioned;
124
+ }
125
+
126
+ // Two rings: half on inner, half on outer.
127
+ const innerCount = Math.ceil(ringCount / 2);
128
+ const outerCount = ringCount - innerCount;
129
+ const innerRadius = Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.22;
130
+ const outerRadius = Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.4;
131
+ const offsetInner = -Math.PI / 2;
132
+ const offsetOuter = -Math.PI / 2 + Math.PI / outerCount;
133
+ ring.slice(0, innerCount).forEach((name, idx) => {
134
+ const angle = offsetInner + (idx * 2 * Math.PI) / innerCount;
135
+ positioned.push({
136
+ name,
137
+ x: cx + innerRadius * Math.cos(angle),
138
+ y: cy + innerRadius * Math.sin(angle),
139
+ });
140
+ });
141
+ ring.slice(innerCount).forEach((name, idx) => {
142
+ const angle = offsetOuter + (idx * 2 * Math.PI) / outerCount;
143
+ positioned.push({
144
+ name,
145
+ x: cx + outerRadius * Math.cos(angle),
146
+ y: cy + outerRadius * Math.sin(angle),
147
+ });
148
+ });
149
+ return positioned;
150
+ }
151
+
152
+ /** Curve from p1 to p2, offset perpendicular by `bend` so two-way edges don't overlap. */
153
+ function buildEdgePath(p1: PositionedNode, p2: PositionedNode, bend: number) {
154
+ const dx = p2.x - p1.x;
155
+ const dy = p2.y - p1.y;
156
+ const distance = Math.sqrt(dx * dx + dy * dy) || 1;
157
+ // Shorten endpoints so arrow doesn't touch the node.
158
+ const ux = dx / distance;
159
+ const uy = dy / distance;
160
+ const startX = p1.x + ux * NODE_RADIUS;
161
+ const startY = p1.y + uy * NODE_RADIUS;
162
+ const endX = p2.x - ux * (NODE_RADIUS + 4);
163
+ const endY = p2.y - uy * (NODE_RADIUS + 4);
164
+ // Perpendicular control point for slight curvature.
165
+ const midX = (startX + endX) / 2;
166
+ const midY = (startY + endY) / 2;
167
+ const px = -uy * bend;
168
+ const py = ux * bend;
169
+ return {
170
+ d: `M ${startX} ${startY} Q ${midX + px} ${midY + py} ${endX} ${endY}`,
171
+ midX: midX + px * 0.6,
172
+ midY: midY + py * 0.6,
173
+ };
174
+ }
175
+
176
+ /* --------------------------------------------------------------------------
177
+ * Component
178
+ * ------------------------------------------------------------------------ */
179
+
180
+ export interface AgentMessageFilter {
181
+ hideMessages: boolean;
182
+ hideTools: boolean;
183
+ hideHooks: boolean;
184
+ }
185
+
186
+ interface AgentNetworkProps {
187
+ agents: AgentStatus[];
188
+ messages: ChatMessage[];
189
+ agentFilters: Record<string, AgentMessageFilter>;
190
+ onSetAgentFilter: (
191
+ agentName: string,
192
+ hideMessages: boolean,
193
+ hideTools: boolean,
194
+ hideHooks?: boolean,
195
+ ) => void;
196
+ }
197
+
198
+ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter }: AgentNetworkProps) {
199
+ const t = useT();
200
+ const [selection, setSelection] = useState<Selection>(null);
201
+ const [hoverEdgeKey, setHoverEdgeKey] = useState<string | null>(null);
202
+ const [activeTab, setActiveTab] = useState<AgentTab>(loadActiveTab);
203
+
204
+ // Persist active tab across reloads (same pattern as PreferencesContext).
205
+ useEffect(() => {
206
+ try {
207
+ localStorage.setItem(TAB_STORAGE_KEY, activeTab);
208
+ } catch {
209
+ /* ignore quota / privacy-mode errors */
210
+ }
211
+ }, [activeTab]);
212
+
213
+ // Selecting a node/edge always brings the Detail tab forward so the user
214
+ // sees what they clicked (Analytics/Timeline are global, not per-selection).
215
+ const selectNode = (id: string) => {
216
+ setSelection({ kind: "node", id });
217
+ setActiveTab("detail");
218
+ };
219
+ const selectEdge = (key: string) => {
220
+ setSelection({ kind: "edge", key });
221
+ setActiveTab("detail");
222
+ };
223
+
224
+ // Node hover tooltip: a short delay on show (avoid flicker on quick passes)
225
+ // and a short delay on hide (so the mouse can travel onto the tooltip).
226
+ const [hovered, setHovered] = useState<{
227
+ name: string;
228
+ anchor: { left: number; top: number; width: number; height: number };
229
+ } | null>(null);
230
+ const viewportRef = useRef<HTMLDivElement | null>(null);
231
+ const showTimerRef = useRef<number | null>(null);
232
+ const hideTimerRef = useRef<number | null>(null);
233
+
234
+ const clearHoverTimers = () => {
235
+ if (showTimerRef.current !== null) {
236
+ window.clearTimeout(showTimerRef.current);
237
+ showTimerRef.current = null;
238
+ }
239
+ if (hideTimerRef.current !== null) {
240
+ window.clearTimeout(hideTimerRef.current);
241
+ hideTimerRef.current = null;
242
+ }
243
+ };
244
+
245
+ const handleNodeEnter = (name: string, nodeEl: SVGGElement) => {
246
+ if (hideTimerRef.current !== null) {
247
+ window.clearTimeout(hideTimerRef.current);
248
+ hideTimerRef.current = null;
249
+ }
250
+ if (showTimerRef.current !== null) {
251
+ window.clearTimeout(showTimerRef.current);
252
+ }
253
+ showTimerRef.current = window.setTimeout(() => {
254
+ const container = viewportRef.current;
255
+ if (!container) return;
256
+ const nodeRect = nodeEl.getBoundingClientRect();
257
+ const containerRect = container.getBoundingClientRect();
258
+ setHovered({
259
+ name,
260
+ anchor: {
261
+ left: nodeRect.left - containerRect.left,
262
+ top: nodeRect.top - containerRect.top,
263
+ width: nodeRect.width,
264
+ height: nodeRect.height,
265
+ },
266
+ });
267
+ }, 300);
268
+ };
269
+
270
+ const handleNodeLeave = () => {
271
+ if (showTimerRef.current !== null) {
272
+ window.clearTimeout(showTimerRef.current);
273
+ showTimerRef.current = null;
274
+ }
275
+ if (hideTimerRef.current !== null) {
276
+ window.clearTimeout(hideTimerRef.current);
277
+ }
278
+ hideTimerRef.current = window.setTimeout(() => setHovered(null), 200);
279
+ };
280
+
281
+ // Clean up any pending timers on unmount.
282
+ useEffect(() => clearHoverTimers, []);
283
+
284
+ const edges = useMemo(() => buildEdges(messages), [messages]);
285
+
286
+ // Live agents from the session — anything sandboxed has spawned this entry.
287
+ const liveNames = useMemo(() => new Set(agents.map((a) => a.name)), [agents]);
288
+
289
+ // All node names: built-ins (always shown as dormant placeholders) ∪ live
290
+ // agents ∪ any names referenced in edges (defensive). Order is stable so
291
+ // the layout doesn't reflow when an agent comes/goes.
292
+ const nodeNames = useMemo(() => {
293
+ const set = new Set<string>();
294
+ BUILTIN_AGENT_NAMES.forEach((n) => set.add(n));
295
+ agents.forEach((a) => set.add(a.name));
296
+ edges.forEach((e) => {
297
+ set.add(e.from);
298
+ set.add(e.to);
299
+ });
300
+ // Stable ordering: builtins first (in declared order), then custom names sorted.
301
+ const builtinSet = new Set<string>(BUILTIN_AGENT_NAMES);
302
+ const builtins = BUILTIN_AGENT_NAMES.filter((n) => set.has(n));
303
+ const customs = Array.from(set).filter((n) => !builtinSet.has(n)).sort();
304
+ return [...builtins, ...customs];
305
+ }, [agents, edges]);
306
+
307
+ const positioned = useMemo(() => layoutNodes(nodeNames), [nodeNames]);
308
+ const positionByName = useMemo(() => {
309
+ const map = new Map<string, PositionedNode>();
310
+ positioned.forEach((p) => map.set(p.name, p));
311
+ return map;
312
+ }, [positioned]);
313
+
314
+ const agentByName = useMemo(() => {
315
+ const map = new Map<string, AgentStatus>();
316
+ agents.forEach((a) => map.set(a.name, a));
317
+ return map;
318
+ }, [agents]);
319
+
320
+ // Bend edges so opposite-direction pairs don't overlap.
321
+ const edgesWithGeometry = useMemo(() => {
322
+ const seen = new Set<string>();
323
+ return edges
324
+ .map((edge) => {
325
+ const reverseKey = `${edge.to}->${edge.from}`;
326
+ // First-seen edge of a pair gets +bend; reverse gets -bend.
327
+ const bend = seen.has(reverseKey) ? -22 : edges.some((e) => e.key === reverseKey) ? 22 : 0;
328
+ seen.add(edge.key);
329
+ return { edge, bend };
330
+ })
331
+ .map(({ edge, bend }) => {
332
+ const p1 = positionByName.get(edge.from);
333
+ const p2 = positionByName.get(edge.to);
334
+ if (!p1 || !p2) return null;
335
+ const geometry = buildEdgePath(p1, p2, bend);
336
+ return { edge, geometry };
337
+ })
338
+ .filter((item): item is { edge: AgentEdge; geometry: ReturnType<typeof buildEdgePath> } => item !== null);
339
+ }, [edges, positionByName]);
340
+
341
+ const now = Date.now();
342
+ const totalMessages = edges.reduce((sum, edge) => sum + edge.messages.length, 0);
343
+ const liveCount = agents.length;
344
+ const runningCount = agents.filter((a) => statusKind(a.status) === "running").length;
345
+
346
+ // Keep relative times / the Timeline "now" marker moving. Only tick while a
347
+ // time-sensitive tab is open or an agent is running, so we don't force a
348
+ // re-render every few seconds when the user is just reading detail text.
349
+ const [, setTick] = useState(0);
350
+ const needsTick = activeTab !== "detail" || runningCount > 0;
351
+ useEffect(() => {
352
+ if (!needsTick) return;
353
+ const id = window.setInterval(() => setTick((t) => t + 1), 5000);
354
+ return () => window.clearInterval(id);
355
+ }, [needsTick]);
356
+
357
+ // Selected entity for the side panel.
358
+ const selectedAgent = useMemo<AgentStatus | null>(() => {
359
+ if (!selection || selection.kind !== "node") return null;
360
+ return agentByName.get(selection.id) ?? { name: selection.id, status: "idle", task: "" };
361
+ }, [selection, agentByName]);
362
+
363
+ const selectedEdge = useMemo<AgentEdge | null>(() => {
364
+ if (!selection || selection.kind !== "edge") return null;
365
+ return edges.find((e) => e.key === selection.key) ?? null;
366
+ }, [selection, edges]);
367
+
368
+ const messagesForAgent = useMemo(() => {
369
+ if (!selectedAgent) return { sent: [] as AgentEdgeMessage[], received: [] as AgentEdgeMessage[] };
370
+ const sent: AgentEdgeMessage[] = [];
371
+ const received: AgentEdgeMessage[] = [];
372
+ edges.forEach((edge) => {
373
+ edge.messages.forEach((m) => {
374
+ if (m.from === selectedAgent.name) sent.push(m);
375
+ if (m.to === selectedAgent.name) received.push(m);
376
+ });
377
+ });
378
+ sent.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
379
+ received.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
380
+ return { sent, received };
381
+ }, [selectedAgent, edges]);
382
+
383
+ // Compute comprehensive activity stats for all agents
384
+ const allActivities = useMemo(() => computeAllAgentActivities(messages, edges), [messages, edges]);
385
+
386
+ // Activity stats for the selected agent
387
+ const selectedAgentActivity = useMemo<AgentActivity | null>(() => {
388
+ if (!selectedAgent) return null;
389
+ return allActivities.get(selectedAgent.name) ?? null;
390
+ }, [selectedAgent, allActivities]);
391
+
392
+ const selectedAgentPercentages = useMemo<AgentActivityPercentages | null>(() => {
393
+ if (!selectedAgentActivity) return null;
394
+ return computeAgentActivityPercentages(selectedAgentActivity, allActivities);
395
+ }, [selectedAgentActivity, allActivities]);
396
+
397
+ if (nodeNames.length === 0) {
398
+ return (
399
+ <div className="agent-network agent-network--empty">
400
+ <div className="agent-network__empty-state">
401
+ <Network size={28} />
402
+ <p>{t("network.empty")}</p>
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ const principalPos = positionByName.get("principal");
409
+
410
+ // Tooltip data for the currently-hovered node (if any).
411
+ const hoveredData: NodeTooltipData | null = hovered
412
+ ? (() => {
413
+ const agent = agentByName.get(hovered.name);
414
+ const isLive = liveNames.has(hovered.name);
415
+ const counts = countMessagesFor(hovered.name, edges);
416
+ return {
417
+ name: hovered.name,
418
+ isLive,
419
+ status: agent?.status ?? "idle",
420
+ task: agent?.task ?? "",
421
+ updatedAt: agent?.updatedAt,
422
+ sent: counts.sent,
423
+ received: counts.received,
424
+ };
425
+ })()
426
+ : null;
427
+
428
+ return (
429
+ <div className="agent-network">
430
+ <div className="agent-network__viewport-shell">
431
+ <div className="agent-network__legend" aria-label={t("network.aria.legend")}>
432
+ <span className="agent-network__legend-item">
433
+ <i className="agent-network__legend-dot agent-network__legend-dot--running" /> {t("network.legend.running")}
434
+ </span>
435
+ <span className="agent-network__legend-item">
436
+ <i className="agent-network__legend-dot agent-network__legend-dot--idle" /> {t("network.legend.live")}
437
+ </span>
438
+ <span className="agent-network__legend-item">
439
+ <i className="agent-network__legend-dot agent-network__legend-dot--dormant" /> {t("network.legend.dormant")}
440
+ </span>
441
+ <span className="agent-network__legend-item">
442
+ <i className="agent-network__legend-dot agent-network__legend-dot--error" /> {t("network.legend.error")}
443
+ </span>
444
+ <span className="agent-network__legend-divider" aria-hidden="true" />
445
+ <span className="agent-network__legend-item">
446
+ <i className="agent-network__legend-line agent-network__legend-line--delegate" /> {t("network.legend.delegate")}
447
+ </span>
448
+ <span className="agent-network__legend-item">
449
+ <i className="agent-network__legend-line agent-network__legend-line--result" /> {t("network.legend.result")}
450
+ </span>
451
+ <span className="agent-network__legend-counter">
452
+ {t("network.legend.counter", { live: liveCount, total: nodeNames.length, running: runningCount, edges: edges.length, msgs: totalMessages })}
453
+ </span>
454
+ </div>
455
+
456
+ <div className="agent-network__viewport" aria-label={t("network.aria.viewport")} ref={viewportRef}>
457
+ <svg
458
+ className="agent-network__svg"
459
+ preserveAspectRatio="xMidYMid meet"
460
+ role="img"
461
+ viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
462
+ >
463
+ <defs>
464
+ {/* Engineering-paper backdrop: a fine 32px grid nested inside a
465
+ heavier 160px (5×) major grid. Very low opacity, hidden from
466
+ the a11y tree (purely decorative). */}
467
+ <pattern id="agent-net-grid" width="32" height="32" patternUnits="userSpaceOnUse">
468
+ <path
469
+ className="agent-network__grid-line"
470
+ d="M 32 0 L 0 0 0 32"
471
+ fill="none"
472
+ />
473
+ </pattern>
474
+ <pattern id="agent-net-grid-major" width="160" height="160" patternUnits="userSpaceOnUse">
475
+ <path
476
+ className="agent-network__grid-line agent-network__grid-line--major"
477
+ d="M 160 0 L 0 0 0 160"
478
+ fill="none"
479
+ />
480
+ </pattern>
481
+ {/* Soft radial vignette that draws the eye to PI. */}
482
+ <radialGradient id="agent-net-glow" cx="50%" cy="50%" r="60%">
483
+ <stop offset="0%" className="agent-network__glow-stop-inner" />
484
+ <stop offset="55%" className="agent-network__glow-stop-mid" />
485
+ <stop offset="100%" className="agent-network__glow-stop-outer" />
486
+ </radialGradient>
487
+ {/* Arrow markers for edges (stroke colors come from CSS). */}
488
+ <marker id="agent-net-arrow-neutral" markerHeight="6" markerWidth="6" orient="auto" refX="5" refY="3">
489
+ <path d="M 0 0 L 6 3 L 0 6 z" />
490
+ </marker>
491
+ <marker id="agent-net-arrow-delegate" markerHeight="6" markerWidth="6" orient="auto" refX="5" refY="3">
492
+ <path d="M 0 0 L 6 3 L 0 6 z" />
493
+ </marker>
494
+ <marker id="agent-net-arrow-result" markerHeight="6" markerWidth="6" orient="auto" refX="5" refY="3">
495
+ <path d="M 0 0 L 6 3 L 0 6 z" />
496
+ </marker>
497
+ </defs>
498
+
499
+ {/* clear-selection background — must be FIRST so nodes/edges
500
+ above it receive clicks. (SVG hit-test goes top-down by
501
+ sibling order; transparent fill still captures events.) */}
502
+ <rect
503
+ className="agent-network__bg-hit"
504
+ fill="transparent"
505
+ height={VIEWBOX_HEIGHT}
506
+ onClick={() => setSelection(null)}
507
+ width={VIEWBOX_WIDTH}
508
+ />
509
+
510
+ {/* Decorative backdrop: grid + radial glow + concentric guide
511
+ rings. Pointer-events disabled so the bg-hit rect above
512
+ still catches clicks. Hidden from screen readers. */}
513
+ <g className="agent-network__backdrop" aria-hidden="true">
514
+ <rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#agent-net-grid)" />
515
+ <rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#agent-net-grid-major)" />
516
+ <rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#agent-net-glow)" />
517
+ {principalPos ? (
518
+ <>
519
+ <circle
520
+ className="agent-network__guide-ring"
521
+ cx={principalPos.x}
522
+ cy={principalPos.y}
523
+ r={Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.22}
524
+ />
525
+ <circle
526
+ className="agent-network__guide-ring"
527
+ cx={principalPos.x}
528
+ cy={principalPos.y}
529
+ r={Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.34}
530
+ />
531
+ <circle
532
+ className="agent-network__guide-ring agent-network__guide-ring--outer"
533
+ cx={principalPos.x}
534
+ cy={principalPos.y}
535
+ r={Math.min(VIEWBOX_WIDTH, VIEWBOX_HEIGHT) * 0.4}
536
+ />
537
+ </>
538
+ ) : null}
539
+ </g>
540
+
541
+ {/* Scaffold links: principal ↔ every other agent, drawn behind
542
+ message edges. Always-on so the team graph stays visible
543
+ even when no message has been sent yet. Dormant agents
544
+ use a lighter dasharray to read as "not yet activated". */}
545
+ <g className="agent-network__scaffold" aria-hidden="true">
546
+ {(() => {
547
+ const principal = positionByName.get("principal");
548
+ if (!principal) return null;
549
+ return positioned
550
+ .filter((node) => node.name !== "principal" && node.name !== "user")
551
+ .map((node) => {
552
+ const dx = node.x - principal.x;
553
+ const dy = node.y - principal.y;
554
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
555
+ const ux = dx / dist;
556
+ const uy = dy / dist;
557
+ const x1 = principal.x + ux * NODE_RADIUS;
558
+ const y1 = principal.y + uy * NODE_RADIUS;
559
+ const x2 = node.x - ux * NODE_RADIUS;
560
+ const y2 = node.y - uy * NODE_RADIUS;
561
+ const isLive = liveNames.has(node.name);
562
+ return (
563
+ <line
564
+ className={`agent-network__scaffold-line ${
565
+ isLive ? "agent-network__scaffold-line--live" : "agent-network__scaffold-line--dormant"
566
+ }`}
567
+ key={`scaffold-${node.name}`}
568
+ x1={x1}
569
+ y1={y1}
570
+ x2={x2}
571
+ y2={y2}
572
+ />
573
+ );
574
+ });
575
+ })()}
576
+ </g>
577
+
578
+ {/* edges first so nodes draw on top */}
579
+ <g className="agent-network__edges">
580
+ {edgesWithGeometry.map(({ edge, geometry }) => {
581
+ const lastMs = new Date(edge.lastTimestamp).getTime();
582
+ const isActive = Number.isFinite(lastMs) && now - lastMs < ACTIVE_EDGE_WINDOW_MS;
583
+ const lastMsg = edge.messages[edge.messages.length - 1];
584
+ const kind = msgTypeKind(lastMsg?.msgType);
585
+ const weight = edge.messages.length;
586
+ const strokeWidth = weight <= 1 ? 1.5 : weight <= 5 ? 2 : 2.5;
587
+ const isSelected = selection?.kind === "edge" && selection.key === edge.key;
588
+ const isHover = hoverEdgeKey === edge.key;
589
+ return (
590
+ <g
591
+ aria-label={t(weight === 1 ? "network.aria.edgeOne" : "network.aria.edgeMany", { from: edge.from, count: weight, to: edge.to })}
592
+ className={[
593
+ "agent-network__edge",
594
+ `agent-network__edge--${kind}`,
595
+ isActive ? "agent-network__edge--active" : "",
596
+ isSelected ? "is-selected" : "",
597
+ isHover ? "is-hover" : "",
598
+ ].filter(Boolean).join(" ")}
599
+ key={edge.key}
600
+ onClick={(event) => {
601
+ event.stopPropagation();
602
+ selectEdge(edge.key);
603
+ }}
604
+ onKeyDown={(event) => {
605
+ if (event.key === "Enter" || event.key === " ") {
606
+ event.preventDefault();
607
+ selectEdge(edge.key);
608
+ }
609
+ }}
610
+ onMouseEnter={() => setHoverEdgeKey(edge.key)}
611
+ onMouseLeave={() => setHoverEdgeKey((current) => (current === edge.key ? null : current))}
612
+ role="button"
613
+ tabIndex={0}
614
+ >
615
+ {/* invisible thick hitbox for easier clicking */}
616
+ <path className="agent-network__edge-hitbox" d={geometry.d} />
617
+ <path
618
+ className="agent-network__edge-line"
619
+ d={geometry.d}
620
+ markerEnd={`url(#agent-net-arrow-${kind})`}
621
+ style={{ strokeWidth }}
622
+ />
623
+ {weight > 1 ? (
624
+ <g className="agent-network__edge-badge" transform={`translate(${geometry.midX} ${geometry.midY})`}>
625
+ <circle r="9" />
626
+ <text dy="3" textAnchor="middle">{weight}</text>
627
+ </g>
628
+ ) : null}
629
+ </g>
630
+ );
631
+ })}
632
+ </g>
633
+
634
+ <g className="agent-network__nodes">
635
+ {positioned.map((node) => {
636
+ const agent = agentByName.get(node.name);
637
+ const isLive = liveNames.has(node.name);
638
+ const status = agent ? statusKind(agent.status) : "idle";
639
+ const presence = isLive ? "live" : "dormant";
640
+ const isSelected = selection?.kind === "node" && selection.id === node.name;
641
+ const Icon = getAgentIcon(node.name);
642
+ const accent = getAgentAccentVar(node.name);
643
+ const isPrincipal = node.name === "principal";
644
+ return (
645
+ <g
646
+ aria-label={t("network.aria.node", { name: node.name, status: t(isLive ? statusLabelKey(status) : "network.status.dormant") })}
647
+ className={[
648
+ "agent-network__node",
649
+ `agent-network__node--${status}`,
650
+ `agent-network__node--${presence}`,
651
+ isPrincipal ? "agent-network__node--principal" : "",
652
+ isSelected ? "is-selected" : "",
653
+ ].filter(Boolean).join(" ")}
654
+ key={node.name}
655
+ onClick={(event) => {
656
+ event.stopPropagation();
657
+ selectNode(node.name);
658
+ }}
659
+ onKeyDown={(event) => {
660
+ if (event.key === "Enter" || event.key === " ") {
661
+ event.preventDefault();
662
+ selectNode(node.name);
663
+ }
664
+ }}
665
+ onMouseEnter={(event) => handleNodeEnter(node.name, event.currentTarget)}
666
+ onMouseLeave={handleNodeLeave}
667
+ role="button"
668
+ style={{ ["--agent-accent" as string]: accent }}
669
+ tabIndex={0}
670
+ transform={`translate(${node.x} ${node.y})`}
671
+ >
672
+ {/* Pulse halo: only for running agents, gated on motion preference. */}
673
+ {status === "running" && isLive ? (
674
+ <circle className="agent-network__node-pulse" r={NODE_RADIUS + 6} />
675
+ ) : null}
676
+ {/* Outer ring — status border (solid when live, dashed when dormant). */}
677
+ <circle className="agent-network__node-ring" r={NODE_RADIUS} />
678
+ {/* Inner accent disc — agent brand color, low opacity. */}
679
+ <circle className="agent-network__node-disc" r={NODE_RADIUS - 6} />
680
+ {/* Tiny dormant badge: empty hollow circle in the corner so
681
+ dormant state is readable WITHOUT relying on color. */}
682
+ {!isLive ? (
683
+ <g className="agent-network__node-badge agent-network__node-badge--dormant">
684
+ <circle cx={NODE_RADIUS - 6} cy={-NODE_RADIUS + 6} r="5" />
685
+ </g>
686
+ ) : null}
687
+ {/* Running indicator dot. */}
688
+ {status === "running" && isLive ? (
689
+ <g className="agent-network__node-badge agent-network__node-badge--running">
690
+ <circle cx={NODE_RADIUS - 6} cy={-NODE_RADIUS + 6} r="5" />
691
+ </g>
692
+ ) : null}
693
+ {/* Error indicator. */}
694
+ {status === "error" && isLive ? (
695
+ <g className="agent-network__node-badge agent-network__node-badge--error">
696
+ <circle cx={NODE_RADIUS - 6} cy={-NODE_RADIUS + 6} r="5" />
697
+ </g>
698
+ ) : null}
699
+ <foreignObject x={-12} y={-12} width={24} height={24}>
700
+ <div className="agent-network__node-icon">
701
+ <Icon size={18} />
702
+ </div>
703
+ </foreignObject>
704
+ <text className="agent-network__node-label" textAnchor="middle" y={NODE_RADIUS + 18}>
705
+ {node.name}
706
+ </text>
707
+ {agent?.task ? (
708
+ <text className="agent-network__node-sublabel" textAnchor="middle" y={NODE_RADIUS + 32}>
709
+ {agent.task.length > 32 ? `${agent.task.slice(0, 30)}…` : agent.task}
710
+ </text>
711
+ ) : !isLive ? (
712
+ <text className="agent-network__node-sublabel agent-network__node-sublabel--dormant" textAnchor="middle" y={NODE_RADIUS + 32}>
713
+ {t("network.node.notSpawned")}
714
+ </text>
715
+ ) : null}
716
+ </g>
717
+ );
718
+ })}
719
+ </g>
720
+ </svg>
721
+ {hoveredData && hovered && viewportRef.current ? (
722
+ <NodeTooltip
723
+ data={hoveredData}
724
+ now={now}
725
+ anchor={hovered.anchor}
726
+ container={{
727
+ width: viewportRef.current.clientWidth,
728
+ height: viewportRef.current.clientHeight,
729
+ }}
730
+ />
731
+ ) : null}
732
+ </div>
733
+ </div>
734
+
735
+ <aside className="agent-network__detail" aria-label={t("network.aria.detail")}>
736
+ <div className="agent-network__tabs" role="tablist" aria-label={t("network.aria.tabs")}>
737
+ {(["detail", "analytics", "timeline"] as AgentTab[]).map((tab) => (
738
+ <button
739
+ key={tab}
740
+ role="tab"
741
+ type="button"
742
+ aria-selected={activeTab === tab}
743
+ className={`agent-network__tab ${activeTab === tab ? "agent-network__tab--active" : ""}`}
744
+ onClick={() => setActiveTab(tab)}
745
+ onKeyDown={(event) => {
746
+ const order: AgentTab[] = ["detail", "analytics", "timeline"];
747
+ const idx = order.indexOf(activeTab);
748
+ if (event.key === "ArrowRight") {
749
+ event.preventDefault();
750
+ setActiveTab(order[(idx + 1) % order.length]);
751
+ } else if (event.key === "ArrowLeft") {
752
+ event.preventDefault();
753
+ setActiveTab(order[(idx - 1 + order.length) % order.length]);
754
+ }
755
+ }}
756
+ >
757
+ {tab === "detail" ? t("network.tab.detail") : tab === "analytics" ? t("network.tab.analytics") : t("network.tab.timeline")}
758
+ </button>
759
+ ))}
760
+ </div>
761
+
762
+ <div className="agent-network__tabpanel" role="tabpanel">
763
+ {activeTab === "detail" ? (
764
+ selectedEdge ? (
765
+ <EdgeDetail edge={selectedEdge} now={now} />
766
+ ) : selectedAgent ? (
767
+ <AgentDetail
768
+ agent={selectedAgent}
769
+ isLive={liveNames.has(selectedAgent.name)}
770
+ filter={
771
+ agentFilters[selectedAgent.name] ?? {
772
+ hideMessages: false,
773
+ hideTools: false,
774
+ hideHooks: true,
775
+ }
776
+ }
777
+ now={now}
778
+ onFilterChange={onSetAgentFilter}
779
+ received={messagesForAgent.received}
780
+ sent={messagesForAgent.sent}
781
+ activity={selectedAgentActivity}
782
+ activityPercentages={selectedAgentPercentages}
783
+ />
784
+ ) : (
785
+ <GlobalOverview
786
+ agents={agents}
787
+ edges={edges}
788
+ messages={messages}
789
+ totalNodes={nodeNames.length}
790
+ liveCount={liveCount}
791
+ now={now}
792
+ />
793
+ )
794
+ ) : activeTab === "analytics" ? (
795
+ <AnalyticsTab agents={agents} messages={messages} edges={edges} now={now} />
796
+ ) : (
797
+ <TimelineTab
798
+ messages={messages}
799
+ now={now}
800
+ onSelectMessage={(agentName) => selectNode(agentName)}
801
+ />
802
+ )}
803
+ </div>
804
+ </aside>
805
+ </div>
806
+ );
807
+ }
808
+
809
+ /* --------------------------------------------------------------------------
810
+ * Detail subviews
811
+ * ------------------------------------------------------------------------ */
812
+
813
+ function AgentDetail({
814
+ agent,
815
+ isLive,
816
+ filter,
817
+ now,
818
+ onFilterChange,
819
+ sent,
820
+ received,
821
+ activity,
822
+ activityPercentages,
823
+ }: {
824
+ agent: AgentStatus;
825
+ isLive: boolean;
826
+ filter: AgentMessageFilter;
827
+ now: number;
828
+ onFilterChange: (
829
+ agentName: string,
830
+ hideMessages: boolean,
831
+ hideTools: boolean,
832
+ hideHooks?: boolean,
833
+ ) => void;
834
+ sent: AgentEdgeMessage[];
835
+ received: AgentEdgeMessage[];
836
+ activity: AgentActivity | null;
837
+ activityPercentages: AgentActivityPercentages | null;
838
+ }) {
839
+ const Icon = getAgentIcon(agent.name);
840
+ const status = statusKind(agent.status);
841
+ const profile = getAgentProfile(agent.name);
842
+ const presence = isLive ? "live" : "dormant";
843
+ const t = useT();
844
+ const statusLabel = isLive ? t(statusLabelKey(status)) : t("network.status.dormant");
845
+
846
+ return (
847
+ <>
848
+ <header className="agent-network__detail-title">
849
+ <span
850
+ className={`agent-network__detail-avatar agent-network__detail-avatar--${status} agent-network__detail-avatar--${presence}`}
851
+ style={{ ["--agent-accent" as string]: getAgentAccentVar(agent.name) }}
852
+ >
853
+ <Icon size={16} />
854
+ </span>
855
+ <h3>{agent.name}</h3>
856
+ <span className={`agent-network__status-pill agent-network__status-pill--${isLive ? status : "dormant"}`}>
857
+ {statusLabel}
858
+ </span>
859
+ </header>
860
+ <div className="agent-network__detail-badges">
861
+ <span>{t(profile.role)}</span>
862
+ {!isLive ? <span>{t("network.detail.notSpawned")}</span> : null}
863
+ {agent.alive === false ? <span>{t("network.detail.notAlive")}</span> : null}
864
+ </div>
865
+
866
+ {/* ---- Profile ---- */}
867
+ <section className="agent-network__detail-section">
868
+ <h4><Bot size={13} /> {t("network.detail.responsibility")}</h4>
869
+ <p className="agent-network__detail-text">{t(profile.description)}</p>
870
+ </section>
871
+
872
+ {/* ---- Live status ---- */}
873
+ <section className="agent-network__detail-section">
874
+ <h4><Activity size={13} /> {t("network.detail.status")}</h4>
875
+ <dl className="agent-network__keyvals">
876
+ <div>
877
+ <dt>{t("network.detail.state")}</dt>
878
+ <dd>
879
+ <span className={`agent-network__status-pill agent-network__status-pill--${isLive ? status : "dormant"}`}>
880
+ {statusLabel}
881
+ </span>
882
+ </dd>
883
+ </div>
884
+ <div>
885
+ <dt>{t("network.detail.currentTask")}</dt>
886
+ <dd className="agent-network__keyvals-wrap">
887
+ {isLive ? (agent.task || t("network.detail.idleWaiting")) : t("network.detail.notSpawnedByPrincipal")}
888
+ </dd>
889
+ </div>
890
+ <div>
891
+ <dt>{t("network.detail.updated")}</dt>
892
+ <dd>{agent.updatedAt ? relativeTime(agent.updatedAt, now) : "—"}</dd>
893
+ </div>
894
+ <div>
895
+ <dt>{t("network.detail.communication")}</dt>
896
+ <dd>{t("network.detail.commCount", { sent: sent.length, received: received.length })}</dd>
897
+ </div>
898
+ </dl>
899
+ </section>
900
+
901
+ {/* ---- Activity Statistics ---- */}
902
+ {activity && activityPercentages ? (
903
+ <section className="agent-network__detail-section">
904
+ <h4><MessageSquare size={13} /> {t("network.detail.activityStats")}</h4>
905
+ <dl className="agent-network__keyvals">
906
+ <div>
907
+ <dt>{t("network.detail.totalMessages")}</dt>
908
+ <dd>
909
+ {activity.totalMessages}
910
+ <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
911
+ {t("network.detail.pctOfSession", { pct: activityPercentages.messagePercent.toFixed(1) })}
912
+ </span>
913
+ </dd>
914
+ </div>
915
+ <div>
916
+ <dt>{t("network.detail.messageBreakdown")}</dt>
917
+ <dd className="agent-network__keyvals-wrap">
918
+ {t("network.detail.breakdownValue", { assistant: activity.assistantMessages, reasoning: activity.reasoningMessages, tool: activity.toolMessages })}
919
+ </dd>
920
+ </div>
921
+ <div>
922
+ <dt>{t("network.detail.toolCalls")}</dt>
923
+ <dd>
924
+ {activity.toolCalls}
925
+ {activity.toolCalls > 0 && (
926
+ <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
927
+ {t("network.detail.pctOfSession", { pct: activityPercentages.toolCallPercent.toFixed(1) })}
928
+ </span>
929
+ )}
930
+ </dd>
931
+ </div>
932
+ {activity.topTools.length > 0 && (
933
+ <div>
934
+ <dt>{t("network.detail.topTools")}</dt>
935
+ <dd className="agent-network__keyvals-wrap">
936
+ {activity.topTools.map((t2, i) => (
937
+ <span key={t2.name}>
938
+ {t2.name} ({t2.count}){i < activity.topTools.length - 1 ? " · " : ""}
939
+ </span>
940
+ ))}
941
+ </dd>
942
+ </div>
943
+ )}
944
+ <div>
945
+ <dt>{t("network.detail.contentVolume")}</dt>
946
+ <dd>
947
+ {t("network.detail.volumeValue", { chars: activity.totalChars.toLocaleString(), tokens: activity.estimatedTokens.toLocaleString() })}
948
+ <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
949
+ {t("network.detail.pctOfSession", { pct: activityPercentages.tokenPercent.toFixed(1) })}
950
+ </span>
951
+ </dd>
952
+ </div>
953
+ {activity.communicationPartners.length > 0 && (
954
+ <div>
955
+ <dt>{t("network.detail.communicationPartners")}</dt>
956
+ <dd className="agent-network__keyvals-wrap">
957
+ {activity.communicationPartners.join(", ")}
958
+ </dd>
959
+ </div>
960
+ )}
961
+ </dl>
962
+ </section>
963
+ ) : null}
964
+
965
+ {/* ---- Available tools ---- */}
966
+ <section className="agent-network__detail-section">
967
+ <h4><Wrench size={13} /> {t("network.detail.availableTools")}</h4>
968
+ <ul className="agent-network__tool-list">
969
+ {profile.defaultTools.length === 0 ? (
970
+ <li className="agent-network__tool-chip">{t("network.detail.noMcpTools")}</li>
971
+ ) : (
972
+ profile.defaultTools.map((tool) => (
973
+ <li className="agent-network__tool-chip" key={tool}>{tool}</li>
974
+ ))
975
+ )}
976
+ </ul>
977
+ </section>
978
+
979
+ {/* ---- Display filters ---- */}
980
+ <section className="agent-network__detail-section">
981
+ <h4><Filter size={13} /> {t("network.detail.displayFilters")}</h4>
982
+ <FilterChipBar agentName={agent.name} filter={filter} onChange={onFilterChange} />
983
+ <p className="agent-network__detail-text" style={{ fontSize: 11 }}>
984
+ {t("network.detail.filtersNote")}
985
+ </p>
986
+ </section>
987
+
988
+ {/* ---- Sent ---- */}
989
+ <section className="agent-network__detail-section">
990
+ <h4><Send size={13} /> {t("network.detail.sent", { count: sent.length })}</h4>
991
+ <MessageList items={sent} now={now} dirField="to" emptyText={t("network.detail.noOutgoing")} />
992
+ </section>
993
+
994
+ {/* ---- Received ---- */}
995
+ <section className="agent-network__detail-section">
996
+ <h4><Inbox size={13} /> {t("network.detail.received", { count: received.length })}</h4>
997
+ <MessageList items={received} now={now} dirField="from" emptyText={t("network.detail.noIncoming")} />
998
+ </section>
999
+ </>
1000
+ );
1001
+ }
1002
+
1003
+ function EdgeDetail({ edge, now }: { edge: AgentEdge; now: number }) {
1004
+ const t = useT();
1005
+ return (
1006
+ <>
1007
+ <header className="agent-network__detail-title">
1008
+ <span className="agent-network__detail-avatar"><ArrowRight size={16} /></span>
1009
+ <h3>
1010
+ {edge.from} <ArrowRight size={14} className="agent-network__detail-arrow" /> {edge.to}
1011
+ </h3>
1012
+ </header>
1013
+ <div className="agent-network__detail-badges">
1014
+ <span>{t("network.edge.messages", { count: edge.messages.length })}</span>
1015
+ <span>{t("network.edge.last", { time: relativeTime(edge.lastTimestamp, now) })}</span>
1016
+ </div>
1017
+ <section className="agent-network__detail-section">
1018
+ <h4><MessageSquare size={13} /> {t("network.edge.conversation")}</h4>
1019
+ <ol className="agent-network__msg-list agent-network__msg-list--ordered">
1020
+ {edge.messages.map((m) => (
1021
+ <li className={`agent-network__msg agent-network__msg--${msgTypeKind(m.msgType)}`} key={m.id}>
1022
+ <header className="agent-network__msg-head">
1023
+ <span className="agent-network__msg-type">{m.msgType ?? t("network.msg.fallback")}</span>
1024
+ <time>{relativeTime(m.timestamp, now)}</time>
1025
+ </header>
1026
+ <p>{m.content || <em>{t("network.msg.empty")}</em>}</p>
1027
+ </li>
1028
+ ))}
1029
+ </ol>
1030
+ </section>
1031
+ </>
1032
+ );
1033
+ }
1034
+
1035
+ function MessageList({
1036
+ items,
1037
+ now,
1038
+ dirField,
1039
+ emptyText,
1040
+ }: {
1041
+ items: AgentEdgeMessage[];
1042
+ now: number;
1043
+ dirField: "from" | "to";
1044
+ emptyText: string;
1045
+ }) {
1046
+ const t = useT();
1047
+ if (items.length === 0) {
1048
+ return <p className="agent-network__detail-text">{emptyText}</p>;
1049
+ }
1050
+ return (
1051
+ <ul className="agent-network__msg-list">
1052
+ {items.slice(0, 20).map((m) => (
1053
+ <li className={`agent-network__msg agent-network__msg--${msgTypeKind(m.msgType)}`} key={m.id}>
1054
+ <header className="agent-network__msg-head">
1055
+ <span className="agent-network__msg-route">
1056
+ <strong>{m[dirField]}</strong>
1057
+ </span>
1058
+ <span className="agent-network__msg-type">{m.msgType ?? t("network.msg.fallback")}</span>
1059
+ <time>{relativeTime(m.timestamp, now)}</time>
1060
+ </header>
1061
+ <p>
1062
+ {m.content
1063
+ ? (m.content.length > 200 ? `${m.content.slice(0, 198)}…` : m.content)
1064
+ : <em>{t("network.msg.empty")}</em>}
1065
+ </p>
1066
+ </li>
1067
+ ))}
1068
+ {items.length > 20 ? (
1069
+ <li className="agent-network__msg">{t("network.msg.more", { count: items.length - 20 })}</li>
1070
+ ) : null}
1071
+ </ul>
1072
+ );
1073
+ }
1074
+
1075
+ /* --------------------------------------------------------------------------
1076
+ * Filter chip bar — Linear/GitHub-style active-filter chips.
1077
+ *
1078
+ * Default state: "Hiding: nothing [+ add filter]".
1079
+ * Each active rule is a removable chip; popover lets the user add more.
1080
+ * ------------------------------------------------------------------------ */
1081
+
1082
+ type FilterKey = "messages" | "tools" | "hooks";
1083
+
1084
+ const FILTER_KEYS: FilterKey[] = ["messages", "tools", "hooks"];
1085
+
1086
+ const FILTER_KEY_META: Record<
1087
+ FilterKey,
1088
+ { labelKey: string; descriptionKey: string; Icon: typeof MessageSquare }
1089
+ > = {
1090
+ messages: { labelKey: "network.filter.messages.label", descriptionKey: "network.filter.messages.description", Icon: MessageSquare },
1091
+ tools: { labelKey: "network.filter.tools.label", descriptionKey: "network.filter.tools.description", Icon: Wrench },
1092
+ hooks: { labelKey: "network.filter.hooks.label", descriptionKey: "network.filter.hooks.description", Icon: Webhook },
1093
+ };
1094
+
1095
+ function activeFilterKeys(filter: AgentMessageFilter): FilterKey[] {
1096
+ const out: FilterKey[] = [];
1097
+ if (filter.hideMessages) out.push("messages");
1098
+ if (filter.hideTools) out.push("tools");
1099
+ if (filter.hideHooks) out.push("hooks");
1100
+ return out;
1101
+ }
1102
+
1103
+ function applyFilterKey(filter: AgentMessageFilter, key: FilterKey, value: boolean): AgentMessageFilter {
1104
+ if (key === "messages") return { ...filter, hideMessages: value };
1105
+ if (key === "tools") return { ...filter, hideTools: value };
1106
+ return { ...filter, hideHooks: value };
1107
+ }
1108
+
1109
+ function FilterChipBar({
1110
+ agentName,
1111
+ filter,
1112
+ onChange,
1113
+ }: {
1114
+ agentName: string;
1115
+ filter: AgentMessageFilter;
1116
+ onChange: (
1117
+ agentName: string,
1118
+ hideMessages: boolean,
1119
+ hideTools: boolean,
1120
+ hideHooks?: boolean,
1121
+ ) => void;
1122
+ }) {
1123
+ const t = useT();
1124
+ const [popoverOpen, setPopoverOpen] = useState(false);
1125
+ const popoverRef = useRef<HTMLDivElement | null>(null);
1126
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
1127
+
1128
+ const active = activeFilterKeys(filter);
1129
+ const inactive = FILTER_KEYS.filter((key) => !active.includes(key));
1130
+
1131
+ // Close popover if no choices remain.
1132
+ useEffect(() => {
1133
+ if (popoverOpen && inactive.length === 0) setPopoverOpen(false);
1134
+ }, [popoverOpen, inactive.length]);
1135
+
1136
+ // Close on outside click + ESC.
1137
+ useEffect(() => {
1138
+ if (!popoverOpen) return;
1139
+ const handlePointerDown = (event: PointerEvent) => {
1140
+ const target = event.target as Node | null;
1141
+ if (!target) return;
1142
+ if (popoverRef.current?.contains(target)) return;
1143
+ if (triggerRef.current?.contains(target)) return;
1144
+ setPopoverOpen(false);
1145
+ };
1146
+ const handleKey = (event: KeyboardEvent) => {
1147
+ if (event.key === "Escape") {
1148
+ setPopoverOpen(false);
1149
+ triggerRef.current?.focus();
1150
+ }
1151
+ };
1152
+ document.addEventListener("pointerdown", handlePointerDown, true);
1153
+ document.addEventListener("keydown", handleKey);
1154
+ return () => {
1155
+ document.removeEventListener("pointerdown", handlePointerDown, true);
1156
+ document.removeEventListener("keydown", handleKey);
1157
+ };
1158
+ }, [popoverOpen]);
1159
+
1160
+ const setKey = (key: FilterKey, value: boolean) => {
1161
+ const next = applyFilterKey(filter, key, value);
1162
+ onChange(agentName, next.hideMessages, next.hideTools, next.hideHooks);
1163
+ };
1164
+
1165
+ return (
1166
+ <div className="filter-chips" role="group" aria-label={t("network.filter.ariaGroup")}>
1167
+ <div className="filter-chips__row">
1168
+ <span className="filter-chips__label">
1169
+ <EyeOff size={12} aria-hidden="true" /> {t("network.filter.hiding")}
1170
+ </span>
1171
+
1172
+ {active.length === 0 ? (
1173
+ <span className="filter-chips__empty">{t("network.filter.nothing")}</span>
1174
+ ) : (
1175
+ active.map((key) => {
1176
+ const meta = FILTER_KEY_META[key];
1177
+ const Icon = meta.Icon;
1178
+ const label = t(meta.labelKey);
1179
+ return (
1180
+ <span className="filter-chips__chip" key={key}>
1181
+ <Icon size={11} aria-hidden="true" />
1182
+ <span>{label}</span>
1183
+ <button
1184
+ aria-label={t("network.filter.stopHiding", { label })}
1185
+ className="filter-chips__chip-remove"
1186
+ onClick={() => setKey(key, false)}
1187
+ type="button"
1188
+ >
1189
+ <X size={10} aria-hidden="true" />
1190
+ </button>
1191
+ </span>
1192
+ );
1193
+ })
1194
+ )}
1195
+
1196
+ <span className="filter-chips__add">
1197
+ <button
1198
+ aria-expanded={popoverOpen}
1199
+ aria-haspopup="menu"
1200
+ aria-label={t("network.filter.addRule")}
1201
+ className="filter-chips__add-trigger"
1202
+ disabled={inactive.length === 0}
1203
+ onClick={() => setPopoverOpen((current) => !current)}
1204
+ ref={triggerRef}
1205
+ type="button"
1206
+ >
1207
+ <Plus size={11} aria-hidden="true" />
1208
+ <span>{t("network.filter.addFilter")}</span>
1209
+ </button>
1210
+ {popoverOpen && inactive.length > 0 ? (
1211
+ <div className="filter-chips__popover" ref={popoverRef} role="menu">
1212
+ <div className="filter-chips__popover-header">{t("network.filter.popoverHeader")}</div>
1213
+ {inactive.map((key) => {
1214
+ const meta = FILTER_KEY_META[key];
1215
+ const Icon = meta.Icon;
1216
+ return (
1217
+ <button
1218
+ className="filter-chips__popover-item"
1219
+ key={key}
1220
+ onClick={() => {
1221
+ setKey(key, true);
1222
+ setPopoverOpen(false);
1223
+ triggerRef.current?.focus();
1224
+ }}
1225
+ role="menuitem"
1226
+ type="button"
1227
+ >
1228
+ <Icon size={14} aria-hidden="true" />
1229
+ <span className="filter-chips__popover-item-label">{t(meta.labelKey)}</span>
1230
+ <small>{t(meta.descriptionKey)}</small>
1231
+ </button>
1232
+ );
1233
+ })}
1234
+ </div>
1235
+ ) : null}
1236
+ </span>
1237
+ </div>
1238
+ </div>
1239
+ );
1240
+ }