@illuma-ai/code-sandbox 1.0.0 → 1.2.0

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.
@@ -7,7 +7,28 @@
7
7
  */
8
8
 
9
9
  import React, { useState } from "react";
10
- import type { FileMap, FileNode, FileTreeProps } from "../types";
10
+ import type {
11
+ FileMap,
12
+ FileNode,
13
+ FileTreeProps,
14
+ FileChangeStatus,
15
+ } from "../types";
16
+
17
+ /** Color for each file change status indicator dot */
18
+ const STATUS_COLORS: Record<FileChangeStatus, string> = {
19
+ new: "#4ade80", // green-400
20
+ modified: "#fb923c", // orange-400
21
+ deleted: "#f87171", // red-400
22
+ unchanged: "transparent",
23
+ };
24
+
25
+ /** Short label for each file change status (one character) */
26
+ const STATUS_LETTERS: Record<FileChangeStatus, string> = {
27
+ new: "N",
28
+ modified: "M",
29
+ deleted: "D",
30
+ unchanged: "",
31
+ };
11
32
 
12
33
  /**
13
34
  * Build a tree structure from a flat FileMap.
@@ -201,8 +222,14 @@ function FileIcon({ name }: { name: string }) {
201
222
 
202
223
  /**
203
224
  * FileTree component — renders a collapsible tree of files and folders.
225
+ * Shows change status indicators (N/M/D) next to modified files.
204
226
  */
205
- export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) {
227
+ export function FileTree({
228
+ files,
229
+ selectedFile,
230
+ onSelectFile,
231
+ fileChanges,
232
+ }: FileTreeProps) {
206
233
  return (
207
234
  <div className="h-full overflow-auto bg-sb-sidebar text-sm select-none">
208
235
  <div className="px-3 py-2 text-[11px] font-semibold text-sb-text-muted uppercase tracking-wider border-b border-sb-border">
@@ -216,6 +243,7 @@ export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) {
216
243
  depth={0}
217
244
  selectedFile={selectedFile}
218
245
  onSelectFile={onSelectFile}
246
+ fileChanges={fileChanges}
219
247
  />
220
248
  ))}
221
249
  </div>
