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