@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -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/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 +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -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 +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -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 +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -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 +191 -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 +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -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/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 +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -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 +188 -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 +136 -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 +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -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,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
|
+
}
|