@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,13 +7,15 @@
7
7
  * It is framework-agnostic (no React) — consumed by the useRuntime hook.
8
8
  */
9
9
  import { Nodepod } from "@illuma-ai/nodepod";
10
- import type { BootProgress, BootStage, FileMap, RuntimeConfig } from "../types";
10
+ import type { BootProgress, BootStage, FileMap, RuntimeConfig, SandboxError } from "../types";
11
11
  /** Callback type for progress updates */
12
12
  type ProgressCallback = (progress: BootProgress) => void;
13
13
  /** Callback type for terminal output lines */
14
14
  type OutputCallback = (line: string) => void;
15
15
  /** Callback type for server ready */
16
16
  type ServerReadyCallback = (port: number, url: string) => void;
17
+ /** Callback type for structured errors */
18
+ type ErrorCallback = (error: SandboxError) => void;
17
19
  /**
18
20
  * Manages the Nodepod runtime lifecycle.
19
21
  *
@@ -35,9 +37,12 @@ export declare class NodepodRuntime {
35
37
  private status;
36
38
  private error;
37
39
  private previewUrl;
40
+ private errors;
41
+ private errorIdCounter;
38
42
  private onProgress;
39
43
  private onOutput;
40
44
  private onServerReady;
45
+ private onSandboxError;
41
46
  constructor(config: RuntimeConfig);
42
47
  /** Register a progress callback */
43
48
  setProgressCallback(cb: ProgressCallback): void;
