@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.
- package/dist/assets/index-DWOsU22G.css +1 -0
- package/dist/assets/index-j3rGyO6m.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +96 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/internalToolStrip.test.ts +108 -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 +104 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +98 -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 +72 -17
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +94 -98
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +397 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -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/shell.ts +2 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +289 -70
- 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,47 @@ 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), 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
|
|
65
|
+
export function isDemoConversational(m: ChatMessage): boolean {
|
|
59
66
|
if (m.role === "user") {
|
|
60
67
|
return !!m.content?.trim();
|
|
61
68
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
|
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
|
|
244
|
-
// seed prompt
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
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(
|
|
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>{
|
|
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.
|
|
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>
|