agent-canvas 0.1.13 → 0.1.15

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,6 +1,8 @@
1
1
  import { writeFileSync, unlinkSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { randomUUID } from "crypto";
4
+ import { h, Fragment } from "preact";
5
+ import renderToString from "preact-render-to-string";
4
6
  import { COMPILE_TEMP_DIR } from "./paths";
5
7
 
6
8
  type CompileResult =
@@ -15,6 +17,96 @@ const { Section, Item, Task, FilePreview, CodeBlock, Callout,
15
17
  Markdown, useFeedback, useAnnotations } = C;
16
18
  `;
17
19
 
20
+ // --- Validation via Preact --------------------------------------------------
21
+ // After Bun.build() succeeds we strip ESM syntax from the compiled output,
22
+ // provide Preact as the React implementation with lightweight stub components,
23
+ // and actually render the component tree. Any runtime error (undefined vars,
24
+ // broken template literals, …) is caught and reported back to the CLI.
25
+
26
+ /** jsxDEV(type, props, key, isStaticChildren, source, self) → VNode */
27
+ function mockJsxDEV(type: any, props: any, key?: any) {
28
+ const { children, ...rest } = props || {};
29
+ return h(type, { ...rest, key }, children);
30
+ }
31
+
32
+ /** Passthrough component — just renders its children */
33
+ const Stub = ({ children }: any) => h(Fragment, null, children);
34
+
35
+ const STUB_COMPONENTS: Record<string, any> = {};
36
+ for (const name of [
37
+ "Section", "Item", "Task", "FilePreview", "CodeBlock", "Callout",
38
+ "Mermaid", "Table", "Priority", "Checklist", "Note", "Diff",
39
+ "Choice", "MultiChoice", "UserInput", "RangeInput", "ImageView", "Markdown",
40
+ ]) {
41
+ STUB_COMPONENTS[name] = Stub;
42
+ }
43
+ // Hook stubs — return shapes that won't blow up when destructured
44
+ STUB_COMPONENTS.useFeedback = () => ({ submit: () => {}, value: null });
45
+ STUB_COMPONENTS.useAnnotations = () => ({ annotations: [], addAnnotation: () => {} });
46
+
47
+ const MOCK_REACT = {
48
+ createElement: h,
49
+ Fragment,
50
+ useState: (init: any) => [init, () => {}],
51
+ useEffect: () => {},
52
+ useRef: (init: any) => ({ current: init }),
53
+ useMemo: (fn: any) => fn(),
54
+ useCallback: (fn: any) => fn,
55
+ useReducer: (r: any, init: any) => [init, () => {}],
56
+ useContext: () => ({}),
57
+ createContext: () => ({ Provider: Stub, Consumer: Stub }),
58
+ useLayoutEffect: () => {},
59
+ useImperativeHandle: () => {},
60
+ useDebugValue: () => {},
61
+ useDeferredValue: (v: any) => v,
62
+ useTransition: () => [false, (fn: any) => fn()],
63
+ useId: () => "mock",
64
+ useSyncExternalStore: (sub: any, get: any) => get(),
65
+ memo: (c: any) => c,
66
+ forwardRef: (c: any) => c,
67
+ lazy: (c: any) => c,
68
+ Children: { map: (c: any, fn: any) => (Array.isArray(c) ? c : [c]).map(fn), toArray: (c: any) => (Array.isArray(c) ? c : [c]) },
69
+ isValidElement: () => true,
70
+ cloneElement: h,
71
+ Suspense: Stub,
72
+ StrictMode: Stub,
73
+ };
74
+
75
+ function validateCompiledPlan(js: string): { ok: true } | { ok: false; error: string } {
76
+ let code = js;
77
+
78
+ // Strip ESM import statements
79
+ code = code.replace(/import\s+\w+\s+from\s+"[^"]+";?/g, "");
80
+ code = code.replace(/import\s+\*\s+as\s+\w+\s+from\s+"[^"]+";?/g, "");
81
+ code = code.replace(/import\s*\{[^}]*\}\s*from\s+"[^"]+";?/g, "");
82
+
83
+ // Strip export, extract default name
84
+ let defaultName: string | null = null;
85
+ code = code.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\};?/g, (_, name) => {
86
+ defaultName = name;
87
+ return "";
88
+ });
89
+ if (!defaultName) {
90
+ code = code.replace(/export\s+default\s+(\w+);?/g, (_, name) => {
91
+ defaultName = name;
92
+ return "";
93
+ });
94
+ }
95
+
96
+ const callDefault = defaultName
97
+ ? `\nreturn typeof ${defaultName} === "function" ? ${defaultName}() : null;`
98
+ : "\nreturn null;";
99
+
100
+ try {
101
+ const fn = new Function("React", "C", "jsxDEV", "jsx", "Fragment", code + callDefault);
102
+ const vnode = fn(MOCK_REACT, STUB_COMPONENTS, mockJsxDEV, mockJsxDEV, Fragment);
103
+ if (vnode) renderToString(vnode);
104
+ return { ok: true };
105
+ } catch (e: any) {
106
+ return { ok: false, error: e?.message || String(e) };
107
+ }
108
+ }
109
+
18
110
  export async function compilePlan(jsx: string, projectRoot?: string): Promise<CompileResult> {
19
111
  // Resolve file contents at compile time
20
112
  if (projectRoot) {
@@ -49,6 +141,10 @@ export async function compilePlan(jsx: string, projectRoot?: string): Promise<Co
49
141
  }
50
142
 
51
143
  const js = await result.outputs[0].text();
144
+ const validation = validateCompiledPlan(js);
145
+ if (!validation.ok) {
146
+ return { ok: false, error: `Runtime error: ${validation.error}` };
147
+ }
52
148
  return { ok: true, js };
53
149
  } catch {
54
150
  // Bun.build() can throw "Unknown Error, TODO" in long-running processes
@@ -78,6 +174,10 @@ async function compilePlanSubprocess(tmpFile: string): Promise<CompileResult> {
78
174
  }
79
175
  try {
80
176
  const js = readFileSync(outFile, "utf-8");
177
+ const validation = validateCompiledPlan(js);
178
+ if (!validation.ok) {
179
+ return { ok: false, error: `Runtime error: ${validation.error}` };
180
+ }
81
181
  return { ok: true, js };
82
182
  } finally {
83
183
  try { unlinkSync(outFile); } catch {}
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, readdirSync, existsSync } from "fs";
2
2
  import type { SessionManager } from "../session";
3
3
  import { compilePlan } from "../compiler";
4
4
  import { watchSession } from "../watcher";
@@ -11,24 +11,36 @@ export interface ApiContext {
11
11
  port: number;
12
12
  }
13
13
 
14
- function readRevisionJsx(sessionManager: SessionManager, sessionId: string, rev: number): string | null {
15
- try {
16
- const path = sessionManager.getRevisionJsxPath(sessionId, rev);
17
- return readFileSync(path, "utf-8");
18
- } catch {
19
- return null;
20
- }
21
- }
22
-
23
14
  export function createApiHandlers(ctx: ApiContext): Route[] {
24
15
  const { sessionManager, broadcastPlanUpdate, port } = ctx;
25
16
 
17
+ /**
18
+ * Read *.jsx canvas files from a directory.
19
+ * Returns a Map of filename -> JSX content.
20
+ */
21
+ function resolveCanvasFiles(directory: string): Map<string, string> | null {
22
+ if (!existsSync(directory)) return null;
23
+ const files = readdirSync(directory).filter(f => f.endsWith(".jsx")).sort();
24
+ if (files.length === 0) return null;
25
+ const map = new Map<string, string>();
26
+ for (const f of files) {
27
+ map.set(f, readFileSync(`${directory}/${f}`, "utf-8"));
28
+ }
29
+ return map;
30
+ }
31
+
26
32
  async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
27
33
  const sessionId = match.pathname.groups.id!;
28
34
  try {
29
35
  const body = await req.json();
30
- const { jsx, projectRoot, label, sourceFile, response } = body;
31
- if (!jsx) return jsonResponse({ error: "Missing jsx" }, 400);
36
+
37
+ if (!body.directory) {
38
+ return jsonResponse({ error: "Missing directory" }, 400);
39
+ }
40
+ const canvasFiles = resolveCanvasFiles(body.directory);
41
+ if (!canvasFiles) {
42
+ return jsonResponse({ error: "No .jsx files found in directory" }, 400);
43
+ }
32
44
 
33
45
  const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId);
34
46
  if (unconsumed) {
@@ -41,12 +53,26 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
41
53
  }, 409);
42
54
  }
43
55
 
56
+ const projectRoot = body.projectRoot || process.cwd();
44
57
  const isNew = !sessionManager.get(sessionId);
45
- const session = sessionManager.upsert(sessionId, jsx, projectRoot || process.cwd(), label, sourceFile, response);
58
+ const session = sessionManager.upsert(sessionId, canvasFiles, projectRoot, body.label, body.response);
59
+
60
+ // Compile all canvas files in parallel
61
+ const errors: Record<string, string> = {};
62
+ let anyOk = false;
63
+ await Promise.all(
64
+ [...canvasFiles.entries()].map(async ([filename, jsx]) => {
65
+ const result = await compilePlan(jsx, session.projectRoot);
66
+ if (result.ok) {
67
+ sessionManager.saveCompiled(sessionId, filename, result.js, session.currentRevision);
68
+ anyOk = true;
69
+ } else {
70
+ errors[filename] = result.error;
71
+ }
72
+ }),
73
+ );
46
74
 
47
- const result = await compilePlan(jsx, session.projectRoot);
48
- if (result.ok) {
49
- sessionManager.saveCompiled(sessionId, result.js, session.currentRevision);
75
+ if (anyOk) {
50
76
  broadcastPlanUpdate(sessionId);
51
77
  }
52
78
 
@@ -54,39 +80,47 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
54
80
 
55
81
  const browserUrl = `http://localhost:${port}/s/${sessionId}`;
56
82
  return jsonResponse({
57
- ok: result.ok,
83
+ ok: anyOk,
58
84
  browserUrl,
59
85
  isNew,
60
86
  revision: session.currentRevision,
61
- error: result.ok ? undefined : result.error,
87
+ sessionId,
88
+ canvasFiles: [...canvasFiles.keys()],
89
+ ...(Object.keys(errors).length > 0 ? { errors } : {}),
62
90
  });
63
91
  } catch (e: any) {
64
92
  return jsonResponse({ error: e.message }, 500);
65
93
  }
