@illuma-ai/code-sandbox 1.4.0 → 1.5.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.
@@ -1,262 +0,0 @@
1
- /**
2
- * Preview — Full-width iframe that renders the Nodepod virtual server.
3
- *
4
- * Loads the URL from Nodepod's Service Worker proxy (/__preview__/PORT/).
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.
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
- }
112
-
113
- /** Counter for unique error IDs within this component instance */
114
- let browserErrorCounter = 0;
115
-
116
- /**
117
- * Preview component — renders the dev server output in a full-width iframe.
118
- *
119
- * The URL points to /__preview__/PORT/ which is intercepted by the
120
- * Nodepod Service Worker and proxied to the Express server running
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.
125
- */
126
- export function Preview({
127
- url,
128
- className = "",
129
- onBrowserError,
130
- reloadKey = 0,
131
- }: PreviewProps) {
132
- const iframeRef = useRef<HTMLIFrameElement>(null);
133
- const onBrowserErrorRef = useRef(onBrowserError);
134
- onBrowserErrorRef.current = onBrowserError;
135
-
136
- /**
137
- * Track the previous reloadKey so we only reload on actual changes,
138
- * not on initial mount or unrelated re-renders.
139
- */
140
- const prevReloadKeyRef = useRef(reloadKey);
141
-
142
- /**
143
- * Inject the error-capture script into the iframe's contentWindow.
144
- *
145
- * Called on iframe `load` event. We use direct contentWindow access
146
- * (same-origin) rather than srcdoc to avoid breaking the SW proxy.
147
- */
148
- const injectErrorCapture = useCallback(() => {
149
- const iframe = iframeRef.current;
150
- if (!iframe) return;
151
-
152
- try {
153
- const iframeDoc =
154
- iframe.contentDocument || iframe.contentWindow?.document;
155
- if (!iframeDoc) return;
156
-
157
- const script = iframeDoc.createElement("script");
158
- script.textContent = buildErrorCaptureScript();
159
- // Append to <head> (or <body> if <head> doesn't exist)
160
- const target =
161
- iframeDoc.head || iframeDoc.body || iframeDoc.documentElement;
162
- if (target) {
163
- target.appendChild(script);
164
- }
165
- } catch (err) {
166
- // Cross-origin restriction — shouldn't happen with SW proxy but
167
- // degrade gracefully if it does.
168
- console.warn(
169
- "[CodeSandbox:Preview] Could not inject error capture:",
170
- err,
171
- );
172
- }
173
- }, []);
174
-
175
- // Listen for postMessage from the iframe
176
- useEffect(() => {
177
- const handler = (event: MessageEvent) => {
178
- if (!event.data || event.data.type !== "sandbox-error") return;
179
-
180
- const data = event.data as IframeErrorMessage;
181
- const cb = onBrowserErrorRef.current;
182
- if (!cb) return;
183
-
184
- // Parse filename to extract relative file path.
185
- // The iframe's script src URLs look like:
186
- // https://host/__preview__/3000/js/app.js
187
- // We want just "public/js/app.js" or "js/app.js".
188
- let filePath: string | undefined;
189
- if (data.filename) {
190
- const match = data.filename.match(
191
- /\/__(?:preview|virtual)__\/\d+(\/.*)/,
192
- );
193
- filePath = match ? match[1].replace(/^\//, "") : undefined;
194
- }
195
-
196
- const error: SandboxError = {
197
- id: `browser_${++browserErrorCounter}_${Date.now()}`,
198
- category: data.category,
199
- message: data.message,
200
- stack: data.stack,
201
- filePath,
202
- line: data.lineno ?? undefined,
203
- column: data.colno ?? undefined,
204
- timestamp: new Date().toISOString(),
205
- };
206
-
207
- cb(error);
208
- };
209
-
210
- window.addEventListener("message", handler);
211
- return () => window.removeEventListener("message", handler);
212
- }, []);
213
-
214
- // Reload iframe when URL changes
215
- useEffect(() => {
216
- if (iframeRef.current && url) {
217
- iframeRef.current.src = url;
218
- }
219
- }, [url]);
220
-
221
- // Hot reload: soft-refresh the iframe when reloadKey changes.
222
- // This avoids a full server restart for static asset changes (CSS, HTML).
223
- useEffect(() => {
224
- if (reloadKey !== prevReloadKeyRef.current) {
225
- prevReloadKeyRef.current = reloadKey;
226
- const iframe = iframeRef.current;
227
- if (iframe && url) {
228
- try {
229
- // Try same-origin reload first (preserves scroll position)
230
- iframe.contentWindow?.location.reload();
231
- } catch {
232
- // Cross-origin fallback — re-assign src
233
- iframe.src = url;
234
- }
235
- }
236
- }
237
- }, [reloadKey, url]);
238
-
239
- return (
240
- <div className={`h-full w-full bg-white ${className}`}>
241
- {url ? (
242
- <iframe
243
- ref={iframeRef}
244
- src={url}
245
- className="w-full h-full border-none"
246
- title="Preview"
247
- sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
248
- onLoad={injectErrorCapture}
249
- />
250
- ) : (
251
- <div className="flex items-center justify-center h-full bg-sb-bg">
252
- <div className="text-center px-4">
253
- <div className="w-10 h-10 mx-auto mb-3 border-2 border-sb-accent border-t-transparent rounded-full animate-spin" />
254
- <p className="text-sb-text-muted text-sm">
255
- Waiting for dev server to start...
256
- </p>
257
- </div>
258
- </div>
259
- )}
260
- </div>
261
- );
262
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * Terminal — Clean monochrome terminal output panel with minimize toggle.
3
- *
4
- * Uses a simple div-based approach (not xterm.js) to keep the bundle lean.
5
- * Auto-scrolls to bottom as new output arrives.
6
- * Minimize/expand state is controlled by the parent via props.
7
- */
8
-
9
- import React, { useEffect, useRef } from "react";
10
- import type { TerminalProps } from "../types";
11
-
12
- /**
13
- * Terminal component — renders process output in a clean monochrome panel.
14
- *
15
- * Features:
16
- * - Monospace font with proper line spacing
17
- * - Auto-scroll to bottom on new output
18
- * - Minimize/expand toggle via chevron in header (parent-controlled)
19
- * - Modern thin scrollbar matching the sandbox theme
20
- */
21
- export function Terminal({
22
- output,
23
- className = "",
24
- minimized = false,
25
- onToggleMinimize,
26
- }: TerminalProps) {
27
- const containerRef = useRef<HTMLDivElement>(null);
28
-
29
- // Auto-scroll to bottom when new output arrives
30
- useEffect(() => {
31
- const el = containerRef.current;
32
- if (el && !minimized) {
33
- el.scrollTop = el.scrollHeight;
34
- }
35
- }, [output.length, minimized]);
36
-
37
- return (
38
- <div
39
- className={`flex flex-col ${minimized ? "" : "h-full"} ${className}`}
40
- style={{ background: "var(--sb-terminal)" }}
41
- >
42
- {/* Header */}
43
- <div
44
- className="flex items-center gap-2 px-3 py-1 border-t border-sb-border shrink-0 select-none cursor-pointer"
45
- style={{ background: "var(--sb-terminal-header)" }}
46
- onClick={onToggleMinimize}
47
- >
48
- {/* Chevron toggle */}
49
- <svg
50
- width="14"
51
- height="14"
52
- viewBox="0 0 16 16"
53
- fill="none"
54
- className="shrink-0 transition-transform duration-150"
55
- style={{
56
- transform: minimized ? "rotate(-90deg)" : "rotate(0deg)",
57
- color: "var(--sb-text-muted)",
58
- }}
59
- >
60
- <path
61
- d="M4 6l4 4 4-4"
62
- stroke="currentColor"
63
- strokeWidth="1.5"
64
- strokeLinecap="round"
65
- strokeLinejoin="round"
66
- />
67
- </svg>
68
- <span
69
- className="text-[11px] font-medium tracking-wider"
70
- style={{ color: "var(--sb-text-muted)" }}
71
- >
72
- TERMINAL
73
- </span>
74
- {/* Line count badge */}
75
- {output.length > 0 && (
76
- <span
77
- className="text-[10px] ml-auto tabular-nums"
78
- style={{ color: "var(--sb-text-muted)", opacity: 0.6 }}
79
- >
80
- {output.length} lines
81
- </span>
82
- )}
83
- </div>
84
-
85
- {/* Output area — hidden when minimized */}
86
- {!minimized && (
87
- <div
88
- ref={containerRef}
89
- className="flex-1 overflow-auto px-3.5 py-2 sb-terminal-output overscroll-contain min-h-0"
90
- >
91
- {output.length === 0 ? (
92
- <div style={{ color: "var(--sb-text-muted)" }}>
93
- <span>$ </span>
94
- <span className="animate-pulse">_</span>
95
- </div>
96
- ) : (
97
- output.map((line, i) => (
98
- <div
99
- key={i}
100
- className="whitespace-pre-wrap"
101
- style={{ color: "var(--sb-text)" }}
102
- >
103
- {line}
104
- </div>
105
- ))
106
- )}
107
- </div>
108
- )}
109
- </div>
110
- );
111
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * ViewSlider — Pill-shaped toggle to switch between Code and Preview views.
3
- *
4
- * Inspired by bolt.diy's Slider component. Uses Framer Motion's layoutId
5
- * for a smooth animated pill that slides between options.
6
- *
7
- * @see bolt.diy: app/components/ui/Slider.tsx
8
- */
9
-
10
- import React, { memo } from "react";
11
- import { motion } from "framer-motion";
12
-
13
- /** The two views available in the workbench */
14
- export type WorkbenchView = "code" | "preview";
15
-
16
- interface ViewSliderProps {
17
- selected: WorkbenchView;
18
- onSelect: (view: WorkbenchView) => void;
19
- }
20
-
21
- /** Cubic bezier easing — typed as a 4-tuple for Framer Motion */
22
- const CUBIC_EASING: [number, number, number, number] = [0.4, 0, 0.2, 1];
23
-
24
- /**
25
- * Individual button within the slider.
26
- * When selected, renders an animated pill background via layoutId.
27
- */
28
- const SliderButton = memo(function SliderButton({
29
- selected,
30
- children,
31
- onClick,
32
- }: {
33
- selected: boolean;
34
- children: React.ReactNode;
35
- onClick: () => void;
36
- }) {
37
- return (
38
- <button
39
- onClick={onClick}
40
- className={`relative px-3 py-1 rounded-full text-xs font-medium transition-colors ${
41
- selected ? "text-white" : "text-sb-text-muted hover:text-sb-text"
42
- }`}
43
- >
44
- <span className="relative z-10">{children}</span>
45
- {selected && (
46
- <motion.span
47
- layoutId="view-slider-pill"
48
- transition={{
49
- duration: 0.2,
50
- ease: CUBIC_EASING,
51
- }}
52
- className="absolute inset-0 z-0 bg-sb-accent rounded-full"
53
- />
54
- )}
55
- </button>
56
- );
57
- });
58
-
59
- /**
60
- * ViewSlider — renders a pill-shaped toggle with animated selection indicator.
61
- *
62
- * Usage:
63
- * ```tsx
64
- * <ViewSlider selected={view} onSelect={setView} />
65
- * ```
66
- */
67
- export const ViewSlider = memo(function ViewSlider({
68
- selected,
69
- onSelect,
70
- }: ViewSliderProps) {
71
- return (
72
- <div className="flex items-center gap-0.5 bg-sb-bg rounded-full p-0.5 border border-sb-border">
73
- <SliderButton
74
- selected={selected === "code"}
75
- onClick={() => onSelect("code")}
76
- >
77
- Code
78
- </SliderButton>
79
- <SliderButton
80
- selected={selected === "preview"}
81
- onClick={() => onSelect("preview")}
82
- >
83
- Preview
84
- </SliderButton>
85
- </div>
86
- );
87
- });