@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -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__/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/MessageStream.tsx +97 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +91 -29
- package/src/components/demo/TraceNodeModal.tsx +6 -2
- package/src/components/demo/demoBundle.ts +7 -2
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/session/AgentNetwork.tsx +68 -75
- package/src/components/session/AgentTraceViews.tsx +35 -70
- package/src/components/session/AnalyticsTab.tsx +58 -224
- package/src/components/session/TraceGraphView.tsx +36 -30
- package/src/components/session/TraceNodeDetail.tsx +61 -24
- package/src/components/session/agentNetworkShared.ts +10 -0
- package/src/components/session/traceLayout.ts +32 -0
- package/src/components/settings/SettingsDialog.tsx +19 -1
- package/src/components/shell/DesktopShell.tsx +39 -14
- package/src/components/sidebar/Sidebar.tsx +6 -2
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +354 -43
- package/src/contexts/agentsReducer.ts +49 -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 +53 -2
- package/src/i18n/messages/analytics.ts +16 -6
- package/src/i18n/messages/chat.ts +26 -4
- package/src/i18n/messages/contexts.ts +2 -0
- package/src/i18n/messages/network.ts +13 -9
- package/src/i18n/messages/profile.ts +4 -0
- package/src/i18n/messages/settings.ts +4 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +204 -55
- package/src/utils/api.ts +105 -8
- package/src/utils/toolDisplay.ts +74 -0
- package/dist/assets/index-C-8G4D4j.js +0 -448
- 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,40 @@ function basename(path: string): string {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* Keep
|
|
46
|
+
* Keep the user-facing dialogue backbone for the demo's left panel.
|
|
44
47
|
*
|
|
45
|
-
* This is a multi-agent system
|
|
46
|
-
* (
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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). Drop: reasoning, tool calls/results, hook diagnostics, and
|
|
59
|
+
* the interactive ask_user / auto_retry cards (the reasoning graph on the right
|
|
60
|
+
* tells the internal story, and the cards have no meaning in a read-only
|
|
61
|
+
* replay), plus NO-RENDER placeholders and empties.
|
|
57
62
|
*/
|
|
58
|
-
function
|
|
63
|
+
export function isDemoConversational(m: ChatMessage): boolean {
|
|
59
64
|
if (m.role === "user") {
|
|
60
65
|
return !!m.content?.trim();
|
|
61
66
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
// Agent-attributed warnings/errors the live Chat surfaces as standalone
|
|
68
|
+
// bubbles. system_message carries its own payload; error carries content.
|
|
69
|
+
if (m.kind === "system_message") {
|
|
70
|
+
return !!m.systemMessage;
|
|
71
|
+
}
|
|
72
|
+
if (m.kind === "error") {
|
|
73
|
+
return !!m.content?.trim();
|
|
65
74
|
}
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
75
|
+
// Substantive text replies from ANY agent (principal or expert). MessageStream
|
|
76
|
+
// attributes each row by `agent`, so non-principal messages render with their
|
|
77
|
+
// own avatar/name. Missing agent → treated as principal downstream.
|
|
78
|
+
const isPlainText = m.kind === "text" || m.kind === undefined;
|
|
79
|
+
return isPlainText && !!m.content?.trim();
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
const REPORT_NAME = /report|summary|总结|conclusion|readme/i;
|
|
@@ -89,7 +100,19 @@ function pickDefaultFile(files: DemoFile[]): string | null {
|
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
|
|
92
|
-
export
|
|
103
|
+
export interface DemoViewProps {
|
|
104
|
+
/**
|
|
105
|
+
* Monotonic counter bumped by the shell each time the sidebar "Live Demo"
|
|
106
|
+
* entry is clicked. A *change* (not the initial value) returns the player to
|
|
107
|
+
* the session-selection / import landing — the same effect as the header
|
|
108
|
+
* "Reselect" button — so re-clicking the nav item while a demo is already
|
|
109
|
+
* open isn't a dead no-op (issue #111). Optional so standalone/test mounts
|
|
110
|
+
* work without it.
|
|
111
|
+
*/
|
|
112
|
+
resetSignal?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function DemoView({ resetSignal }: DemoViewProps = {}) {
|
|
93
116
|
const t = useT();
|
|
94
117
|
const { sessions, currentSession, messages } = useSessions();
|
|
95
118
|
const { currentSandbox } = useSandbox();
|
|
@@ -107,6 +130,10 @@ export function DemoView() {
|
|
|
107
130
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
108
131
|
const [pinnedFile, setPinnedFile] = useState<string | null>(null);
|
|
109
132
|
const [modalNodeId, setModalNodeId] = useState<string | null>(null);
|
|
133
|
+
const formatNodeKind = (kind: string) => {
|
|
134
|
+
const key = getNodeKindLabelKey(kind);
|
|
135
|
+
return key ? t(key) : kind;
|
|
136
|
+
};
|
|
110
137
|
|
|
111
138
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
112
139
|
const decodedRef = useRef<Map<string, DecodedFile>>(new Map());
|
|
@@ -189,6 +216,21 @@ export function DemoView() {
|
|
|
189
216
|
return { t0: 0, t1, sorted: [], nodeMs, ordered };
|
|
190
217
|
}, [bundle, nodes]);
|
|
191
218
|
|
|
219
|
+
// Return to the landing when the shell signals a sidebar "Live Demo" re-click
|
|
220
|
+
// (issue #111). Fires only on a *change* of resetSignal, never on the initial
|
|
221
|
+
// mount, so importing/packing a bundle isn't immediately undone. Clearing the
|
|
222
|
+
// bundle is enough — the "reset transport on new bundle" effect below re-inits
|
|
223
|
+
// cursor/zoom/etc. the next time a bundle is selected. The module-level
|
|
224
|
+
// demoCache keeps re-opening the same session instant.
|
|
225
|
+
const prevResetSignal = useRef(resetSignal);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (shouldResetDemo(prevResetSignal.current, resetSignal)) {
|
|
228
|
+
prevResetSignal.current = resetSignal;
|
|
229
|
+
setBundle(null);
|
|
230
|
+
setError(null);
|
|
231
|
+
}
|
|
232
|
+
}, [resetSignal]);
|
|
233
|
+
|
|
192
234
|
// Reset transport on new bundle (start fully revealed, paused, default file).
|
|
193
235
|
useEffect(() => {
|
|
194
236
|
if (!bundle) {
|
|
@@ -240,15 +282,19 @@ export function DemoView() {
|
|
|
240
282
|
return timeline.ordered.slice(0, count);
|
|
241
283
|
}, [bundle, timeline, cursor]);
|
|
242
284
|
|
|
243
|
-
// The left panel shows the conversation backbone — the actual dialogue
|
|
244
|
-
// seed prompt
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
285
|
+
// The left panel shows the conversation backbone — the actual dialogue across
|
|
286
|
+
// every agent: the seed prompt, each agent's substantive text replies, and the
|
|
287
|
+
// error/system_message bubbles the live Chat surfaces. Reasoning, tool calls,
|
|
288
|
+
// tool results, hook notes and empty placeholders are dropped (the reasoning
|
|
289
|
+
// graph on the right tells that story). `applyMessageFilters` mirrors the live
|
|
290
|
+
// Chat's default rules (e.g. hiding spurious single-dot messages) so the
|
|
291
|
+
// replay matches what the user actually saw. This is deliberately a content
|
|
292
|
+
// predicate, not a pin-to-two-messages filter: the latter relied on exact
|
|
293
|
+
// id-matching across two independent event folds and silently emptied the
|
|
294
|
+
// panel whenever a MESSAGES_SNAPSHOT reshuffled ids or no clean seed/summary
|
|
295
|
+
// message existed.
|
|
250
296
|
const condensedMessages = useMemo<ChatMessage[]>(
|
|
251
|
-
() => revealedMessages.filter(
|
|
297
|
+
() => applyMessageFilters(revealedMessages.filter(isDemoConversational), defaultFilterRules),
|
|
252
298
|
[revealedMessages],
|
|
253
299
|
);
|
|
254
300
|
|
|
@@ -525,12 +571,19 @@ export function DemoView() {
|
|
|
525
571
|
}
|
|
526
572
|
|
|
527
573
|
// ----- Player -----
|
|
574
|
+
// Prefer the authoritative title from the live session list (it tracks
|
|
575
|
+
// backend `session_title` updates) over the snapshot captured into the bundle
|
|
576
|
+
// at pack time, which can be stale (e.g. "Session f8f35032" before a reload).
|
|
577
|
+
// Falls back to the bundle title for imported bundles whose source session is
|
|
578
|
+
// not in this client's list.
|
|
579
|
+
const liveSession = sessions.find((s) => s.id === bundle.session.id);
|
|
580
|
+
const displayTitle = liveSession?.title || bundle.session.title;
|
|
528
581
|
return (
|
|
529
582
|
<main className="demo-view" aria-label={t("demo.title")}>
|
|
530
583
|
<header className="demo-header">
|
|
531
584
|
<div className="demo-header__title">
|
|
532
585
|
<span className="workspace-panel__eyebrow">{t("demo.eyebrow")}</span>
|
|
533
|
-
<h1>{
|
|
586
|
+
<h1>{displayTitle}</h1>
|
|
534
587
|
<span className="demo-header__meta">
|
|
535
588
|
{t("demo.meta.exported", { time: new Date(bundle.exportedAt).toLocaleString() })}
|
|
536
589
|
</span>
|
|
@@ -596,6 +649,13 @@ export function DemoView() {
|
|
|
596
649
|
zoom={zoom}
|
|
597
650
|
onZoomChange={setZoom}
|
|
598
651
|
fitToken={revealedNodes.length}
|
|
652
|
+
formatKind={formatNodeKind}
|
|
653
|
+
zoomLabels={{
|
|
654
|
+
controls: t("trace.aria.zoomControls"),
|
|
655
|
+
zoomIn: t("trace.aria.zoomIn"),
|
|
656
|
+
zoomOut: t("trace.aria.zoomOut"),
|
|
657
|
+
reset: t("trace.aria.resetZoom"),
|
|
658
|
+
}}
|
|
599
659
|
/>
|
|
600
660
|
</div>
|
|
601
661
|
<div className="demo-transport">
|
|
@@ -658,9 +718,11 @@ export function DemoView() {
|
|
|
658
718
|
node={modalNode}
|
|
659
719
|
onClose={() => setModalNodeId(null)}
|
|
660
720
|
onSelectNode={(id) => { setSelectedNodeId(id); setModalNodeId(id); }}
|
|
721
|
+
nodes={nodes}
|
|
661
722
|
onSelectArtifact={selectFile}
|
|
662
723
|
activeArtifactPath={currentArtifactPath}
|
|
663
724
|
closeLabel={t("demo.node.modalClose")}
|
|
725
|
+
formatKind={formatNodeKind}
|
|
664
726
|
t={t}
|
|
665
727
|
/>
|
|
666
728
|
</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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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) =>
|
|
303
|
-
const customs = Array.from(
|
|
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
|
-
}, [
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
935
|
-
<dd
|
|
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>
|