@brainpilot/web 0.0.5 → 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.
Files changed (52) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
@@ -1,9 +1,8 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Network, Pause, Play, RefreshCw, Search, UserRoundCog, X } from "lucide-react";
3
- import { TraceGraph, TraceNode } from "../../contracts/backend";
3
+ import { TraceNode } from "../../contracts/backend";
4
4
  import { useSessions } from "../../contexts/SessionContext";
5
5
  import { useT } from "../../i18n/useT";
6
- import { api } from "../../utils/api";
7
6
  import { CustomSelect } from "../primitives/CustomSelect";
8
7
  import { IconButton } from "../primitives/IconButton";
9
8
  import { AgentNetwork } from "./AgentNetwork";
@@ -12,6 +11,7 @@ import { TraceNodeDetail } from "./TraceNodeDetail";
12
11
  import {
13
12
  formatTime,
14
13
  getNodeKind,
14
+ getNodeKindLabelKey,
15
15
  getStatusLabelKey,
16
16
  normalizeStatus,
17
17
  } from "./traceLayout";
@@ -54,23 +54,27 @@ export function AgentsPanel() {
54
54
  }
55
55
 
56
56
  export function TracePanel() {
57
- const { currentSession } = useSessions();
57
+ // #79: trace is now live — seeded + kept current by SessionContext via SSE
58
+ // (CUSTOM:trace_node), so this panel reads it instead of polling.
59
+ const { currentSession, currentTrace, refreshTrace } = useSessions();
58
60
  const t = useT();
59
- const [trace, setTrace] = useState<TraceGraph | null>(null);
61
+ const trace = currentTrace;
60
62
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
61
63
  const [query, setQuery] = useState("");
62
64
  const [statusFilter, setStatusFilter] = useState("all");
63
65
  const [typeFilter, setTypeFilter] = useState("all");
64
66
  const [direction, setDirection] = useState<"LR" | "TB">("LR");
65
67
  const [zoom, setZoom] = useState(1);
66
- const [isAutoRefresh, setIsAutoRefresh] = useState(true);
67
- const [isLoading, setIsLoading] = useState(false);
68
- const [error, setError] = useState<string | null>(null);
68
+ const [isRefreshing, setIsRefreshing] = useState(false);
69
69
  const [playbackIndex, setPlaybackIndex] = useState(0);
70
70
  const [isPlaying, setIsPlaying] = useState(false);
71
71
  const [fitToken, setFitToken] = useState(0);
72
72
  const wasUserAdjustedRef = useRef(false);
73
73
  const prevNodeCountRef = useRef(0);
74
+ const formatNodeKind = (kind: string) => {
75
+ const key = getNodeKindLabelKey(kind);
76
+ return key ? t(key) : kind;
77
+ };
74
78
 
75
79
  const allNodes = trace?.nodes ?? [];
76
80
  const playbackNodes = useMemo(() => allNodes.slice(0, playbackIndex), [allNodes, playbackIndex]);
@@ -126,54 +130,24 @@ export function TracePanel() {
126
130
  return visibleNodes.find((node) => node.id === selectedNodeId) ?? visibleNodes[0] ?? null;
127
131
  }, [selectedNodeId, trace, visibleNodes]);
128
132
 
129
- const loadTrace = async (silent = false) => {
130
- if (!currentSession) {
131
- setTrace(null);
132
- setSelectedNodeId(null);
133
- return;
134
- }
135
- if (!silent) {
136
- setIsLoading(true);
137
- }
138
- setError(null);
133
+ const handleRefresh = async () => {
134
+ if (!currentSession) return;
135
+ setIsRefreshing(true);
139
136
  try {
140
- const nextTrace = await api.sessions.getTrace(currentSession.id);
141
- setTrace(nextTrace);
142
- if (!silent) {
143
- prevNodeCountRef.current = nextTrace.nodes.length;
144
- if (!wasUserAdjustedRef.current) {
145
- setPlaybackIndex(nextTrace.nodes.length);
146
- }
147
- }
148
- setSelectedNodeId((current) => {
149
- if (current && nextTrace.nodes.some((node) => node.id === current)) {
150
- return current;
151
- }
152
- return nextTrace.nodes[0]?.id ?? null;
153
- });
154
- } catch (err) {
155
- if (!silent) {
156
- setTrace(null);
157
- setError(err instanceof Error ? err.message : t("trace.loadFailed"));
158
- }
137
+ await refreshTrace(currentSession.id);
159
138
  } finally {
160
- if (!silent) {
161
- setIsLoading(false);
162
- }
139
+ setIsRefreshing(false);
163
140
  }
164
141
  };
165
142
 
143
+ // Keep a selection valid as nodes stream in: default to the first node, and
144
+ // hold the current selection while it still exists.
166
145
  useEffect(() => {
167
- void loadTrace();
168
- }, [currentSession?.id]);
169
-
170
- useEffect(() => {
171
- if (!currentSession || !isAutoRefresh) {
172
- return;
173
- }
174
- const interval = window.setInterval(() => void loadTrace(true), 3000);
175
- return () => window.clearInterval(interval);
176
- }, [currentSession?.id, isAutoRefresh]);
146
+ setSelectedNodeId((current) => {
147
+ if (current && allNodes.some((node) => node.id === current)) return current;
148
+ return allNodes[0]?.id ?? null;
149
+ });
150
+ }, [allNodes]);
177
151
 