66
94
  }
67
95
 
68
- async function handlePlanJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
96
+ async function handleCanvasJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
69
97
  const sessionId = match.pathname.groups.id!;
98
+ const jsFilename = match.pathname.groups.filename!;
99
+ const jsxFilename = jsFilename.replace(/\.js$/, ".jsx");
70
100
  const revParam = url.searchParams.get("rev");
71
101
  const rev = revParam ? parseInt(revParam, 10) : undefined;
72
102
 
73
- let compiled = sessionManager.getCompiled(sessionId, rev);
103
+ let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev);
74
104
 
75
105
  if (!compiled && rev) {
76
106
  const session = sessionManager.get(sessionId);
77
107
  if (session) {
78
- const jsx = readRevisionJsx(sessionManager, sessionId, rev);
108
+ const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename);
79
109
  if (jsx) {
80
110
  const result = await compilePlan(jsx, session.projectRoot);
81
111
  if (result.ok) {
82
- sessionManager.saveCompiled(sessionId, result.js, rev);
112
+ sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev);
83
113
  compiled = result.js;
114
+ } else {
115
+ console.warn(`[canvas.js] compilation failed for ${sessionId}/${jsxFilename} rev ${rev}: ${result.error}`);
84
116
  }
117
+ } else {
118
+ console.warn(`[canvas.js] no JSX found for ${sessionId}/${jsxFilename} rev ${rev}`);
85
119
  }
