@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.
Files changed (114) 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/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,346 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Network, Pause, Play, RefreshCw, Search, UserRoundCog, X } from "lucide-react";
3
+ import { TraceNode } from "../../contracts/backend";
4
+ import { useSessions } from "../../contexts/SessionContext";
5
+ import { useT } from "../../i18n/useT";
6
+ import { CustomSelect } from "../primitives/CustomSelect";
7
+ import { IconButton } from "../primitives/IconButton";
8
+ import { AgentNetwork } from "./AgentNetwork";
9
+ import { TraceGraphView } from "./TraceGraphView";
10
+ import { TraceNodeDetail } from "./TraceNodeDetail";
11
+ import {
12
+ formatTime,
13
+ getNodeKind,
14
+ getNodeKindLabelKey,
15
+ getStatusLabelKey,
16
+ normalizeStatus,
17
+ } from "./traceLayout";
18
+
19
+ export function AgentsPanel() {
20
+ const { agents, currentSession, agentFilters, setAgentFilter, messages } = useSessions();
21
+ const t = useT();
22
+
23
+ return (
24
+ <section className="workspace-panel" aria-labelledby="agents-panel-heading">
25
+ <div className="workspace-panel__inner workspace-panel__inner--trace">
26
+ <header className="workspace-panel__header">
27
+ <div>
28
+ <span className="workspace-panel__eyebrow">
29
+ <Network size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
30
+ {t("trace.agents.eyebrow")}
31
+ </span>
32
+ <h2 id="agents-panel-heading">{t("trace.agents.title")}</h2>
33
+ </div>
34
+ <UserRoundCog size={18} />
35
+ </header>
36
+
37
+ {!currentSession ? (
38
+ <p className="workspace-panel__empty">{t("trace.agents.emptyNoSession")}</p>
39
+ ) : (
40
+ <AgentNetwork
41
+ agents={agents}
42
+ agentFilters={agentFilters}
43
+ messages={messages}
44
+ onSetAgentFilter={setAgentFilter}
45
+ />
46
+ )}
47
+
48
+ {currentSession && agents.length === 0 ? (
49
+ <p className="workspace-panel__empty">{t("trace.agents.emptyNoEvents")}</p>
50
+ ) : null}
51
+ </div>
52
+ </section>
53
+ );
54
+ }
55
+
56
+ export function TracePanel() {
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();
60
+ const t = useT();
61
+ const trace = currentTrace;
62
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
63
+ const [query, setQuery] = useState("");
64
+ const [statusFilter, setStatusFilter] = useState("all");
65
+ const [typeFilter, setTypeFilter] = useState("all");
66
+ const [direction, setDirection] = useState<"LR" | "TB">("LR");
67
+ const [zoom, setZoom] = useState(1);
68
+ const [isRefreshing, setIsRefreshing] = useState(false);
69
+ const [playbackIndex, setPlaybackIndex] = useState(0);
70
+ const [isPlaying, setIsPlaying] = useState(false);
71
+ const [fitToken, setFitToken] = useState(0);
72
+ const wasUserAdjustedRef = useRef(false);
73
+ const prevNodeCountRef = useRef(0);
74
+ const formatNodeKind = (kind: string) => {
75
+ const key = getNodeKindLabelKey(kind);
76
+ return key ? t(key) : kind;
77
+ };
78
+
79
+ const allNodes = trace?.nodes ?? [];
80
+ const playbackNodes = useMemo(() => allNodes.slice(0, playbackIndex), [allNodes, playbackIndex]);
81
+
82
+ const filteredNodes = useMemo(() => {
83
+ const normalizedQuery = query.trim().toLowerCase();
84
+ return playbackNodes.filter((node) => {
85
+ const status = normalizeStatus(node.status);
86
+ const nodeKind = getNodeKind(node);
87
+ const matchesStatus = statusFilter === "all" || status === statusFilter;
88
+ const matchesType = typeFilter === "all" || nodeKind === typeFilter;
89
+ const searchText = [
90
+ node.id,
91
+ node.title,
92
+ node.description,
93
+ node.summary,
94
+ node.reason,
95
+ node.context,
96
+ node.agent,
97
+ nodeKind,
98
+ ...node.toolCalls,
99
+ ...node.artifacts.map((artifact) => artifact.path),
100
+ ].filter(Boolean).join(" ").toLowerCase();
101
+ return matchesStatus && matchesType && (!normalizedQuery || searchText.includes(normalizedQuery));
102
+ });
103
+ }, [query, statusFilter, playbackNodes, typeFilter]);
104
+
105
+ const visibleNodeIds = useMemo(() => new Set(filteredNodes.map((node) => node.id)), [filteredNodes]);
106
+ const visibleNodes = useMemo(
107
+ () => filteredNodes.map((node) => ({
108
+ ...node,
109
+ parentIds: node.parentIds.filter((parentId) => visibleNodeIds.has(parentId)),
110
+ parents: node.parents.filter((parent) => visibleNodeIds.has(parent.id)),
111
+ childIds: node.childIds.filter((childId) => visibleNodeIds.has(childId)),
112
+ })),
113
+ [filteredNodes, visibleNodeIds],
114
+ );
115
+ const statusOptions = useMemo(
116
+ () => Array.from(new Set((trace?.nodes ?? []).map((node) => normalizeStatus(node.status)).filter(Boolean))).sort(),
117
+ [trace?.nodes],
118
+ );
119
+ const typeOptions = useMemo(
120
+ () => Array.from(new Set((trace?.nodes ?? []).map(getNodeKind).filter(Boolean))).sort(),
121
+ [trace?.nodes],
122
+ );
123
+ const selectedNode = useMemo<TraceNode | null>(() => {
124
+ if (!trace) {
125
+ return null;
126
+ }
127
+ if (visibleNodes.length === 0) {
128
+ return null;
129
+ }
130
+ return visibleNodes.find((node) => node.id === selectedNodeId) ?? visibleNodes[0] ?? null;
131
+ }, [selectedNodeId, trace, visibleNodes]);
132
+
133
+ const handleRefresh = async () => {
134
+ if (!currentSession) return;
135
+ setIsRefreshing(true);
136
+ try {
137
+ await refreshTrace(currentSession.id);
138
+ } finally {
139
+ setIsRefreshing(false);
140
+ }
141
+ };
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.
145
+ useEffect(() => {
146
+ setSelectedNodeId((current) => {
147
+ if (current && allNodes.some((node) => node.id === current)) return current;
148
+ return allNodes[0]?.id ?? null;
149
+ });
150
+ }, [allNodes]);
151
+
152
+ useEffect(() => {
153
+ if (selectedNodeId && visibleNodes.length > 0 && !visibleNodeIds.has(selectedNodeId)) {
154
+ setSelectedNodeId(visibleNodes[0].id);
155
+ }
156
+ }, [selectedNodeId, visibleNodeIds, visibleNodes]);
157
+
158
+ useEffect(() => {
159
+ setPlaybackIndex(allNodes.length);
160
+ prevNodeCountRef.current = allNodes.length;
161
+ wasUserAdjustedRef.current = false;
162
+ setIsPlaying(false);
163
+ }, [currentSession?.id]);
164
+
165
+ useEffect(() => {
166
+ if (allNodes.length === 0) {
167
+ setPlaybackIndex(0);
168
+ prevNodeCountRef.current = 0;
169
+ return;
170
+ }
171
+ if (!wasUserAdjustedRef.current) {
172
+ if (allNodes.length > prevNodeCountRef.current) {
173
+ if (playbackIndex >= prevNodeCountRef.current || prevNodeCountRef.current === 0) {
174
+ setPlaybackIndex(allNodes.length);
175
+ }
176
+ } else if (allNodes.length < playbackIndex) {
177
+ setPlaybackIndex(allNodes.length);
178
+ }
179
+ }
180
+ prevNodeCountRef.current = allNodes.length;
181
+ }, [allNodes.length, playbackIndex]);
182
+
183
+ useEffect(() => {
184
+ if (!isPlaying || allNodes.length === 0) {
185
+ return;
186
+ }
187
+ const interval = window.setInterval(() => {
188
+ setPlaybackIndex((current) => {
189
+ if (current >= allNodes.length) {
190
+ setIsPlaying(false);
191
+ return current;
192
+ }
193
+ return current + 1;
194
+ });
195
+ setFitToken((token) => token + 1);
196
+ }, 800);
197
+ return () => window.clearInterval(interval);
198
+ }, [isPlaying, allNodes.length]);
199
+
200
+ const togglePlayback = () => {
201
+ if (allNodes.length === 0) {
202
+ return;
203
+ }
204
+ if (playbackIndex >= allNodes.length) {
205
+ setPlaybackIndex(0);
206
+ }
207
+ wasUserAdjustedRef.current = false;
208
+ setIsPlaying((current) => !current);
209
+ };
210
+
211
+ const handleSliderChange = (value: number) => {
212
+ setIsPlaying(false);
213
+ wasUserAdjustedRef.current = true;
214
+ setPlaybackIndex(value);
215
+ };
216
+
217
+ return (
218
+ <section className="workspace-panel" aria-labelledby="trace-panel-heading">
219
+ <div className="workspace-panel__inner workspace-panel__inner--trace">
220
+ <header className="workspace-panel__header trace-header">
221
+ <div>
222
+ <span className="workspace-panel__eyebrow">{t("trace.eyebrow")}</span>
223
+ <h2 id="trace-panel-heading">{t("trace.title")}</h2>
224
+ </div>
225
+ <div className="trace-toolbar">
226
+ <div className="trace-segmented" aria-label={t("trace.aria.layoutDir")}>
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")}
232
+ </button>
233
+ </div>
234
+ <IconButton className={isRefreshing ? "is-active" : ""} disabled={!currentSession} label={t("trace.aria.refresh")} onClick={() => void handleRefresh()}>
235
+ <RefreshCw size={15} />
236
+ </IconButton>
237
+ </div>
238
+ </header>
239
+
240
+ {!currentSession ? <p className="workspace-panel__empty">{t("trace.emptyNoSession")}</p> : null}
241
+
242
+ {trace ? (
243
+ <>
244
+ <div className="trace-meta">
245
+ <span>{trace.meta.projectName || currentSession?.title || t("trace.untitled")}</span>
246
+ {trace.meta.currentFocus ? <span>{t("trace.focus", { focus: String(trace.meta.currentFocus) })}</span> : null}
247
+ <span>{t("trace.nodes", { visible: visibleNodes.length, total: trace.nodes.length })}</span>
248
+ <span>{t("trace.created", { time: formatTime(trace.meta.createdAt) })}</span>
249
+ </div>
250
+
251
+ <div className="trace-controls">
252
+ <label className="trace-search">
253
+ <Search size={14} />
254
+ <input placeholder={t("trace.searchPlaceholder")} value={query} onChange={(event) => setQuery(event.target.value)} />
255
+ {query ? (
256
+ <button aria-label={t("trace.aria.clearSearch")} onClick={() => setQuery("")} type="button">
257
+ <X size={13} />
258
+ </button>
259
+ ) : null}
260
+ </label>
261
+ <div className="trace-control">
262
+ <span>{t("trace.status")}</span>
263
+ <CustomSelect
264
+ ariaLabel={t("trace.aria.statusFilter")}
265
+ className="trace-control__select"
266
+ onChange={setStatusFilter}
267
+ options={[
268
+ { label: t("trace.allStatus"), value: "all" },
269
+ ...statusOptions.map((status) => {
270
+ const key = getStatusLabelKey(status);
271
+ return { label: key ? t(key) : status, value: status };
272
+ }),
273
+ ]}
274
+ value={statusFilter}
275
+ />
276
+ </div>
277
+ <div className="trace-control">
278
+ <span>{t("trace.type")}</span>
279
+ <CustomSelect
280
+ ariaLabel={t("trace.aria.typeFilter")}
281
+ className="trace-control__select"
282
+ onChange={setTypeFilter}
283
+ options={[
284
+ { label: t("trace.allTypes"), value: "all" },
285
+ ...typeOptions.map((type) => ({ label: formatNodeKind(type), value: type })),
286
+ ]}
287
+ value={typeFilter}
288
+ />
289
+ </div>
290
+ </div>
291
+
292
+ <div className="trace-layout">
293
+ <div className="trace-map" aria-label={t("trace.aria.graph")}>
294
+ <TraceGraphView
295
+ nodes={visibleNodes}
296
+ direction={direction}
297
+ selectedNodeId={selectedNode?.id ?? null}
298
+ onSelectNode={setSelectedNodeId}
299
+ zoom={zoom}
300
+ onZoomChange={setZoom}
301
+ fitToken={fitToken}
302
+ emptyLabel={t("trace.noMatch")}
303
+ formatKind={formatNodeKind}
304
+ zoomLabels={{
305
+ controls: t("trace.aria.zoomControls"),
306
+ zoomIn: t("trace.aria.zoomIn"),
307
+ zoomOut: t("trace.aria.zoomOut"),
308
+ reset: t("trace.aria.resetZoom"),
309
+ }}
310
+ />
311
+ {allNodes.length > 0 ? (
312
+ <div className="trace-playback-bar" aria-label={t("trace.aria.playbackControls")}>
313
+ <button
314
+ aria-label={isPlaying ? t("trace.aria.pause") : t("trace.aria.play")}
315
+ className="trace-playback-bar__button"
316
+ onClick={togglePlayback}
317
+ type="button"
318
+ >
319
+ {isPlaying ? <Pause size={14} /> : <Play size={14} />}
320
+ </button>
321
+ <input
322
+ aria-label={t("trace.aria.playbackProgress")}
323
+ className="trace-playback-bar__slider"
324
+ max={allNodes.length}
325
+ min={0}
326
+ onChange={(e) => handleSliderChange(Number(e.target.value))}
327
+ type="range"
328
+ value={playbackIndex}
329
+ />
330
+ <span className="trace-playback-bar__count">
331
+ {playbackIndex} / {allNodes.length}
332
+ </span>
333
+ </div>
334
+ ) : null}
335
+ </div>
336
+
337
+ <article className="trace-detail">
338
+ <TraceNodeDetail node={selectedNode} nodes={allNodes} onSelectNode={setSelectedNodeId} formatKind={formatNodeKind} t={t} />
339
+ </article>
340
+ </div>
341
+ </>
342
+ ) : null}
343
+ </div>
344
+ </section>
345
+ );
346
+ }
@@ -0,0 +1,220 @@
1
+ /* --------------------------------------------------------------------------
2
+ * AnalyticsTab — session-level statistics rendered as hand-rolled SVG (the
3
+ * house style; no chart library). Always global: not affected by node
4
+ * selection. Kept intentionally compact for end users: traffic, average
5
+ * message length, and lifecycle heatmap.
6
+ * ------------------------------------------------------------------------ */
7
+ import { useMemo } from "react";
8
+ import { Inbox } from "lucide-react";
9
+ import { AgentStatus, ChatMessage, TokenUsage } from "../../contracts/backend";
10
+ import { AgentEdge, getAgentAccentVar } from "./agentNetworkShared";
11
+ import {
12
+ computeAgentLoad,
13
+ computeLifecycleHeatmap,
14
+ estimateTokens,
15
+ } from "./agentAnalytics";
16
+ import { useSessions } from "../../contexts/SessionContext";
17
+ import { useT } from "../../i18n/useT";
18
+
19
+ interface AnalyticsTabProps {
20
+ agents: AgentStatus[];
21
+ messages: ChatMessage[];
22
+ edges: AgentEdge[];
23
+ now: number;
24
+ }
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
+
33
+ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps) {
34
+ const t = useT();
35
+ const { tokenUsage } = useSessions();
36
+ const load = useMemo(() => computeAgentLoad(edges), [edges]);
37
+ const lengthRows = useMemo(() => estimateTokens(messages), [messages]);
38
+ const heatmap = useMemo(
39
+ () => computeLifecycleHeatmap(messages, agents.map((a) => a.name), now),
40
+ [messages, agents, now],
41
+ );
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
+
52
+ const totalMessages = edges.reduce((s, e) => s + e.messages.length, 0);
53
+
54
+ if (totalMessages === 0) {
55
+ return (
56
+ <div className="agent-analytics__empty">
57
+ <Inbox size={20} />
58
+ <p>{t("analytics.empty")}</p>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div className="agent-analytics">
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}
98
+
99
+ <section className="agent-analytics__chart">
100
+ <h4 className="agent-analytics__chart-title">{t("analytics.chart.load")}</h4>
101
+ <BarChart rows={load} />
102
+ </section>
103
+
104
+ <section className="agent-analytics__chart">
105
+ <h4 className="agent-analytics__chart-title">{t("analytics.chart.avgLength")}</h4>
106
+ <table className="agent-analytics__table">
107
+ <thead>
108
+ <tr>
109
+ <th>{t("analytics.table.agent")}</th>
110
+ <th>{t("analytics.table.msgs")}</th>
111
+ <th>{t("analytics.table.avgLen")}</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>
115
+ {lengthRows.map((row) => (
116
+ <tr key={row.name}>
117
+ <td>{row.name}</td>
118
+ <td>{row.sentMsgs}</td>
119
+ <td>{row.avgLen}</td>
120
+ </tr>
121
+ ))}
122
+ </tbody>
123
+ </table>
124
+ </section>
125
+
126
+ {heatmap.agents.length > 0 ? (
127
+ <section className="agent-analytics__chart">
128
+ <h4 className="agent-analytics__chart-title">{t("analytics.chart.heatmap")}</h4>
129
+ <HeatmapGrid heatmap={heatmap} />
130
+ </section>
131
+ ) : null}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /* --------------------------------------------------------------------------
137
+ * Hand-rolled SVG primitives
138
+ * ------------------------------------------------------------------------ */
139
+
140
+ function BarChart({ rows }: { rows: { name: string; total: number }[] }) {
141
+ const t = useT();
142
+ if (rows.length === 0) return <p className="agent-analytics__hint">No traffic yet.</p>;
143
+ const max = Math.max(1, ...rows.map((r) => r.total));
144
+ const rowH = 22;
145
+ const labelW = 96;
146
+ const w = 320;
147
+ const barMax = w - labelW - 36;
148
+ const h = rows.length * rowH + 4;
149
+ return (
150
+ <svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.load")}>
151
+ {rows.map((r, i) => {
152
+ const y = i * rowH + 4;
153
+ const bw = (r.total / max) * barMax;
154
+ return (
155
+ <g key={r.name}>
156
+ <text x={0} y={y + 11} className="agent-analytics__bar-label">
157
+ {r.name.length > 12 ? `${r.name.slice(0, 11)}…` : r.name}
158
+ </text>
159
+ <rect
160
+ x={labelW}
161
+ y={y + 2}
162
+ width={Math.max(2, bw)}
163
+ height={rowH - 8}
164
+ rx={2}
165
+ fill={getAgentAccentVar(r.name)}
166
+ opacity={0.85}
167
+ />
168
+ <text x={labelW + bw + 6} y={y + 11} className="agent-analytics__bar-value">
169
+ {r.total}
170
+ </text>
171
+ </g>
172
+ );
173
+ })}
174
+ </svg>
175
+ );
176
+ }
177
+
178
+ function HeatmapGrid({
179
+ heatmap,
180
+ }: {
181
+ heatmap: { agents: string[]; buckets: number; counts: number[][]; max: number };
182
+ }) {
183
+ const t = useT();
184
+ const cell = 14;
185
+ const gap = 2;
186
+ const labelW = 90;
187
+ const w = labelW + heatmap.buckets * (cell + gap);
188
+ const h = heatmap.agents.length * (cell + gap) + 2;
189
+ return (
190
+ <svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.heatmap")}>
191
+ {heatmap.agents.map((name, ai) => (
192
+ <g key={name}>
193
+ <text x={0} y={ai * (cell + gap) + cell - 2} className="agent-analytics__bar-label">
194
+ {name.length > 11 ? `${name.slice(0, 10)}…` : name}
195
+ </text>
196
+ {heatmap.counts[ai].map((count, bi) => {
197
+ const intensity = heatmap.max > 0 ? count / heatmap.max : 0;
198
+ return (
199
+ <rect
200
+ key={bi}
201
+ x={labelW + bi * (cell + gap)}
202
+ y={ai * (cell + gap)}
203
+ width={cell}
204
+ height={cell}
205
+ rx={2}
206
+ fill={
207
+ count === 0
208
+ ? "var(--color-surface-soft)"
209
+ : `color-mix(in srgb, var(--color-info) ${Math.round(15 + intensity * 75)}%, transparent)`
210
+ }
211
+ >
212
+ <title>{`${name} · bucket ${bi + 1}: ${count} msg`}</title>
213
+ </rect>
214
+ );
215
+ })}
216
+ </g>
217
+ ))}
218
+ </svg>
219
+ );
220
+ }
@@ -0,0 +1,108 @@
1
+ /* --------------------------------------------------------------------------
2
+ * GlobalOverview — the Detail tab's "nothing selected" state. Replaces the old
3
+ * EmptyDetail with an at-a-glance session summary so the panel is informative
4
+ * even before the user clicks a node or edge.
5
+ * ------------------------------------------------------------------------ */
6
+ import { Activity, Inbox, Network, Timer, Users } from "lucide-react";
7
+ import { AgentStatus, ChatMessage } from "../../contracts/backend";
8
+ import { useT } from "../../i18n/useT";
9
+ import { AgentEdge, relativeTime } from "./agentNetworkShared";
10
+ import {
11
+ computeResponseLatencies,
12
+ formatDuration,
13
+ summarizeLatencies,
14
+ } from "./agentAnalytics";
15
+
16
+ interface GlobalOverviewProps {
17
+ agents: AgentStatus[];
18
+ edges: AgentEdge[];
19
+ messages: ChatMessage[];
20
+ totalNodes: number;
21
+ liveCount: number;
22
+ now: number;
23
+ }
24
+
25
+ export function GlobalOverview({
26
+ agents,
27
+ edges,
28
+ messages,
29
+ totalNodes,
30
+ liveCount,
31
+ now,
32
+ }: GlobalOverviewProps) {
33
+ const t = useT();
34
+ const totalMessages = edges.reduce((sum, e) => sum + e.messages.length, 0);
35
+ const dormantCount = Math.max(0, totalNodes - liveCount);
36
+ const runningCount = agents.filter(
37
+ (a) => a.status === "running" || a.status === "in_progress",
38
+ ).length;
39
+
40
+ const lastActivityIso = edges.reduce<string>((latest, e) => {
41
+ return e.lastTimestamp > latest ? e.lastTimestamp : latest;
42
+ }, "");
43
+
44
+ const latencyStats = summarizeLatencies(computeResponseLatencies(messages));
45
+
46
+ return (
47
+ <div className="agent-network__overview">
48
+ <header className="agent-network__overview-head">
49
+ <span className="agent-network__overview-icon">
50
+ <Network size={16} />
51
+ </span>
52
+ <h3>{t("overview.title")}</h3>
53
+ </header>
54
+
55
+ <dl className="agent-network__overview-stats">
56
+ <div>
57
+ <dt>
58
+ <Users size={13} /> {t("overview.agents")}
59
+ </dt>
60
+ <dd>
61
+ {t("overview.liveDormant", { live: liveCount, dormant: dormantCount })}
62
+ <span className="agent-network__overview-sub">{t("overview.total", { total: totalNodes })}</span>
63
+ </dd>
64
+ </div>
65
+ <div>
66
+ <dt>
67
+ <Activity size={13} /> {t("overview.runningNow")}
68
+ </dt>
69
+ <dd>{runningCount}</dd>
70
+ </div>
71
+ <div>
72
+ <dt>
73
+ <Inbox size={13} /> {t("overview.messages")}
74
+ </dt>
75
+ <dd>
76
+ {totalMessages}
77
+ <span className="agent-network__overview-sub">{t("overview.acrossLinks", { count: edges.length })}</span>
78
+ </dd>
79
+ </div>
80
+ <div>
81
+ <dt>
82
+ <Timer size={13} /> {t("overview.avgResponse")}
83
+ </dt>
84
+ <dd>
85
+ {latencyStats ? formatDuration(latencyStats.mean) : "—"}
86
+ {latencyStats ? (
87
+ <span className="agent-network__overview-sub">
88
+ {t("overview.median", { value: formatDuration(latencyStats.median) })}
89
+ </span>
90
+ ) : null}
91
+ </dd>
92
+ </div>
93
+ <div>
94
+ <dt>
95
+ <Activity size={13} /> {t("overview.lastActivity")}
96
+ </dt>
97
+ <dd>{lastActivityIso ? relativeTime(lastActivityIso, now) : "—"}</dd>
98
+ </div>
99
+ </dl>
100
+
101
+ <p className="agent-network__overview-tip">
102
+ {t("overview.tipPrefix")}
103
+ <strong>{t("network.tab.analytics")}</strong> / <strong>{t("network.tab.timeline")}</strong>
104
+ {t("overview.tipSuffix")}
105
+ </p>
106
+ </div>
107
+ );
108
+ }