@@ -45,6 +50,8 @@ export declare class NodepodRuntime {
45
50
  setOutputCallback(cb: OutputCallback): void;
46
51
  /** Register a server ready callback */
47
52
  setServerReadyCallback(cb: ServerReadyCallback): void;
53
+ /** Register a structured error callback */
54
+ setErrorCallback(cb: ErrorCallback): void;
48
55
  /** Get the current preview URL (null if server not ready) */
49
56
  getPreviewUrl(): string | null;
50
57
  /** Get current status */
@@ -59,6 +66,17 @@ export declare class NodepodRuntime {
59
66
  getChangedFiles(): FileMap;
60
67
  /** Get the error message if status is 'error' */
61
68
  getError(): string | null;
69
+ /** Get all structured errors collected during this session */
70
+ getErrors(): SandboxError[];
71
+ /**
72
+ * Report an external error (e.g., from the preview iframe).
73
+ *
74
+ * This is the public entry point for errors that originate outside
75
+ * the runtime — browser console errors, unhandled rejections, etc.
76
+ * The runtime enriches them with source context from the virtual FS
77
+ * and emits them via the onSandboxError callback.
78
+ */
79
+ reportError(error: SandboxError): Promise<void>;
62
80
  /**
63
81
  * Boot the runtime: initialize Nodepod → write files → install → start server.
64
82
  *
@@ -115,5 +133,44 @@ export declare class NodepodRuntime {
115
133
  private emitProgress;
116
134
  /** Append a line to terminal output */
117
135
  private appendOutput;
136
+ /** Generate a unique error ID */
137
+ private nextErrorId;
138
+ /**
139
+ * Create and record a structured error from a process or boot event.
140
+ *
141
+ * Parses the error message to extract file path and line number when
142
+ * possible, then enriches with surrounding source context.
143
+ */
144
+ private emitError;
145
+ /**
146
+ * Filter out common non-error stderr output.
147
+ *
148
+ * Many Node.js tools write informational messages to stderr
149
+ * (deprecation warnings, experimental feature notices, etc.).
150
+ * We don't want to flood the agent with these.
151
+ */
152
+ private isStderrNoise;
153
+ /**
154
+ * Parse file path and line/column from common error message formats.
155
+ *
156
+ * Handles:
157
+ * - Node.js stack frames: "at func (/app/file.js:12:5)"
158
+ * - Direct references: "/app/file.js:12:5"
159
+ * - SyntaxError: "/app/file.js: Unexpected token (12:5)"
160
+ */
161
+ private parseErrorLocation;
162
+ /**
163
+ * Get surrounding source code lines from the virtual filesystem.
164
+ *
165
+ * Returns ~10 lines centered on the target line, formatted as
166
+ * "lineNum: content" for each line. This gives the AI agent enough
167
+ * context to construct a surgical fix.
168
+ */
169
+ private getSourceContext;
170
+ /**
171
+ * Format source code lines centered on a target line.
172
+ * Output: "lineNum: content\n" for each line in the window.
173
+ */
174
+ private formatSourceContext;
118
175
  }
119
176
  export {};
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @illuma-ai/code-sandbox — Type definitions
3
3
  *
4
4
  * Core types for the browser-native code sandbox.
5
- * Covers file maps, runtime states, git operations, and component props.
5
+ * Covers file maps, runtime states, imperative handle, and component props.
6
6
  */
7
7
  /** A flat map of file paths to their string contents */
8
8
  export type FileMap = Record<string, string>;
@@ -13,6 +13,62 @@ export interface FileNode {
13
13
  type: "file" | "directory";
14
14
  children?: FileNode[];
15
15
  }
16
+ /**
17
+ * Change status for a file relative to the baseline (original) snapshot.
18
+ *
19
+ * - `new`: File exists in current but not in original
20
+ * - `modified`: File exists in both but content differs
21
+ * - `deleted`: File exists in original but not in current
22
+ * - `unchanged`: File content is identical
23
+ */
24
+ export type FileChangeStatus = "new" | "modified" | "deleted" | "unchanged";
25
+ /**
26
+ * Categories of errors that can occur in the sandbox.
27
+ *
28
+ * - `process-stderr`: Output written to stderr by the Node.js process
29
+ * - `process-exit`: Process exited with a non-zero code
30
+ * - `runtime-exception`: Uncaught exception in the Node.js runtime (Nodepod)
31
+ * - `browser-error`: JavaScript error in the preview iframe (window.onerror)
32
+ * - `browser-unhandled-rejection`: Unhandled promise rejection in the iframe
33
+ * - `browser-console-error`: console.error() calls in the iframe
34
+ * - `compilation`: Syntax/import errors detected at module load time
35
+ * - `network`: Failed HTTP requests from the preview (fetch/XHR errors)
36
+ * - `boot`: Error during Nodepod boot, file writing, or dependency install
37
+ */
38
+ export type SandboxErrorCategory = "process-stderr" | "process-exit" | "runtime-exception" | "browser-error" | "browser-unhandled-rejection" | "browser-console-error" | "compilation" | "network" | "boot";
39
+ /**
40
+ * A structured error emitted by the sandbox.
41
+ *
42
+ * Designed for AI agent consumption — includes enough context for the
43
+ * agent to construct a targeted fix prompt (file path, line number,
44
+ * surrounding code, stack trace).
45
+ *
46
+ * @see Ranger's auto-fix: client/src/components/Artifacts/Artifacts.tsx
47
+ */
48
+ export interface SandboxError {
49
+ /** Unique ID for deduplication */
50
+ id: string;
51
+ /** Error category — determines how the agent should interpret it */
52
+ category: SandboxErrorCategory;
53
+ /** Human-readable error message (the "what") */
54
+ message: string;
55
+ /** Full stack trace if available */
56
+ stack?: string;
57
+ /** File path where the error occurred (relative to workdir) */
58
+ filePath?: string;
59
+ /** Line number in the file (1-indexed) */
60
+ line?: number;
61
+ /** Column number in the file (1-indexed) */
62
+ column?: number;
63
+ /** ISO 8601 timestamp */
64
+ timestamp: string;
65
+ /**
66
+ * Surrounding source code lines for context.
67
+ * Format: "lineNum: content" per line (e.g., "42: const x = foo();")
68
+ * Typically ~10 lines centered on the error line.
69
+ */
70
+ sourceContext?: string;
71
+ }
16
72
  /** Boot stages shown during the loading animation */
17
73
  export type BootStage = "initializing" | "writing-files" | "installing" | "starting" | "ready" | "error";
18
74
  export interface BootProgress {
@@ -44,8 +100,28 @@ export interface RuntimeState {
44
100
  terminalOutput: string[];
45
101
  /** Current files in the virtual FS (may differ from original after edits) */
46
102
  files: FileMap;
103
+ /**
104
+ * Baseline file snapshot — the files as they were before the latest
105
+ * update (via updateFiles). Used by the diff viewer to show what changed.
106
+ * Empty on first load (everything is "new"), populated after the first
107
+ * file update.
108
+ */
109
+ originalFiles: FileMap;
110
+ /**
111
+ * Per-file change status relative to originalFiles.
112
+ *
113
+ * Only includes files that have a non-"unchanged" status.
114
+ * Missing keys should be treated as "unchanged".
115
+ */
116
+ fileChanges: Record<string, FileChangeStatus>;
47
117
  /** Error message if status is 'error' */
48
118
  error: string | null;
119
+ /**
120
+ * All structured errors collected during this session.
121
+ * New errors are appended — the array grows monotonically.
122
+ * Use `errors[errors.length - 1]` to get the latest.
123
+ */
124
+ errors: SandboxError[];
49
125
  }
50
126
  export interface GitHubRepo {
51
127
  owner: string;
@@ -76,13 +152,101 @@ export interface GitPRResult {
76
152
  url: string;
77
153
  branch: string;
78
154
  }
155
+ /**
156
+ * Imperative handle exposed by CodeSandbox via React.forwardRef.
157
+ *
158
+ * This is the primary API for host applications (like Ranger) to
159
+ * control the sandbox programmatically. The sandbox is a "dumb renderer"
160
+ * — the host pushes files in and reads state out.
161
+ *
162
+ * @example
163
+ * ```tsx
164
+ * const ref = useRef<CodeSandboxHandle>(null);
165
+ *
166
+ * // Push files from any source (GitHub, DB, S3, agent output)
167
+ * ref.current?.updateFiles(fileMap);
168
+ *
169
+ * // Push a single file (e.g., agent modified one file)
170
+ * ref.current?.updateFile('server.js', newContent);
171
+ *
172
+ * // Read current state
173
+ * const files = ref.current?.getFiles();
174
+ * const changes = ref.current?.getChangedFiles();
175
+ * const errors = ref.current?.getErrors();
176
+ * ```
177
+ */
178
+ export interface CodeSandboxHandle {
179
+ /**
180
+ * Replace the entire file set. Diffs against current files, writes only
181
+ * changed files to Nodepod FS, and restarts the server if anything changed.
182
+ *
183
+ * The previous file set becomes `originalFiles` for diff tracking.
184
+ *
185
+ * @param files - Complete new file set
186
+ * @param options - Optional: restartServer (default true)
187
+ */
188
+ updateFiles: (files: FileMap, options?: {
189
+ restartServer?: boolean;
190
+ }) => Promise<void>;
191
+ /**
192
+ * Update a single file. Writes to Nodepod FS and updates state.
193
+ * Does NOT restart the server — call `restart()` manually if needed,
194
+ * or use `updateFiles()` for bulk updates with auto-restart.
195
+ *
196
+ * @param path - File path (e.g., "server.js", "public/index.html")
197
+ * @param content - New file content
198
+ */
199
+ updateFile: (path: string, content: string) => Promise<void>;
200
+ /**
201
+ * Force restart the server process. Kills the current process
202
+ * and re-runs the entry command.
203
+ */
204
+ restart: () => Promise<void>;
205
+ /**
206
+ * Get the current file map (all files including edits).
207
+ */
208
+ getFiles: () => FileMap;
209
+ /**
210
+ * Get only files that have been modified relative to originalFiles.
211
+ * Returns a FileMap containing only the changed files.
212
+ */
213
+ getChangedFiles: () => FileMap;
214
+ /**
215
+ * Get the per-file change status map (new/modified/deleted).
216
+ * Missing keys should be treated as "unchanged".
217
+ */
218
+ getFileChanges: () => Record<string, FileChangeStatus>;
219
+ /**
220
+ * Get all structured errors collected during this session.
221
+ */
222
+ getErrors: () => SandboxError[];
223
+ /**
224
+ * Get the current runtime state snapshot.
225
+ */
226
+ getState: () => RuntimeState;
227
+ }
228
+ /**
229
+ * Props for the CodeSandbox component.
230
+ *
231
+ * The sandbox is a pure renderer — it receives files via props and
232
+ * exposes an imperative handle for programmatic updates. It does NOT
233
+ * interact with any storage backend (GitHub, S3, DB) directly.
234
+ *
235
+ * File sources (for initial load, pick ONE):
236
+ * - `files` — pass a FileMap directly
237
+ * - `template` — use a built-in template name (e.g., "fullstack-starter")
238
+ *
239
+ * For live updates after mount, use the imperative handle:
240
+ * ```tsx
241
+ * const ref = useRef<CodeSandboxHandle>(null);
242
+ * <CodeSandbox ref={ref} files={initialFiles} />
243
+ * // Later:
244
+ * ref.current.updateFiles(newFiles);
245
+ * ```
246
+ */
79
247
  export interface CodeSandboxProps {
80
- /** Pass files directly */
248
+ /** Pass files directly (from any source — GitHub, DB, S3, agent, etc.) */
81
249
  files?: FileMap;
82
- /** OR load from GitHub */
83
- github?: GitHubRepo;
84
- /** GitHub personal access token (for private repos and write-back) */
85
- gitToken?: string;
86
250
  /** OR use a built-in template name */
87
251
  template?: string;
88
252
  /** Command to start the dev server (default inferred from package.json or template) */
@@ -91,14 +255,30 @@ export interface CodeSandboxProps {
91
255
  port?: number;
92
256
  /** Environment variables */
93
257
  env?: Record<string, string>;
94
- /** Fires when a file is modified in the editor */
258
+ /** Fires when a file is modified in the editor by the user */
95
259
  onFileChange?: (path: string, content: string) => void;
96
- /** Fires when the dev server is ready */
260
+ /** Fires when the dev server is ready (initial boot or after restart) */
97
261
  onServerReady?: (port: number, url: string) => void;
98
262
  /** Fires on boot progress changes */
99
263
  onProgress?: (progress: BootProgress) => void;
100
- /** Fires on errors */
264
+ /** Fires on errors (legacy — simple string message) */
101
265
  onError?: (error: string) => void;
266
+ /**
267
+ * Fires when a structured error is detected.
268
+ *
269
+ * This is the primary error channel for AI agent integration.
270
+ * Errors include file path, line number, source context, and category
271
+ * so the agent can construct a targeted fix prompt.
272
+ *
273
+ * Max 2 auto-fix attempts per error is recommended (see Ranger pattern).
274
+ */
275
+ onSandboxError?: (error: SandboxError) => void;
276
+ /**
277
+ * Fires after updateFiles() completes (files written + server restarted).
278
+ * Useful for the host to know when the sandbox has finished processing
279
+ * a file push.
280
+ */
281
+ onFilesUpdated?: (fileChanges: Record<string, FileChangeStatus>) => void;
102
282
  /** CSS class name for the root element */
103
283
  className?: string;
104
284
  /** Height of the sandbox (default: '100vh') */
@@ -108,6 +288,8 @@ export interface FileTreeProps {
108
288
  files: FileNode[];
109
289
  selectedFile: string | null;
110
290
  onSelectFile: (path: string) => void;
291
+ /** Per-file change status for visual indicators (colored dots) */
292
+ fileChanges?: Record<string, FileChangeStatus>;
111
293
  onCreateFile?: (path: string) => void;
112
294
  onCreateFolder?: (path: string) => void;
113
295
  onDeleteFile?: (path: string) => void;
@@ -115,6 +297,10 @@ export interface FileTreeProps {
115
297
  }
116
298
  export interface CodeEditorProps {
117
299
  files: FileMap;
300
+ /** Baseline files for diff comparison (empty = no diff available) */
301
+ originalFiles: FileMap;
302
+ /** Per-file change status for tab indicators */
303
+ fileChanges: Record<string, FileChangeStatus>;
118
304
  activeFile: string | null;
119
305
  openFiles: string[];
120
306
  onSelectFile: (path: string) => void;
@@ -130,6 +316,8 @@ export interface PreviewProps {
130
316
  url: string | null;
131
317
  className?: string;
132
318
  onRefresh?: () => void;
319
+ /** Called when a JavaScript error occurs inside the preview iframe */
320
+ onBrowserError?: (error: SandboxError) => void;
133
321
  }
134
322
  export interface BootOverlayProps {
135
323
  progress: BootProgress;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@illuma-ai/code-sandbox",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Illuma AI (https://github.com/illuma-ai)",
@@ -1,27 +1,37 @@
1
1
  /**
2
- * CodeEditor — Monaco editor with file tabs.
2
+ * CodeEditor — Monaco editor with file tabs, diff mode, and change indicators.
3
3
  *
4
- * Uses @monaco-editor/react for the editor and renders a tab bar
5
- * of open files. Supports dark theme that matches the sandbox.
4
+ * Uses @monaco-editor/react for both the regular editor and diff editor.
5
+ * Renders a tab bar of open files with change status indicators:
6
+ * - Green dot: new file
7
+ * - Orange dot: modified file
8
+ * - Red dot: deleted file (shown as read-only)
9
+ *
10
+ * When diff mode is active, displays Monaco's side-by-side DiffEditor
11
+ * comparing the original file content (left) with the current content (right).
12
+ *
13
+ * @see @monaco-editor/react DiffEditor: https://github.com/suren-atoyan/monaco-react
6
14
  */
7
15
 
8
- import React, { useCallback, useMemo } from "react";
9
- import type { CodeEditorProps } from "../types";
16
+ import React, { useCallback, useMemo, useState } from "react";
17
+ import type { CodeEditorProps, FileChangeStatus } from "../types";
10
18
 
11
19
  // Lazy import Monaco to keep the bundle splittable
12
20
  let MonacoEditor: React.ComponentType<any> | null = null;
21
+ let MonacoDiffEditor: React.ComponentType<any> | null = null;
13
22
  let monacoLoadPromise: Promise<void> | null = null;
14
23
 
15
24
  /**
16
- * Load Monaco editor dynamically.
17
- * Returns the Editor component from @monaco-editor/react.
25
+ * Load Monaco editor (regular + diff) dynamically.
26
+ * Returns both Editor and DiffEditor from @monaco-editor/react.
18
27
  */
19
28
  function loadMonaco(): Promise<void> {
20
- if (MonacoEditor) return Promise.resolve();
29
+ if (MonacoEditor && MonacoDiffEditor) return Promise.resolve();
21
30
  if (monacoLoadPromise) return monacoLoadPromise;
22
31
 
23
32
  monacoLoadPromise = import("@monaco-editor/react").then((mod) => {
24
33
  MonacoEditor = mod.default || mod.Editor;
34
+ MonacoDiffEditor = mod.DiffEditor;
25
35
  });
26
36
 
27
37
  return monacoLoadPromise;
@@ -59,11 +69,34 @@ function getLanguage(path: string): string {
59
69
  return langMap[ext] || "plaintext";
60
70
  }
61
71
 
72
+ /** Color for each file change status indicator dot */
73
+ const STATUS_COLORS: Record<FileChangeStatus, string> = {
74
+ new: "#4ade80", // green-400
75
+ modified: "#fb923c", // orange-400
76
+ deleted: "#f87171", // red-400
77
+ unchanged: "transparent",
78
+ };
79
+
80
+ /** Tooltip for each file change status */
81
+ const STATUS_LABELS: Record<FileChangeStatus, string> = {
82
+ new: "New file",
83
+ modified: "Modified",
84
+ deleted: "Deleted",
85
+ unchanged: "",
86
+ };
87
+
62
88
  /**
63
- * CodeEditor component — Monaco editor with file tabs.
89
+ * CodeEditor component — Monaco editor with file tabs, diff view, and
90
+ * change status indicators.
91
+ *
92
+ * When `originalFiles` has content for the active file (meaning a prior
93
+ * version exists), a "Diff" toggle button appears in the tab bar. Clicking
94
+ * it switches to Monaco's DiffEditor showing the old (left) vs new (right).
64
95
  */
65
96
  export function CodeEditor({
66
97
  files,
98
+ originalFiles,
99
+ fileChanges,
67
100
  activeFile,
68
101
  openFiles,
69
102
  onSelectFile,
@@ -72,6 +105,7 @@ export function CodeEditor({
72
105
  readOnly = false,
73
106
  }: CodeEditorProps) {
74
107
  const [loaded, setLoaded] = React.useState(!!MonacoEditor);
108
+ const [diffMode, setDiffMode] = useState(false);
75
109
 
76
110
  // Load Monaco on mount
77
111
  React.useEffect(() => {
@@ -81,8 +115,29 @@ export function CodeEditor({
81
115
  }, []);
82
116
 
83
117
  const activeContent = activeFile ? files[activeFile] || "" : "";
118
+ const activeOriginalContent = activeFile
119
+ ? (originalFiles[activeFile] ?? "")
120
+ : "";
84
121
  const language = activeFile ? getLanguage(activeFile) : "plaintext";
85
122
 
123
+ /**
124
+ * Whether the active file has a diff available.
125
+ * A diff is available when:
126
+ * - The file is marked as "modified" (content differs from original), OR
127
+ * - The file is marked as "new" (no original — diff shows empty left side)
128
+ */
129
+ const hasDiff = activeFile
130
+ ? fileChanges[activeFile] === "modified" ||
131
+ fileChanges[activeFile] === "new"
132
+ : false;
133
+
134
+ // Auto-disable diff mode when switching to a file with no diff
135
+ React.useEffect(() => {
136
+ if (!hasDiff && diffMode) {
137
+ setDiffMode(false);
138
+ }
139
+ }, [hasDiff, diffMode, activeFile]);
140
+
86
141
  const handleEditorChange = useCallback(
87
142
  (value: string | undefined) => {
88
143
  if (activeFile && value !== undefined) {
@@ -92,6 +147,23 @@ export function CodeEditor({
92
147
  [activeFile, onFileChange],
93
148
  );
94
149
 
150
+ /**
151
+ * Handler for Monaco DiffEditor's modified content changes.
152
+ * The DiffEditor fires `onMount` with the editor instance — we
153
+ * listen to the modified editor's onDidChangeModelContent event.
154
+ */
155
+ const handleDiffEditorMount = useCallback(
156
+ (editor: any) => {
157
+ if (!editor || !activeFile) return;
158
+ const modifiedEditor = editor.getModifiedEditor();
159
+ modifiedEditor.onDidChangeModelContent(() => {
160
+ const value = modifiedEditor.getValue();
161
+ onFileChange(activeFile, value);
162
+ });
163
+ },
164
+ [activeFile, onFileChange],
165
+ );
166
+
95
167
  return (
96
168
  <div className="flex flex-col h-full bg-sb-editor">
97
169
  {/* Tab bar */}
@@ -99,6 +171,8 @@ export function CodeEditor({
99
171
  {openFiles.map((path) => {
100
172
  const fileName = path.split("/").pop() || path;
101
173
  const isActive = path === activeFile;
174
+ const changeStatus: FileChangeStatus =
175
+ fileChanges[path] || "unchanged";
102
176
 
103
177
  return (
104
178
  <div
@@ -111,6 +185,14 @@ export function CodeEditor({
111
185
  }`}
112
186
  onClick={() => onSelectFile(path)}
113
187
  >
188
+ {/* Change status indicator dot */}
189
+ {changeStatus !== "unchanged" && (
190
+ <span
191
+ className="w-2 h-2 rounded-full shrink-0"
192
+ style={{ backgroundColor: STATUS_COLORS[changeStatus] }}
193
+ title={STATUS_LABELS[changeStatus]}
194
+ />
195
+ )}
114
196
  <span className="truncate max-w-[120px]">{fileName}</span>
115
197
  <button
116
198
  className="opacity-0 group-hover:opacity-100 hover:text-sb-text-active ml-1 text-[10px]"
@@ -125,9 +207,27 @@ export function CodeEditor({
125
207
  </div>
126
208
  );
127
209
  })}
210
+
211
+ {/* Spacer */}
212
+ <div className="flex-1" />
213
+
214
+ {/* Diff toggle button — only shown when a diff is available */}
215
+ {hasDiff && (
216
+ <button
217
+ className={`shrink-0 px-2 py-1 mr-1 text-[10px] font-medium rounded transition-colors ${
218
+ diffMode
219
+ ? "bg-sb-accent text-white"
220
+ : "text-sb-text-muted hover:text-sb-text-active hover:bg-sb-bg-hover"
221
+ }`}
222
+ onClick={() => setDiffMode((prev) => !prev)}
223
+ title={diffMode ? "Switch to editor" : "Show diff view"}
224
+ >
225
+ {diffMode ? "Editor" : "Diff"}
226
+ </button>
227
+ )}
128
228
  </div>
129
229
 
130
- {/* Editor */}
230
+ {/* Editor / DiffEditor / placeholder */}
131
231
  <div className="flex-1 overflow-hidden">
132
232
  {!activeFile && (
133
233
  <div className="flex items-center justify-center h-full text-sb-text-muted text-sm">
@@ -135,7 +235,37 @@ export function CodeEditor({
135
235
  </div>
136
236
  )}
137
237
 
138
- {activeFile && loaded && MonacoEditor && (
238
+ {/* Diff mode: Monaco DiffEditor (side-by-side) */}
239
+ {activeFile && loaded && diffMode && MonacoDiffEditor && (
240
+ <MonacoDiffEditor
241
+ height="100%"
242
+ language={language}
243
+ original={activeOriginalContent}
244
+ modified={activeContent}
245
+ theme="vs-dark"
246
+ onMount={handleDiffEditorMount}
247
+ options={{
248
+ readOnly,
249
+ minimap: { enabled: false },
250
+ fontSize: 13,
251
+ wordWrap: "on",
252
+ scrollBeyondLastLine: false,
253
+ padding: { top: 8 },
254
+ renderWhitespace: "selection",
255
+ automaticLayout: true,
256
+ tabSize: 2,
257
+ // DiffEditor-specific options
258
+ renderSideBySide: true,
259
+ enableSplitViewResizing: true,
260
+ renderIndicators: true,
261
+ renderMarginRevertIcon: false,
262
+ originalEditable: false,
263
+ }}
264
+ />
265
+ )}
266
+
267
+ {/* Regular mode: Monaco Editor */}
268
+ {activeFile && loaded && !diffMode && MonacoEditor && (
139
269
  <MonacoEditor
140
270
  height="100%"
141
271
  language={language}