@brainpilot/web 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure helpers for classifying and describing workspace files. Shared by the
3
+ * live file preview panel (FileSidebar) and the demo player's embedded preview.
4
+ */
5
+
6
+ export const ONE_MB = 1024 * 1024;
7
+
8
+ /**
9
+ * Upper bound for previewing BINARY files (images/PDF) via a blob URL. Text
10
+ * files keep the smaller {@link ONE_MB} inline cap (they're held in memory as a
11
+ * string); binary previews are streamed to an <img>/<iframe>, so a larger cap
12
+ * is fine. Above this, the panel shows a "too large" notice + a download button.
13
+ */
14
+ export const MAX_BINARY_PREVIEW = 50 * ONE_MB;
15
+
16
+ export type PreviewKind = "text" | "image" | "pdf" | "download";
17
+
18
+ export function getPreviewKind(fileName: string): PreviewKind {
19
+ if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(fileName)) {
20
+ return "image";
21
+ }
22
+ if (/\.pdf$/i.test(fileName)) {
23
+ return "pdf";
24
+ }
25
+ if (/\.(md|txt|py|yaml|yml|csv|json|ts|tsx|js|jsx|css|html|xml|toml|ini|log)$/i.test(fileName)) {
26
+ return "text";
27
+ }
28
+ return "download";
29
+ }
30
+
31
+ export function isMarkdown(fileName: string): boolean {
32
+ return /\.(md|markdown)$/i.test(fileName);
33
+ }
34
+
35
+ /**
36
+ * highlight.js language id for a filename, for syntax-highlighted code preview.
37
+ * Returns "plaintext" for plain/unknown text so callers skip highlighting.
38
+ * Language ids are limited to those in highlight.js's "common" bundle.
39
+ */
40
+ export function codeLanguage(fileName: string): string {
41
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
42
+ const map: Record<string, string> = {
43
+ py: "python",
44
+ ts: "typescript",
45
+ tsx: "typescript",
46
+ js: "javascript",
47
+ jsx: "javascript",
48
+ json: "json",
49
+ yaml: "yaml",
50
+ yml: "yaml",
51
+ css: "css",
52
+ html: "xml",
53
+ xml: "xml",
54
+ toml: "ini",
55
+ ini: "ini",
56
+ };
57
+ return map[ext] ?? "plaintext";
58
+ }
59
+
60
+ export function formatBytes(bytes: number): string {
61
+ if (bytes === 0) {
62
+ return "-";
63
+ }
64
+ const units = ["B", "KB", "MB", "GB"];
65
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
66
+ const value = bytes / 1024 ** index;
67
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
68
+ }
69
+
70
+ export function formatModified(timestamp: number): string {
71
+ if (!timestamp) {
72
+ return "-";
73
+ }
74
+ return new Intl.DateTimeFormat("en", {
75
+ month: "short",
76
+ day: "numeric",
77
+ hour: "2-digit",
78
+ minute: "2-digit",
79
+ }).format(new Date(timestamp * 1000));
80
+ }
81
+
82
+ /** Best-effort MIME type from a file extension (used when embedding files). */
83
+ export function mimeFromName(fileName: string): string {
84
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
85
+ const map: Record<string, string> = {
86
+ png: "image/png",
87
+ jpg: "image/jpeg",
88
+ jpeg: "image/jpeg",
89
+ gif: "image/gif",
90
+ webp: "image/webp",
91
+ svg: "image/svg+xml",
92
+ pdf: "application/pdf",
93
+ md: "text/markdown",
94
+ markdown: "text/markdown",
95
+ txt: "text/plain",
96
+ csv: "text/csv",
97
+ json: "application/json",
98
+ yaml: "text/yaml",
99
+ yml: "text/yaml",
100
+ py: "text/x-python",
101
+ ts: "text/typescript",
102
+ tsx: "text/typescript",
103
+ js: "text/javascript",
104
+ jsx: "text/javascript",
105
+ css: "text/css",
106
+ html: "text/html",
107
+ xml: "application/xml",
108
+ toml: "text/plain",
109
+ ini: "text/plain",
110
+ log: "text/plain",
111
+ };
112
+ return map[ext] ?? "application/octet-stream";
113
+ }
@@ -0,0 +1,200 @@
1
+ import { Check, ChevronDown } from "lucide-react";
2
+ import { CSSProperties, KeyboardEvent, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ import type { ReactNode } from "react";
6
+
7
+ export type CustomSelectOption = {
8
+ value: string;
9
+ label: string;
10
+ indicator?: ReactNode;
11
+ };
12
+
13
+ type CustomSelectProps = {
14
+ value: string;
15
+ options: CustomSelectOption[];
16
+ onChange: (value: string) => void;
17
+ ariaLabel: string;
18
+ className?: string;
19
+ disabled?: boolean;
20
+ placeholder?: string;
21
+ title?: string;
22
+ };
23
+
24
+ export function CustomSelect({
25
+ value,
26
+ options,
27
+ onChange,
28
+ ariaLabel,
29
+ className = "",
30
+ disabled = false,
31
+ placeholder = "Select",
32
+ title,
33
+ }: CustomSelectProps) {
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
36
+ const [placement, setPlacement] = useState<"top" | "bottom">("bottom");
37
+ const rootRef = useRef<HTMLDivElement | null>(null);
38
+ const menuRef = useRef<HTMLDivElement | null>(null);
39
+ const listboxId = useId();
40
+ const selectedIndex = options.findIndex((option) => option.value === value);
41
+ const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : null;
42
+ const activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
43
+ const activeOptionId = isOpen && options[activeIndex] ? `${listboxId}-${activeIndex}` : undefined;
44
+ const rootClassName = `custom-select ${isOpen ? "is-open" : ""} custom-select--${placement} ${disabled ? "is-disabled" : ""} ${className}`.trim();
45
+ const updateMenuPosition = useCallback(() => {
46
+ const root = rootRef.current;
47
+ if (!root) {
48
+ return;
49
+ }
50
+ const rect = root.getBoundingClientRect();
51
+ const gap = 6;
52
+ const viewportPadding = 12;
53
+ const preferredMenuHeight = Math.min(220, Math.max(44, options.length * 34 + 8));
54
+ const availableBelow = window.innerHeight - rect.bottom - viewportPadding;
55
+ const availableAbove = rect.top - viewportPadding;
56
+ const nextPlacement = availableBelow < preferredMenuHeight && availableAbove > availableBelow ? "top" : "bottom";
57
+ const menuWidth = Math.max(rect.width, Math.min(280, window.innerWidth - viewportPadding * 2));
58
+ const left = Math.min(Math.max(viewportPadding, rect.right - menuWidth), window.innerWidth - menuWidth - viewportPadding);
59
+
60
+ setPlacement(nextPlacement);
61
+ setMenuStyle({
62
+ left,
63
+ minWidth: rect.width,
64
+ width: menuWidth,
65
+ ...(nextPlacement === "top"
66
+ ? { bottom: window.innerHeight - rect.top + gap, maxHeight: Math.max(120, availableAbove - gap) }
67
+ : { top: rect.bottom + gap, maxHeight: Math.max(120, availableBelow - gap) }),
68
+ });
69
+ }, [options.length]);
70
+
71
+ useLayoutEffect(() => {
72
+ if (isOpen) {
73
+ updateMenuPosition();
74
+ }
75
+ }, [isOpen, updateMenuPosition]);
76
+
77
+ useEffect(() => {
78
+ if (!isOpen) {
79
+ return;
80
+ }
81
+ const handlePointerDown = (event: PointerEvent) => {
82
+ const target = event.target as Node;
83
+ if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) {
84
+ setIsOpen(false);
85
+ }
86
+ };
87
+ const handleReposition = () => updateMenuPosition();
88
+ document.addEventListener("pointerdown", handlePointerDown);
89
+ window.addEventListener("resize", handleReposition);
90
+ window.addEventListener("scroll", handleReposition, true);
91
+ return () => {
92
+ document.removeEventListener("pointerdown", handlePointerDown);
93
+ window.removeEventListener("resize", handleReposition);
94
+ window.removeEventListener("scroll", handleReposition, true);
95
+ };
96
+ }, [isOpen, updateMenuPosition]);
97
+
98
+ const normalizedOptions = useMemo(() => options, [options]);
99
+
100
+ const selectOption = (nextValue: string) => {
101
+ onChange(nextValue);
102
+ setIsOpen(false);
103
+ };
104
+
105
+ const moveSelection = (direction: 1 | -1) => {
106
+ if (normalizedOptions.length === 0) {
107
+ return;
108
+ }
109
+ const nextIndex = selectedIndex >= 0
110
+ ? (selectedIndex + direction + normalizedOptions.length) % normalizedOptions.length
111
+ : 0;
112
+ onChange(normalizedOptions[nextIndex].value);
113
+ };
114
+
115
+ const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
116
+ if (event.key === "Escape") {
117
+ setIsOpen(false);
118
+ return;
119
+ }
120
+ if (event.key === "ArrowDown") {
121
+ event.preventDefault();
122
+ if (!isOpen) {
123
+ setIsOpen(true);
124
+ return;
125
+ }
126
+ moveSelection(1);
127
+ return;
128
+ }
129
+ if (event.key === "ArrowUp") {
130
+ event.preventDefault();
131
+ if (!isOpen) {
132
+ setIsOpen(true);
133
+ return;
134
+ }
135
+ moveSelection(-1);
136
+ return;
137
+ }
138
+ if (event.key === "Enter" || event.key === " ") {
139
+ event.preventDefault();
140
+ setIsOpen((current) => !current);
141
+ }
142
+ };
143
+
144
+ const menu = isOpen ? createPortal(
145
+ <div
146
+ className={`custom-select__menu custom-select__menu--floating custom-select__menu--${placement}`}
147
+ id={listboxId}
148
+ ref={menuRef}
149
+ role="listbox"
150
+ style={menuStyle}
151
+ >
152
+ {normalizedOptions.map((option, index) => {
153
+ const isSelected = option.value === value;
154
+ return (
155
+ <button
156
+ aria-selected={isSelected}
157
+ className={`custom-select__option ${isSelected ? "is-selected" : ""}`.trim()}
158
+ id={`${listboxId}-${index}`}
159
+ key={option.value}
160
+ onClick={() => selectOption(option.value)}
161
+ role="option"
162
+ type="button"
163
+ >
164
+ <span className="custom-select__option-label">
165
+ {option.indicator ? <span className="custom-select__option-indicator">{option.indicator}</span> : null}
166
+ {option.label}
167
+ </span>
168
+ {isSelected ? <Check aria-hidden="true" size={14} /> : null}
169
+ </button>
170
+ );
171
+ })}
172
+ </div>,
173
+ document.body,
174
+ ) : null;
175
+
176
+ return (
177
+ <div className={rootClassName} ref={rootRef}>
178
+ <button
179
+ aria-activedescendant={activeOptionId}
180
+ aria-controls={listboxId}
181
+ aria-expanded={isOpen}
182
+ aria-haspopup="listbox"
183
+ aria-label={ariaLabel}
184
+ className="custom-select__trigger"
185
+ disabled={disabled}
186
+ onClick={() => setIsOpen((current) => !current)}
187
+ onKeyDown={handleKeyDown}
188
+ title={title}
189
+ type="button"
190
+ >
191
+ <span className={`custom-select__value ${selectedOption ? "" : "is-placeholder"}`.trim()}>
192
+ {selectedOption?.indicator ? <span className="custom-select__value-indicator">{selectedOption.indicator}</span> : null}
193
+ {selectedOption?.label ?? placeholder}
194
+ </span>
195
+ <ChevronDown aria-hidden="true" size={14} />
196
+ </button>
197
+ {menu}
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,27 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+
3
+ type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
4
+ label: string;
5
+ children: ReactNode;
6
+ variant?: "ghost" | "soft" | "strong";
7
+ };
8
+
9
+ export function IconButton({
10
+ label,
11
+ children,
12
+ className = "",
13
+ variant = "ghost",
14
+ ...props
15
+ }: IconButtonProps) {
16
+ return (
17
+ <button
18
+ type="button"
19
+ aria-label={label}
20
+ title={label}
21
+ className={`icon-button icon-button--${variant} ${className}`.trim()}
22
+ {...props}
23
+ >
24
+ {children}
25
+ </button>
26
+ );
27
+ }
@@ -0,0 +1,56 @@
1
+ import { AlertTriangle } from "lucide-react";
2
+ import { QuotaFileManager } from "./QuotaFileManager";
3
+ import { formatBytes } from "../../utils/format";
4
+ import { useT } from "../../i18n/useT";
5
+
6
+ type DiskQuotaCriticalDialogProps = {
7
+ isOpen: boolean;
8
+ sandboxId: string | null;
9
+ workspaceUsedBytes: number;
10
+ quotaBytes: number;
11
+ percentOfQuota: number;
12
+ };
13
+
14
+ export function DiskQuotaCriticalDialog({
15
+ isOpen,
16
+ sandboxId,
17
+ workspaceUsedBytes,
18
+ quotaBytes,
19
+ percentOfQuota,
20
+ }: DiskQuotaCriticalDialogProps) {
21
+ const t = useT();
22
+ if (!isOpen) return null;
23
+
24
+ return (
25
+ <div className="quota-critical-modal" role="presentation">
26
+ <section
27
+ className="quota-critical-dialog"
28
+ role="dialog"
29
+ aria-modal="true"
30
+ aria-labelledby="quota-critical-title"
31
+ >
32
+ <div className="quota-critical-dialog__header">
33
+ <h2 id="quota-critical-title">
34
+ <AlertTriangle size={16} style={{ marginRight: 8, verticalAlign: "-2px" }} />
35
+ {t("quota.critical.title")}
36
+ </h2>
37
+ </div>
38
+ <div className="quota-critical-dialog__body">
39
+ {sandboxId ? (
40
+ <QuotaFileManager sandboxId={sandboxId} />
41
+ ) : (
42
+ <p className="file-sidebar__empty">{t("quota.critical.unavailable")}</p>
43
+ )}
44
+ </div>
45
+ <div className="quota-critical-dialog__footer">
46
+ <span>
47
+ {t("quota.critical.used", { used: formatBytes(workspaceUsedBytes), limit: formatBytes(quotaBytes) })}
48
+ </span>
49
+ <span style={{ color: "var(--color-danger)", fontWeight: 600 }}>
50
+ {percentOfQuota.toFixed(0)}%
51
+ </span>
52
+ </div>
53
+ </section>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect } from "react";
2
+ import { AlertTriangle, X } from "lucide-react";
3
+ import { useT } from "../../i18n/useT";
4
+ import { IconButton } from "../primitives/IconButton";
5
+
6
+ type DiskQuotaWarningDialogProps = {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ percentOfQuota: number;
10
+ };
11
+
12
+ export function DiskQuotaWarningDialog({ isOpen, onClose, percentOfQuota }: DiskQuotaWarningDialogProps) {
13
+ const t = useT();
14
+ useEffect(() => {
15
+ if (!isOpen) return;
16
+ const handleKeyDown = (event: KeyboardEvent) => {
17
+ if (event.key === "Escape") {
18
+ onClose();
19
+ }
20
+ };
21
+ document.addEventListener("keydown", handleKeyDown);
22
+ return () => document.removeEventListener("keydown", handleKeyDown);
23
+ }, [isOpen, onClose]);
24
+
25
+ if (!isOpen) return null;
26
+
27
+ return (
28
+ <div
29
+ className="quota-warning-modal"
30
+ onClick={onClose}
31
+ role="presentation"
32
+ >
33
+ <section
34
+ className="quota-warning-dialog"
35
+ onClick={(e) => e.stopPropagation()}
36
+ role="dialog"
37
+ aria-modal="true"
38
+ aria-labelledby="quota-warning-title"
39
+ >
40
+ <div className="quota-warning-dialog__header">
41
+ <h2 id="quota-warning-title">
42
+ <AlertTriangle size={16} style={{ marginRight: 8, verticalAlign: "-2px", color: "var(--color-warning)" }} />
43
+ {t("quota.warning.title")}
44
+ </h2>
45
+ <IconButton label={t("quota.warning.close")} onClick={onClose}>
46
+ <X size={16} />
47
+ </IconButton>
48
+ </div>
49
+ <div className="quota-warning-dialog__body">
50
+ <p>
51
+ {t("quota.warning.bodyPrefix")}<strong>{percentOfQuota.toFixed(0)}%</strong>{t("quota.warning.bodySuffix")}
52
+ </p>
53
+ <p style={{ marginTop: 8 }}>
54
+ {t("quota.warning.body2")}
55
+ </p>
56
+ </div>
57
+ <div className="quota-warning-dialog__actions">
58
+ <button onClick={onClose} type="button">
59
+ {t("quota.warning.ack")}
60
+ </button>
61
+ </div>
62
+ </section>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,197 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { ChevronRight, File, Folder, Trash2 } from "lucide-react";
3
+ import { FileEntry } from "../../contracts/backend";
4
+ import { useT } from "../../i18n/useT";
5
+ import { api } from "../../utils/api";
6
+
7
+ type FileNode = FileEntry & {
8
+ path: string;
9
+ children?: FileNode[];
10
+ loaded?: boolean;
11
+ };
12
+
13
+ type QuotaFileManagerProps = {
14
+ sandboxId: string;
15
+ };
16
+
17
+ const rootNode: FileNode = {
18
+ name: "workspace",
19
+ path: "/workspace",
20
+ type: "folder",
21
+ size: 0,
22
+ modified: 0,
23
+ permissions: "",
24
+ };
25
+
26
+ function joinPath(parent: string, name: string) {
27
+ return `${parent.replace(/\/$/, "")}/${name}`;
28
+ }
29
+
30
+ function formatBytes(bytes: number) {
31
+ if (bytes === 0) return "-";
32
+ const units = ["B", "KB", "MB", "GB"];
33
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
34
+ const value = bytes / 1024 ** index;
35
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
36
+ }
37
+
38
+ export function QuotaFileManager({ sandboxId }: QuotaFileManagerProps) {
39
+ const t = useT();
40
+ const [tree, setTree] = useState<FileNode>({ ...rootNode });
41
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set(["/workspace"]));
42
+ const [isDeleting, setIsDeleting] = useState<Set<string>>(new Set());
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [isLoading, setIsLoading] = useState(false);
45
+
46
+ const loadDirectory = useCallback(
47
+ async (path: string) => {
48
+ setIsLoading(true);
49
+ setError(null);
50
+ try {
51
+ const entries = await api.sandbox.listFiles(sandboxId, path);
52
+ const children = entries.map((e) => ({
53
+ ...e,
54
+ path: joinPath(path, e.name),
55
+ }));
56
+ setTree((current) => updateNodeChildren(current, path, children));
57
+ } catch (err) {
58
+ setError(err instanceof Error ? err.message : t("quota.fm.loadFailed"));
59
+ } finally {
60
+ setIsLoading(false);
61
+ }
62
+ },
63
+ [sandboxId, t]
64
+ );
65
+
66
+ useEffect(() => {
67
+ void loadDirectory("/workspace");
68
+ }, [loadDirectory]);
69
+
70
+ const toggleFolder = useCallback(
71
+ (node: FileNode) => {
72
+ setExpandedPaths((current) => {
73
+ const next = new Set(current);
74
+ if (next.has(node.path)) {
75
+ next.delete(node.path);
76
+ } else {
77
+ next.add(node.path);
78
+ if (!node.loaded) {
79
+ void loadDirectory(node.path);
80
+ }
81
+ }
82
+ return next;
83
+ });
84
+ },
85
+ [loadDirectory]
86
+ );
87
+
88
+ const handleDelete = useCallback(
89
+ async (node: FileNode) => {
90
+ if (!window.confirm(t("quota.fm.confirmDelete", { name: node.name }))) return;
91
+ setIsDeleting((current) => {
92
+ const next = new Set(current);
93
+ next.add(node.path);
94
+ return next;
95
+ });
96
+ setError(null);
97
+ try {
98
+ await api.sandbox.deleteFile(sandboxId, node.path);
99
+ setTree((current) => removeNode(current, node.path));
100
+ setExpandedPaths((current) => {
101
+ const next = new Set(current);
102
+ next.delete(node.path);
103
+ return next;
104
+ });
105
+ } catch (err) {
106
+ setError(err instanceof Error ? err.message : t("quota.fm.deleteFailed"));
107
+ } finally {
108
+ setIsDeleting((current) => {
109
+ const next = new Set(current);
110
+ next.delete(node.path);
111
+ return next;
112
+ });
113
+ }
114
+ },
115
+ [sandboxId, t]
116
+ );
117
+
118
+ const renderNode = (node: FileNode, depth = 0): JSX.Element => {
119
+ const isFolder = node.type === "folder";
120
+ const isExpanded = expandedPaths.has(node.path);
121
+ const isRowDeleting = isDeleting.has(node.path);
122
+
123
+ return (
124
+ <div className="file-node" key={node.path}>
125
+ <div className="file-row file-row--deletable">
126
+ <button
127
+ className="file-row__open"
128
+ onClick={() => {
129
+ if (isFolder) {
130
+ toggleFolder(node);
131
+ }
132
+ }}
133
+ style={{ marginLeft: depth * 16 }}
134
+ type="button"
135
+ >
136
+ <span className={`file-row__chevron ${isFolder && isExpanded ? "is-expanded" : ""}`}>
137
+ {isFolder ? <ChevronRight size={14} /> : null}
138
+ </span>
139
+ {isFolder ? (
140
+ <Folder size={14} />
141
+ ) : (
142
+ <File size={14} />
143
+ )}
144
+ <span className="file-row__name">{node.name}</span>
145
+ <span className="file-row__size">{formatBytes(node.size)}</span>
146
+ </button>
147
+ <button
148
+ className="file-row__delete"
149
+ disabled={isRowDeleting}
150
+ onClick={() => void handleDelete(node)}
151
+ title={t("quota.fm.delete")}
152
+ type="button"
153
+ >
154
+ <Trash2 size={14} />
155
+ </button>
156
+ </div>
157
+ {isFolder && isExpanded
158
+ ? node.children?.map((child) => renderNode(child, depth + 1))
159
+ : null}
160
+ </div>
161
+ );
162
+ };
163
+
164
+ return (
165
+ <div>
166
+ {error ? <p className="quota-file-manager__error">{error}</p> : null}
167
+ {isLoading && tree.children == null ? (
168
+ <p className="file-sidebar__empty">{t("quota.fm.loading")}</p>
169
+ ) : (
170
+ <div className="file-sidebar__tree">{renderNode(tree)}</div>
171
+ )}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function updateNodeChildren(root: FileNode, targetPath: string, children: FileNode[]): FileNode {
177
+ if (root.path === targetPath) {
178
+ return { ...root, children: [...children], loaded: true };
179
+ }
180
+ if (root.children) {
181
+ return {
182
+ ...root,
183
+ children: root.children.map((child) => updateNodeChildren(child, targetPath, children)),
184
+ };
185
+ }
186
+ return root;
187
+ }
188
+
189
+ function removeNode(root: FileNode, targetPath: string): FileNode {
190
+ if (root.children) {
191
+ const filtered = root.children
192
+ .filter((child) => child.path !== targetPath)
193
+ .map((child) => removeNode(child, targetPath));
194
+ return { ...root, children: filtered };
195
+ }
196
+ return root;
197
+ }