178
152
  useEffect(() => {
179
153
  if (selectedNodeId && visibleNodes.length > 0 && !visibleNodeIds.has(selectedNodeId)) {
@@ -250,36 +224,26 @@ export function TracePanel() {
250
224
  </div>
251
225
  <div className="trace-toolbar">
252
226
  <div className="trace-segmented" aria-label={t("trace.aria.layoutDir")}>
253
- <button className={direction === "LR" ? "is-active" : ""} onClick={() => setDirection("LR")} type="button">LR</button>
254
- <button className={direction === "TB" ? "is-active" : ""} onClick={() => setDirection("TB")} type="button">TB</button>
255
- </div>
256
- <div className="trace-refresh-group" aria-label={t("trace.aria.refreshControls")}>
257
- <IconButton className={isLoading ? "is-active" : ""} disabled={!currentSession} label={t("trace.aria.refresh")} onClick={() => void loadTrace()}>
258
- <RefreshCw size={15} />
259
- </IconButton>
260
- <button
261
- aria-pressed={isAutoRefresh}
262
- className={`trace-live-toggle ${isAutoRefresh ? "is-active" : ""}`}
263
- disabled={!currentSession}
264
- onClick={() => setIsAutoRefresh((current) => !current)}
265
- title={t("trace.autoRefreshTitle")}
266
- type="button"
267
- >
268
- <span aria-hidden="true" />
269
- {t("trace.live")}
227
+ <button className={direction === "LR" ? "is-active" : ""} onClick={() => setDirection("LR")} type="button">
228
+ {t("trace.layout.horizontal")}
229
+ </button>
230
+ <button className={direction === "TB" ? "is-active" : ""} onClick={() => setDirection("TB")} type="button">
231
+ {t("trace.layout.vertical")}
270
232
  </button>
271
233
  </div>
234
+ <IconButton className={isRefreshing ? "is-active" : ""} disabled={!currentSession} label={t("trace.aria.refresh")} onClick={() => void handleRefresh()}>
235
+ <RefreshCw size={15} />
236
+ </IconButton>
272
237
  </div>
273
238
  </header>
274
239
 
275
- {error ? <p className="workspace-panel__empty workspace-panel__empty--error">{error}</p> : null}
276
240
  {!currentSession ? <p className="workspace-panel__empty">{t("trace.emptyNoSession")}</p> : null}
277
241
 
278
242
  {trace ? (
279
243
  <>
280
244
  <div className="trace-meta">
281
245
  <span>{trace.meta.projectName || currentSession?.title || t("trace.untitled")}</span>
282
- <span>{t("trace.focus", { focus: String(trace.meta.currentFocus || "-") })}</span>
246
+ {trace.meta.currentFocus ? <span>{t("trace.focus", { focus: String(trace.meta.currentFocus) })}</span> : null}
283
247
  <span>{t("trace.nodes", { visible: visibleNodes.length, total: trace.nodes.length })}</span>
284
248
  <span>{t("trace.created", { time: formatTime(trace.meta.createdAt) })}</span>
285
249
  </div>
@@ -318,7 +282,7 @@ export function TracePanel() {
318
282
  onChange={setTypeFilter}
319
283
  options={[
320
284
  { label: t("trace.allTypes"), value: "all" },
321
- ...typeOptions.map((type) => ({ label: type, value: type })),
285
+ ...typeOptions.map((type) => ({ label: formatNodeKind(type), value: type })),
322
286
  ]}
323
287
  value={typeFilter}
324
288
  />
@@ -336,6 +300,7 @@ export function TracePanel() {
336
300
  onZoomChange={setZoom}
337
301
  fitToken={fitToken}
338
302
  emptyLabel={t("trace.noMatch")}
303
+ formatKind={formatNodeKind}
339
304
  zoomLabels={{
340
305
  controls: t("trace.aria.zoomControls"),
341
306
  zoomIn: t("trace.aria.zoomIn"),
@@ -370,7 +335,7 @@ export function TracePanel() {
370
335
  </div>
371
336
 
372
337
  <article className="trace-detail">
373
- <TraceNodeDetail node={selectedNode} onSelectNode={setSelectedNodeId} t={t} />
338
+ <TraceNodeDetail node={selectedNode} nodes={allNodes} onSelectNode={setSelectedNodeId} formatKind={formatNodeKind} t={t} />
374
339
  </article>
375
340
  </div>
376
341
  </>
@@ -1,29 +1,19 @@
1
1
  /* --------------------------------------------------------------------------
2
2
  * AnalyticsTab — session-level statistics rendered as hand-rolled SVG (the
3
3
  * house style; no chart library). Always global: not affected by node
4
- * selection. Three groups: overview cards, trends/distribution, advanced.
4
+ * selection. Kept intentionally compact for end users: traffic, average
5
+ * message length, and lifecycle heatmap.
5
6
  * ------------------------------------------------------------------------ */
6
7
  import { useMemo } from "react";
7
- import {
8
- AlertTriangle,
9
- Inbox,
10
- Timer,
11
- TrendingUp,
12
- Users,
13
- } from "lucide-react";
14
- import { AgentStatus, ChatMessage } from "../../contracts/backend";
8
+ import { Inbox } from "lucide-react";
9
+ import { AgentStatus, ChatMessage, TokenUsage } from "../../contracts/backend";
15
10
  import { AgentEdge, getAgentAccentVar } from "./agentNetworkShared";
16
11
  import {
17
12
  computeAgentLoad,
18
- computeErrorCount,
19
13
  computeLifecycleHeatmap,
20
- computeMessageTrend,
21
- computeResponseLatencies,
22
- computeTypeDistribution,
23
14
  estimateTokens,
24
- formatDuration,
25
- summarizeLatencies,
26
15
  } from "./agentAnalytics";
16
+ import { useSessions } from "../../contexts/SessionContext";
27
17
  import { useT } from "../../i18n/useT";
28
18
 
29
19
  interface AnalyticsTabProps {
@@ -33,27 +23,33 @@ interface AnalyticsTabProps {
33
23
  now: number;
34
24
  }
35
25
 
26
+ /** Compact number formatting for token counts (1.2k, 3.4M). */
27
+ function fmtTokens(n: number): string {
28
+ if (n < 1000) return String(n);
29
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
30
+ return `${(n / 1_000_000).toFixed(1)}M`;
31
+ }
32
+
36
33
  export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps) {
37
34
  const t = useT();
38
- const trend = useMemo(() => computeMessageTrend(messages, now), [messages, now]);
35
+ const { tokenUsage } = useSessions();
39
36
  const load = useMemo(() => computeAgentLoad(edges), [edges]);
40
- const typeDist = useMemo(() => computeTypeDistribution(messages), [messages]);
41
- const latencyStats = useMemo(
42
- () => summarizeLatencies(computeResponseLatencies(messages)),
43
- [messages],
44
- );
45
- const tokens = useMemo(() => estimateTokens(messages), [messages]);
37
+ const lengthRows = useMemo(() => estimateTokens(messages), [messages]);
46
38
  const heatmap = useMemo(
47
39
  () => computeLifecycleHeatmap(messages, agents.map((a) => a.name), now),
48
40
  [messages, agents, now],
49
41
  );
50
- const errorCount = useMemo(() => computeErrorCount(messages), [messages]);
42
+
43
+ // Per-agent real-usage rows, sorted by total desc. Empty when no provider
44
+ // usage has been reported yet (e.g. a freshly restored session pre-turn).
45
+ const usageRows = useMemo(() => {
46
+ if (!tokenUsage) return [] as Array<{ name: string } & TokenUsage>;
47
+ return Object.entries(tokenUsage.byAgent)
48
+ .map(([name, u]) => ({ name, ...u }))
49
+ .sort((a, b) => b.total - a.total);
50
+ }, [tokenUsage]);
51
51
 
52
52
  const totalMessages = edges.reduce((s, e) => s + e.messages.length, 0);
53
- const liveCount = agents.length;
54
- const runningCount = agents.filter(
55
- (a) => a.status === "running" || a.status === "in_progress",
56
- ).length;
57
53
 
58
54
  if (totalMessages === 0) {
59
55
  return (
@@ -66,56 +62,39 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
66
62
 
67
63
  return (
68
64
  <div className="agent-analytics">
69
- {/* ---- Group 1: overview cards ---- */}
70
- <div className="agent-analytics__cards">
71
- <div className="agent-analytics__card">
72
- <span className="agent-analytics__card-label">
73
- <Inbox size={12} /> {t("analytics.card.totalMessages")}
74
- </span>
75
- <span className="agent-analytics__card-value">{totalMessages}</span>
76
- <Sparkline values={trend.map((t) => t.count)} />
77
- </div>
78
-
79
- <div className="agent-analytics__card">
80
- <span className="agent-analytics__card-label">
81
- <Users size={12} /> {t("analytics.card.activeAgents")}
82
- </span>
83
- <span className="agent-analytics__card-value">
84
- {liveCount}
85
- </span>
86
- <span className="agent-analytics__card-sub">{t("analytics.card.runningNow", { count: runningCount })}</span>
87
- </div>
88
-
89
- <div className="agent-analytics__card">
90
- <span className="agent-analytics__card-label">
91
- <Timer size={12} /> {t("analytics.card.avgLatency")}
92
- </span>
93
- <span className="agent-analytics__card-value">
94
- {latencyStats ? formatDuration(latencyStats.mean) : "—"}
95
- </span>
96
- <span className="agent-analytics__card-sub">
97
- {latencyStats ? t("analytics.card.median", { value: formatDuration(latencyStats.median) }) : t("analytics.card.noPairs")}
98
- </span>
99
- </div>
100
-
101
- <div
102
- className={`agent-analytics__card ${errorCount > 0 ? "agent-analytics__card--danger" : "agent-analytics__card--ok"}`}
103
- >
104
- <span className="agent-analytics__card-label">
105
- <AlertTriangle size={12} /> {t("analytics.card.errors")}
106
- </span>
107
- <span className="agent-analytics__card-value">{errorCount}</span>
108
- <span className="agent-analytics__card-sub">{errorCount === 0 ? t("analytics.card.allClear") : t("analytics.card.needsAttention")}</span>
109
- </div>
110
- </div>
111
-
112
- {/* ---- Group 2: trends & distribution ---- */}
113
- <section className="agent-analytics__chart">
114
- <h4 className="agent-analytics__chart-title">
115
- <TrendingUp size={13} /> {t("analytics.chart.volume")}
116
- </h4>
117
- <LineChart values={trend.map((t) => t.count)} />
118
- </section>
65
+ {tokenUsage && tokenUsage.total.total > 0 ? (
66
+ <section className="agent-analytics__chart">
67
+ <h4 className="agent-analytics__chart-title">{t("analytics.chart.tokens")}</h4>
68
+ <div className="agent-analytics__token-total">
69
+ {fmtTokens(tokenUsage.total.total)}{" "}
70
+ <span className="agent-analytics__token-total-label">
71
+ {t("analytics.tokens.total")}
72
+ </span>
73
+ </div>
74
+ <table className="agent-analytics__table">
75
+ <thead>
76
+ <tr>
77
+ <th>{t("analytics.table.agent")}</th>
78
+ <th>{t("analytics.tokens.input")}</th>
79
+ <th>{t("analytics.tokens.output")}</th>
80
+ <th>{t("analytics.tokens.cache")}</th>
81
+ <th>{t("analytics.tokens.total")}</th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ {usageRows.map((row) => (
86
+ <tr key={row.name}>
87
+ <td>{row.name}</td>
88
+ <td>{fmtTokens(row.input)}</td>
89
+ <td>{fmtTokens(row.output)}</td>
90
+ <td>{fmtTokens(row.cacheRead + row.cacheWrite)}</td>
91
+ <td>{fmtTokens(row.total)}</td>
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </section>
97
+ ) : null}
119
98
 
120
99
  <section className="agent-analytics__chart">
121
100
  <h4 className="agent-analytics__chart-title">{t("analytics.chart.load")}</h4>
@@ -123,36 +102,21 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
123
102
  </section>
124
103
 
125
104
  <section className="agent-analytics__chart">
126
- <h4 className="agent-analytics__chart-title">{t("analytics.chart.types")}</h4>
127
- <PieChart dist={typeDist} />
128
- </section>
129
-
130
- {/* ---- Group 3: advanced ---- */}
131
- {latencyStats ? (
132
- <section className="agent-analytics__chart">
133
- <h4 className="agent-analytics__chart-title">{t("analytics.chart.latency")}</h4>
134
- <BoxPlot stats={latencyStats} />
135
- </section>
136
- ) : null}
137
-
138
- <section className="agent-analytics__chart">
139
- <h4 className="agent-analytics__chart-title">{t("analytics.chart.tokens")}</h4>
105
+ <h4 className="agent-analytics__chart-title">{t("analytics.chart.avgLength")}</h4>
140
106
  <table className="agent-analytics__table">
141
107
  <thead>
142
108
  <tr>
143
109
  <th>{t("analytics.table.agent")}</th>
144
110
  <th>{t("analytics.table.msgs")}</th>
145
111
  <th>{t("analytics.table.avgLen")}</th>
146
- <th>{t("analytics.table.tokens")}</th>
147
112
  </tr>
148
113
  </thead>
149
114
  <tbody>
150
- {tokens.map((row) => (
115
+ {lengthRows.map((row) => (
151
116
  <tr key={row.name}>
152
117
  <td>{row.name}</td>
153
118
  <td>{row.sentMsgs}</td>
154
119
  <td>{row.avgLen}</td>
155
- <td>{row.tokens.toLocaleString()}</td>
156
120
  </tr>
157
121
  ))}
158
122
  </tbody>
@@ -173,41 +137,6 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
173
137
  * Hand-rolled SVG primitives
174
138
  * ------------------------------------------------------------------------ */
175
139
 
176
- function Sparkline({ values }: { values: number[] }) {
177
- const w = 200;
178
- const h = 28;
179
- if (values.length === 0) return null;
180
- const max = Math.max(1, ...values);
181
- const step = values.length > 1 ? w / (values.length - 1) : w;
182
- const points = values.map((v, i) => `${i * step},${h - (v / max) * (h - 2) - 1}`).join(" ");
183
- return (
184
- <svg className="agent-analytics__sparkline" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
185
- <polyline points={points} fill="none" stroke="var(--color-info)" strokeWidth="1.5" />
186
- </svg>
187
- );
188
- }
189
-
190
- function LineChart({ values }: { values: number[] }) {
191
- const t = useT();
192
- const w = 320;
193
- const h = 110;
194
- const pad = 6;
195
- const max = Math.max(1, ...values);
196
- const step = values.length > 1 ? (w - pad * 2) / (values.length - 1) : w;
197
- const pts = values.map((v, i) => ({
198
- x: pad + i * step,
199
- y: h - pad - (v / max) * (h - pad * 2),
200
- }));
201
- const line = pts.map((p) => `${p.x},${p.y}`).join(" ");
202
- const area = `${pad},${h - pad} ${line} ${pad + (values.length - 1) * step},${h - pad}`;
203
- return (
204
- <svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.volume")}>
205
- <polygon points={area} fill="color-mix(in srgb, var(--color-info) 16%, transparent)" stroke="none" />
206
- <polyline points={line} fill="none" stroke="var(--color-info)" strokeWidth="1.75" strokeLinejoin="round" />
207
- </svg>
208
- );
209
- }
210
-
211
140
  function BarChart({ rows }: { rows: { name: string; total: number }[] }) {
212
141
  const t = useT();
213
142
  if (rows.length === 0) return <p className="agent-analytics__hint">No traffic yet.</p>;
@@ -246,101 +175,6 @@ function BarChart({ rows }: { rows: { name: string; total: number }[] }) {
246
175
  );
247
176
  }
248
177
 
249
- function PieChart({ dist }: { dist: { delegate: number; result: number; other: number } }) {
250
- const t = useT();
251
- const segments = [
252
- { key: "delegate", value: dist.delegate, color: "var(--color-info)" },
253
- { key: "result", value: dist.result, color: "var(--color-success)" },
254
- { key: "other", value: dist.other, color: "var(--color-text-subtle)" },
255
- ].filter((s) => s.value > 0);
256
- const total = segments.reduce((s, seg) => s + seg.value, 0);
257
- if (total === 0) return <p className="agent-analytics__hint">No typed messages.</p>;
258
-
259
- const cx = 55;
260
- const cy = 55;
261
- const r = 48;
262
- let angle = -Math.PI / 2;
263
- const arcs = segments.map((seg) => {
264
- const frac = seg.value / total;
265
- const start = angle;
266
- const end = angle + frac * Math.PI * 2;
267
- angle = end;
268
- const x1 = cx + r * Math.cos(start);
269
- const y1 = cy + r * Math.sin(start);
270
- const x2 = cx + r * Math.cos(end);
271
- const y2 = cy + r * Math.sin(end);
272
- const large = end - start > Math.PI ? 1 : 0;
273
- // Full-circle guard (single segment = 100%).
274
- const d =
275
- segments.length === 1
276
- ? `M ${cx - r} ${cy} a ${r} ${r} 0 1 0 ${r * 2} 0 a ${r} ${r} 0 1 0 ${-r * 2} 0`
277
- : `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`;
278
- return { d, color: seg.color, key: seg.key };
279
- });
280
-
281
- return (
282
- <div className="agent-analytics__pie-wrap">
283
- <svg className="agent-analytics__pie" viewBox="0 0 110 110" role="img" aria-label={t("analytics.aria.types")}>
284
- {arcs.map((a) => (
285
- <path key={a.key} d={a.d} fill={a.color} stroke="var(--color-surface)" strokeWidth="1" />
286
- ))}
287
- <circle cx={cx} cy={cy} r={22} fill="var(--color-surface-raised)" />
288
- <text x={cx} y={cy + 4} textAnchor="middle" className="agent-analytics__pie-total">
289
- {total}
290
- </text>
291
- </svg>
292
- <ul className="agent-analytics__legend">
293
- {segments.map((s) => (
294
- <li key={s.key}>
295
- <i style={{ background: s.color }} /> {s.key} ({s.value})
296
- </li>
297
- ))}
298
- </ul>
299
- </div>
300
- );
301
- }
302
-
303
- function BoxPlot({
304
- stats,
305
- }: {
306
- stats: { min: number; q1: number; median: number; q3: number; max: number };
307
- }) {
308
- const t = useT();
309
- const w = 320;
310
- const h = 56;
311
- const pad = 14;
312
- const span = Math.max(1, stats.max - stats.min);
313
- const scale = (v: number) => pad + ((v - stats.min) / span) * (w - pad * 2);
314
- const midY = h / 2;
315
- return (
316
- <div>
317
- <svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.latency")}>
318
- {/* whisker */}
319
- <line x1={scale(stats.min)} x2={scale(stats.max)} y1={midY} y2={midY} stroke="var(--color-border-strong)" strokeWidth="1.5" />
320
- <line x1={scale(stats.min)} x2={scale(stats.min)} y1={midY - 8} y2={midY + 8} stroke="var(--color-border-strong)" />
321
- <line x1={scale(stats.max)} x2={scale(stats.max)} y1={midY - 8} y2={midY + 8} stroke="var(--color-border-strong)" />
322
- {/* box */}
323
- <rect
324
- x={scale(stats.q1)}
325
- y={midY - 12}
326
- width={Math.max(2, scale(stats.q3) - scale(stats.q1))}
327
- height={24}
328
- rx={3}
329
- fill="color-mix(in srgb, var(--color-info) 20%, transparent)"
330
- stroke="var(--color-info)"
331
- />
332
- {/* median */}
333
- <line x1={scale(stats.median)} x2={scale(stats.median)} y1={midY - 12} y2={midY + 12} stroke="var(--color-info)" strokeWidth="2" />
334
- </svg>
335
- <div className="agent-analytics__boxplot-legend">
336
- <span>min {formatDuration(stats.min)}</span>
337
- <span>med {formatDuration(stats.median)}</span>
338
- <span>max {formatDuration(stats.max)}</span>
339
- </div>
340
- </div>
341
- );
342
- }
343
-
344
178
  function HeatmapGrid({
345
179
  heatmap,
346
180
  }: {
@@ -26,6 +26,7 @@ interface TraceGraphViewProps {
26
26
  fitToken?: number | string;
27
27
  /** Shown when there are no nodes to display. */
28
28
  emptyLabel?: string;
29
+ formatKind?: (kind: string) => string;
29
30
  zoomLabels?: {
30
31
  controls?: string;
31
32
  zoomIn?: string;
@@ -49,6 +50,7 @@ export function TraceGraphView({
49
50
  onZoomChange,
50
51
  fitToken,
51
52
  emptyLabel,
53
+ formatKind,
52
54
  zoomLabels,
53
55
  }: TraceGraphViewProps) {
54
56
  const [nodeOffsets, setNodeOffsets] = useState<Map<string, { dx: number; dy: number }>>(new Map());
@@ -264,36 +266,40 @@ export function TraceGraphView({
264
266
  );
265
267
  }),
266
268
  )}
267
- {adjustedLayout.positioned.map(({ node, x, y }) => (
268
- <g
269
- className={`trace-map-node trace-map-node--${getNodeKind(node)} trace-map-node--${normalizeStatus(node.status)} ${selectedNodeId === node.id ? "is-selected" : ""} ${draggingNodeRef.current?.nodeId === node.id ? "is-dragging" : ""}`}
270
- key={node.id}
271
- onMouseDown={(e) => {
272
- e.stopPropagation();
273
- const offset = nodeOffsets.get(node.id) || { dx: 0, dy: 0 };
274
- draggingNodeRef.current = {
275
- nodeId: node.id,
276
- startClientX: e.clientX,
277
- startClientY: e.clientY,
278
- startDx: offset.dx,
279
- startDy: offset.dy,
280
- };
281
- }}
282
- onClick={() => {
283
- if (dragRef.current.hasPanned || draggingNodeRef.current) {
284
- return;
285
- }
286
- onSelectNode(node.id);
287
- }}
288
- style={{ transform: `translate(${x}px, ${y}px)` }}
289
- >
290
- <rect height={adjustedLayout.nodeHeight} rx="8" width={adjustedLayout.nodeWidth} />
291
- <circle className={`trace-node__dot--${normalizeStatus(node.status)}`} cx="16" cy="24" r="4" />
292
- <text className="trace-map-node__title" x="28" y="26">{truncateNodeTitle(node.title)}</text>
293
- <text className="trace-map-node__meta" x="28" y="44">{node.agent || getNodeKind(node)}</text>
294
- <text className="trace-map-node__kind" x="28" y="58">{getNodeKind(node)}</text>
295
- </g>
296
- ))}
269
+ {adjustedLayout.positioned.map(({ node, x, y }) => {
270
+ const kind = getNodeKind(node);
271
+ const kindLabel = formatKind?.(kind) ?? kind;
272
+ return (
273
+ <g
274
+ className={`trace-map-node trace-map-node--${kind} trace-map-node--${normalizeStatus(node.status)} ${selectedNodeId === node.id ? "is-selected" : ""} ${draggingNodeRef.current?.nodeId === node.id ? "is-dragging" : ""}`}
275
+ key={node.id}
276
+ onMouseDown={(e) => {
277
+ e.stopPropagation();
278
+ const offset = nodeOffsets.get(node.id) || { dx: 0, dy: 0 };
279
+ draggingNodeRef.current = {
280
+ nodeId: node.id,
281
+ startClientX: e.clientX,
282
+ startClientY: e.clientY,
283
+ startDx: offset.dx,
284
+ startDy: offset.dy,
285
+ };
286
+ }}
287
+ onClick={() => {
288
+ if (dragRef.current.hasPanned || draggingNodeRef.current) {
289
+ return;
290
+ }
291
+ onSelectNode(node.id);
292
+ }}
293
+ style={{ transform: `translate(${x}px, ${y}px)` }}
294
+ >
295
+ <rect height={adjustedLayout.nodeHeight} rx="8" width={adjustedLayout.nodeWidth} />
296
+ <circle className={`trace-node__dot--${normalizeStatus(node.status)}`} cx="16" cy="24" r="4" />
297
+ <text className="trace-map-node__title" x="28" y="26">{truncateNodeTitle(node.title)}</text>
298
+ <text className="trace-map-node__meta" x="28" y="44">{node.agent || kindLabel}</text>
299
+ <text className="trace-map-node__kind" x="28" y="58">{kindLabel}</text>
300
+ </g>
301
+ );
302
+ })}
297
303
  </svg>
298
304
  </div>
299
305
  </>