86
120
  }
87
121
  }
88
122
 
89
- if (!compiled) return jsonResponse({ error: "No compiled plan" }, 404);
123
+ if (!compiled) return jsonResponse({ error: "No compiled canvas" }, 404);
90
124
  return new Response(compiled, {
91
125
  headers: {
92
126
  "Content-Type": "application/javascript",
@@ -108,6 +142,7 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
108
142
  projectRoot: session.projectRoot,
109
143
  currentRevision: session.currentRevision,
110
144
  revisions: session.revisions,
145
+ canvasFiles: session.canvasFiles,
111
146
  createdAt: session.createdAt,
112
147
  updatedAt: session.updatedAt,
113
148
  });
@@ -141,7 +176,7 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
141
176
  return [
142
177
  { method: "GET", pattern: new URLPattern({ pathname: "/health" }), handler: handleHealth },
143
178
  { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/plan" }), handler: handlePlanPost },
144
- { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/plan.js" }), handler: handlePlanJs },
179
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/canvas/:filename" }), handler: handleCanvasJs },
145
180
  { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
146
181
  { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
147
182
  { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync } from "fs";
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { SESSIONS_DIR } from "./paths";
4
4
 
@@ -7,21 +7,25 @@ export interface DiffStats {
7
7
  removed: number;
8
8
  }
9
9
 
10
+ export interface CanvasFileInfo {
11
+ filename: string;
12
+ diffStats?: DiffStats;
13
+ }
14
+
10
15
  export interface RevisionInfo {
11
16
  revision: number;
12
17
  label?: string;
13
- sourceFile?: string;
18
+ canvasFiles: CanvasFileInfo[];
14
19
  createdAt: string;
15
20
  hasFeedback: boolean;
16
21
  feedbackConsumed: boolean;
17
22
  response?: string;
18
- diffStats?: DiffStats;
19
23
  }
20
24
 
21
25
  export interface SessionData {
22
26
  id: string;
23
27
  projectRoot: string;
24
- jsx: string;
28
+ canvasFiles: string[];
25
29
  currentRevision: number;
26
30
  revisions: RevisionInfo[];
27
31
  createdAt: string;
@@ -36,7 +40,7 @@ interface SessionMeta {
36
40
  revisions: RevisionInfo[];
37
41
  }
38
42
 
39
- // Legacy format
43
+ // Legacy format (flat files, pre-revision)
40
44
  interface LegacyMeta {
41
45
  projectRoot: string;
42
46
  createdAt: string;
@@ -44,6 +48,18 @@ interface LegacyMeta {
44
48
  version: number;
45
49
  }
46
50
 
51
+ // Previous format (single sourceFile per revision)
52
+ interface LegacyRevisionInfo {
53
+ revision: number;
54
+ label?: string;
55
+ sourceFile?: string;
56
+ createdAt: string;
57
+ hasFeedback: boolean;
58
+ feedbackConsumed: boolean;
59
+ response?: string;
60
+ diffStats?: DiffStats;
61
+ }
62
+
47
63
  const STALE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
48
64
 
49
65
  function computeLineDiffStats(oldText: string, newText: string): DiffStats {
@@ -83,6 +99,32 @@ export class SessionManager {
83
99
  return join(this.sessionDir(id), "revisions", String(rev));
84
100
  }
85
101
 
102
+ /** Compute file order from revision history: first-appearance order, alpha for ties */
103
+ private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
104
+ const seen = new Set<string>();
105
+ const ordered: string[] = [];
106
+ for (const rev of revisions) {
107
+ for (const cf of rev.canvasFiles) {
108
+ if (!seen.has(cf.filename)) {
109
+ seen.add(cf.filename);
110
+ ordered.push(cf.filename);
111
+ }
112
+ }
113
+ }
114
+ // Keep only files that still exist, then append any unknown ones
115
+ const current = new Set(currentFiles);
116
+ const result = ordered.filter(f => current.has(f));
117
+ const remaining = currentFiles.filter(f => !seen.has(f)).sort();
118
+ return [...result, ...remaining];
119
+ }
120
+
121
+ /** List *.jsx filenames in a revision directory */
122
+ getRevisionCanvasFiles(id: string, rev: number): string[] {
123
+ const dir = this.revisionDir(id, rev);
124
+ if (!existsSync(dir)) return [];
125
+ return readdirSync(dir).filter(f => f.endsWith(".jsx")).sort();
126
+ }
127
+
86
128
  private loadFromDisk() {
87
129
  if (!existsSync(SESSIONS_DIR)) return;
88
130
  for (const name of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
@@ -94,17 +136,16 @@ export class SessionManager {
94
136
  try {
95
137
  const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
96
138
 
97
- // Detect and migrate legacy format
139
+ // Detect and migrate legacy flat-file format
98
140
  if ("version" in raw && !("currentRevision" in raw)) {
99
141
  this.migrateLegacy(name.name, raw as LegacyMeta);
100
- // Re-read after migration
101
142
  const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
102
- const jsx = this.readRevisionJsx(name.name, migrated.currentRevision);
103
- if (!jsx) continue;
143
+ const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision);
144
+ if (canvasFiles.length === 0) continue;
104
145
  this.sessions.set(name.name, {
105
146
  id: name.name,
106
147
  projectRoot: migrated.projectRoot,
107
- jsx,
148
+ canvasFiles,
108
149
  currentRevision: migrated.currentRevision,
109
150
  revisions: migrated.revisions,
110
151
  createdAt: migrated.createdAt,
@@ -113,14 +154,34 @@ export class SessionManager {
113
154
  continue;
114
155
  }
115
156
 
116
- const meta = raw as SessionMeta;
117
- const jsx = this.readRevisionJsx(name.name, meta.currentRevision);
118
- if (!jsx) continue;
157
+ let meta = raw as SessionMeta;
158
+
159
+ // Migrate single-sourceFile revision format to canvasFiles
160
+ if (meta.revisions.length > 0 && !("canvasFiles" in meta.revisions[0])) {
161
+ meta = {
162
+ ...meta,
163
+ revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
164
+ revision: r.revision,
165
+ label: r.label,
166
+ canvasFiles: [{ filename: r.sourceFile || "plan.jsx", diffStats: r.diffStats }],
167
+ createdAt: r.createdAt,
168
+ hasFeedback: r.hasFeedback,
169
+ feedbackConsumed: r.feedbackConsumed,
170
+ response: r.response,
171
+ })),
172
+ };
173
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2));
174
+ }
175
+
176
+ const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision);
177
+ if (diskFiles.length === 0) continue;
178
+ // Derive file order from revision history (first-appearance order)
179
+ const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles);
119
180
 
120
181
  this.sessions.set(name.name, {
121
182
  id: name.name,
122
183
  projectRoot: meta.projectRoot,
123
- jsx,
184
+ canvasFiles,
124
185
  currentRevision: meta.currentRevision,
125
186
  revisions: meta.revisions,
126
187
  createdAt: meta.createdAt,
@@ -136,7 +197,6 @@ export class SessionManager {
136
197
  const flatCompiled = join(dir, "plan.compiled.js");
137
198
  const historyDir = join(dir, "history");
138
199
 
139
- // Collect all historical revisions + current
140
200
  const revisions: RevisionInfo[] = [];
141
201
 
142
202
  // Migrate history files
@@ -148,7 +208,13 @@ export class SessionManager {
148
208
  const revDir = this.revisionDir(id, num);
149
209
  mkdirSync(revDir, { recursive: true });
150
210
  renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
151
- revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false, feedbackConsumed: false });
211
+ revisions.push({
212
+ revision: num,
213
+ canvasFiles: [{ filename: "plan.jsx" }],
214
+ createdAt: legacy.createdAt,
215
+ hasFeedback: false,
216
+ feedbackConsumed: false,
217
+ });
152
218
  }
153
219
  rmSync(historyDir, { recursive: true, force: true });
154
220
  }
@@ -163,9 +229,14 @@ export class SessionManager {
163
229
  if (existsSync(flatCompiled)) {
164
230
  renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
165
231
  }
166
- revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false, feedbackConsumed: false });
232
+ revisions.push({
233
+ revision: currentRev,
234
+ canvasFiles: [{ filename: "plan.jsx" }],
235
+ createdAt: legacy.updatedAt,
236
+ hasFeedback: false,
237
+ feedbackConsumed: false,
238
+ });
167
239
 
168
- // Write new meta
169
240
  const meta: SessionMeta = {
170
241
  projectRoot: legacy.projectRoot,
171
242
  createdAt: legacy.createdAt,
@@ -176,37 +247,62 @@ export class SessionManager {
176
247
  writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
177
248
  }
178
249
 
179
- private readRevisionJsx(id: string, rev: number): string | null {
250
+ readRevisionJsx(id: string, rev: number, filename: string): string | null {
180
251
  try {
181
- return readFileSync(join(this.revisionDir(id, rev), "plan.jsx"), "utf-8");
252
+ return readFileSync(join(this.revisionDir(id, rev), filename), "utf-8");
182
253
  } catch {
183
254
  return null;
184
255
  }
185
256
  }
186
257
 
187
- upsert(id: string, jsx: string, projectRoot: string, label?: string, sourceFile?: string, response?: string): SessionData {
258
+ /**
259
+ * Create or update a session with a set of canvas files.
260
+ * @param canvasFiles Map of filename -> JSX content
261
+ */
262
+ upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string): SessionData {
188
263
  const existing = this.sessions.get(id);
189
264
  const now = new Date().toISOString();
190
265
  const revision = existing ? existing.currentRevision + 1 : 1;
191
266
 
192
- let diffStats: DiffStats | undefined;
193
- if (existing && sourceFile) {
194
- const prev = [...existing.revisions]
195
- .reverse()
196
- .find((r) => r.sourceFile === sourceFile);
197
- if (prev) {
198
- const prevJsx = this.readRevisionJsx(id, prev.revision);
199
- if (prevJsx) diffStats = computeLineDiffStats(prevJsx, jsx);
267
+ // Compute per-file diffStats against the previous revision
268
+ const prevRev = existing ? existing.currentRevision : 0;
269
+ const canvasFileInfos: CanvasFileInfo[] = [];
270
+ for (const [filename, jsx] of canvasFiles) {
271
+ let diffStats: DiffStats | undefined;
272
+ if (prevRev > 0) {
273
+ const prevJsx = this.readRevisionJsx(id, prevRev, filename);
274
+ if (prevJsx) {
275
+ diffStats = computeLineDiffStats(prevJsx, jsx);
276
+ }
200
277
  }
278
+ canvasFileInfos.push({
279
+ filename,
280
+ ...(diffStats ? { diffStats } : {}),
281
+ });
201
282
  }
202
283
 
203
- const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, feedbackConsumed: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}), ...(response ? { response } : {}), ...(diffStats ? { diffStats } : {}) };
284
+ const revInfo: RevisionInfo = {
285
+ revision,
286
+ canvasFiles: canvasFileInfos,
287
+ createdAt: now,
288
+ hasFeedback: false,
289
+ feedbackConsumed: false,
290
+ ...(label ? { label } : {}),
291
+ ...(response ? { response } : {}),
292
+ };
204
293
  const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
294
+ // Order: files from previous revision first (preserving their order), then new files alphabetically
295
+ const prevOrder = existing?.canvasFiles ?? [];
296
+ const currentNames = new Set(canvasFiles.keys());
297
+ const filenames = [
298
+ ...prevOrder.filter(f => currentNames.has(f)),
299
+ ...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
300
+ ];
205
301
 
206
302
  const session: SessionData = {
207
303
  id,
208
304
  projectRoot,
209
- jsx,
305
+ canvasFiles: filenames,
210
306
  currentRevision: revision,
211
307
  revisions,
212
308
  createdAt: existing?.createdAt ?? now,
@@ -214,7 +310,7 @@ export class SessionManager {
214
310
  };
215
311
 
216
312
  this.sessions.set(id, session);
217
- this.persistToDisk(session);
313
+ this.persistToDisk(session, canvasFiles);
218
314
  return session;
219
315
  }
220
316
 
@@ -229,10 +325,12 @@ export class SessionManager {
229
325
  writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
230
326
  }
231
327
 
232
- private persistToDisk(session: SessionData) {
328
+ private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
233
329
  const revDir = this.revisionDir(session.id, session.currentRevision);
234
330
  mkdirSync(revDir, { recursive: true });
235
- writeFileSync(join(revDir, "plan.jsx"), session.jsx);
331
+ for (const [filename, jsx] of canvasFiles) {
332
+ writeFileSync(join(revDir, filename), jsx);
333
+ }
236
334
  this.persistMeta(session);
237
335
  }
238
336
 
@@ -249,21 +347,23 @@ export class SessionManager {
249
347
  rmSync(this.sessionDir(id), { recursive: true, force: true });
250
348
  }
251
349
 
252
- saveCompiled(id: string, js: string, rev?: number) {
350
+ saveCompiled(id: string, filename: string, js: string, rev?: number) {
253
351
  const session = this.sessions.get(id);
254
352
  const revision = rev ?? session?.currentRevision;
255
353
  if (!revision) return;
256
354
  const revDir = this.revisionDir(id, revision);
257
355
  mkdirSync(revDir, { recursive: true });
258
- writeFileSync(join(revDir, "plan.compiled.js"), js);
356
+ const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
357
+ writeFileSync(join(revDir, compiledName), js);
259
358
  }
260
359
 
261
- getCompiled(id: string, rev?: number): string | null {
360
+ getCompiled(id: string, filename: string, rev?: number): string | null {
262
361
  const session = this.sessions.get(id);
263
362
  const revision = rev ?? session?.currentRevision;
264
363
  if (!revision) return null;
364
+ const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
265
365
  try {
266
- return readFileSync(join(this.revisionDir(id, revision), "plan.compiled.js"), "utf-8");
366
+ return readFileSync(join(this.revisionDir(id, revision), compiledName), "utf-8");
267
367
  } catch {
268
368
  return null;
269
369
  }
@@ -305,7 +405,6 @@ export class SessionManager {
305
405
  getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
306
406
  const session = this.sessions.get(id);
307
407
  if (!session) return null;
308
- // Search from newest to oldest
309
408
  for (let i = session.revisions.length - 1; i >= 0; i--) {
310
409
  const ri = session.revisions[i];
311
410
  if (ri.hasFeedback && !ri.feedbackConsumed) {
@@ -316,8 +415,8 @@ export class SessionManager {
316
415
  return null;
317
416
  }
318
417
 
319
- getRevisionJsxPath(id: string, rev: number): string {
320
- return join(this.revisionDir(id, rev), "plan.jsx");
418
+ getRevisionJsxPath(id: string, rev: number, filename: string): string {
419
+ return join(this.revisionDir(id, rev), filename);
321
420
  }
322
421
 
323
422
  cleanupStale(maxAge = STALE_TIMEOUT_MS) {