agent-canvas 0.1.14 → 0.1.16

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,6 @@
1
1
  import { parseArgs } from "util";
2
- import { readFileSync, existsSync } from "fs";
3
- import { resolve } from "path";
2
+ import { existsSync, statSync } from "fs";
3
+ import { resolve, basename } from "path";
4
4
  import { BASE_URL } from "../config.ts";
5
5
  import { ensureDaemon } from "../daemon-lifecycle.ts";
6
6
  import { getSessionId, openBrowser } from "../helpers.ts";
@@ -16,30 +16,40 @@ export async function handlePush(args: string[]) {
16
16
  allowPositionals: true,
17
17
  });
18
18
 
19
- const filePath = positionals[0];
20
- if (!filePath) {
21
- console.error("Error: No file specified. Usage: agent-canvas push <file.jsx>");
19
+ const target = positionals[0];
20
+ if (!target) {
21
+ console.error("Error: No directory specified. Usage: agent-canvas push <directory>");
22
22
  process.exit(1);
23
23
  }
24
24
 
25
- const resolvedPath = resolve(filePath);
25
+ const resolvedPath = resolve(target);
26
26
  if (!existsSync(resolvedPath)) {
27
- console.error(`Error: File not found: ${resolvedPath}`);
27
+ console.error(`Error: Path not found: ${resolvedPath}`);
28
+ process.exit(1);
29
+ }
30
+
31
+ if (statSync(resolvedPath).isFile()) {
32
+ console.error(`Error: Expected a directory, got a file: ${resolvedPath}`);
33
+ console.error("Usage: agent-canvas push <directory>");
28
34
  process.exit(1);
29
35
  }
30
- const jsx = readFileSync(resolvedPath, "utf-8");
31
36
 
32
37
  const sessionId = getSessionId(values.session);
33
38
  const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
34
39
  const label = values.label;
35
- const autoLabel = !label ? resolvedPath.split("/").pop()?.replace(/\.jsx$/, "") : undefined;
40
+ const autoLabel = !label ? basename(resolvedPath) : undefined;
36
41
 
37
42
  await ensureDaemon();
38
43
 
39
44
  const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
40
45
  method: "POST",
41
46
  headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify({ jsx, projectRoot, label: label || autoLabel, sourceFile: resolvedPath.split("/").pop(), ...(values.response ? { response: values.response } : {}) }),
47
+ body: JSON.stringify({
48
+ directory: resolvedPath,
49
+ projectRoot,
50
+ label: label || autoLabel,
51
+ ...(values.response ? { response: values.response } : {}),
52
+ }),
43
53
  });
44
54
 
45
55
  const result = await response.json() as any;
@@ -50,6 +60,11 @@ export async function handlePush(args: string[]) {
50
60
  console.log(result.unconsumedFeedback);
51
61
  } else {
52
62
  console.error(`Compilation error:\n${result.error}`);
63
+ if (result.errors) {
64
+ for (const [file, err] of Object.entries(result.errors)) {
65
+ console.error(` ${file}: ${err}`);
66
+ }
67
+ }
53
68
  }
54
69
  process.exit(1);
55
70
  }
@@ -63,5 +78,6 @@ export async function handlePush(args: string[]) {
63
78
  browserUrl: result.browserUrl,
64
79
  revision: result.revision,
65
80
  sessionId,
81
+ canvasFiles: result.canvasFiles,
66
82
  }));
67
83
  }
package/bun.lock CHANGED
@@ -7,6 +7,7 @@
7
7
  "dependencies": {
8
8
  "marked": "^17.0.4",
9
9
  "preact": "^10.25.0",
10
+ "preact-render-to-string": "^6.6.6",
10
11
  },
11
12
  "devDependencies": {
12
13
  "@tailwindcss/cli": "4",
@@ -151,6 +152,8 @@
151
152
 
152
153
  "preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
153
154
 
155
+ "preact-render-to-string": ["preact-render-to-string@6.6.6", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ=="],
156
+
154
157
  "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
155
158
 
156
159
  "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
@@ -82,8 +82,8 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
82
82
  return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
83
83
  }, [annotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
84
84
 
85
- const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => {
86
- setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}) }]);
85
+ const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
86
+ setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
87
87
  }, []);
