@brainpilot/web 0.0.4 → 0.0.5
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-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +187 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +184 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +84 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { DragEvent } from "react";
|
|
3
|
+
import { FileUp, MessageSquare, Pause, Play, RotateCcw, SkipBack, SkipForward, Upload } from "lucide-react";
|
|
4
|
+
import type { ChatMessage, TraceNode, WebSocketEvent } from "../../contracts/backend";
|
|
5
|
+
import { normalizeWebSocketEvent } from "../../contracts/backend";
|
|
6
|
+
import { DemoBundle, DemoFile } from "../../contracts/demoBundle";
|
|
7
|
+
import { reduceMessagesForEvent } from "../../contexts/messageReducer";
|
|
8
|
+
import { useSandbox } from "../../contexts/SandboxContext";
|
|
9
|
+
import { useSessions } from "../../contexts/SessionContext";
|
|
10
|
+
import { useT } from "../../i18n/useT";
|
|
11
|
+
import { downloadBlob } from "../../utils/download";
|
|
12
|
+
import { MessageStream } from "../chat/MessageStream";
|
|
13
|
+
import { FilePreviewView, PreviewSource } from "../files/FilePreviewView";
|
|
14
|
+
import { getPreviewKind, isMarkdown } from "../files/filePreview";
|
|
15
|
+
import { IconButton } from "../primitives/IconButton";
|
|
16
|
+
import { TraceGraphView } from "../session/TraceGraphView";
|
|
17
|
+
import { buildDemoBundle, parseDemoBundle } from "./demoBundle";
|
|
18
|
+
import { getCachedBundle, setCachedBundle } from "./demoCache";
|
|
19
|
+
import { DemoFileTree } from "./DemoFileTree";
|
|
20
|
+
import { TraceNodeModal } from "./TraceNodeModal";
|
|
21
|
+
|
|
22
|
+
const TICK_MS = 60;
|
|
23
|
+
/** Full timeline plays in this many ms at 1× regardless of real span. */
|
|
24
|
+
const FULL_PLAY_MS = 8000;
|
|
25
|
+
const SPEEDS = [1, 2, 4, 8];
|
|
26
|
+
|
|
27
|
+
type DecodedFile = { source: PreviewSource; objectUrl?: string };
|
|
28
|
+
|
|
29
|
+
function base64ToBlob(b64: string, mime: string): Blob {
|
|
30
|
+
const binary = atob(b64);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return new Blob([bytes], { type: mime });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function basename(path: string): string {
|
|
39
|
+
return path.split("/").pop() || path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keep only the user-facing dialogue backbone for the demo's left panel.
|
|
44
|
+
*
|
|
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).
|
|
51
|
+
*
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
function isConversationalMessage(m: ChatMessage): boolean {
|
|
59
|
+
if (m.role === "user") {
|
|
60
|
+
return !!m.content?.trim();
|
|
61
|
+
}
|
|
62
|
+
const isPlainText = m.kind === "text" || m.kind === undefined;
|
|
63
|
+
if (!isPlainText || !m.content?.trim()) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
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";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REPORT_NAME = /report|summary|总结|conclusion|readme/i;
|
|
72
|
+
|
|
73
|
+
/** Pick a sensible default file to show first: prefer report/summary-type. */
|
|
74
|
+
function pickDefaultFile(files: DemoFile[]): string | null {
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const usable = files.filter((f) => !f.truncated);
|
|
79
|
+
const pool = usable.length > 0 ? usable : files;
|
|
80
|
+
const byName = pool.find((f) => REPORT_NAME.test(basename(f.path)));
|
|
81
|
+
if (byName) {
|
|
82
|
+
return byName.path;
|
|
83
|
+
}
|
|
84
|
+
const md = pool.find((f) => isMarkdown(f.path));
|
|
85
|
+
if (md) {
|
|
86
|
+
return md.path;
|
|
87
|
+
}
|
|
88
|
+
return pool[0].path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
export function DemoView() {
|
|
93
|
+
const t = useT();
|
|
94
|
+
const { sessions, currentSession, messages } = useSessions();
|
|
95
|
+
const { currentSandbox } = useSandbox();
|
|
96
|
+
|
|
97
|
+
const [bundle, setBundle] = useState<DemoBundle | null>(null);
|
|
98
|
+
const [busy, setBusy] = useState(false);
|
|
99
|
+
const [progress, setProgress] = useState("");
|
|
100
|
+
const [error, setError] = useState<string | null>(null);
|
|
101
|
+
const [dragOver, setDragOver] = useState(false);
|
|
102
|
+
|
|
103
|
+
const [cursor, setCursor] = useState(0);
|
|
104
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
105
|
+
const [speed, setSpeed] = useState(1);
|
|
106
|
+
const [zoom, setZoom] = useState(1);
|
|
107
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
108
|
+
const [pinnedFile, setPinnedFile] = useState<string | null>(null);
|
|
109
|
+
const [modalNodeId, setModalNodeId] = useState<string | null>(null);
|
|
110
|
+
|
|
111
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
112
|
+
const decodedRef = useRef<Map<string, DecodedFile>>(new Map());
|
|
113
|
+
|
|
114
|
+
// Decode embedded files into preview sources (lazy blob URLs for binaries).
|
|
115
|
+
const decoded = useMemo(() => {
|
|
116
|
+
// Revoke URLs from the previous bundle.
|
|
117
|
+
decodedRef.current.forEach((d) => d.objectUrl && URL.revokeObjectURL(d.objectUrl));
|
|
118
|
+
const map = new Map<string, DecodedFile>();
|
|
119
|
+
for (const file of bundle?.files ?? []) {
|
|
120
|
+
if (file.truncated || file.data === undefined) {
|
|
121
|
+
map.set(file.path, {
|
|
122
|
+
source: file.reason === "unreadable"
|
|
123
|
+
? { kind: "unreadable", detail: file.detail }
|
|
124
|
+
: { kind: "tooLarge" },
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const kind = getPreviewKind(basename(file.path));
|
|
129
|
+
if (kind === "text") {
|
|
130
|
+
map.set(file.path, { source: { kind: "text", text: file.data } });
|
|
131
|
+
} else if (kind === "image" || kind === "pdf") {
|
|
132
|
+
const url = URL.createObjectURL(base64ToBlob(file.data, file.mime));
|
|
133
|
+
map.set(file.path, { source: { kind, blobUrl: url }, objectUrl: url });
|
|
134
|
+
} else {
|
|
135
|
+
map.set(file.path, { source: { kind: "download" } });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
decodedRef.current = map;
|
|
139
|
+
return map;
|
|
140
|
+
}, [bundle]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => () => {
|
|
143
|
+
decodedRef.current.forEach((d) => d.objectUrl && URL.revokeObjectURL(d.objectUrl));
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const nodes = bundle?.trace.nodes ?? [];
|
|
147
|
+
|
|
148
|
+
// Build the master timeline (ms). Timestamped bundles use real event/node
|
|
149
|
+
// times; ordered (fallback) bundles synthesize an index-based timeline.
|
|
150
|
+
const timeline = useMemo(() => {
|
|
151
|
+
if (!bundle) {
|
|
152
|
+
return { t0: 0, t1: 1, sorted: [] as { ev: WebSocketEvent; ms: number }[], nodeMs: [] as number[], ordered: [] as ChatMessage[] };
|
|
153
|
+
}
|
|
154
|
+
if (bundle.timeline === "timestamped") {
|
|
155
|
+
let last = 0;
|
|
156
|
+
const sorted = [...(bundle.events ?? [])]
|
|
157
|
+
.map((ev) => {
|
|
158
|
+
// Bundles produced by the real backend store raw snake_case events
|
|
159
|
+
// (agent_name, message_id). The live path camelizes via SSEContext
|
|
160
|
+
// before reducing; mirror that here so the reducer sees agentName /
|
|
161
|
+
// messageId and agent attribution survives the replay. camelizeKey is
|
|
162
|
+
// a no-op on already-camelCase keys (e.g. mock bundles), so this is
|
|
163
|
+
// safe for both shapes.
|
|
164
|
+
const normalized = normalizeWebSocketEvent(ev) as WebSocketEvent;
|
|
165
|
+
const parsed = normalized._ts ? Date.parse(String(normalized._ts)) : NaN;
|
|
166
|
+
const ms = Number.isFinite(parsed) ? parsed : last;
|
|
167
|
+
last = ms;
|
|
168
|
+
return { ev: normalized, ms };
|
|
169
|
+
})
|
|
170
|
+
.sort((a, b) => a.ms - b.ms);
|
|
171
|
+
const all = sorted.map((s) => s.ms).filter(Number.isFinite);
|
|
172
|
+
const t0 = all.length ? Math.min(...all) : 0;
|
|
173
|
+
const t1 = all.length ? Math.max(...all) : 1;
|
|
174
|
+
const span = t1 > t0 ? t1 - t0 : 1;
|
|
175
|
+
// Reveal trace nodes evenly across the timeline in creation (array) order.
|
|
176
|
+
// Bundle node timestamps are unreliable — often missing, equal, or
|
|
177
|
+
// clustered — which previously made the graph pop in all at once or out of
|
|
178
|
+
// order (filter-≤-cursor count diverged from the array slice). Even
|
|
179
|
+
// spacing keeps nodeMs monotonic in array order, so nodes stream in one by
|
|
180
|
+
// one, in order, paced across the replay.
|
|
181
|
+
const nodeMs = nodes.map((_, j) =>
|
|
182
|
+
nodes.length <= 1 ? t1 : t0 + (j / (nodes.length - 1)) * span,
|
|
183
|
+
);
|
|
184
|
+
return { t0, t1: t1 > t0 ? t1 : t0 + 1, sorted, nodeMs, ordered: [] as ChatMessage[] };
|
|
185
|
+
}
|
|
186
|
+
const ordered = bundle.messages ?? [];
|
|
187
|
+
const t1 = Math.max(1, ordered.length - 1, nodes.length - 1);
|
|
188
|
+
const nodeMs = nodes.map((_, j) => (nodes.length <= 1 ? 0 : (j / (nodes.length - 1)) * t1));
|
|
189
|
+
return { t0: 0, t1, sorted: [], nodeMs, ordered };
|
|
190
|
+
}, [bundle, nodes]);
|
|
191
|
+
|
|
192
|
+
// Reset transport on new bundle (start fully revealed, paused, default file).
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!bundle) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setCursor(timeline.t1);
|
|
198
|
+
setIsPlaying(false);
|
|
199
|
+
setSelectedNodeId(null);
|
|
200
|
+
setModalNodeId(null);
|
|
201
|
+
setPinnedFile(pickDefaultFile(bundle.files));
|
|
202
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
|
+
}, [bundle]);
|
|
204
|
+
|
|
205
|
+
// Play loop.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!isPlaying || !bundle) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const span = timeline.t1 - timeline.t0;
|
|
211
|
+
const perTick = (span * TICK_MS) / FULL_PLAY_MS * speed;
|
|
212
|
+
const id = window.setInterval(() => {
|
|
213
|
+
setCursor((current) => {
|
|
214
|
+
const next = current + perTick;
|
|
215
|
+
if (next >= timeline.t1) {
|
|
216
|
+
setIsPlaying(false);
|
|
217
|
+
return timeline.t1;
|
|
218
|
+
}
|
|
219
|
+
return next;
|
|
220
|
+
});
|
|
221
|
+
}, TICK_MS);
|
|
222
|
+
return () => window.clearInterval(id);
|
|
223
|
+
}, [isPlaying, bundle, timeline, speed]);
|
|
224
|
+
|
|
225
|
+
const revealedMessages = useMemo<ChatMessage[]>(() => {
|
|
226
|
+
if (!bundle) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
if (bundle.timeline === "timestamped") {
|
|
230
|
+
let acc: ChatMessage[] = [];
|
|
231
|
+
for (const { ev, ms } of timeline.sorted) {
|
|
232
|
+
if (ms > cursor) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
acc = reduceMessagesForEvent(acc, ev);
|
|
236
|
+
}
|
|
237
|
+
return acc;
|
|
238
|
+
}
|
|
239
|
+
const count = Math.max(0, Math.min(timeline.ordered.length, Math.floor(cursor) + 1));
|
|
240
|
+
return timeline.ordered.slice(0, count);
|
|
241
|
+
}, [bundle, timeline, cursor]);
|
|
242
|
+
|
|
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.
|
|
250
|
+
const condensedMessages = useMemo<ChatMessage[]>(
|
|
251
|
+
() => revealedMessages.filter(isConversationalMessage),
|
|
252
|
+
[revealedMessages],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const revealedNodes = useMemo<TraceNode[]>(() => {
|
|
256
|
+
const count = timeline.nodeMs.filter((ms) => ms <= cursor).length;
|
|
257
|
+
const slice = nodes.slice(0, count);
|
|
258
|
+
const visibleIds = new Set(slice.map((n) => n.id));
|
|
259
|
+
return slice.map((node) => ({
|
|
260
|
+
...node,
|
|
261
|
+
parentIds: node.parentIds.filter((id) => visibleIds.has(id)),
|
|
262
|
+
parents: node.parents.filter((p) => visibleIds.has(p.id)),
|
|
263
|
+
childIds: node.childIds.filter((id) => visibleIds.has(id)),
|
|
264
|
+
}));
|
|
265
|
+
}, [nodes, timeline, cursor]);
|
|
266
|
+
|
|
267
|
+
const selectedNode = useMemo<TraceNode | null>(() => {
|
|
268
|
+
if (revealedNodes.length === 0) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return revealedNodes.find((n) => n.id === selectedNodeId) ?? revealedNodes[revealedNodes.length - 1];
|
|
272
|
+
}, [revealedNodes, selectedNodeId]);
|
|
273
|
+
|
|
274
|
+
// Files the currently-selected node produced — highlighted in the file tree.
|
|
275
|
+
const highlightedPaths = useMemo<Set<string>>(
|
|
276
|
+
() => new Set((selectedNode?.artifacts ?? []).map((a) => a.path)),
|
|
277
|
+
[selectedNode],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Which file the middle preview shows: explicit pin > latest produced artifact
|
|
281
|
+
// up to the cursor > the report/summary default.
|
|
282
|
+
const currentArtifactPath = useMemo<string | null>(() => {
|
|
283
|
+
if (pinnedFile) {
|
|
284
|
+
return pinnedFile;
|
|
285
|
+
}
|
|
286
|
+
for (let i = revealedNodes.length - 1; i >= 0; i -= 1) {
|
|
287
|
+
const artifact = revealedNodes[i].artifacts?.[0];
|
|
288
|
+
if (artifact?.path) {
|
|
289
|
+
return artifact.path;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return pickDefaultFile(bundle?.files ?? []);
|
|
293
|
+
}, [revealedNodes, pinnedFile, bundle]);
|
|
294
|
+
|
|
295
|
+
const previewFile = bundle?.files.find((f) => f.path === currentArtifactPath) ?? null;
|
|
296
|
+
const previewSource: PreviewSource | null = currentArtifactPath
|
|
297
|
+
? decoded.get(currentArtifactPath)?.source ?? { kind: "tooLarge" }
|
|
298
|
+
: null;
|
|
299
|
+
|
|
300
|
+
const modalNode = useMemo<TraceNode | null>(
|
|
301
|
+
() => (modalNodeId ? nodes.find((n) => n.id === modalNodeId) ?? null : null),
|
|
302
|
+
[modalNodeId, nodes],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// ----- Transport -----
|
|
306
|
+
const stepIndex = revealedNodes.length; // # nodes revealed at the cursor
|
|
307
|
+
|
|
308
|
+
const togglePlay = () => {
|
|
309
|
+
if (!bundle) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (cursor >= timeline.t1) {
|
|
313
|
+
setCursor(timeline.t0);
|
|
314
|
+
}
|
|
315
|
+
setPinnedFile(null);
|
|
316
|
+
setIsPlaying((p) => !p);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const restart = () => {
|
|
320
|
+
setIsPlaying(false);
|
|
321
|
+
setPinnedFile(null);
|
|
322
|
+
setCursor(timeline.t0);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const stepTo = (nodeIdx: number) => {
|
|
326
|
+
// Reveal nodes [0..nodeIdx]; cursor lands on that node's time.
|
|
327
|
+
setIsPlaying(false);
|
|
328
|
+
setPinnedFile(null);
|
|
329
|
+
if (nodeIdx < 0) {
|
|
330
|
+
setCursor(timeline.t0);
|
|
331
|
+
} else {
|
|
332
|
+
setCursor(timeline.nodeMs[nodeIdx] ?? timeline.t1);
|
|
333
|
+
}
|
|
334
|
+
const node = nodes[Math.max(0, Math.min(nodeIdx, nodes.length - 1))];
|
|
335
|
+
if (node) {
|
|
336
|
+
setSelectedNodeId(node.id);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const stepNext = () => {
|
|
341
|
+
if (stepIndex >= nodes.length) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
stepTo(stepIndex); // reveal one more node
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const stepPrev = () => {
|
|
348
|
+
if (stepIndex <= 1) {
|
|
349
|
+
setCursor(timeline.t0);
|
|
350
|
+
setIsPlaying(false);
|
|
351
|
+
setPinnedFile(null);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
stepTo(stepIndex - 2);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const scrub = (value: number) => {
|
|
358
|
+
setIsPlaying(false);
|
|
359
|
+
setPinnedFile(null);
|
|
360
|
+
setCursor(value);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const selectFile = (path: string) => {
|
|
364
|
+
setPinnedFile(path);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const onNodeClick = (id: string) => {
|
|
368
|
+
setSelectedNodeId(id);
|
|
369
|
+
setModalNodeId(id);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const handlePackSession = async (sessionId: string, title: string, updatedAt?: string) => {
|
|
373
|
+
// Page-lifetime cache: re-opening the same (unchanged) session is instant
|
|
374
|
+
// and issues no requests.
|
|
375
|
+
const cached = getCachedBundle(sessionId, updatedAt);
|
|
376
|
+
if (cached) {
|
|
377
|
+
setError(null);
|
|
378
|
+
setBundle(cached);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// A running sandbox lets us embed produced files; without one we still pack
|
|
382
|
+
// the conversation, trace and events (all host-persisted) and mark the
|
|
383
|
+
// files unreadable. So the export is never hard-blocked on the sandbox.
|
|
384
|
+
const runningSandbox =
|
|
385
|
+
currentSandbox && currentSandbox.status === "running" ? currentSandbox : null;
|
|
386
|
+
setBusy(true);
|
|
387
|
+
setError(null);
|
|
388
|
+
setProgress(t("demo.packing"));
|
|
389
|
+
try {
|
|
390
|
+
const built = await buildDemoBundle({
|
|
391
|
+
session: {
|
|
392
|
+
id: sessionId,
|
|
393
|
+
title,
|
|
394
|
+
createdAt: currentSession?.id === sessionId ? currentSession.createdAt : undefined,
|
|
395
|
+
updatedAt: updatedAt ?? (currentSession?.id === sessionId ? currentSession.updatedAt : undefined),
|
|
396
|
+
},
|
|
397
|
+
sandboxId: runningSandbox?.id,
|
|
398
|
+
filesUnavailableDetail: runningSandbox ? undefined : t("demo.files.noSandbox"),
|
|
399
|
+
fallbackMessages: currentSession?.id === sessionId ? messages : undefined,
|
|
400
|
+
onProgress: setProgress,
|
|
401
|
+
});
|
|
402
|
+
setCachedBundle(sessionId, updatedAt, built);
|
|
403
|
+
setBundle(built);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
setError(err instanceof Error ? err.message : t("demo.error.build"));
|
|
406
|
+
} finally {
|
|
407
|
+
setBusy(false);
|
|
408
|
+
setProgress("");
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const handleImportFile = async (file: File) => {
|
|
413
|
+
setBusy(true);
|
|
414
|
+
setError(null);
|
|
415
|
+
try {
|
|
416
|
+
const parsed = parseDemoBundle(await file.text());
|
|
417
|
+
setBundle(parsed);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
setError(err instanceof Error ? err.message : t("demo.error.parse"));
|
|
420
|
+
} finally {
|
|
421
|
+
setBusy(false);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const handleDrop = (e: DragEvent) => {
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
setDragOver(false);
|
|
428
|
+
if (busy) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const file = e.dataTransfer.files?.[0];
|
|
432
|
+
if (file) {
|
|
433
|
+
void handleImportFile(file);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const handleExport = () => {
|
|
438
|
+
if (!bundle) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const blob = new Blob([JSON.stringify(bundle)], { type: "application/json" });
|
|
442
|
+
downloadBlob(blob, `${bundle.session.title || "session"}-demo.json`);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// ----- Landing -----
|
|
446
|
+
if (!bundle) {
|
|
447
|
+
return (
|
|
448
|
+
<main className="demo-view" aria-label={t("demo.title")}>
|
|
449
|
+
<div className="demo-landing">
|
|
450
|
+
<header className="demo-landing__header">
|
|
451
|
+
<span className="workspace-panel__eyebrow">{t("demo.eyebrow")}</span>
|
|
452
|
+
<h1>{t("demo.landing.heading")}</h1>
|
|
453
|
+
<p>{t("demo.landing.subtitle")}</p>
|
|
454
|
+
</header>
|
|
455
|
+
{error ? <p className="demo-landing__error">{error}</p> : null}
|
|
456
|
+
<div className="demo-landing__cards">
|
|
457
|
+
<section className="demo-card">
|
|
458
|
+
<div className="demo-card__head">
|
|
459
|
+
<MessageSquare size={16} />
|
|
460
|
+
<h2>{t("demo.landing.fromSession.title")}</h2>
|
|
461
|
+
</div>
|
|
462
|
+
<p>{t("demo.landing.fromSession.desc")}</p>
|
|
463
|
+
<div className="demo-card__sessions">
|
|
464
|
+
{sessions.length === 0 ? (
|
|
465
|
+
<p className="demo-card__empty">{t("demo.landing.fromSession.empty")}</p>
|
|
466
|
+
) : (
|
|
467
|
+
sessions.map((session) => (
|
|
468
|
+
<button
|
|
469
|
+
key={session.id}
|
|
470
|
+
className="demo-session-row"
|
|
471
|
+
disabled={busy}
|
|
472
|
+
onClick={() => void handlePackSession(session.id, session.title, session.updatedAt)}
|
|
473
|
+
type="button"
|
|
474
|
+
>
|
|
475
|
+
<span>{session.title}</span>
|
|
476
|
+
<small>{new Date(session.updatedAt).toLocaleDateString()}</small>
|
|
477
|
+
</button>
|
|
478
|
+
))
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
</section>
|
|
482
|
+
<section className="demo-card">
|
|
483
|
+
<div className="demo-card__head">
|
|
484
|
+
<FileUp size={16} />
|
|
485
|
+
<h2>{t("demo.landing.import.title")}</h2>
|
|
486
|
+
</div>
|
|
487
|
+
<p>{t("demo.landing.import.desc")}</p>
|
|
488
|
+
<button
|
|
489
|
+
className={`demo-dropzone ${dragOver ? "is-dragover" : ""}`}
|
|
490
|
+
disabled={busy}
|
|
491
|
+
onClick={() => fileInputRef.current?.click()}
|
|
492
|
+
onDragOver={(e) => {
|
|
493
|
+
e.preventDefault();
|
|
494
|
+
if (!busy) {
|
|
495
|
+
setDragOver(true);
|
|
496
|
+
}
|
|
497
|
+
}}
|
|
498
|
+
onDragLeave={() => setDragOver(false)}
|
|
499
|
+
onDrop={handleDrop}
|
|
500
|
+
type="button"
|
|
501
|
+
>
|
|
502
|
+
<Upload size={20} className="demo-dropzone__icon" />
|
|
503
|
+
<span className="demo-dropzone__primary">{t("demo.landing.import.button")}</span>
|
|
504
|
+
<span className="demo-dropzone__hint">{t("demo.landing.import.dropHint")}</span>
|
|
505
|
+
</button>
|
|
506
|
+
<input
|
|
507
|
+
ref={fileInputRef}
|
|
508
|
+
type="file"
|
|
509
|
+
accept="application/json,.json"
|
|
510
|
+
style={{ display: "none" }}
|
|
511
|
+
onChange={(e) => {
|
|
512
|
+
const file = e.target.files?.[0];
|
|
513
|
+
if (file) {
|
|
514
|
+
void handleImportFile(file);
|
|
515
|
+
}
|
|
516
|
+
e.target.value = "";
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
</section>
|
|
520
|
+
</div>
|
|
521
|
+
{busy ? <p className="demo-landing__progress">{progress || t("demo.packing")}</p> : null}
|
|
522
|
+
</div>
|
|
523
|
+
</main>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ----- Player -----
|
|
528
|
+
return (
|
|
529
|
+
<main className="demo-view" aria-label={t("demo.title")}>
|
|
530
|
+
<header className="demo-header">
|
|
531
|
+
<div className="demo-header__title">
|
|
532
|
+
<span className="workspace-panel__eyebrow">{t("demo.eyebrow")}</span>
|
|
533
|
+
<h1>{bundle.session.title}</h1>
|
|
534
|
+
<span className="demo-header__meta">
|
|
535
|
+
{t("demo.meta.exported", { time: new Date(bundle.exportedAt).toLocaleString() })}
|
|
536
|
+
</span>
|
|
537
|
+
</div>
|
|
538
|
+
<div className="demo-header__actions">
|
|
539
|
+
<button className="demo-export" onClick={handleExport} type="button">
|
|
540
|
+
<Upload size={14} />
|
|
541
|
+
<span>{t("demo.exportButton")}</span>
|
|
542
|
+
</button>
|
|
543
|
+
<button className="demo-reselect" onClick={() => setBundle(null)} type="button">
|
|
544
|
+
{t("demo.reselect")}
|
|
545
|
+
</button>
|
|
546
|
+
</div>
|
|
547
|
+
</header>
|
|
548
|
+
|
|
549
|
+
<div className="demo-layout">
|
|
550
|
+
<section className="demo-panel demo-panel--chat">
|
|
551
|
+
<header className="demo-panel__head">
|
|
552
|
+
<h2>{t("demo.conversation.title")}</h2>
|
|
553
|
+
</header>
|
|
554
|
+
{condensedMessages.length === 0 ? (
|
|
555
|
+
<p className="demo-panel__empty">{t("demo.conversation.empty")}</p>
|
|
556
|
+
) : (
|
|
557
|
+
<MessageStream messages={condensedMessages} showToolbarCount={false} className="demo-message-stream" />
|
|
558
|
+
)}
|
|
559
|
+
</section>
|
|
560
|
+
|
|
561
|
+
<section className="demo-panel demo-panel--preview">
|
|
562
|
+
<header className="demo-panel__head demo-preview-head">
|
|
563
|
+
<h2>{t("demo.files.title")}</h2>
|
|
564
|
+
{previewFile ? (
|
|
565
|
+
<span className="demo-preview-name" title={previewFile.path}>
|
|
566
|
+
{basename(previewFile.path)}
|
|
567
|
+
{previewFile.truncated ? <small> · {t("demo.files.skipped")}</small> : null}
|
|
568
|
+
</span>
|
|
569
|
+
) : null}
|
|
570
|
+
</header>
|
|
571
|
+
<div className="demo-preview-body">
|
|
572
|
+
{previewSource && previewFile ? (
|
|
573
|
+
<FilePreviewView
|
|
574
|
+
name={basename(previewFile.path)}
|
|
575
|
+
source={previewSource}
|
|
576
|
+
renderMarkdown={isMarkdown(previewFile.path)}
|
|
577
|
+
t={t}
|
|
578
|
+
/>
|
|
579
|
+
) : (
|
|
580
|
+
<p className="demo-panel__empty">{bundle.files.length === 0 ? t("demo.files.empty") : t("demo.files.none")}</p>
|
|
581
|
+
)}
|
|
582
|
+
</div>
|
|
583
|
+
</section>
|
|
584
|
+
|
|
585
|
+
<div className="demo-right">
|
|
586
|
+
<section className="demo-panel demo-panel--trace">
|
|
587
|
+
<header className="demo-panel__head">
|
|
588
|
+
<h2>{t("demo.trace.title")}</h2>
|
|
589
|
+
</header>
|
|
590
|
+
<div className="demo-trace-map">
|
|
591
|
+
<TraceGraphView
|
|
592
|
+
nodes={revealedNodes}
|
|
593
|
+
direction="LR"
|
|
594
|
+
selectedNodeId={selectedNode?.id ?? null}
|
|
595
|
+
onSelectNode={onNodeClick}
|
|
596
|
+
zoom={zoom}
|
|
597
|
+
onZoomChange={setZoom}
|
|
598
|
+
fitToken={revealedNodes.length}
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
601
|
+
<div className="demo-transport">
|
|
602
|
+
<IconButton label={t("demo.transport.prev")} onClick={stepPrev} disabled={stepIndex <= 1}>
|
|
603
|
+
<SkipBack size={14} />
|
|
604
|
+
</IconButton>
|
|
605
|
+
<IconButton
|
|
606
|
+
label={isPlaying ? t("demo.transport.pause") : t("demo.transport.play")}
|
|
607
|
+
onClick={togglePlay}
|
|
608
|
+
>
|
|
609
|
+
{isPlaying ? <Pause size={15} /> : <Play size={15} />}
|
|
610
|
+
</IconButton>
|
|
611
|
+
<IconButton label={t("demo.transport.next")} onClick={stepNext} disabled={stepIndex >= nodes.length}>
|
|
612
|
+
<SkipForward size={14} />
|
|
613
|
+
</IconButton>
|
|
614
|
+
<IconButton label={t("demo.transport.restart")} onClick={restart}>
|
|
615
|
+
<RotateCcw size={13} />
|
|
616
|
+
</IconButton>
|
|
617
|
+
<input
|
|
618
|
+
className="demo-transport__slider"
|
|
619
|
+
type="range"
|
|
620
|
+
min={timeline.t0}
|
|
621
|
+
max={timeline.t1}
|
|
622
|
+
step={(timeline.t1 - timeline.t0) / 1000 || 1}
|
|
623
|
+
value={cursor}
|
|
624
|
+
onChange={(e) => scrub(Number(e.target.value))}
|
|
625
|
+
aria-label={t("demo.transport.play")}
|
|
626
|
+
/>
|
|
627
|
+
<span className="demo-transport__step">{t("demo.transport.step", { index: stepIndex, total: nodes.length })}</span>
|
|
628
|
+
<div className="demo-transport__speeds" aria-label={t("demo.transport.speed")}>
|
|
629
|
+
{SPEEDS.map((s) => (
|
|
630
|
+
<button key={s} className={speed === s ? "is-active" : ""} onClick={() => setSpeed(s)} type="button">
|
|
631
|
+
{s}×
|
|
632
|
+
</button>
|
|
633
|
+
))}
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
</section>
|
|
637
|
+
|
|
638
|
+
<section className="demo-panel demo-panel--tree">
|
|
639
|
+
<header className="demo-panel__head">
|
|
640
|
+
<h2>{t("demo.tree.title")}</h2>
|
|
641
|
+
</header>
|
|
642
|
+
<div className="demo-tree-body">
|
|
643
|
+
<DemoFileTree
|
|
644
|
+
files={bundle.files}
|
|
645
|
+
highlightedPaths={highlightedPaths}
|
|
646
|
+
activePath={currentArtifactPath}
|
|
647
|
+
onSelect={selectFile}
|
|
648
|
+
emptyLabel={t("demo.files.empty")}
|
|
649
|
+
skippedLabel={t("demo.files.skipped")}
|
|
650
|
+
unreadableLabel={t("demo.files.unreadable")}
|
|
651
|
+
/>
|
|
652
|
+
</div>
|
|
653
|
+
</section>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
<TraceNodeModal
|
|
658
|
+
node={modalNode}
|
|
659
|
+
onClose={() => setModalNodeId(null)}
|
|
660
|
+
onSelectNode={(id) => { setSelectedNodeId(id); setModalNodeId(id); }}
|
|
661
|
+
onSelectArtifact={selectFile}
|
|
662
|
+
activeArtifactPath={currentArtifactPath}
|
|
663
|
+
closeLabel={t("demo.node.modalClose")}
|
|
664
|
+
t={t}
|
|
665
|
+
/>
|
|
666
|
+
</main>
|
|
667
|
+
);
|
|
668
|
+
}
|