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,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 */}