88
88
 
89
89
  const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
@@ -12,22 +12,27 @@ import { FileViewer } from "./FileViewer";
12
12
  import { SessionSwitcher } from "./SessionSwitcher";
13
13
  import { exportCanvasToMarkdown } from "./exportMarkdown";
14
14
  import { CompareView } from "./CompareView";
15
+ import { OverviewView, categorizeChanges, getAffectedFiles } from "./OverviewView";
15
16
  import { RevisionSelect } from "./RevisionSelect";
16
17
  import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
17
18
  import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
18
19
  import { extractContext } from "./annotationContext";
19
20
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
20
21
 
21
- export type ActiveView = { type: "plan" } | { type: "file"; path: string };
22
+ export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
23
+
24
+ export interface CanvasFileInfo {
25
+ filename: string;
26
+ diffStats?: { added: number; removed: number };
27
+ }
22
28
 
23
29
  export interface RevisionInfo {
24
30
  revision: number;
25
31
  label?: string;
26
- sourceFile?: string;
32
+ canvasFiles: CanvasFileInfo[];
27
33
  createdAt: string;
28
34
  hasFeedback: boolean;
29
35
  response?: string;
30
- diffStats?: { added: number; removed: number };
31
36
  }
32
37
 
33
38
  export const ActiveViewContext = createContext<{
@@ -35,11 +40,13 @@ export const ActiveViewContext = createContext<{
35
40
  setActiveView: (v: ActiveView) => void;
36
41
  openFiles: string[];
37
42
  closeFile: (path: string) => void;
43
+ canvasFiles: string[];
38
44
  }>({
39
- activeView: { type: "plan" },
45
+ activeView: { type: "overview" },
40
46
  setActiveView: () => {},
41
47
  openFiles: [],
42
48
  closeFile: () => {},
49
+ canvasFiles: [],
43
50
  });
44
51
 
45
52
  export const RevisionContext = createContext<{
@@ -146,14 +153,11 @@ function RevisionSelector() {
146
153
  />
147
154
  <button
148
155
  onClick={() => {
149
- const current = revisions.find((r) => r.revision === selectedRevision);
150
- const sourceFile = current?.sourceFile;
151
156
  let left: number;
152
157
  if (selectedRevision === currentRevision) {
153
- // Find previous revision with same sourceFile
154
158
  const prev = [...revisions]
155
159
  .reverse()
156
- .find((r) => r.revision < currentRevision && (!sourceFile || r.sourceFile === sourceFile));
160
+ .find((r) => r.revision < currentRevision);
157
161
  left = prev?.revision ?? Math.max(1, currentRevision - 1);
158
162
  } else {
159
163
  left = selectedRevision;
@@ -176,7 +180,8 @@ function App() {
176
180
  const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null);
177
181
  const [connected, setConnected] = useState(false);
178
182
  const [previewOpen, setPreviewOpen] = useState(false);
179
- const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "plan" });
183
+ const [canvasFiles, setCanvasFiles] = useState<string[]>([]);
184
+ const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
180
185
  const [openFiles, setOpenFiles] = useState<string[]>([]);
181
186
  const wsRef = useRef<WebSocket | null>(null);
182
187
 
@@ -196,6 +201,10 @@ function App() {
196
201
  }
197
202
  if (activeView.type === "file") {
198
203
  parts.push(activeView.path.split("/").pop() || activeView.path);
204
+ } else if (activeView.type === "overview") {
205
+ parts.push("Overview");
206
+ } else if (activeView.type === "canvas" && canvasFiles.length > 1 && activeView.filename) {
207
+ parts.push(activeView.filename.replace(/\.jsx$/, ""));
199
208
  } else if (compareRevision) {
200
209
  parts.push("Compare");
201
210
  }
@@ -210,10 +219,8 @@ function App() {
210
219
 
211
220
  const setActiveView = useCallback((v: ActiveView) => {
212
221
  // Save current scroll position before switching
213
- scrollPositions.current.set(
214
- activeView.type === "plan" ? "plan" : `file:${activeView.path}`,
215
- window.scrollY
216
- );
222
+ const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
223
+ scrollPositions.current.set(key, window.scrollY);
217
224
  setActiveViewRaw(v);
218
225
  if (v.type === "file") {
219
226
  setOpenFiles((prev) => prev.includes(v.path) ? prev : [...prev, v.path]);
@@ -225,16 +232,15 @@ function App() {
225
232
  setOpenFiles((prev) => prev.filter((p) => p !== path));
226
233
  setActiveViewRaw((prev) => {
227
234
  if (prev.type === "file" && prev.path === path) {
228
- scrollPositions.current.set(`file:${path}`, window.scrollY);
229
- return { type: "plan" };
235
+ return canvasFiles.length > 1 ? { type: "overview" } : { type: "canvas", filename: canvasFiles[0] || "" };
230
236
  }
231
237
  return prev;
232
238
  });
233
- }, []);
239
+ }, [canvasFiles]);
234
240
 
235
241
  // Restore scroll synchronously after DOM update (before paint)
236
242
  useLayoutEffect(() => {
237
- const key = activeView.type === "plan" ? "plan" : `file:${activeView.path}`;
243
+ const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
238
244
  window.scrollTo(0, scrollPositions.current.get(key) || 0);
239
245
  }, [activeView]);
240
246
 
@@ -249,6 +255,20 @@ function App() {
249
255
  setSelectedRevision(data.currentRevision);
250
256
  setRevisions(data.revisions || []);
251
257
  }
258
+ if (data.canvasFiles) {
259
+ const files = (data.canvasFiles as string[]).sort();
260
+ setCanvasFiles(files);
261
+ setActiveViewRaw((prev) => {
262
+ if (prev.type === "overview") {
263
+ // On initial load: overview if multiple files, direct if single
264
+ return files.length > 1 ? prev : { type: "canvas", filename: files[0] || "" };
265
+ }
266
+ if (prev.type === "canvas" && (!prev.filename || !files.includes(prev.filename))) {
267
+ return { type: "canvas", filename: files[0] || "" };
268
+ }
269
+ return prev;
270
+ });
271
+ }
252
272
  if (data.projectRoot) setProjectRoot(data.projectRoot);
253
273
  })
254
274
  .catch(() => {});
@@ -270,7 +290,26 @@ function App() {
270
290
  setCurrentRevision(data.currentRevision);
271
291
  setSelectedRevision(data.currentRevision);
272
292
  setCompareRevision(null);
273
- if (data.revisions) setRevisions(data.revisions);
293
+ if (data.revisions) {
294
+ const allRevisions = data.revisions as RevisionInfo[];
295
+ setRevisions(allRevisions);
296
+ const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision);
297
+ if (latest?.canvasFiles) {
298
+ const files = latest.canvasFiles.map((cf: CanvasFileInfo) => cf.filename).sort();
299
+ setCanvasFiles(files);
300
+ // Determine what changed and auto-navigate
301
+ const previous = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision - 1);
302
+ const changes = categorizeChanges(latest, previous);
303
+ const affected = getAffectedFiles(changes);
304
+ if (affected.length === 1) {
305
+ setActiveViewRaw({ type: "canvas", filename: affected[0] });
306
+ } else if (affected.length > 1 || files.length > 1) {
307
+ setActiveViewRaw({ type: "overview" });
308
+ } else {
309
+ setActiveViewRaw({ type: "canvas", filename: files[0] || "" });
310
+ }
311
+ }
312
+ }
274
313
  }
275
314
  if (data.type === "revision-updated") {
276
315
  if (data.revisions) setRevisions(data.revisions);
@@ -299,7 +338,7 @@ function App() {
299
338
  <SessionContext.Provider value={sessionId}>
300
339
  <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision }}>
301
340
  <AnnotationProvider key={`${sessionId}:${selectedRevision}`} sessionId={sessionId} revision={selectedRevision} isReadOnly={isReadOnly}>
302
- <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile }}>
341
+ <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
303
342
  <ActiveViewCtx.Provider value={{ setActiveView }}>