@@ -229,18 +257,36 @@ function TreeNode({
229
257
  depth,
230
258
  selectedFile,
231
259
  onSelectFile,
260
+ fileChanges,
232
261
  }: {
233
262
  node: FileNode;
234
263
  depth: number;
235
264
  selectedFile: string | null;
236
265
  onSelectFile: (path: string) => void;
266
+ fileChanges?: Record<string, FileChangeStatus>;
237
267
  }) {
238
268
  const [expanded, setExpanded] = useState(depth < 2);
239
269
 
240
270
  const isSelected = node.path === selectedFile;
241
271
  const paddingLeft = 8 + depth * 16;
272
+ const changeStatus: FileChangeStatus =
273
+ fileChanges?.[node.path] ?? "unchanged";
242
274
 
243
275
  if (node.type === "directory") {
276
+ // Check if any child has changes (to show a subtle indicator on folders)
277
+ const hasChangedChild =
278
+ fileChanges &&
279
+ node.children?.some((c) => {
280
+ if (fileChanges[c.path]) return true;
281
+ // For directories, check recursively by prefix
282
+ if (c.type === "directory") {
283
+ return Object.keys(fileChanges).some(
284
+ (p) => p.startsWith(c.path + "/") && fileChanges[p] !== "unchanged",
285
+ );
286
+ }
287
+ return false;
288
+ });
289
+
244
290
  return (
245
291
  <div>
246
292
  <button
@@ -252,7 +298,13 @@ function TreeNode({
252
298
  >
253
299
  <ChevronIcon expanded={expanded} />
254
300
  <FolderIcon open={expanded} />
255
- <span className="truncate ml-0.5">{node.name}</span>
301
+ <span className="truncate ml-0.5 flex-1">{node.name}</span>
302
+ {hasChangedChild && (
303
+ <span
304
+ className="w-1.5 h-1.5 rounded-full shrink-0 mr-2"
305
+ style={{ backgroundColor: "#fb923c" }}
306
+ />
307
+ )}
256
308
  </button>
257
309
  {expanded && node.children && (
258
310
  <div>
@@ -263,6 +315,7 @@ function TreeNode({
263
315
  depth={depth + 1}
264
316
  selectedFile={selectedFile}
265
317
  onSelectFile={onSelectFile}
318
+ fileChanges={fileChanges}
266
319
  />
267
320
  ))}
268
321
  </div>
@@ -280,7 +333,16 @@ function TreeNode({
280
333
  onClick={() => onSelectFile(node.path)}
281
334
  >
282
335
  <FileIcon name={node.name} />
283
- <span className="truncate ml-0.5">{node.name}</span>
336
+ <span className="truncate ml-0.5 flex-1">{node.name}</span>
337
+ {changeStatus !== "unchanged" && (
338
+ <span
339
+ className="text-[9px] font-bold shrink-0 mr-2"
340
+ style={{ color: STATUS_COLORS[changeStatus] }}
341
+ title={changeStatus}
342
+ >
343
+ {STATUS_LETTERS[changeStatus]}
344
+ </span>
345
+ )}
284
346
  </button>
285
347
  );
286
348
  }
@@ -1,22 +1,204 @@
1
1
  /**
2
2
  * Preview — Full-width iframe that renders the Nodepod virtual server.
3
3
  *
4
- * Loads the URL from Nodepod's Service Worker proxy (/__virtual__/PORT/).
4
+ * Loads the URL from Nodepod's Service Worker proxy (/__preview__/PORT/).
5
5
  * The toolbar handles the URL bar and refresh — this is just the iframe.
6
+ *
7
+ * Error capture: After the iframe loads, we inject a small script that
8
+ * hooks `window.onerror`, `window.onunhandledrejection`, and
9
+ * `console.error` inside the iframe. These are relayed to the parent
10
+ * via `postMessage`, where we parse them into SandboxError objects
11
+ * and forward to the `onBrowserError` callback.
12
+ *
13
+ * The iframe and parent are same-origin (both served through the
14
+ * Nodepod Service Worker), so direct contentWindow access works.
15
+ */
16
+
17
+ import React, { useRef, useEffect, useCallback } from "react";
18
+ import type {
19
+ PreviewProps,
20
+ SandboxError,
21
+ SandboxErrorCategory,
22
+ } from "../types";
23
+
24
+ /** Message type sent from the error-capture script inside the iframe */
25
+ interface IframeErrorMessage {
26
+ type: "sandbox-error";
27
+ category: SandboxErrorCategory;
28
+ message: string;
29
+ stack?: string;
30
+ filename?: string;
31
+ lineno?: number;
32
+ colno?: number;
33
+ }
34
+
35
+ /**
36
+ * Build the error-capture script injected into the iframe.
37
+ *
38
+ * This script runs inside the preview iframe's global scope and
39
+ * intercepts three error vectors:
40
+ * 1. window.onerror — uncaught exceptions
41
+ * 2. window.onunhandledrejection — unhandled Promise rejections
42
+ * 3. console.error — explicit error logging
43
+ *
44
+ * Each error is posted to the parent via postMessage with a
45
+ * structured payload so the parent can construct a SandboxError.
6
46
  */
47
+ function buildErrorCaptureScript(): string {
48
+ return `
49
+ (function() {
50
+ // Guard against double-injection
51
+ if (window.__sandboxErrorCapture) return;
52
+ window.__sandboxErrorCapture = true;
53
+
54
+ var post = function(category, message, stack, filename, lineno, colno) {
55
+ try {
56
+ window.parent.postMessage({
57
+ type: 'sandbox-error',
58
+ category: category,
59
+ message: String(message || 'Unknown error'),
60
+ stack: stack || undefined,
61
+ filename: filename || undefined,
62
+ lineno: lineno || undefined,
63
+ colno: colno || undefined
64
+ }, '*');
65
+ } catch(e) { /* swallow — parent may not be listening */ }
66
+ };
67
+
68
+ // 1. Uncaught exceptions
69
+ window.onerror = function(msg, source, lineno, colno, error) {
70
+ post(
71
+ 'browser-error',
72
+ msg,
73
+ error && error.stack ? error.stack : undefined,
74
+ source,
75
+ lineno,
76
+ colno
77
+ );
78
+ };
79
+
80
+ // 2. Unhandled promise rejections
81
+ window.onunhandledrejection = function(event) {
82
+ var reason = event.reason;
83
+ var msg = reason instanceof Error ? reason.message : String(reason);
84
+ var stack = reason instanceof Error ? reason.stack : undefined;
85
+ post('browser-unhandled-rejection', msg, stack);
86
+ };
87
+
88
+ // 3. console.error intercept
89
+ var origError = console.error;
90
+ console.error = function() {
91
+ // Forward to original so DevTools still shows it
92
+ origError.apply(console, arguments);
93
+ var parts = [];
94
+ for (var i = 0; i < arguments.length; i++) {
95
+ var arg = arguments[i];
96
+ if (arg instanceof Error) {
97
+ parts.push(arg.message);
98
+ // Use the stack from the first Error argument
99
+ if (parts.length === 1) {
100
+ post('browser-console-error', arg.message, arg.stack);
101
+ return;
102
+ }
103
+ } else {
104
+ parts.push(String(arg));
105
+ }
106
+ }
107
+ post('browser-console-error', parts.join(' '));
108
+ };
109
+ })();
110
+ `;
111
+ }
7
112
 
8
- import React, { useRef, useEffect } from "react";
9
- import type { PreviewProps } from "../types";
113
+ /** Counter for unique error IDs within this component instance */
114
+ let browserErrorCounter = 0;
10
115
 
11
116
  /**
12
117
  * Preview component — renders the dev server output in a full-width iframe.
13
118
  *
14
- * The URL points to /__virtual__/PORT/ which is intercepted by the
119
+ * The URL points to /__preview__/PORT/ which is intercepted by the
15
120
  * Nodepod Service Worker and proxied to the Express server running
16
121
  * in the Web Worker.
122
+ *
123
+ * After the iframe loads, an error-capture script is injected so that
124
+ * runtime errors inside the preview are surfaced to the AI agent.
17
125
  */
18
- export function Preview({ url, className = "" }: PreviewProps) {
126
+ export function Preview({ url, className = "", onBrowserError }: PreviewProps) {
19
127
  const iframeRef = useRef<HTMLIFrameElement>(null);
128
+ const onBrowserErrorRef = useRef(onBrowserError);
129
+ onBrowserErrorRef.current = onBrowserError;
130
+
131
+ /**
132
+ * Inject the error-capture script into the iframe's contentWindow.
133
+ *
134
+ * Called on iframe `load` event. We use direct contentWindow access
135
+ * (same-origin) rather than srcdoc to avoid breaking the SW proxy.
136
+ */
137
+ const injectErrorCapture = useCallback(() => {
138
+ const iframe = iframeRef.current;
139
+ if (!iframe) return;
140
+
141
+ try {
142
+ const iframeDoc =
143
+ iframe.contentDocument || iframe.contentWindow?.document;
144
+ if (!iframeDoc) return;
145
+
146
+ const script = iframeDoc.createElement("script");
147
+ script.textContent = buildErrorCaptureScript();
148
+ // Append to <head> (or <body> if <head> doesn't exist)
149
+ const target =
150
+ iframeDoc.head || iframeDoc.body || iframeDoc.documentElement;
151
+ if (target) {
152
+ target.appendChild(script);
153
+ }
154
+ } catch (err) {
155
+ // Cross-origin restriction — shouldn't happen with SW proxy but
156
+ // degrade gracefully if it does.
157
+ console.warn(
158
+ "[CodeSandbox:Preview] Could not inject error capture:",
159
+ err,
160
+ );
161
+ }
162
+ }, []);
163
+
164
+ // Listen for postMessage from the iframe
165
+ useEffect(() => {
166
+ const handler = (event: MessageEvent) => {
167
+ if (!event.data || event.data.type !== "sandbox-error") return;
168
+
169
+ const data = event.data as IframeErrorMessage;
170
+ const cb = onBrowserErrorRef.current;
171
+ if (!cb) return;
172
+
173
+ // Parse filename to extract relative file path.
174
+ // The iframe's script src URLs look like:
175
+ // https://host/__preview__/3000/js/app.js
176
+ // We want just "public/js/app.js" or "js/app.js".
177
+ let filePath: string | undefined;
178
+ if (data.filename) {
179
+ const match = data.filename.match(
180
+ /\/__(?:preview|virtual)__\/\d+(\/.*)/,
181
+ );
182
+ filePath = match ? match[1].replace(/^\//, "") : undefined;
183
+ }
184
+
185
+ const error: SandboxError = {
186
+ id: `browser_${++browserErrorCounter}_${Date.now()}`,
187
+ category: data.category,
188
+ message: data.message,
189
+ stack: data.stack,
190
+ filePath,
191
+ line: data.lineno ?? undefined,
192
+ column: data.colno ?? undefined,
193
+ timestamp: new Date().toISOString(),
194
+ };
195
+
196
+ cb(error);
197
+ };
198
+
199
+ window.addEventListener("message", handler);
200
+ return () => window.removeEventListener("message", handler);
201
+ }, []);
20
202
 
21
203
  // Reload iframe when URL changes
22
204
  useEffect(() => {
@@ -34,6 +216,7 @@ export function Preview({ url, className = "" }: PreviewProps) {
34
216
  className="w-full h-full border-none"
35
217
  title="Preview"
36
218
  sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
219
+ onLoad={injectErrorCapture}
37
220
  />
38
221
  ) : (
39
222
  <div className="flex items-center justify-center h-full bg-sb-bg">
@@ -20,6 +20,8 @@ import React, {
20
20
  useEffect,
21
21
  useCallback,
22
22
  useRef,
23
+ useImperativeHandle,
24
+ forwardRef,
23
25
  } from "react";
24
26
  import { Allotment } from "allotment";
25
27
  import { motion, AnimatePresence } from "framer-motion";
@@ -32,7 +34,11 @@ import { Preview } from "./Preview";
32
34
  import { BootOverlay } from "./BootOverlay";
33
35
  import { ViewSlider, type WorkbenchView } from "./ViewSlider";
34
36
  import { useRuntime } from "../hooks/useRuntime";
35
- import type { CodeSandboxProps } from "../types";
37
+ import type {
38
+ CodeSandboxProps,
39
+ CodeSandboxHandle,
40
+ FileChangeStatus,
41
+ } from "../types";
36
42
 
37
43
  /** Transition for view crossfade */
38
44
  const VIEW_TRANSITION = {
@@ -50,99 +56,142 @@ const VIEW_TRANSITION = {
50
56
  * A compact toolbar row contains the view toggle and (in preview mode)
51
57
  * a URL bar with refresh button.
52
58
  */
53
- export function CodeSandbox(props: CodeSandboxProps) {
54
- const { className = "", height = "100vh", port = 3000 } = props;
59
+ export const CodeSandbox = forwardRef<CodeSandboxHandle, CodeSandboxProps>(
60
+ function CodeSandbox(props, ref) {
61
+ const { className = "", height = "100vh", port = 3000 } = props;
55
62
 
56
- const {
57
- state,
58
- selectedFile,
59
- openFiles,
60
- handleFileChange,
61
- handleSelectFile,
62
- handleCloseFile,
63
- restart,
64
- } = useRuntime(props);
63
+ const runtime = useRuntime(props);
64
+ const {
65
+ state,
66
+ selectedFile,
67
+ openFiles,
68
+ handleFileChange,
69
+ handleSelectFile,
70
+ handleCloseFile,
71
+ handleBrowserError,
72
+ restart,
73
+ updateFiles,
74
+ updateFile,
75
+ getFiles,
76
+ getChangedFiles,
77
+ getFileChanges,
78
+ getErrors,
79
+ getState,
80
+ } = runtime;
65
81
 
66
- const fileTree = useMemo(() => buildFileTree(state.files), [state.files]);
67
- const isBooting = state.status !== "ready" && state.status !== "error";
82
+ // Expose imperative handle to parent via ref
83
+ useImperativeHandle(
84
+ ref,
85
+ () => ({
86
+ updateFiles,
87
+ updateFile,
88
+ restart,
89
+ getFiles,
90
+ getChangedFiles,
91
+ getFileChanges,
92
+ getErrors,
93
+ getState,
94
+ }),
95
+ [
96
+ updateFiles,
97
+ updateFile,
98
+ restart,
99
+ getFiles,
100
+ getChangedFiles,
101
+ getFileChanges,
102
+ getErrors,
103
+ getState,
104
+ ],
105
+ );
68
106
 
69
- // Active view: code or preview
70
- const [activeView, setActiveView] = useState<WorkbenchView>("code");
107
+ const fileTree = useMemo(() => buildFileTree(state.files), [state.files]);
108
+ const isBooting = state.status !== "ready" && state.status !== "error";
71
109
 
72
- // Auto-switch to preview when server becomes ready
73
- const hasPreview = !!state.previewUrl;
74
- useEffect(() => {
75
- if (hasPreview) {
76
- setActiveView("preview");
77
- }
78
- }, [hasPreview]);
110
+ // Active view: code or preview
111
+ const [activeView, setActiveView] = useState<WorkbenchView>("code");
79
112
 
80
- // Switch to code view when a file is selected from the tree
81
- const onFileSelect = useCallback(
82
- (path: string) => {
83
- handleSelectFile(path);
84
- setActiveView("code");
85
- },
86
- [handleSelectFile],
87
- );
113
+ // Auto-switch to preview when server becomes ready
114
+ const hasPreview = !!state.previewUrl;
115
+ useEffect(() => {
116
+ if (hasPreview) {
117
+ setActiveView("preview");
118
+ }
119
+ }, [hasPreview]);
88
120
 
89
- return (
90
- <div
91
- className={`sb-root relative w-full bg-sb-bg text-sb-text overflow-hidden flex flex-col ${className}`}
92
- style={{ height }}
93
- >
94
- {/* Boot overlay */}
95
- {isBooting && <BootOverlay progress={state.progress} />}
121
+ // Switch to code view when a file is selected from the tree
122
+ const onFileSelect = useCallback(
123
+ (path: string) => {
124
+ handleSelectFile(path);
125
+ setActiveView("code");
126
+ },
127
+ [handleSelectFile],
128
+ );
96
129
 
97
- {/* Toolbar — compact row with view toggle + URL bar */}
98
- <Toolbar
99
- activeView={activeView}
100
- onViewChange={setActiveView}
101
- previewUrl={state.previewUrl}
102
- port={port}
103
- onRefresh={restart}
104
- />
130
+ return (
131
+ <div
132
+ className={`sb-root relative w-full bg-sb-bg text-sb-text overflow-hidden flex flex-col ${className}`}
133
+ style={{ height }}
134
+ >
135
+ {/* Boot overlay */}
136
+ {isBooting && <BootOverlay progress={state.progress} />}
105
137
 
106
- {/* Viewsfull width, toggled */}
107
- <div className="flex-1 relative overflow-hidden min-h-0">
108
- {/* Code view */}
109
- <motion.div
110
- className="absolute inset-0"
111
- initial={false}
112
- animate={{
113
- opacity: activeView === "code" ? 1 : 0,
114
- pointerEvents: activeView === "code" ? "auto" : "none",
115
- }}
116
- transition={{ duration: 0.15 }}
117
- >
118
- <CodeView
119
- fileTree={fileTree}
120
- files={state.files}
121
- selectedFile={selectedFile}
122
- openFiles={openFiles}
123
- terminalOutput={state.terminalOutput}
124
- onSelectFile={onFileSelect}
125
- onCloseFile={handleCloseFile}
126
- onFileChange={handleFileChange}
127
- />
128
- </motion.div>
138
+ {/* Toolbarcompact row with view toggle + URL bar */}
139
+ <Toolbar
140
+ activeView={activeView}
141
+ onViewChange={setActiveView}
142
+ previewUrl={state.previewUrl}
143
+ port={port}
144
+ onRefresh={restart}
145
+ />
129
146
 
130
- {/* Preview view */}
131
- <motion.div
132
- className="absolute inset-0"
133
- initial={false}
134
- animate={{
135
- opacity: activeView === "preview" ? 1 : 0,
136
- pointerEvents: activeView === "preview" ? "auto" : "none",
137
- }}
138
- transition={{ duration: 0.15 }}
139
- >
140
- <Preview url={state.previewUrl} />
141
- </motion.div>
147
+ {/* Views full width, toggled */}
148
+ <div className="flex-1 relative overflow-hidden min-h-0">
149
+ {/* Code view */}
150
+ <motion.div
151
+ className="absolute inset-0"
152
+ initial={false}
153
+ animate={{
154
+ opacity: activeView === "code" ? 1 : 0,
155
+ pointerEvents: activeView === "code" ? "auto" : "none",
156
+ }}
157
+ transition={{ duration: 0.15 }}
158
+ >
159
+ <CodeView
160
+ fileTree={fileTree}
161
+ files={state.files}
162
+ originalFiles={state.originalFiles}
163
+ fileChanges={state.fileChanges}
164
+ selectedFile={selectedFile}
165
+ openFiles={openFiles}
166
+ terminalOutput={state.terminalOutput}
167
+ onSelectFile={onFileSelect}
168
+ onCloseFile={handleCloseFile}
169
+ onFileChange={handleFileChange}
170
+ />
171
+ </motion.div>
172
+
173
+ {/* Preview view */}
174
+ <motion.div
175
+ className="absolute inset-0"
176
+ initial={false}
177
+ animate={{
178
+ opacity: activeView === "preview" ? 1 : 0,
179
+ pointerEvents: activeView === "preview" ? "auto" : "none",
180
+ }}
181
+ transition={{ duration: 0.15 }}
182
+ >
183
+ <Preview
184
+ url={state.previewUrl}
185
+ onBrowserError={handleBrowserError}
186
+ />
187
+ </motion.div>
188
+ </div>
142
189
  </div>
143
- </div>
144
- );
145
- }
190
+ );
191
+ },
192
+ );
193
+
194
+ CodeSandbox.displayName = "CodeSandbox";
146
195
 
147
196
  // ---------------------------------------------------------------------------
148
197
  // Toolbar
@@ -241,6 +290,8 @@ function RefreshIcon() {
241
290
  interface CodeViewProps {
242
291
  fileTree: ReturnType<typeof buildFileTree>;
243
292
  files: Record<string, string>;
293
+ originalFiles: Record<string, string>;
294
+ fileChanges: Record<string, FileChangeStatus>;
244
295
  selectedFile: string | null;
245
296
  openFiles: string[];
246
297
  terminalOutput: string[];
@@ -257,6 +308,8 @@ interface CodeViewProps {
257
308
  function CodeView({
258
309
  fileTree,
259
310
  files,
311
+ originalFiles,
312
+ fileChanges,
260
313
  selectedFile,
261
314
  openFiles,
262
315
  terminalOutput,
@@ -273,6 +326,7 @@ function CodeView({
273
326
  files={fileTree}
274
327
  selectedFile={selectedFile}
275
328
  onSelectFile={onSelectFile}
329
+ fileChanges={fileChanges}
276
330
  />
277
331
  </Allotment.Pane>
278
332
 
@@ -282,6 +336,8 @@ function CodeView({
282
336
  <Allotment.Pane preferredSize="70%">
283
337
  <CodeEditor
284
338
  files={files}
339
+ originalFiles={originalFiles}
340
+ fileChanges={fileChanges}
285
341
  activeFile={selectedFile}
286
342
  openFiles={openFiles}
287
343
  onSelectFile={onSelectFile}