@brainpilot/web 0.0.5 → 0.0.7

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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. package/dist/assets/index-C501m5OS.css +0 -1
@@ -4,6 +4,7 @@ import { FileUp, MessageSquare, Pause, Play, RotateCcw, SkipBack, SkipForward, U
4
4
  import type { ChatMessage, TraceNode, WebSocketEvent } from "../../contracts/backend";
5
5
  import { normalizeWebSocketEvent } from "../../contracts/backend";
6
6
  import { DemoBundle, DemoFile } from "../../contracts/demoBundle";
7
+ import { applyMessageFilters, defaultFilterRules } from "../../contexts/messageFilters";
7
8
  import { reduceMessagesForEvent } from "../../contexts/messageReducer";
8
9
  import { useSandbox } from "../../contexts/SandboxContext";
9
10
  import { useSessions } from "../../contexts/SessionContext";
@@ -14,8 +15,10 @@ import { FilePreviewView, PreviewSource } from "../files/FilePreviewView";
14
15
  import { getPreviewKind, isMarkdown } from "../files/filePreview";
15
16
  import { IconButton } from "../primitives/IconButton";
16
17
  import { TraceGraphView } from "../session/TraceGraphView";
18
+ import { getNodeKindLabelKey } from "../session/traceLayout";
17
19
  import { buildDemoBundle, parseDemoBundle } from "./demoBundle";
18
20
  import { getCachedBundle, setCachedBundle } from "./demoCache";
21
+ import { shouldResetDemo } from "./demoReset";
19
22
  import { DemoFileTree } from "./DemoFileTree";
20
23
  import { TraceNodeModal } from "./TraceNodeModal";
21
24
 
@@ -40,32 +43,47 @@ function basename(path: string): string {
40
43
  }
41
44
 
42
45
  /**
43
- * Keep only the user-facing dialogue backbone for the demo's left panel.
46
+ * Keep the user-facing dialogue backbone for the demo's left panel.
44
47
  *
45
- * This is a multi-agent system: the live EventRouter
46
- * (agent_runtime/event_router.py) forwards only the principal (PI) agent's
47
- * messages to the frontend, while worker agents (engineer / trace /
48
- * experimentalist) run internally. The demo bundle, however, captures *all*
49
- * raw events, so a naive "any assistant text" filter floods the panel with
50
- * internal worker narration ("Recorded as T001…", engineer step notes).
48
+ * This is a multi-agent system. The demo bundle captures *all* raw events, and
49
+ * the live Chat (PromptComposer) does NOT collapse the transcript to the
50
+ * principal it renders every agent's substantive replies, plus error and
51
+ * system_message bubbles, with per-agent attribution. The demo must mirror that
52
+ * so the replay faithfully represents what the user saw: a librarian's progress
53
+ * reply or an expert's error alert is first-class conversation, not internal
54
+ * noise (issue #98).
51
55
  *
52
- * Mirror the live behavior: keep the user's prompts and the principal's
53
- * substantive text replies the seed prompt, the key exchanges, and the final
54
- * result and drop everything else (worker narration, reasoning, tool
55
- * calls/results, hook diagnostics, NO-RENDER placeholders, empties). The
56
- * reasoning graph on the right tells the internal story.
56
+ * Keep: user prompts; assistant/system plain-text replies from ANY agent;
57
+ * error and system_message bubbles (the agent-attributed warnings/alerts the
58
+ * live Chat shows), plus answered ask_user cards (the question + the user's
59
+ * answer are a user-facing decision point, issue #132 — rendered read-only by
60
+ * AskUserCard since DemoView passes no onAskUserSubmit). Drop: reasoning, tool
61
+ * calls/results, hook diagnostics, the auto_retry card and UNANSWERED ask_user
62
+ * prompts (no meaning in a read-only replay), plus NO-RENDER placeholders and
63
+ * empties.
57
64
  */
58
- function isConversationalMessage(m: ChatMessage): boolean {
65
+ export function isDemoConversational(m: ChatMessage): boolean {
59
66
  if (m.role === "user") {
60
67
  return !!m.content?.trim();
61
68
  }
62
- const isPlainText = m.kind === "text" || m.kind === undefined;
63
- if (!isPlainText || !m.content?.trim()) {
64
- return false;
69
+ // Answered ask_user: keep as a read-only Q&A step. Unanswered prompts have no
70
+ // meaning in a replay and are dropped.
71
+ if (m.kind === "ask_user") {
72
+ return m.askUser?.answer !== undefined;
73
+ }
74
+ // Agent-attributed warnings/errors the live Chat surfaces as standalone
75
+ // bubbles. system_message carries its own payload; error carries content.
76
+ if (m.kind === "system_message") {
77
+ return !!m.systemMessage;
78
+ }
79
+ if (m.kind === "error") {
80
+ return !!m.content?.trim();
65
81
  }
66
- // Only the principal agent is user-facing. Missing agent treat as principal
67
- // for resilience against older bundles where attribution was not recorded.
68
- return m.agent === undefined || m.agent === "principal";
82
+ // Substantive text replies from ANY agent (principal or expert). MessageStream
83
+ // attributes each row by `agent`, so non-principal messages render with their
84
+ // own avatar/name. Missing agent treated as principal downstream.
85
+ const isPlainText = m.kind === "text" || m.kind === undefined;
86
+ return isPlainText && !!m.content?.trim();
69
87
  }
70
88
 
71
89
  const REPORT_NAME = /report|summary|总结|conclusion|readme/i;
@@ -89,7 +107,19 @@ function pickDefaultFile(files: DemoFile[]): string | null {
89
107
  }
90
108
 
91
109
 
92
- export function DemoView() {
110
+ export interface DemoViewProps {
111
+ /**
112
+ * Monotonic counter bumped by the shell each time the sidebar "Live Demo"
113
+ * entry is clicked. A *change* (not the initial value) returns the player to
114
+ * the session-selection / import landing — the same effect as the header
115
+ * "Reselect" button — so re-clicking the nav item while a demo is already
116
+ * open isn't a dead no-op (issue #111). Optional so standalone/test mounts
117
+ * work without it.
118
+ */
119
+ resetSignal?: number;
120
+ }
121
+
122
+ export function DemoView({ resetSignal }: DemoViewProps = {}) {
93
123
  const t = useT();
94
124
  const { sessions, currentSession, messages } = useSessions();
95
125
  const { currentSandbox } = useSandbox();
@@ -107,6 +137,10 @@ export function DemoView() {
107
137
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
108
138
  const [pinnedFile, setPinnedFile] = useState<string | null>(null);
109
139
  const [modalNodeId, setModalNodeId] = useState<string | null>(null);
140
+ const formatNodeKind = (kind: string) => {
141
+ const key = getNodeKindLabelKey(kind);
142
+ return key ? t(key) : kind;
143
+ };
110
144
 
111
145
  const fileInputRef = useRef<HTMLInputElement | null>(null);
112
146
  const decodedRef = useRef<Map<string, DecodedFile>>(new Map());
@@ -189,6 +223,21 @@ export function DemoView() {
189
223
  return { t0: 0, t1, sorted: [], nodeMs, ordered };
190
224
  }, [bundle, nodes]);
191
225
 
226
+ // Return to the landing when the shell signals a sidebar "Live Demo" re-click
227
+ // (issue #111). Fires only on a *change* of resetSignal, never on the initial
228
+ // mount, so importing/packing a bundle isn't immediately undone. Clearing the
229
+ // bundle is enough — the "reset transport on new bundle" effect below re-inits
230
+ // cursor/zoom/etc. the next time a bundle is selected. The module-level
231
+ // demoCache keeps re-opening the same session instant.
232
+ const prevResetSignal = useRef(resetSignal);
233
+ useEffect(() => {
234
+ if (shouldResetDemo(prevResetSignal.current, resetSignal)) {
235
+ prevResetSignal.current = resetSignal;
236
+ setBundle(null);
237
+ setError(null);
238
+ }
239
+ }, [resetSignal]);
240
+
192
241
  // Reset transport on new bundle (start fully revealed, paused, default file).
193
242
  useEffect(() => {
194
243
  if (!bundle) {
@@ -240,15 +289,19 @@ export function DemoView() {
240
289
  return timeline.ordered.slice(0, count);
241
290
  }, [bundle, timeline, cursor]);
242
291
 
243
- // The left panel shows the conversation backbone — the actual dialogue: the
244
- // seed prompt and every substantive text reply. Reasoning, tool calls, tool
245
- // results, hook notes and empty placeholders are dropped (the reasoning graph
246
- // on the right tells that story). This is deliberately a content predicate,
247
- // not a pin-to-two-messages filter: the latter relied on exact id-matching
248
- // across two independent event folds and silently emptied the panel whenever a
249
- // MESSAGES_SNAPSHOT reshuffled ids or no clean seed/summary message existed.
292
+ // The left panel shows the conversation backbone — the actual dialogue across
293
+ // every agent: the seed prompt, each agent's substantive text replies, and the
294
+ // error/system_message bubbles the live Chat surfaces. Reasoning, tool calls,
295
+ // tool results, hook notes and empty placeholders are dropped (the reasoning
296
+ // graph on the right tells that story). `applyMessageFilters` mirrors the live
297
+ // Chat's default rules (e.g. hiding spurious single-dot messages) so the
298
+ // replay matches what the user actually saw. This is deliberately a content
299
+ // predicate, not a pin-to-two-messages filter: the latter relied on exact
300
+ // id-matching across two independent event folds and silently emptied the
301
+ // panel whenever a MESSAGES_SNAPSHOT reshuffled ids or no clean seed/summary
302
+ // message existed.
250
303
  const condensedMessages = useMemo<ChatMessage[]>(
251
- () => revealedMessages.filter(isConversationalMessage),
304
+ () => applyMessageFilters(revealedMessages.filter(isDemoConversational), defaultFilterRules),
252
305
  [revealedMessages],
253
306
  );
254
307
 
@@ -525,12 +578,19 @@ export function DemoView() {
525
578
  }
526
579
 
527
580
  // ----- Player -----
581
+ // Prefer the authoritative title from the live session list (it tracks
582
+ // backend `session_title` updates) over the snapshot captured into the bundle
583
+ // at pack time, which can be stale (e.g. "Session f8f35032" before a reload).
584
+ // Falls back to the bundle title for imported bundles whose source session is
585
+ // not in this client's list.
586
+ const liveSession = sessions.find((s) => s.id === bundle.session.id);
587
+ const displayTitle = liveSession?.title || bundle.session.title;
528
588
  return (
529
589
  <main className="demo-view" aria-label={t("demo.title")}>
530
590
  <header className="demo-header">
531
591
  <div className="demo-header__title">
532
592
  <span className="workspace-panel__eyebrow">{t("demo.eyebrow")}</span>
533
- <h1>{bundle.session.title}</h1>
593
+ <h1>{displayTitle}</h1>
534
594
  <span className="demo-header__meta">
535
595
  {t("demo.meta.exported", { time: new Date(bundle.exportedAt).toLocaleString() })}
536
596
  </span>
@@ -596,6 +656,13 @@ export function DemoView() {
596
656
  zoom={zoom}
597
657
  onZoomChange={setZoom}
598
658
  fitToken={revealedNodes.length}
659
+ formatKind={formatNodeKind}
660
+ zoomLabels={{
661
+ controls: t("trace.aria.zoomControls"),
662
+ zoomIn: t("trace.aria.zoomIn"),
663
+ zoomOut: t("trace.aria.zoomOut"),
664
+ reset: t("trace.aria.resetZoom"),
665
+ }}
599
666
  />
600
667
  </div>
601
668
  <div className="demo-transport">
@@ -658,9 +725,11 @@ export function DemoView() {
658
725
  node={modalNode}
659
726
  onClose={() => setModalNodeId(null)}
660
727
  onSelectNode={(id) => { setSelectedNodeId(id); setModalNodeId(id); }}
728
+ nodes={nodes}
661
729
  onSelectArtifact={selectFile}
662
730
  activeArtifactPath={currentArtifactPath}
663
731
  closeLabel={t("demo.node.modalClose")}
732
+ formatKind={formatNodeKind}
664
733
  t={t}
665
734
  />
666
735
  </main>
@@ -7,12 +7,14 @@ import { TraceNodeDetail } from "../session/TraceNodeDetail";
7
7
 
8
8
  interface TraceNodeModalProps {
9
9
  node: TraceNode | null;
10
+ nodes?: TraceNode[];
10
11
  onClose: () => void;
11
12
  onSelectNode: (id: string) => void;
12
13
  /** Focus a produced file in the preview (closes the modal). */
13
14
  onSelectArtifact: (path: string) => void;
14
15
  activeArtifactPath: string | null;
15
16
  closeLabel: string;
17
+ formatKind?: (kind: string) => string;
16
18
  t: (key: string, vars?: TranslateVars) => string;
17
19
  }
18
20
 
@@ -22,7 +24,7 @@ interface TraceNodeModalProps {
22
24
  * (fixed backdrop + centered panel, click-outside / Escape to close) and the
23
25
  * shared TraceNodeDetail body.
24
26
  */
25
- export function TraceNodeModal({ node, onClose, onSelectNode, onSelectArtifact, activeArtifactPath, closeLabel, t }: TraceNodeModalProps) {
27
+ export function TraceNodeModal({ node, nodes, onClose, onSelectNode, onSelectArtifact, activeArtifactPath, closeLabel, formatKind, t }: TraceNodeModalProps) {
26
28
  useEffect(() => {
27
29
  if (!node) {
28
30
  return;
@@ -52,7 +54,7 @@ export function TraceNodeModal({ node, onClose, onSelectNode, onSelectArtifact,
52
54
  <div className="trace-node-modal__head">
53
55
  <span className="trace-node-modal__eyebrow">
54
56
  <GitBranch size={13} style={{ marginRight: 5, verticalAlign: "-2px" }} />
55
- {node.id}
57
+ {node.agent || node.nodeType || node.type}
56
58
  </span>
57
59
  <IconButton label={closeLabel} onClick={onClose}>
58
60
  <X size={16} />
@@ -61,7 +63,9 @@ export function TraceNodeModal({ node, onClose, onSelectNode, onSelectArtifact,
61
63
  <div className="trace-node-modal__body trace-detail">
62
64
  <TraceNodeDetail
63
65
  node={node}
66
+ nodes={nodes}
64
67
  onSelectNode={onSelectNode}
68
+ formatKind={formatKind}
65
69
  onSelectArtifact={(path) => {
66
70
  onSelectArtifact(path);
67
71
  onClose();
@@ -157,11 +157,16 @@ export async function buildDemoBundle(opts: BuildDemoOptions): Promise<DemoBundl
157
157
 
158
158
  onProgress?.("reading conversation timeline…");
159
159
  let timeline: DemoBundle["timeline"] = "timestamped";
160
- let events = await api.sessions.getEvents(session.id);
160
+ // Pull the persisted event timeline from the new history endpoint (the
161
+ // legacy `/sessions/:id/events` path is an SSE alias and returns no JSON).
162
+ // Cap at 5000 — the endpoint enforces the same cap, but stating it here
163
+ // documents the bundle's max footprint.
164
+ const historyEnvelope = await api.sessions.getHistory(session.id, { limit: 5000 });
165
+ let events: typeof historyEnvelope.events | undefined = historyEnvelope.events;
161
166
  let messages: ChatMessage[] | undefined;
162
167
  if (!events || events.length === 0) {
163
168
  timeline = "ordered";
164
- events = undefined as never;
169
+ events = undefined;
165
170
  messages = fallbackMessages ?? [];
166
171
  }
167
172
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pure reset-signal logic for the Live Demo player (issue #111).
3
+ *
4
+ * The shell bumps a monotonic `resetSignal` every time the sidebar "Live Demo"
5
+ * entry is clicked. DemoView must return to its session-selection landing on a
6
+ * *change* of that signal — but NOT on the initial mount, or importing/packing
7
+ * a freshly-selected bundle would be undone immediately. Extracted as a pure
8
+ * function so this guard is unit-testable without rendering the component (the
9
+ * monorepo has no jsdom/@testing-library).
10
+ */
11
+ export function shouldResetDemo(
12
+ previous: number | undefined,
13
+ next: number | undefined,
14
+ ): boolean {
15
+ return next !== previous;
16
+ }
@@ -31,9 +31,7 @@ import {
31
31
  import {
32
32
  computeAgentActivity,
33
33
  computeAllAgentActivities,
34
- computeAgentActivityPercentages,
35
34
  type AgentActivity,
36
- type AgentActivityPercentages,
37
35
  } from "./agentAnalytics";
38
36
  import { NodeTooltip, NodeTooltipData } from "./NodeTooltip";
39
37
  import { GlobalOverview } from "./GlobalOverview";
@@ -70,6 +68,26 @@ function statusLabelKey(status: "running" | "idle" | "error" | "stopped"): strin
70
68
  return `network.status.${status}`;
71
69
  }
72
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
+
73
91
  /* --------------------------------------------------------------------------
74
92
  * Layout: deterministic concentric ring placement (no force layout, no jitter).
75
93
  * ------------------------------------------------------------------------ */
@@ -283,26 +301,30 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
283
301
 
284
302
  const edges = useMemo(() => buildEdges(messages), [messages]);
285
303
 
286
- // Live agents from the session anything sandboxed has spawned this entry.
287
- const liveNames = useMemo(() => new Set(agents.map((a) => a.name)), [agents]);
288
-
289
- // All node names: built-ins (always shown as dormant placeholders) live
290
- // agents ∪ any names referenced in edges (defensive). Order is stable so
291
- // the layout doesn't reflow when an agent comes/goes.
292
- const nodeNames = useMemo(() => {
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(() => {
293
308
  const set = new Set<string>();
294
- BUILTIN_AGENT_NAMES.forEach((n) => set.add(n));
295
309
  agents.forEach((a) => set.add(a.name));
296
310
  edges.forEach((e) => {
297
311
  set.add(e.from);
298
312
  set.add(e.to);
299
313
  });
300
- // Stable ordering: builtins first (in declared order), then custom names sorted.
314
+ return set;
315
+ }, [agents, edges]);
316
+
317
+ const nodeNames = useMemo(() => {
301
318
  const builtinSet = new Set<string>(BUILTIN_AGENT_NAMES);
302
- const builtins = BUILTIN_AGENT_NAMES.filter((n) => set.has(n));
303
- const customs = Array.from(set).filter((n) => !builtinSet.has(n)).sort();
319
+ const builtins = BUILTIN_AGENT_NAMES.filter((n) => activeNames.has(n));
320
+ const customs = Array.from(activeNames).filter((n) => !builtinSet.has(n)).sort();
304
321
  return [...builtins, ...customs];
305
- }, [agents, edges]);
322
+ }, [activeNames]);
323
+
324
+ const availableNames = useMemo(
325
+ () => BUILTIN_AGENT_NAMES.filter((name) => !activeNames.has(name)),
326
+ [activeNames],
327
+ );
306
328
 
307
329
  const positioned = useMemo(() => layoutNodes(nodeNames), [nodeNames]);
308
330
  const positionByName = useMemo(() => {
@@ -340,7 +362,7 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
340
362
 
341
363
  const now = Date.now();
342
364
  const totalMessages = edges.reduce((sum, edge) => sum + edge.messages.length, 0);
343
- const liveCount = agents.length;
365
+ const liveCount = nodeNames.length;
344
366
  const runningCount = agents.filter((a) => statusKind(a.status) === "running").length;
345
367
 
346
368
  // Keep relative times / the Timeline "now" marker moving. Only tick while a
@@ -389,11 +411,6 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
389
411
  return allActivities.get(selectedAgent.name) ?? null;
390
412
  }, [selectedAgent, allActivities]);
391
413
 
392
- const selectedAgentPercentages = useMemo<AgentActivityPercentages | null>(() => {
393
- if (!selectedAgentActivity) return null;
394
- return computeAgentActivityPercentages(selectedAgentActivity, allActivities);
395
- }, [selectedAgentActivity, allActivities]);
396
-
397
414
  if (nodeNames.length === 0) {
398
415
  return (
399
416
  <div className="agent-network agent-network--empty">
@@ -411,7 +428,7 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
411
428
  const hoveredData: NodeTooltipData | null = hovered
412
429
  ? (() => {
413
430
  const agent = agentByName.get(hovered.name);
414
- const isLive = liveNames.has(hovered.name);
431
+ const isLive = activeNames.has(hovered.name);
415
432
  const counts = countMessagesFor(hovered.name, edges);
416
433
  return {
417
434
  name: hovered.name,
@@ -435,9 +452,6 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
435
452
  <span className="agent-network__legend-item">
436
453
  <i className="agent-network__legend-dot agent-network__legend-dot--idle" /> {t("network.legend.live")}
437
454
  </span>
438
- <span className="agent-network__legend-item">
439
- <i className="agent-network__legend-dot agent-network__legend-dot--dormant" /> {t("network.legend.dormant")}
440
- </span>
441
455
  <span className="agent-network__legend-item">
442
456
  <i className="agent-network__legend-dot agent-network__legend-dot--error" /> {t("network.legend.error")}
443
457
  </span>
@@ -452,6 +466,16 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
452
466
  {t("network.legend.counter", { live: liveCount, total: nodeNames.length, running: runningCount, edges: edges.length, msgs: totalMessages })}
453
467
  </span>
454
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}
455
479
 
456
480
  <div className="agent-network__viewport" aria-label={t("network.aria.viewport")} ref={viewportRef}>
457
481
  <svg
@@ -558,7 +582,7 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
558
582
  const y1 = principal.y + uy * NODE_RADIUS;
559
583
  const x2 = node.x - ux * NODE_RADIUS;
560
584
  const y2 = node.y - uy * NODE_RADIUS;
561
- const isLive = liveNames.has(node.name);
585
+ const isLive = activeNames.has(node.name);
562
586
  return (
563
587
  <line
564
588
  className={`agent-network__scaffold-line ${
@@ -634,13 +658,15 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
634
658
  <g className="agent-network__nodes">
635
659
  {positioned.map((node) => {
636
660
  const agent = agentByName.get(node.name);
637
- const isLive = liveNames.has(node.name);
661
+ const isLive = activeNames.has(node.name);
638
662
  const status = agent ? statusKind(agent.status) : "idle";
639
663
  const presence = isLive ? "live" : "dormant";
640
664
  const isSelected = selection?.kind === "node" && selection.id === node.name;
641
665
  const Icon = getAgentIcon(node.name);
642
666
  const accent = getAgentAccentVar(node.name);
643
667
  const isPrincipal = node.name === "principal";
668
+ const fallbackAgent = agent ?? { name: node.name, status: "idle", task: "" };
669
+ const taskLabel = taskLabelFor(fallbackAgent, status, t);
644
670
  return (
645
671
  <g
646
672
  aria-label={t("network.aria.node", { name: node.name, status: t(isLive ? statusLabelKey(status) : "network.status.dormant") })}
@@ -704,9 +730,9 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
704
730
  <text className="agent-network__node-label" textAnchor="middle" y={NODE_RADIUS + 18}>
705
731
  {node.name}
706
732
  </text>
707
- {agent?.task ? (
733
+ {isLive && (agent?.task || status === "running") ? (
708
734
  <text className="agent-network__node-sublabel" textAnchor="middle" y={NODE_RADIUS + 32}>
709
- {agent.task.length > 32 ? `${agent.task.slice(0, 30)}…` : agent.task}
735
+ {taskLabel.length > 32 ? `${taskLabel.slice(0, 30)}…` : taskLabel}
710
736
  </text>
711
737
  ) : !isLive ? (
712
738
  <text className="agent-network__node-sublabel agent-network__node-sublabel--dormant" textAnchor="middle" y={NODE_RADIUS + 32}>
@@ -766,7 +792,7 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
766
792
  ) : selectedAgent ? (
767
793
  <AgentDetail
768
794
  agent={selectedAgent}
769
- isLive={liveNames.has(selectedAgent.name)}
795
+ isLive={activeNames.has(selectedAgent.name)}
770
796
  filter={
771
797
  agentFilters[selectedAgent.name] ?? {
772
798
  hideMessages: false,
@@ -779,7 +805,6 @@ export function AgentNetwork({ agents, messages, agentFilters, onSetAgentFilter
779
805
  received={messagesForAgent.received}
780
806
  sent={messagesForAgent.sent}
781
807
  activity={selectedAgentActivity}
782
- activityPercentages={selectedAgentPercentages}
783
808
  />
784
809
  ) : (
785
810
  <GlobalOverview
@@ -819,7 +844,6 @@ function AgentDetail({
819
844
  sent,
820
845
  received,
821
846
  activity,
822
- activityPercentages,
823
847
  }: {
824
848
  agent: AgentStatus;
825
849
  isLive: boolean;
@@ -834,7 +858,6 @@ function AgentDetail({
834
858
  sent: AgentEdgeMessage[];
835
859
  received: AgentEdgeMessage[];
836
860
  activity: AgentActivity | null;
837
- activityPercentages: AgentActivityPercentages | null;
838
861
  }) {
839
862
  const Icon = getAgentIcon(agent.name);
840
863
  const status = statusKind(agent.status);
@@ -842,6 +865,7 @@ function AgentDetail({
842
865
  const presence = isLive ? "live" : "dormant";
843
866
  const t = useT();
844
867
  const statusLabel = isLive ? t(statusLabelKey(status)) : t("network.status.dormant");
868
+ const currentTask = isLive ? taskLabelFor(agent, status, t) : t("network.detail.notSpawnedByPrincipal");
845
869
 
846
870
  return (
847
871
  <>
@@ -881,12 +905,12 @@ function AgentDetail({
881
905
  </span>
882
906
  </dd>
883
907
  </div>
884
- <div>
885
- <dt>{t("network.detail.currentTask")}</dt>
886
- <dd className="agent-network__keyvals-wrap">
887
- {isLive ? (agent.task || t("network.detail.idleWaiting")) : t("network.detail.notSpawnedByPrincipal")}
888
- </dd>
889
- </div>
908
+ <div>
909
+ <dt>{t("network.detail.currentTask")}</dt>
910
+ <dd className="agent-network__keyvals-wrap">
911
+ {currentTask}
912
+ </dd>
913
+ </div>
890
914
  <div>
891
915
  <dt>{t("network.detail.updated")}</dt>
892
916
  <dd>{agent.updatedAt ? relativeTime(agent.updatedAt, now) : "—"}</dd>
@@ -899,18 +923,13 @@ function AgentDetail({
899
923
  </section>
900
924
 
901
925
  {/* ---- Activity Statistics ---- */}
902
- {activity && activityPercentages ? (
926
+ {activity ? (
903
927
  <section className="agent-network__detail-section">
904
928
  <h4><MessageSquare size={13} /> {t("network.detail.activityStats")}</h4>
905
929
  <dl className="agent-network__keyvals">
906
930
  <div>
907
931
  <dt>{t("network.detail.totalMessages")}</dt>
908
- <dd>
909
- {activity.totalMessages}
910
- <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
911
- {t("network.detail.pctOfSession", { pct: activityPercentages.messagePercent.toFixed(1) })}
912
- </span>
913
- </dd>
932
+ <dd>{activity.totalMessages}</dd>
914
933
  </div>
915
934
  <div>
916
935
  <dt>{t("network.detail.messageBreakdown")}</dt>
@@ -918,38 +937,12 @@ function AgentDetail({
918
937
  {t("network.detail.breakdownValue", { assistant: activity.assistantMessages, reasoning: activity.reasoningMessages, tool: activity.toolMessages })}
919
938
  </dd>
920
939
  </div>
921
- <div>
922
- <dt>{t("network.detail.toolCalls")}</dt>
923
- <dd>
924
- {activity.toolCalls}
925
- {activity.toolCalls > 0 && (
926
- <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
927
- {t("network.detail.pctOfSession", { pct: activityPercentages.toolCallPercent.toFixed(1) })}
928
- </span>
929
- )}
930
- </dd>
931
- </div>
932
- {activity.topTools.length > 0 && (
940
+ {activity.toolCalls > 0 ? (
933
941
  <div>
934
- <dt>{t("network.detail.topTools")}</dt>
935
- <dd className="agent-network__keyvals-wrap">
936
- {activity.topTools.map((t2, i) => (
937
- <span key={t2.name}>
938
- {t2.name} ({t2.count}){i < activity.topTools.length - 1 ? " · " : ""}
939
- </span>
940
- ))}
941
- </dd>
942
+ <dt>{t("network.detail.toolCalls")}</dt>
943
+ <dd>{activity.toolCalls}</dd>
942
944
  </div>
943
- )}
944
- <div>
945
- <dt>{t("network.detail.contentVolume")}</dt>
946
- <dd>
947
- {t("network.detail.volumeValue", { chars: activity.totalChars.toLocaleString(), tokens: activity.estimatedTokens.toLocaleString() })}
948
- <span style={{ fontSize: 11, opacity: 0.7, marginLeft: 6 }}>
949
- {t("network.detail.pctOfSession", { pct: activityPercentages.tokenPercent.toFixed(1) })}
950
- </span>
951
- </dd>
952
- </div>
945
+ ) : null}
953
946
  {activity.communicationPartners.length > 0 && (
954
947
  <div>
955
948
  <dt>{t("network.detail.communicationPartners")}</dt>