304
343
  <div className="min-h-screen bg-bg-base">
305
344
  {/* Left panel — fixed to viewport */}
@@ -344,12 +383,19 @@ function App() {
344
383
  </div>
345
384
 
346
385
  <ContentTabs />
347
- {/* All views stay mounted; inactive ones are hidden */}
348
- <div style={{ display: activeView.type === "plan" ? undefined : "none" }}>
386
+ {/* Overview — shows all affected canvases inline */}
387
+ {activeView.type === "overview" && (
388
+ <OverviewView
389
+ revision={selectedRevision}
390
+ responseBanner={selectedRevInfo?.response ? <ResponseBanner markdown={selectedRevInfo.response} /> : undefined}
391
+ />
392
+ )}
393
+ {/* Individual canvas view — only mounted when active */}
394
+ {activeView.type === "canvas" && canvasFiles.includes(activeView.filename) && (
349
395
  <div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
350
396
  <button
351
397
  onClick={() => {
352
- const planContent = document.querySelector(".plan-content");
398
+ const planContent = document.querySelector(`[data-canvas-file="${activeView.filename}"] .plan-content`);
353
399
  if (!planContent) return;
354
400
  const md = exportCanvasToMarkdown(planContent as HTMLElement);
355
401
  navigator.clipboard.writeText(md).then(() => {
@@ -372,12 +418,15 @@ function App() {
372
418
  <polyline points="20 6 9 17 4 12"/>
373
419
  </svg>
374
420
  </button>
375
- {selectedRevInfo?.response && (
421
+ {/* Show response banner on individual canvas only when there's no overview (single canvas) */}
422
+ {canvasFiles.length <= 1 && selectedRevInfo?.response && (
376
423
  <ResponseBanner markdown={selectedRevInfo.response} />
377
424
  )}
378
- <PlanRenderer revision={selectedRevision} />
425
+ <div data-canvas-file={activeView.filename}>
426
+ <PlanRenderer key={activeView.filename} revision={selectedRevision} filename={activeView.filename} />
427
+ </div>
379
428
  </div>
380
- </div>
429
+ )}
381
430
  {openFiles.map((filePath) => (
382
431
  <div key={filePath} style={{ display: activeView.type === "file" && activeView.path === filePath ? undefined : "none" }}>
383
432
  <FileViewer path={filePath} />
@@ -715,37 +764,66 @@ function ResponseBanner({ markdown }: { markdown: string }) {
715
764
  }
716
765
 
717
766
  function ContentTabs() {
718
- const { activeView, setActiveView, openFiles, closeFile } = React.useContext(ActiveViewContext);
767
+ const { activeView, setActiveView, openFiles, closeFile, canvasFiles } = React.useContext(ActiveViewContext);
719
768
  const sessionId = React.useContext(SessionContext);
720
769
  const { addAnnotationWithId, setActiveAnnotationId } = useAnnotations();
721
770
 
722
- const showTabs = openFiles.length > 0;
771
+ const showTabs = canvasFiles.length > 1 || openFiles.length > 0;
723
772
  if (!showTabs) return null;
724
773
 
725
774
  return (
726
775
  <div className="flex items-center border-b border-border-medium bg-bg-surface overflow-x-auto sticky top-0 z-10">
727
- {/* Plan tab — only show when file tabs exist */}
728
- {showTabs && (
729
- <button
730
- onClick={() => setActiveView({ type: "plan" })}
731
- className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
732
- activeView.type === "plan"
733
- ? "text-text-primary"
734
- : "text-text-tertiary hover:text-text-secondary"
735
- }`}
736
- >
737
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
738
- <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
739
- </svg>
740
- Canvas
741
- {activeView.type === "plan" && (
742
- <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
743
- )}
744
- </button>
776
+ {/* Overview tab — only when multiple canvases */}
777
+ {canvasFiles.length > 1 && (
778
+ <>
779
+ <button
780
+ onClick={() => setActiveView({ type: "overview" })}
781
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
782
+ activeView.type === "overview"
783
+ ? "text-text-primary"
784
+ : "text-text-tertiary hover:text-text-secondary"
785
+ }`}
786
+ >
787
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
788
+ <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
789
+ </svg>
790
+ Overview
791
+ {activeView.type === "overview" && (
792
+ <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
793
+ )}
794
+ </button>
795
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
796
+ </>
745
797
  )}
746
798
 
747
- {showTabs && <>
748
- {/* Separator */}
799
+ {/* Canvas tabs */}
800
+ {canvasFiles.map((filename, i) => {
801
+ const label = filename.replace(/\.jsx$/, "");
802
+ const isActive = activeView.type === "canvas" && activeView.filename === filename;
803
+ return (
804
+ <React.Fragment key={filename}>
805
+ <button
806
+ onClick={() => setActiveView({ type: "canvas", filename })}
807
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
808
+ isActive
809
+ ? "text-text-primary"
810
+ : "text-text-tertiary hover:text-text-secondary"
811
+ }`}
812
+ >
813
+ {label}
814
+ {isActive && (
815
+ <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
816
+ )}
817
+ </button>
818
+ {i < canvasFiles.length - 1 && (
819
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
820
+ )}
821
+ </React.Fragment>
822
+ );
823
+ })}
824
+
825
+ {openFiles.length > 0 && <>
826
+ {/* Separator between canvas and file tabs */}
749
827
  <span className="w-px h-4 bg-border-medium flex-shrink-0" />
750
828
 
751
829
  {/* File tabs */}
@@ -1,5 +1,6 @@
1
- import React, { useEffect, useState, useRef, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useState, useRef, useCallback, useMemo, useContext } from "react";
2
2
  import { RevisionSelect } from "./RevisionSelect";
3
+ import { RevisionContext, type RevisionInfo, type CanvasFileInfo } from "./App";
3
4
  import { runDomDiff } from "./domDiff";
4
5
  import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
5
6
  import { useAnnotations } from "./AnnotationProvider";
@@ -15,12 +16,43 @@ interface CompareViewProps {
15
16
 
16
17
  type CompareMode = "unified" | "side-by-side";
17
18
 
19
+ /** Get union of canvas filenames from two revisions */
20
+ function getCompareFiles(revisions: RevisionInfo[], leftRev: number, rightRev: number): string[] {
21
+ const left = revisions.find(r => r.revision === leftRev);
22
+ const right = revisions.find(r => r.revision === rightRev);
23
+ const files = new Set<string>();
24
+ for (const cf of left?.canvasFiles || []) files.add(cf.filename);
25
+ for (const cf of right?.canvasFiles || []) files.add(cf.filename);
26
+ return [...files].sort();
27
+ }
28
+
18
29
  export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
19
30
  const [leftRev, setLeftRev] = useState(initialLeft);
20
31
  const [rightRev, setRightRev] = useState(initialRight);
21
32
  const [mode, setMode] = useState<CompareMode>("unified");
22
33
  const [hasChanges, setHasChanges] = useState(true);
23
34
  const [diffDone, setDiffDone] = useState(false);
35
+ const { revisions } = useContext(RevisionContext);
36
+
37
+ const compareFiles = useMemo(() => getCompareFiles(revisions, leftRev, rightRev), [revisions, leftRev, rightRev]);
38
+ const [selectedFile, setSelectedFile] = useState<string>(() => compareFiles[0] || "plan.jsx");
39
+
40
+ // Reset selected file when revisions change
41
+ useEffect(() => {
42
+ if (!compareFiles.includes(selectedFile)) {
43
+ setSelectedFile(compareFiles[0] || "plan.jsx");
44
+ }
45
+ }, [compareFiles, selectedFile]);
46
+
47
+ // Check if file exists in each revision
48
+ const leftHasFile = useMemo(() => {
49
+ const rev = revisions.find(r => r.revision === leftRev);
50
+ return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false;
51
+ }, [revisions, leftRev, selectedFile]);
52
+ const rightHasFile = useMemo(() => {
53
+ const rev = revisions.find(r => r.revision === rightRev);
54
+ return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false;
55
+ }, [revisions, rightRev, selectedFile]);
24
56
 
25
57
  return (
26
58
  <div className="flex flex-col h-screen">
@@ -74,20 +106,65 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
74
106
  </div>
75
107
  </div>
76
108
 
77
- {mode === "unified" ? (
109
+ {/* Canvas file selector — only show when multiple files */}
110
+ {compareFiles.length > 1 && (
111
+ <div className="flex items-center gap-1 px-6 py-2 border-b border-border-subtle bg-bg-surface flex-shrink-0 overflow-x-auto">
112
+ {compareFiles.map(filename => {
113
+ const label = filename.replace(/\.jsx$/, "");
114
+ const isSelected = filename === selectedFile;
115
+ const inLeft = revisions.find(r => r.revision === leftRev)?.canvasFiles.some(cf => cf.filename === filename);
116
+ const inRight = revisions.find(r => r.revision === rightRev)?.canvasFiles.some(cf => cf.filename === filename);
117
+ const statusLabel = !inLeft ? " (added)" : !inRight ? " (removed)" : "";
118
+ return (
119
+ <button
120
+ key={filename}
121
+ onClick={() => setSelectedFile(filename)}
122
+ className={`text-[12px] font-body px-3 py-1 rounded-md transition-colors whitespace-nowrap ${
123
+ isSelected
124
+ ? "bg-bg-elevated text-text-primary font-medium"
125
+ : "text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated"
126
+ }`}
127
+ >
128
+ {label}
129
+ {statusLabel && (
130
+ <span className={`ml-1 text-[10px] ${!inLeft ? "text-accent-green" : "text-accent-red"}`}>
131
+ {statusLabel}
132
+ </span>
133
+ )}
134
+ </button>
135
+ );
136
+ })}
137
+ </div>
138
+ )}
139
+
140
+ {/* Show placeholder for files that only exist in one revision */}
141
+ {!leftHasFile || !rightHasFile ? (
142
+ <div className="flex-1 flex items-center justify-center">
143
+ <div className="text-center text-text-tertiary font-body">
144
+ <div className="text-lg mb-2">
145
+ {!leftHasFile ? "New file" : "Removed file"}
146
+ </div>
147
+ <div className="text-[13px]">
148
+ {selectedFile.replace(/\.jsx$/, "")} {!leftHasFile ? "was added" : "was removed"} in this revision
149
+ </div>
150
+ </div>
151
+ </div>
152
+ ) : mode === "unified" ? (
78
153
  <UnifiedView
79
- key={`unified-${leftRev}-${rightRev}`}
154
+ key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
80
155
  sessionId={sessionId}
81
156
  leftRev={leftRev}
82
157
  rightRev={rightRev}
158
+ filename={selectedFile}
83
159
  onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
84
160
  />
85
161
  ) : (
86
162
  <SideBySideView
87
- key={`sbs-${leftRev}-${rightRev}`}
163
+ key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
88
164
  sessionId={sessionId}
89
165
  leftRev={leftRev}
90
166
  rightRev={rightRev}
167
+ filename={selectedFile}
91
168
  onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
92
169
  />
93
170
  )}
@@ -99,7 +176,6 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
99
176
 
100
177
  function SyncScrollCheckbox() {
101
178
  const [checked, setChecked] = useState(true);
102
- // Store in a ref accessible to SideBySideView via DOM attribute
103
179
  useEffect(() => {
104
180
  document.documentElement.dataset.syncScroll = checked ? "1" : "0";
105
181
  return () => { delete document.documentElement.dataset.syncScroll; };
@@ -124,10 +200,11 @@ interface ViewProps {
124
200
  sessionId: string;
125
201
  leftRev: number;
126
202
  rightRev: number;
203
+ filename: string;
127
204
  onDiffResult: (done: boolean, hasChanges: boolean) => void;
128
205
  }
129
206
 
130
- function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
207
+ function UnifiedView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
131
208
  const oldRef = useRef<HTMLDivElement>(null);
132
209
  const newRef = useRef<HTMLDivElement>(null);
133
210
  const unifiedRef = useRef<HTMLDivElement>(null);
@@ -139,15 +216,15 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
139
216
  const [slow, setSlow] = useState(false);
140
217
 
141
218
  const { annotations } = useAnnotations();
142
- const planAnnotations = annotations.filter(a => !a.filePath);
219
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
143
220
  const { popovers } = useTextAnnotation({
144
221
  containerRef: unifiedRef,
145
222
  restoreKey: done,
146
- restoreAnnotations: planAnnotations,
223
+ restoreAnnotations: canvasAnnotations,
147
224
  extractContext: (range) => extractContext(range, unifiedRef.current!),
148
225
  });
149
226
 
150
- // Detect stuck loading — log diagnostic state after 10s
227
+ // Detect stuck loading
151
228
  useEffect(() => {
152
229
  if (done || loadError) return;
153
230
  const id = setTimeout(() => {
@@ -192,13 +269,14 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
192
269
 
193
270
  return (
194
271
  <div className="flex-1 min-h-0 overflow-y-auto">
195
- {/* Offscreen source panels — unmount after diff is built */}
272
+ {/* Offscreen source panels */}
196
273
  {!done && !loadError && (
197
274
  <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
198
275
  <ComparePanelRenderer
199
276
  ref={oldRef}
200
277
  sessionId={sessionId}
201
278
  revision={leftRev}
279
+ filename={filename}
202
280
  onReady={() => setLeftReady(true)}
203
281
  onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
204
282
  />
@@ -206,6 +284,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
206
284
  ref={newRef}
207
285
  sessionId={sessionId}
208
286
  revision={rightRev}
287
+ filename={filename}
209
288
  onReady={() => setRightReady(true)}
210
289
  onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
211
290
  />
@@ -239,7 +318,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
239
318
 
240
319
  /* ── Side-by-side view ── */
241
320
 
242
- function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
321
+ function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
243
322
  const oldRef = useRef<HTMLDivElement>(null);
244
323
  const newRef = useRef<HTMLDivElement>(null);
245
324
  const [leftReady, setLeftReady] = useState(false);
@@ -255,11 +334,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
255
334
  const handleRightReady = useCallback(() => setRightReady(true), []);
256
335
 
257
336
  const { annotations } = useAnnotations();
258
- const planAnnotations = annotations.filter(a => !a.filePath);
337
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
259
338
  const { popovers } = useTextAnnotation({
260
339
  containerRef: viewRef,
261
340
  restoreKey: done,
262
- restoreAnnotations: planAnnotations,
341
+ restoreAnnotations: canvasAnnotations,
263
342
  extractContext: (range) => extractContext(range, viewRef.current!),
264
343
  });
265
344
 
@@ -314,6 +393,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
314
393
  ref={oldRef}
315
394
  sessionId={sessionId}
316
395
  revision={leftRev}
396
+ filename={filename}
317
397
  onReady={handleLeftReady}
318
398
  />
319
399
  </div>
@@ -324,6 +404,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
324
404
  ref={newRef}
325
405
  sessionId={sessionId}
326
406
  revision={rightRev}
407
+ filename={filename}
327
408
  onReady={handleRightReady}
328
409
  />
329
410
  </div>
@@ -338,12 +419,13 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
338
419
  interface ComparePanelRendererProps {
339
420
  sessionId: string;
340
421
  revision: number;
422
+ filename: string;
341
423
  onReady: () => void;
342
424
  onError?: (message: string) => void;
343
425
  }
344
426
 
345
- const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
346
- ({ sessionId, revision, onReady, onError }, ref) => {
427
+ export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
428
+ ({ sessionId, revision, filename, onReady, onError }, ref) => {
347
429
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
348
430
  const [error, setError] = useState<string | null>(null);
349
431
  const [loading, setLoading] = useState(true);
@@ -351,7 +433,8 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
351
433
  useEffect(() => {
352
434
  setLoading(true);
353
435
  setError(null);
354
- import(`/api/session/${sessionId}/plan.js?rev=${revision}&t=${Date.now()}`)
436
+ const jsName = filename.replace(/\.jsx$/, ".js");
437
+ import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
355
438
  .then((mod) => {
356
439
  if (!mod.default) {
357
440
  const msg = "Module has no default export";
@@ -368,7 +451,7 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
368
451
  setLoading(false);
369
452
  onError?.(e.message);
370
453
  });
371
- }, [sessionId, revision]);
454
+ }, [sessionId, revision, filename]);
372
455
 
373
456
  useEffect(() => {
374
457
  if (!PlanComponent) return;