@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,80 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { GitBranch, X } from "lucide-react";
|
|
3
|
+
import type { TraceNode } from "../../contracts/backend";
|
|
4
|
+
import type { TranslateVars } from "../../i18n/translate";
|
|
5
|
+
import { IconButton } from "../primitives/IconButton";
|
|
6
|
+
import { TraceNodeDetail } from "../session/TraceNodeDetail";
|
|
7
|
+
|
|
8
|
+
interface TraceNodeModalProps {
|
|
9
|
+
node: TraceNode | null;
|
|
10
|
+
nodes?: TraceNode[];
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onSelectNode: (id: string) => void;
|
|
13
|
+
/** Focus a produced file in the preview (closes the modal). */
|
|
14
|
+
onSelectArtifact: (path: string) => void;
|
|
15
|
+
activeArtifactPath: string | null;
|
|
16
|
+
closeLabel: string;
|
|
17
|
+
formatKind?: (kind: string) => string;
|
|
18
|
+
t: (key: string, vars?: TranslateVars) => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Popup showing a single reasoning-trace node's full info (what the agent did,
|
|
23
|
+
* dependencies, tool calls, produced files). Reuses the app's modal pattern
|
|
24
|
+
* (fixed backdrop + centered panel, click-outside / Escape to close) and the
|
|
25
|
+
* shared TraceNodeDetail body.
|
|
26
|
+
*/
|
|
27
|
+
export function TraceNodeModal({ node, nodes, onClose, onSelectNode, onSelectArtifact, activeArtifactPath, closeLabel, formatKind, t }: TraceNodeModalProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!node) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
33
|
+
if (event.key === "Escape") {
|
|
34
|
+
onClose();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
38
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
39
|
+
}, [node, onClose]);
|
|
40
|
+
|
|
41
|
+
if (!node) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="trace-node-modal" onMouseDown={onClose} role="presentation">
|
|
47
|
+
<section
|
|
48
|
+
className="trace-node-modal__panel"
|
|
49
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
50
|
+
role="dialog"
|
|
51
|
+
aria-modal="true"
|
|
52
|
+
aria-label={node.title}
|
|
53
|
+
>
|
|
54
|
+
<div className="trace-node-modal__head">
|
|
55
|
+
<span className="trace-node-modal__eyebrow">
|
|
56
|
+
<GitBranch size={13} style={{ marginRight: 5, verticalAlign: "-2px" }} />
|
|
57
|
+
{node.agent || node.nodeType || node.type}
|
|
58
|
+
</span>
|
|
59
|
+
<IconButton label={closeLabel} onClick={onClose}>
|
|
60
|
+
<X size={16} />
|
|
61
|
+
</IconButton>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="trace-node-modal__body trace-detail">
|
|
64
|
+
<TraceNodeDetail
|
|
65
|
+
node={node}
|
|
66
|
+
nodes={nodes}
|
|
67
|
+
onSelectNode={onSelectNode}
|
|
68
|
+
formatKind={formatKind}
|
|
69
|
+
onSelectArtifact={(path) => {
|
|
70
|
+
onSelectArtifact(path);
|
|
71
|
+
onClose();
|
|
72
|
+
}}
|
|
73
|
+
activeArtifactPath={activeArtifactPath}
|
|
74
|
+
t={t}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { AgentStatus, ChatMessage } from "../../contracts/backend";
|
|
2
|
+
import {
|
|
3
|
+
DEMO_BUNDLE_FORMAT,
|
|
4
|
+
DEMO_BUNDLE_VERSION,
|
|
5
|
+
DemoBundle,
|
|
6
|
+
DemoFile,
|
|
7
|
+
MAX_FILE_BYTES,
|
|
8
|
+
MAX_TOTAL_BYTES,
|
|
9
|
+
isDemoBundle,
|
|
10
|
+
} from "../../contracts/demoBundle";
|
|
11
|
+
import { api } from "../../utils/api";
|
|
12
|
+
import { getPreviewKind, mimeFromName } from "../files/filePreview";
|
|
13
|
+
|
|
14
|
+
export interface BuildDemoOptions {
|
|
15
|
+
session: { id: string; title: string; createdAt?: string; updatedAt?: string };
|
|
16
|
+
/**
|
|
17
|
+
* The running sandbox to read produced files from. Optional: when absent (no
|
|
18
|
+
* running sandbox), the conversation / trace / events are still packed from
|
|
19
|
+
* host-persisted storage and every produced file is recorded as unreadable.
|
|
20
|
+
*/
|
|
21
|
+
sandboxId?: string;
|
|
22
|
+
/** In-memory folded messages, used only when no timestamped events exist. */
|
|
23
|
+
fallbackMessages?: ChatMessage[];
|
|
24
|
+
/** Detail shown on files that could not be read because no sandbox was available. */
|
|
25
|
+
filesUnavailableDetail?: string;
|
|
26
|
+
/** Progress notices for the UI (e.g. "packing 3 files…"). */
|
|
27
|
+
onProgress?: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Normalize a trace artifact path to a sandbox `/workspace/...` path. */
|
|
31
|
+
function toWorkspacePath(path: string): string {
|
|
32
|
+
if (path.startsWith("/workspace")) {
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
// Absolute paths outside /workspace (e.g. "/data/out.csv", "/tmp/x") are
|
|
36
|
+
// remapped under /workspace by their basename-bearing tail, since the sandbox
|
|
37
|
+
// file API only serves /workspace. A bare relative path is joined directly.
|
|
38
|
+
const rel = path.replace(/^\/+/, "").replace(/^\.\//, "");
|
|
39
|
+
return `/workspace/${rel}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Chunked base64 encode of binary data (avoids call-stack overflow). */
|
|
43
|
+
async function blobToBase64(blob: Blob): Promise<string> {
|
|
44
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
45
|
+
let binary = "";
|
|
46
|
+
const chunk = 0x8000;
|
|
47
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
48
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
49
|
+
}
|
|
50
|
+
return btoa(binary);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function utf8ByteLength(text: string): number {
|
|
54
|
+
return new TextEncoder().encode(text).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collect the produced-file set referenced by the trace, fetch each, and embed
|
|
59
|
+
* it (utf8 for text, base64 for binary) honoring per-file and total size caps.
|
|
60
|
+
*/
|
|
61
|
+
async function collectFiles(
|
|
62
|
+
sandboxId: string | undefined,
|
|
63
|
+
paths: string[],
|
|
64
|
+
onProgress?: (message: string) => void,
|
|
65
|
+
unavailableDetail?: string,
|
|
66
|
+
): Promise<DemoFile[]> {
|
|
67
|
+
const files: DemoFile[] = [];
|
|
68
|
+
let totalEncoded = 0;
|
|
69
|
+
let index = 0;
|
|
70
|
+
for (const rawPath of paths) {
|
|
71
|
+
index += 1;
|
|
72
|
+
onProgress?.(`packing ${index}/${paths.length}: ${rawPath.split("/").pop() ?? rawPath}`);
|
|
73
|
+
const path = toWorkspacePath(rawPath);
|
|
74
|
+
const name = path.split("/").pop() ?? path;
|
|
75
|
+
const mime = mimeFromName(name);
|
|
76
|
+
const isText = getPreviewKind(name) === "text";
|
|
77
|
+
// No running sandbox to read from: the file bytes live in a sandbox
|
|
78
|
+
// workspace we can't reach right now. Record each as unreadable (with an
|
|
79
|
+
// honest reason) instead of failing the whole export — the conversation,
|
|
80
|
+
// trace and events still pack fine from host-persisted storage.
|
|
81
|
+
if (!sandboxId) {
|
|
82
|
+
files.push({
|
|
83
|
+
path: rawPath,
|
|
84
|
+
mime,
|
|
85
|
+
encoding: isText ? "utf8" : "base64",
|
|
86
|
+
size: 0,
|
|
87
|
+
truncated: true,
|
|
88
|
+
reason: "unreadable",
|
|
89
|
+
detail: unavailableDetail,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
if (isText) {
|
|
95
|
+
const content = await api.sandbox.readFile(sandboxId, path);
|
|
96
|
+
const size = content.size ?? utf8ByteLength(content.content);
|
|
97
|
+
const encodedLen = utf8ByteLength(content.content);
|
|
98
|
+
if (size > MAX_FILE_BYTES || totalEncoded + encodedLen > MAX_TOTAL_BYTES) {
|
|
99
|
+
files.push({ path: rawPath, mime, encoding: "utf8", size, truncated: true, reason: "tooLarge" });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
totalEncoded += encodedLen;
|
|
103
|
+
files.push({ path: rawPath, mime, encoding: "utf8", size, truncated: false, data: content.content });
|
|
104
|
+
} else {
|
|
105
|
+
const blob = await api.sandbox.readRawFile(sandboxId, path);
|
|
106
|
+
const size = blob.size;
|
|
107
|
+
if (size > MAX_FILE_BYTES) {
|
|
108
|
+
files.push({ path: rawPath, mime, encoding: "base64", size, truncated: true, reason: "tooLarge" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const data = await blobToBase64(blob);
|
|
112
|
+
if (totalEncoded + data.length > MAX_TOTAL_BYTES) {
|
|
113
|
+
files.push({ path: rawPath, mime, encoding: "base64", size, truncated: true, reason: "tooLarge" });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
totalEncoded += data.length;
|
|
117
|
+
files.push({ path: rawPath, mime, encoding: "base64", size, truncated: false, data });
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Read failed (missing, path rejected, outside workspace, wrong sandbox).
|
|
121
|
+
// Record it as unreadable — NOT as "too large" — so the player can show
|
|
122
|
+
// an honest reason instead of a misleading size notice.
|
|
123
|
+
files.push({
|
|
124
|
+
path: rawPath,
|
|
125
|
+
mime,
|
|
126
|
+
encoding: isText ? "utf8" : "base64",
|
|
127
|
+
size: 0,
|
|
128
|
+
truncated: true,
|
|
129
|
+
reason: "unreadable",
|
|
130
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Build a portable demo bundle for an arbitrary session. */
|
|
138
|
+
export async function buildDemoBundle(opts: BuildDemoOptions): Promise<DemoBundle> {
|
|
139
|
+
const { session, sandboxId, fallbackMessages, filesUnavailableDetail, onProgress } = opts;
|
|
140
|
+
|
|
141
|
+
onProgress?.("reading reasoning trace…");
|
|
142
|
+
const trace = await api.sessions.getTrace(session.id);
|
|
143
|
+
|
|
144
|
+
let appVersion: string | undefined;
|
|
145
|
+
try {
|
|
146
|
+
appVersion = (await api.getVersion()).version;
|
|
147
|
+
} catch {
|
|
148
|
+
appVersion = undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let agents: AgentStatus[] = [];
|
|
152
|
+
try {
|
|
153
|
+
agents = (await api.sessions.state(session.id)).agents;
|
|
154
|
+
} catch {
|
|
155
|
+
agents = [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
onProgress?.("reading conversation timeline…");
|
|
159
|
+
let timeline: DemoBundle["timeline"] = "timestamped";
|
|
160
|
+
// Pull the persisted event timeline from the new history endpoint (the
|
|
161
|
+
// legacy `/sessions/:id/events` path is an SSE alias and returns no JSON).
|
|
162
|
+
// Cap at 5000 — the endpoint enforces the same cap, but stating it here
|
|
163
|
+
// documents the bundle's max footprint.
|
|
164
|
+
const historyEnvelope = await api.sessions.getHistory(session.id, { limit: 5000 });
|
|
165
|
+
let events: typeof historyEnvelope.events | undefined = historyEnvelope.events;
|
|
166
|
+
let messages: ChatMessage[] | undefined;
|
|
167
|
+
if (!events || events.length === 0) {
|
|
168
|
+
timeline = "ordered";
|
|
169
|
+
events = undefined;
|
|
170
|
+
messages = fallbackMessages ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Collect produced-file paths from trace artifacts (dedupe, skip dirs).
|
|
174
|
+
const seen = new Set<string>();
|
|
175
|
+
const paths: string[] = [];
|
|
176
|
+
for (const node of trace.nodes) {
|
|
177
|
+
for (const artifact of node.artifacts ?? []) {
|
|
178
|
+
if (!artifact.path || artifact.type === "dir") {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!seen.has(artifact.path)) {
|
|
182
|
+
seen.add(artifact.path);
|
|
183
|
+
paths.push(artifact.path);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const files = await collectFiles(sandboxId, paths, onProgress, filesUnavailableDetail);
|
|
189
|
+
|
|
190
|
+
onProgress?.("assembling bundle…");
|
|
191
|
+
return {
|
|
192
|
+
format: DEMO_BUNDLE_FORMAT,
|
|
193
|
+
version: DEMO_BUNDLE_VERSION,
|
|
194
|
+
exportedAt: new Date().toISOString(),
|
|
195
|
+
appVersion,
|
|
196
|
+
timeline,
|
|
197
|
+
session: {
|
|
198
|
+
id: session.id,
|
|
199
|
+
title: session.title,
|
|
200
|
+
createdAt: session.createdAt,
|
|
201
|
+
updatedAt: session.updatedAt,
|
|
202
|
+
},
|
|
203
|
+
events: timeline === "timestamped" ? events : undefined,
|
|
204
|
+
messages: timeline === "ordered" ? messages : undefined,
|
|
205
|
+
trace,
|
|
206
|
+
agents,
|
|
207
|
+
files,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse + validate an imported bundle file. Throws on invalid input. */
|
|
212
|
+
export function parseDemoBundle(text: string): DemoBundle {
|
|
213
|
+
let parsed: unknown;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(text);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new Error("Invalid JSON file.");
|
|
218
|
+
}
|
|
219
|
+
if (!isDemoBundle(parsed)) {
|
|
220
|
+
throw new Error("Not a valid live-demo bundle.");
|
|
221
|
+
}
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DemoBundle } from "../../contracts/demoBundle";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Module-level (page-lifetime) cache of built demo bundles, keyed by
|
|
5
|
+
* sessionId + updatedAt. Survives DemoView unmount/remount so re-opening the
|
|
6
|
+
* same session's demo is instant and re-issues no requests. The updatedAt in
|
|
7
|
+
* the key auto-invalidates a stale bundle when the conversation has advanced.
|
|
8
|
+
*
|
|
9
|
+
* Bundles embed file bytes (base64), so an LRU cap bounds memory.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_CACHED = 6;
|
|
13
|
+
const cache = new Map<string, DemoBundle>();
|
|
14
|
+
|
|
15
|
+
function keyFor(sessionId: string, updatedAt?: string): string {
|
|
16
|
+
return updatedAt ? `${sessionId}::${updatedAt}` : sessionId;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getCachedBundle(sessionId: string, updatedAt?: string): DemoBundle | null {
|
|
20
|
+
const key = keyFor(sessionId, updatedAt);
|
|
21
|
+
const hit = cache.get(key);
|
|
22
|
+
if (!hit) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
// Mark as most-recently-used (Map preserves insertion order).
|
|
26
|
+
cache.delete(key);
|
|
27
|
+
cache.set(key, hit);
|
|
28
|
+
return hit;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setCachedBundle(sessionId: string, updatedAt: string | undefined, bundle: DemoBundle): void {
|
|
32
|
+
const key = keyFor(sessionId, updatedAt);
|
|
33
|
+
cache.delete(key);
|
|
34
|
+
cache.set(key, bundle);
|
|
35
|
+
while (cache.size > MAX_CACHED) {
|
|
36
|
+
const oldest = cache.keys().next().value;
|
|
37
|
+
if (oldest === undefined) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
cache.delete(oldest);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reset-signal logic for the Live Demo player (issue #111).
|
|
3
|
+
*
|
|
4
|
+
* The shell bumps a monotonic `resetSignal` every time the sidebar "Live Demo"
|
|
5
|
+
* entry is clicked. DemoView must return to its session-selection landing on a
|
|
6
|
+
* *change* of that signal — but NOT on the initial mount, or importing/packing
|
|
7
|
+
* a freshly-selected bundle would be undone immediately. Extracted as a pure
|
|
8
|
+
* function so this guard is unit-testable without rendering the component (the
|
|
9
|
+
* monorepo has no jsdom/@testing-library).
|
|
10
|
+
*/
|
|
11
|
+
export function shouldResetDemo(
|
|
12
|
+
previous: number | undefined,
|
|
13
|
+
next: number | undefined,
|
|
14
|
+
): boolean {
|
|
15
|
+
return next !== previous;
|
|
16
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { MarkdownMessage } from "../chat/MarkdownMessage";
|
|
2
|
+
import { TranslateVars } from "../../i18n/translate";
|
|
3
|
+
import { codeLanguage, isMarkdown } from "./filePreview";
|
|
4
|
+
|
|
5
|
+
/** Skip highlighting very large files to avoid blocking the main thread. */
|
|
6
|
+
const MAX_HIGHLIGHT_BYTES = 200_000;
|
|
7
|
+
|
|
8
|
+
/** A fence longer than any backtick run in the text, so content can't break out. */
|
|
9
|
+
function fenceFor(text: string): string {
|
|
10
|
+
let max = 0;
|
|
11
|
+
const runs = text.match(/`+/g);
|
|
12
|
+
if (runs) {
|
|
13
|
+
for (const r of runs) {
|
|
14
|
+
max = Math.max(max, r.length);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return "`".repeat(Math.max(3, max + 1));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PreviewSource =
|
|
21
|
+
| { kind: "text"; text: string; truncated?: boolean }
|
|
22
|
+
| { kind: "image"; blobUrl?: string }
|
|
23
|
+
| { kind: "pdf"; blobUrl?: string }
|
|
24
|
+
| { kind: "download"; blobUrl?: string }
|
|
25
|
+
| { kind: "tooLarge" }
|
|
26
|
+
| { kind: "unreadable"; detail?: string };
|
|
27
|
+
|
|
28
|
+
interface FilePreviewViewProps {
|
|
29
|
+
name: string;
|
|
30
|
+
source: PreviewSource;
|
|
31
|
+
/** Render markdown (.md) text as formatted HTML instead of raw <pre>. */
|
|
32
|
+
renderMarkdown?: boolean;
|
|
33
|
+
/** Error loading the bytes (image/pdf), shown instead of the media. */
|
|
34
|
+
error?: string | null;
|
|
35
|
+
t: (key: string, vars?: TranslateVars) => string;
|
|
36
|
+
/** When provided, "too large" / "not previewable" notices offer a download. */
|
|
37
|
+
onDownload?: () => void;
|
|
38
|
+
/** Disables the download button while a download is in flight. */
|
|
39
|
+
isDownloading?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Notice text + an optional download fallback for un-previewable files. */
|
|
43
|
+
function NoticeWithDownload({
|
|
44
|
+
text,
|
|
45
|
+
onDownload,
|
|
46
|
+
isDownloading,
|
|
47
|
+
t,
|
|
48
|
+
}: {
|
|
49
|
+
text: string;
|
|
50
|
+
onDownload?: () => void;
|
|
51
|
+
isDownloading?: boolean;
|
|
52
|
+
t: (key: string, vars?: TranslateVars) => string;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="file-preview__notice">
|
|
56
|
+
<p>{text}</p>
|
|
57
|
+
{onDownload ? (
|
|
58
|
+
<button
|
|
59
|
+
className="file-preview__download"
|
|
60
|
+
disabled={isDownloading}
|
|
61
|
+
onClick={() => onDownload()}
|
|
62
|
+
type="button"
|
|
63
|
+
>
|
|
64
|
+
{isDownloading ? t("files.preview.downloading") : t("files.preview.download")}
|
|
65
|
+
</button>
|
|
66
|
+
) : null}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Presentational file-content body — image / pdf / text / markdown / notices.
|
|
73
|
+
* Extracted from FileSidebar's FilePreviewPanel so the live preview and the
|
|
74
|
+
* demo player (which feeds embedded bytes) render identically. Callers own
|
|
75
|
+
* blob-URL lifecycle and byte loading; this component only renders.
|
|
76
|
+
*/
|
|
77
|
+
export function FilePreviewView({
|
|
78
|
+
name,
|
|
79
|
+
source,
|
|
80
|
+
renderMarkdown,
|
|
81
|
+
error,
|
|
82
|
+
t,
|
|
83
|
+
onDownload,
|
|
84
|
+
isDownloading,
|
|
85
|
+
}: FilePreviewViewProps) {
|
|
86
|
+
if (source.kind === "tooLarge") {
|
|
87
|
+
return (
|
|
88
|
+
<NoticeWithDownload
|
|
89
|
+
text={t("files.preview.tooLarge")}
|
|
90
|
+
onDownload={onDownload}
|
|
91
|
+
isDownloading={isDownloading}
|
|
92
|
+
t={t}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (source.kind === "unreadable") {
|
|
97
|
+
return (
|
|
98
|
+
<p className="file-preview__notice">
|
|
99
|
+
{t("files.preview.unreadable")}
|
|
100
|
+
{source.detail ? <small className="file-preview__notice-detail"> · {source.detail}</small> : null}
|
|
101
|
+
</p>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (source.kind === "image") {
|
|
105
|
+
return (
|
|
106
|
+
<div className="file-preview__media">
|
|
107
|
+
{error ? <p>{error}</p> : source.blobUrl ? <img alt={name} src={source.blobUrl} /> : <p>{t("files.preview.loadingImage")}</p>}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (source.kind === "pdf") {
|
|
112
|
+
return (
|
|
113
|
+
<div className="file-preview__media">
|
|
114
|
+
{error ? <p>{error}</p> : source.blobUrl ? <iframe src={source.blobUrl} title={name} /> : <p>{t("files.preview.loadingPdf")}</p>}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (source.kind === "download") {
|
|
119
|
+
return (
|
|
120
|
+
<NoticeWithDownload
|
|
121
|
+
text={t("files.preview.notPreviewable")}
|
|
122
|
+
onDownload={onDownload}
|
|
123
|
+
isDownloading={isDownloading}
|
|
124
|
+
t={t}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// text
|
|
129
|
+
if (renderMarkdown && isMarkdown(name)) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="file-preview__markdown">
|
|
132
|
+
<MarkdownMessage content={source.text} />
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
// Syntax-highlight code/text by rendering it as a fenced code block through
|
|
137
|
+
// the markdown pipeline (reuses rehype-highlight + the chat code theme; no
|
|
138
|
+
// extra bundle weight). Falls back to plain text for unknown or huge files.
|
|
139
|
+
const lang = codeLanguage(name);
|
|
140
|
+
if (lang !== "plaintext" && source.text.length <= MAX_HIGHLIGHT_BYTES) {
|
|
141
|
+
const fence = fenceFor(source.text);
|
|
142
|
+
return (
|
|
143
|
+
<div className="file-preview__markdown file-preview__code">
|
|
144
|
+
<MarkdownMessage content={`${fence}${lang}\n${source.text}\n${fence}`} />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return (
|
|
149
|
+
<pre className="file-preview__content">
|
|
150
|
+
<code>{source.text}</code>
|
|
151
|
+
</pre>
|
|
152
|
+
);
|
|
153
|
+
}
|