@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,664 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ChevronRight,
4
+ Download,
5
+ File,
6
+ FileImage,
7
+ FileText,
8
+ Folder,
9
+ Package,
10
+ Maximize2,
11
+ Minimize2,
12
+ RefreshCw,
13
+ X,
14
+ } from "lucide-react";
15
+ import { FileContent, FileEntry } from "../../contracts/backend";
16
+ import { useSandbox } from "../../contexts/SandboxContext";
17
+ import { useSessions } from "../../contexts/SessionContext";
18
+ import { runtimeConfig } from "../../config";
19
+ import { useT } from "../../i18n/useT";
20
+ import { api } from "../../utils/api";
21
+ import { downloadBlob } from "../../utils/download";
22
+ import { createZipBlob, type ZipEntry } from "../../utils/zip";
23
+ import { IconButton } from "../primitives/IconButton";
24
+ import { ONE_MB, MAX_BINARY_PREVIEW, formatBytes, formatModified, getPreviewKind, isMarkdown } from "./filePreview";
25
+ import { FilePreviewView, PreviewSource } from "./FilePreviewView";
26
+
27
+ type FileNode = FileEntry & {
28
+ path: string;
29
+ children?: FileNode[];
30
+ loaded?: boolean;
31
+ };
32
+
33
+ type FileSidebarProps = {
34
+ isOpen: boolean;
35
+ onClose: () => void;
36
+ onResize: (width: number) => void;
37
+ onResizeEnd: () => void;
38
+ onResizeStart: () => void;
39
+ width: number;
40
+ };
41
+
42
+ const MIN_FILE_SIDEBAR_WIDTH = 320;
43
+ const MAX_FILE_SIDEBAR_WIDTH = 680;
44
+ const MIN_PREVIEW_WIDTH = 360;
45
+ const MAX_PREVIEW_WIDTH = 900;
46
+ const DEFAULT_PREVIEW_WIDTH = 560;
47
+
48
+ const rootNode: FileNode = {
49
+ name: "workspace",
50
+ path: "/workspace",
51
+ type: "folder",
52
+ size: 0,
53
+ modified: 0,
54
+ permissions: "",
55
+ };
56
+
57
+ function joinPath(parent: string, name: string) {
58
+ return `${parent.replace(/\/$/, "")}/${name}`;
59
+ }
60
+
61
+ function basename(path: string) {
62
+ return path.split("/").filter(Boolean).pop() || "download";
63
+ }
64
+
65
+ function workspaceRelativePath(path: string) {
66
+ if (path === "/workspace") {
67
+ return "workspace";
68
+ }
69
+ return path.startsWith("/workspace/") ? path.slice("/workspace/".length) : path.replace(/^\/+/, "");
70
+ }
71
+
72
+ function removeNestedSelections(paths: string[]): string[] {
73
+ return [...paths]
74
+ .sort((a, b) => a.length - b.length)
75
+ .filter((path, index, sorted) => !sorted.slice(0, index).some((parent) => path.startsWith(`${parent}/`)));
76
+ }
77
+
78
+ function FileIcon({ node }: { node: FileNode }) {
79
+ if (node.type === "folder" || node.type === "symlink") {
80
+ return <Folder size={16} />;
81
+ }
82
+ if (/\.(md|txt|py|yaml|yml|csv|json|ts|tsx|js|jsx)$/i.test(node.name)) {
83
+ return <FileText size={16} />;
84
+ }
85
+ if (/\.(svg|png|jpg|jpeg|gif|webp)$/i.test(node.name)) {
86
+ return <FileImage size={16} />;
87
+ }
88
+ return <File size={16} />;
89
+ }
90
+
91
+ function sortNodes(nodes: FileNode[]): FileNode[] {
92
+ return [...nodes].sort((a, b) => {
93
+ const aIsFolder = a.type === "folder" || a.type === "symlink";
94
+ const bIsFolder = b.type === "folder" || b.type === "symlink";
95
+ if (aIsFolder !== bIsFolder) {
96
+ return aIsFolder ? -1 : 1;
97
+ }
98
+ return a.name.localeCompare(b.name);
99
+ });
100
+ }
101
+
102
+ function updateNode(root: FileNode, path: string, updater: (node: FileNode) => FileNode): FileNode {
103
+ if (root.path === path) {
104
+ return updater(root);
105
+ }
106
+ return {
107
+ ...root,
108
+ children: root.children?.map((child) => updateNode(child, path, updater)),
109
+ };
110
+ }
111
+
112
+ function findNode(root: FileNode, path: string | null): FileNode | null {
113
+ if (!path) {
114
+ return null;
115
+ }
116
+ if (root.path === path) {
117
+ return root;
118
+ }
119
+ for (const child of root.children ?? []) {
120
+ const found = findNode(child, path);
121
+ if (found) {
122
+ return found;
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeStart, width }: FileSidebarProps) {
129
+ const { currentSandbox } = useSandbox();
130
+ const { currentSession } = useSessions();
131
+ // In single-user local mode the workspace is addressed by the active session
132
+ // id (workspaces/<sid>/), not a container id. Elsewhere the sandbox id is the
133
+ // addressing key. `currentSandbox.status` still gates whether files are live.
134
+ const sandboxId = runtimeConfig.localMode ? currentSession?.id ?? null : currentSandbox?.id ?? null;
135
+ const t = useT();
136
+ const [tree, setTree] = useState<FileNode>(rootNode);
137
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["/workspace"]));
138
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
139
+ const [selectedContent, setSelectedContent] = useState<FileContent | null>(null);
140
+ const [selectedDownloadPaths, setSelectedDownloadPaths] = useState<Set<string>>(() => new Set());
141
+ const [isRefreshing, setIsRefreshing] = useState(false);
142
+ const [isDownloadingSelection, setIsDownloadingSelection] = useState(false);
143
+ const [error, setError] = useState<string | null>(null);
144
+ const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
145
+ const resizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
146
+
147
+ const loadDirectory = useCallback(
148
+ async (path: string) => {
149
+ if (!currentSandbox || currentSandbox.status !== "running" || !sandboxId) {
150
+ setError(t("files.error.notRunning"));
151
+ return;
152
+ }
153
+ setError(null);
154
+ const entries = await api.sandbox.listFiles(sandboxId, path);
155
+ const children = entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) }));
156
+ setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
157
+ },
158
+ [currentSandbox, sandboxId],
159
+ );
160
+
161
+ useEffect(() => {
162
+ if (isOpen && currentSandbox?.status === "running") {
163
+ void loadDirectory("/workspace");
164
+ }
165
+ if (!isOpen || currentSandbox?.status !== "running") {
166
+ setSelectedDownloadPaths(new Set());
167
+ setSelectedPath(null);
168
+ setSelectedContent(null);
169
+ setIsPreviewMaximized(false);
170
+ }
171
+ }, [currentSandbox?.status, isOpen, loadDirectory]);
172
+
173
+ useEffect(() => {
174
+ const handlePointerMove = (event: PointerEvent) => {
175
+ if (!resizeStartRef.current) {
176
+ return;
177
+ }
178
+ const delta = resizeStartRef.current.pointerX - event.clientX;
179
+ const nextWidth = Math.max(
180
+ MIN_FILE_SIDEBAR_WIDTH,
181
+ Math.min(MAX_FILE_SIDEBAR_WIDTH, resizeStartRef.current.width + delta),
182
+ );
183
+ onResize(nextWidth);
184
+ };
185
+ const handlePointerUp = () => {
186
+ if (!resizeStartRef.current) {
187
+ return;
188
+ }
189
+ resizeStartRef.current = null;
190
+ onResizeEnd();
191
+ };
192
+ window.addEventListener("pointermove", handlePointerMove);
193
+ window.addEventListener("pointerup", handlePointerUp);
194
+ return () => {
195
+ window.removeEventListener("pointermove", handlePointerMove);
196
+ window.removeEventListener("pointerup", handlePointerUp);
197
+ };
198
+ }, [onResize, onResizeEnd]);
199
+
200
+ const selectedNode = useMemo(() => findNode(tree, selectedPath), [selectedPath, tree]);
201
+ const selectedFile = selectedNode?.type === "file" ? selectedNode : null;
202
+ const selectedDownloadCount = selectedDownloadPaths.size;
203
+
204
+ const getNodeForPath = useCallback((path: string) => findNode(tree, path), [tree]);
205
+
206
+ const loadDirectoryEntries = useCallback(
207
+ async (path: string): Promise<FileNode[]> => {
208
+ if (!sandboxId) {
209
+ throw new Error("No active sandbox");
210
+ }
211
+ const cached = findNode(tree, path);
212
+ if (cached?.loaded && cached.children) {
213
+ return cached.children;
214
+ }
215
+ const entries = await api.sandbox.listFiles(sandboxId, path);
216
+ const children = sortNodes(entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) })));
217
+ setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
218
+ return children;
219
+ },
220
+ [sandboxId, tree],
221
+ );
222
+
223
+ const collectZipEntries = useCallback(
224
+ async (node: FileNode, zipEntries: ZipEntry[]) => {
225
+ if (!sandboxId) {
226
+ throw new Error("No active sandbox");
227
+ }
228
+ if (node.type === "folder" || node.type === "symlink") {
229
+ const children = sortNodes(await loadDirectoryEntries(node.path));
230
+ if (children.length === 0) {
231
+ zipEntries.push({ path: `${workspaceRelativePath(node.path)}/`, data: new Uint8Array() });
232
+ return;
233
+ }
234
+ for (const child of children) {
235
+ await collectZipEntries(child, zipEntries);
236
+ }
237
+ return;
238
+ }
239
+
240
+ const blob = await api.sandbox.readRawFile(sandboxId, node.path);
241
+ zipEntries.push({
242
+ path: workspaceRelativePath(node.path),
243
+ data: new Uint8Array(await blob.arrayBuffer()),
244
+ });
245
+ },
246
+ [sandboxId, loadDirectoryEntries],
247
+ );
248
+
249
+ const toggleDownloadSelection = useCallback((path: string) => {
250
+ setSelectedDownloadPaths((current) => {
251
+ const next = new Set(current);
252
+ if (next.has(path)) {
253
+ next.delete(path);
254
+ } else {
255
+ next.add(path);
256
+ }
257
+ return next;
258
+ });
259
+ }, []);
260
+
261
+ const clearDownloadSelection = useCallback(() => {
262
+ setSelectedDownloadPaths(new Set());
263
+ }, []);
264
+
265
+ const downloadPaths = useCallback(
266
+ async (paths: string[]) => {
267
+ if (!sandboxId || isDownloadingSelection) {
268
+ return;
269
+ }
270
+ const requestedPaths = removeNestedSelections(paths);
271
+ if (!requestedPaths.length) {
272
+ return;
273
+ }
274
+
275
+ setIsDownloadingSelection(true);
276
+ setError(null);
277
+ try {
278
+ const nodes = requestedPaths.map((path) => {
279
+ const node = getNodeForPath(path);
280
+ if (!node) {
281
+ throw new Error(`Cannot find ${path}`);
282
+ }
283
+ return node;
284
+ });
285
+
286
+ if (nodes.length === 1 && nodes[0].type === "file") {
287
+ const blob = await api.sandbox.readRawFile(sandboxId, nodes[0].path);
288
+ downloadBlob(blob, nodes[0].name);
289
+ return;
290
+ }
291
+
292
+ const zipEntries: ZipEntry[] = [];
293
+ for (const node of nodes) {
294
+ await collectZipEntries(node, zipEntries);
295
+ }
296
+ const filename = nodes.length === 1 ? `${basename(nodes[0].path)}.zip` : "workspace-files.zip";
297
+ downloadBlob(createZipBlob(zipEntries), filename);
298
+ } catch (err) {
299
+ setError(err instanceof Error ? err.message : t("files.error.downloadFailed"));
300
+ } finally {
301
+ setIsDownloadingSelection(false);
302
+ }
303
+ },
304
+ [collectZipEntries, sandboxId, getNodeForPath, isDownloadingSelection],
305
+ );
306
+
307
+ const refreshFiles = async () => {
308
+ setIsRefreshing(true);
309
+ try {
310
+ const paths = Array.from(expandedPaths);
311
+ for (const path of paths.length ? paths : ["/workspace"]) {
312
+ await loadDirectory(path);
313
+ }
314
+ } catch (err) {
315
+ setError(err instanceof Error ? err.message : t("files.error.refreshFailed"));
316
+ } finally {
317
+ setIsRefreshing(false);
318
+ }
319
+ };
320
+
321
+ const toggleFolder = async (node: FileNode) => {
322
+ setExpandedPaths((current) => {
323
+ const next = new Set(current);
324
+ if (next.has(node.path)) {
325
+ next.delete(node.path);
326
+ } else {
327
+ next.add(node.path);
328
+ }
329
+ return next;
330
+ });
331
+ if (!node.loaded) {
332
+ await loadDirectory(node.path);
333
+ }
334
+ };
335
+
336
+ const selectFile = async (node: FileNode) => {
337
+ setSelectedPath(node.path);
338
+ setSelectedContent(null);
339
+ if (!sandboxId) {
340
+ return;
341
+ }
342
+ // Only text files are loaded inline (subject to the 1 MB cap). Binary files
343
+ // (image/pdf/download) are streamed as blobs by FilePreviewPanel regardless
344
+ // of size, so they must NOT short-circuit here.
345
+ if (getPreviewKind(node.name) !== "text" || node.size > ONE_MB) {
346
+ return;
347
+ }
348
+ try {
349
+ setSelectedContent(await api.sandbox.readFile(sandboxId, node.path));
350
+ } catch (err) {
351
+ setSelectedContent({
352
+ path: node.path,
353
+ content: err instanceof Error ? err.message : t("files.error.previewFailed"),
354
+ size: node.size,
355
+ });
356
+ }
357
+ };
358
+
359
+ const renderNode = (node: FileNode, depth = 0) => {
360
+ const isFolder = node.type === "folder" || node.type === "symlink";
361
+ const isExpanded = expandedPaths.has(node.path);
362
+ const isSelected = selectedPath === node.path;
363
+ const isDownloadSelected = selectedDownloadPaths.has(node.path);
364
+
365
+ return (
366
+ <div className="file-node" key={node.path}>
367
+ <div className={`file-row ${isSelected ? "is-selected" : ""} ${isDownloadSelected ? "is-download-selected" : ""}`}>
368
+ <label className="file-row__check" style={{ marginLeft: 10 + depth * 16 }}>
369
+ <span className="sr-only">{t("files.selectForDownload", { name: node.name })}</span>
370
+ <input
371
+ checked={isDownloadSelected}
372
+ disabled={isDownloadingSelection}
373
+ onChange={() => toggleDownloadSelection(node.path)}
374
+ type="checkbox"
375
+ />
376
+ </label>
377
+ <button
378
+ className="file-row__open"
379
+ onClick={() => {
380
+ if (isFolder) {
381
+ void toggleFolder(node);
382
+ } else {
383
+ void selectFile(node);
384
+ }
385
+ }}
386
+ type="button"
387
+ >
388
+ <span className={`file-row__chevron ${isFolder && isExpanded ? "is-expanded" : ""}`}>
389
+ {isFolder ? <ChevronRight size={14} /> : null}
390
+ </span>
391
+ <FileIcon node={node} />
392
+ <span className="file-row__name">{node.name}</span>
393
+ <span className="file-row__size">{formatBytes(node.size)}</span>
394
+ </button>
395
+ </div>
396
+ {isFolder && isExpanded ? node.children?.map((child) => renderNode(child, depth + 1)) : null}
397
+ </div>
398
+ );
399
+ };
400
+
401
+ return (
402
+ <>
403
+ <aside aria-hidden={!isOpen} aria-label={t("files.aria.sidebar")} className={`file-sidebar ${isOpen ? "is-open" : ""}`}>
404
+ <div
405
+ aria-label={t("files.aria.resizeSidebar")}
406
+ className="file-sidebar__resize-handle"
407
+ onPointerDown={(event) => {
408
+ resizeStartRef.current = { pointerX: event.clientX, width };
409
+ onResizeStart();
410
+ }}
411
+ role="separator"
412
+ />
413
+ <header className="file-sidebar__header">
414
+ <div>
415
+ <span className="file-sidebar__eyebrow">{t("files.eyebrow.workspace")}</span>
416
+ <h2>{t("files.title")}</h2>
417
+ </div>
418
+ <div className="file-sidebar__actions">
419
+ {selectedDownloadCount > 0 ? (
420
+ <div className="file-sidebar__selection-actions">
421
+ <span>{t("files.selectedCount", { count: selectedDownloadCount })}</span>
422
+ <button
423
+ className="file-sidebar__download-selected"
424
+ disabled={!currentSandbox || isDownloadingSelection}
425
+ onClick={() => void downloadPaths(Array.from(selectedDownloadPaths))}
426
+ type="button"
427
+ >
428
+ <Package size={14} />
429
+ <span>{isDownloadingSelection ? t("files.packing") : t("files.download")}</span>
430
+ </button>
431
+ <IconButton disabled={isDownloadingSelection} label={t("files.aria.clearSelection")} onClick={clearDownloadSelection}>
432
+ <X size={14} />
433
+ </IconButton>
434
+ </div>
435
+ ) : null}
436
+ <IconButton className={isRefreshing ? "is-active" : ""} label={t("files.aria.refresh")} onClick={() => void refreshFiles()}>
437
+ <RefreshCw size={15} />
438
+ </IconButton>
439
+ <IconButton label={t("files.aria.close")} onClick={onClose}>
440
+ <X size={15} />
441
+ </IconButton>
442
+ </div>
443
+ </header>
444
+
445
+ <div className="file-sidebar__path">
446
+ <span>/workspace</span>
447
+ <small>{currentSandbox?.status === "running" ? t("files.live") : t("files.offline")}</small>
448
+ </div>
449
+
450
+ {error ? <p className="file-sidebar__empty">{error}</p> : null}
451
+ <div className="file-sidebar__tree" aria-label={t("files.aria.tree")}>
452
+ {renderNode(tree)}
453
+ </div>
454
+ </aside>
455
+
456
+ <FilePreviewPanel
457
+ content={selectedContent}
458
+ file={isOpen ? selectedFile : null}
459
+ isMaximized={isPreviewMaximized}
460
+ onClose={() => {
461
+ setSelectedPath(null);
462
+ setSelectedContent(null);
463
+ setIsPreviewMaximized(false);
464
+ }}
465
+ sandboxId={sandboxId}
466
+ onToggleMaximize={() => setIsPreviewMaximized((current) => !current)}
467
+ />
468
+ </>
469
+ );
470
+ }
471
+
472
+ function FilePreviewPanel({
473
+ file,
474
+ content,
475
+ isMaximized,
476
+ onClose,
477
+ sandboxId,
478
+ onToggleMaximize,
479
+ }: {
480
+ file: FileNode | null;
481
+ content: FileContent | null;
482
+ isMaximized: boolean;
483
+ onClose: () => void;
484
+ sandboxId: string | null;
485
+ onToggleMaximize: () => void;
486
+ }) {
487
+ const t = useT();
488
+ const [previewWidth, setPreviewWidth] = useState(DEFAULT_PREVIEW_WIDTH);
489
+ const [isResizingPreview, setIsResizingPreview] = useState(false);
490
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
491
+ const [blobError, setBlobError] = useState<string | null>(null);
492
+ const [isDownloading, setIsDownloading] = useState(false);
493
+ const previewResizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
494
+ const previewKind = file ? getPreviewKind(file.name) : "download";
495
+
496
+ useEffect(() => {
497
+ if (
498
+ !file ||
499
+ !sandboxId ||
500
+ (previewKind !== "image" && previewKind !== "pdf") ||
501
+ file.size > MAX_BINARY_PREVIEW
502
+ ) {
503
+ setBlobUrl(null);
504
+ setBlobError(null);
505
+ return;
506
+ }
507
+
508
+ let objectUrl: string | null = null;
509
+ let isCancelled = false;
510
+
511
+ setBlobUrl(null);
512
+ setBlobError(null);
513
+ void api.sandbox
514
+ .readRawFile(sandboxId, file.path)
515
+ .then((blob) => {
516
+ if (isCancelled) {
517
+ return;
518
+ }
519
+ objectUrl = URL.createObjectURL(blob);
520
+ setBlobUrl(objectUrl);
521
+ })
522
+ .catch((err) => {
523
+ if (!isCancelled) {
524
+ setBlobError(err instanceof Error ? err.message : t("files.error.loadPreviewFailed"));
525
+ }
526
+ });
527
+
528
+ return () => {
529
+ isCancelled = true;
530
+ if (objectUrl) {
531
+ URL.revokeObjectURL(objectUrl);
532
+ }
533
+ };
534
+ }, [file?.path, previewKind, sandboxId]);
535
+
536
+ useEffect(() => {
537
+ const handlePointerMove = (event: PointerEvent) => {
538
+ if (!previewResizeStartRef.current) {
539
+ return;
540
+ }
541
+ const availableWidth = window.innerWidth - MIN_FILE_SIDEBAR_WIDTH - 80;
542
+ const maxWidth = Math.max(MIN_PREVIEW_WIDTH, Math.min(MAX_PREVIEW_WIDTH, availableWidth));
543
+ const delta = previewResizeStartRef.current.pointerX - event.clientX;
544
+ const nextWidth = Math.max(MIN_PREVIEW_WIDTH, Math.min(maxWidth, previewResizeStartRef.current.width + delta));
545
+ setPreviewWidth(nextWidth);
546
+ };
547
+ const handlePointerUp = () => {
548
+ previewResizeStartRef.current = null;
549
+ setIsResizingPreview(false);
550
+ };
551
+ window.addEventListener("pointermove", handlePointerMove);
552
+ window.addEventListener("pointerup", handlePointerUp);
553
+ return () => {
554
+ window.removeEventListener("pointermove", handlePointerMove);
555
+ window.removeEventListener("pointerup", handlePointerUp);
556
+ };
557
+ }, []);
558
+
559
+ if (!file) {
560
+ return null;
561
+ }
562
+
563
+ // Size cap is applied PER TYPE: binary (image/pdf) previews tolerate up to
564
+ // MAX_BINARY_PREVIEW (streamed to a blob); text is held inline so it keeps the
565
+ // 1 MB cap. Classifying by previewKind FIRST is what lets a large PDF/image
566
+ // reach its blob branch instead of being force-classified "tooLarge".
567
+ const previewSource: PreviewSource =
568
+ previewKind === "image"
569
+ ? file.size > MAX_BINARY_PREVIEW
570
+ ? { kind: "tooLarge" }
571
+ : { kind: "image", blobUrl: blobUrl ?? undefined }
572
+ : previewKind === "pdf"
573
+ ? file.size > MAX_BINARY_PREVIEW
574
+ ? { kind: "tooLarge" }
575
+ : { kind: "pdf", blobUrl: blobUrl ?? undefined }
576
+ : previewKind === "download"
577
+ ? { kind: "download" }
578
+ : file.size > ONE_MB
579
+ ? { kind: "tooLarge" }
580
+ : { kind: "text", text: content?.content ?? t("files.preview.loading") };
581
+
582
+ const downloadFile = async () => {
583
+ if (!sandboxId) {
584
+ return;
585
+ }
586
+ setIsDownloading(true);
587
+ try {
588
+ const blob = await api.sandbox.readRawFile(sandboxId, file.path);
589
+ downloadBlob(blob, file.name);
590
+ } finally {
591
+ setIsDownloading(false);
592
+ }
593
+ };
594
+
595
+ return (
596
+ <section
597
+ aria-label={t("files.preview.aria")}
598
+ className={`file-preview-panel is-open ${isMaximized ? "is-maximized" : ""} ${
599
+ isResizingPreview ? "is-resizing" : ""
600
+ }`}
601
+ style={{ "--preview-panel-width": `${previewWidth}px` } as React.CSSProperties}
602
+ >
603
+ <div
604
+ aria-label={t("files.preview.ariaResize")}
605
+ className="file-preview-panel__resize-handle"
606
+ onPointerDown={(event) => {
607
+ if (isMaximized) {
608
+ return;
609
+ }
610
+ previewResizeStartRef.current = { pointerX: event.clientX, width: previewWidth };
611
+ setIsResizingPreview(true);
612
+ }}
613
+ role="separator"
614
+ />
615
+ <div className="file-preview__header">
616
+ <div>
617
+ <span className="file-sidebar__eyebrow">{t("files.preview.eyebrow")}</span>
618
+ <h3>{file.name}</h3>
619
+ </div>
620
+ <div className="file-preview__actions">
621
+ <button className="file-preview__download" disabled={!sandboxId || isDownloading} onClick={() => void downloadFile()} type="button">
622
+ <Download size={14} />
623
+ <span>{isDownloading ? t("files.preview.downloading") : t("files.preview.download")}</span>
624
+ </button>
625
+ <IconButton label={isMaximized ? t("files.preview.restore") : t("files.preview.maximize")} onClick={onToggleMaximize}>
626
+ {isMaximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
627
+ </IconButton>
628
+ <IconButton label={t("files.preview.close")} onClick={onClose}>
629
+ <X size={15} />
630
+ </IconButton>
631
+ </div>
632
+ </div>
633
+
634
+ <dl className="file-preview__meta">
635
+ <div>
636
+ <dt>{t("files.preview.path")}</dt>
637
+ <dd>{file.path}</dd>
638
+ </div>
639
+ <div>
640
+ <dt>{t("files.preview.size")}</dt>
641
+ <dd>{formatBytes(file.size)}</dd>
642
+ </div>
643
+ <div>
644
+ <dt>{t("files.preview.modified")}</dt>
645
+ <dd>{formatModified(file.modified)}</dd>
646
+ </div>
647
+ <div>
648
+ <dt>{t("files.preview.mode")}</dt>
649
+ <dd>{file.permissions || "-"}</dd>
650
+ </div>
651
+ </dl>
652
+
653
+ <FilePreviewView
654
+ name={file.name}
655
+ source={previewSource}
656
+ renderMarkdown={isMarkdown(file.name)}
657
+ error={blobError}
658
+ t={t}
659
+ onDownload={sandboxId ? () => void downloadFile() : undefined}
660
+ isDownloading={isDownloading}
661
+ />
662
+ </section>
663
+ );
664
+ }