@brainpilot/web 0.0.4 → 0.0.6

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