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.
@@ -0,0 +1,326 @@
1
+ import React, { useState, useContext, useMemo, useRef, useEffect, useCallback } from "react";
2
+ import { SessionContext } from "#canvas/runtime";
3
+ import { PlanRenderer } from "./PlanRenderer";
4
+ import { ComparePanelRenderer } from "./CompareView";
5
+ import { ActiveViewContext, RevisionContext, type CanvasFileInfo, type RevisionInfo } from "./App";
6
+ import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
7
+ import { runDomDiff } from "./domDiff";
8
+
9
+ interface OverviewViewProps {
10
+ revision: number;
11
+ responseBanner?: React.ReactNode;
12
+ }
13
+
14
+ interface FileChange {
15
+ filename: string;
16
+ status: "new" | "changed" | "unchanged";
17
+ diffStats?: { added: number; removed: number };
18
+ }
19
+
20
+ export function categorizeChanges(
21
+ currentRev: RevisionInfo | undefined,
22
+ previousRev: RevisionInfo | undefined,
23
+ ): FileChange[] {
24
+ if (!currentRev) return [];
25
+
26
+ return currentRev.canvasFiles.map(cf => {
27
+ if (!cf.diffStats) {
28
+ return { filename: cf.filename, status: "new" as const };
29
+ }
30
+ if (cf.diffStats.added === 0 && cf.diffStats.removed === 0) {
31
+ return { filename: cf.filename, status: "unchanged" as const, diffStats: cf.diffStats };
32
+ }
33
+ return { filename: cf.filename, status: "changed" as const, diffStats: cf.diffStats };
34
+ });
35
+ }
36
+
37
+ export function getAffectedFiles(changes: FileChange[]): string[] {
38
+ return changes.filter(c => c.status !== "unchanged").map(c => c.filename);
39
+ }
40
+
41
+ export function OverviewView({ revision, responseBanner }: OverviewViewProps) {
42
+ const { setActiveView } = useContext(ActiveViewContext);
43
+ const { revisions } = useContext(RevisionContext);
44
+
45
+ const currentRev = revisions.find(r => r.revision === revision);
46
+ const previousRev = revisions.find(r => r.revision === revision - 1);
47
+ const changes = useMemo(() => categorizeChanges(currentRev, previousRev), [currentRev, previousRev]);
48
+
49
+ const affected = changes.filter(c => c.status !== "unchanged");
50
+ const unchanged = changes.filter(c => c.status === "unchanged");
51
+
52
+ return (
53
+ <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
+ {responseBanner}
55
+
56
+ {affected.map(change => (
57
+ <OverviewFileSection
58
+ key={change.filename}
59
+ change={change}
60
+ revision={revision}
61
+ previousRevision={previousRev?.revision}
62
+ onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
63
+ />
64
+ ))}
65
+
66
+ {unchanged.length > 0 && (
67
+ <div className="mt-8 border-t border-border-subtle pt-4">
68
+ <div className="text-[11px] font-body font-medium uppercase tracking-widest text-text-tertiary mb-3">
69
+ Unchanged
70
+ </div>
71
+ {unchanged.map(change => (
72
+ <UnchangedFileRow
73
+ key={change.filename}
74
+ change={change}
75
+ revision={revision}
76
+ onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
77
+ />
78
+ ))}
79
+ </div>
80
+ )}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ type ViewMode = "rendered" | "unified" | "side-by-side";
86
+
87
+ function ViewModeToggle({ mode, onChange, showDiff }: { mode: ViewMode; onChange: (m: ViewMode) => void; showDiff: boolean }) {
88
+ if (!showDiff) return null;
89
+ return (
90
+ <div className="flex items-center bg-bg-elevated rounded-md p-0.5">
91
+ {(["rendered", "unified", "side-by-side"] as ViewMode[]).map(m => (
92
+ <button
93
+ key={m}
94
+ onClick={() => onChange(m)}
95
+ className={`text-[10px] font-body px-2 py-0.5 rounded transition-colors ${
96
+ mode === m
97
+ ? "bg-bg-surface text-text-primary font-medium shadow-sm"
98
+ : "text-text-tertiary hover:text-text-secondary"
99
+ }`}
100
+ >
101
+ {m === "rendered" ? "Full" : m === "unified" ? "Unified" : "Split"}
102
+ </button>
103
+ ))}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function OverviewFileSection({
109
+ change,
110
+ revision,
111
+ previousRevision,
112
+ onOpenTab,
113
+ }: {
114
+ change: FileChange;
115
+ revision: number;
116
+ previousRevision?: number;
117
+ onOpenTab: () => void;
118
+ }) {
119
+ const label = change.filename.replace(/\.jsx$/, "");
120
+ const isNew = change.status === "new";
121
+ const diffStats = change.diffStats;
122
+ const canDiff = change.status === "changed" && previousRevision != null;
123
+ const [viewMode, setViewMode] = useState<ViewMode>("rendered");
124
+
125
+ return (
126
+ <div className="mb-8">
127
+ {/* File header */}
128
+ <div className="flex items-center gap-2 mb-3 pb-2 border-b border-border-subtle">
129
+ <span className={`text-[11px] font-mono px-1.5 py-0.5 rounded ${
130
+ isNew
131
+ ? "bg-accent-green/10 text-accent-green border border-accent-green/20"
132
+ : "bg-accent-amber/10 text-accent-amber border border-accent-amber/20"
133
+ }`}>
134
+ {isNew ? "new" : "modified"}
135
+ </span>
136
+ <span className="text-[14px] font-body font-medium text-text-primary">{label}</span>
137
+ {diffStats && (diffStats.added > 0 || diffStats.removed > 0) && (
138
+ <span className="text-[11px] font-mono text-text-tertiary">
139
+ <span className="text-accent-green">+{diffStats.added}</span>
140
+ {" "}
141
+ <span className="text-accent-red">-{diffStats.removed}</span>
142
+ </span>
143
+ )}
144
+ <ViewModeToggle mode={viewMode} onChange={setViewMode} showDiff={canDiff} />
145
+ <button
146
+ onClick={onOpenTab}
147
+ className={`${canDiff ? "" : "ml-auto"} text-[11px] font-body font-medium text-accent-blue hover:text-accent-blue/80 transition-colors`}
148
+ >
149
+ Open in tab
150
+ </button>
151
+ </div>
152
+
153
+ {/* Content based on view mode */}
154
+ {viewMode === "rendered" ? (
155
+ <div data-canvas-file={change.filename}>
156
+ <PlanRenderer revision={revision} filename={change.filename} />
157
+ </div>
158
+ ) : canDiff && previousRevision != null ? (
159
+ <InlineDiff
160
+ key={`${viewMode}-${revision}-${previousRevision}`}
161
+ filename={change.filename}
162
+ leftRev={previousRevision}
163
+ rightRev={revision}
164
+ mode={viewMode}
165
+ />
166
+ ) : null}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ /* ── Inline diff for overview ── */
172
+
173
+ function InlineDiff({
174
+ filename,
175
+ leftRev,
176
+ rightRev,
177
+ mode,
178
+ }: {
179
+ filename: string;
180
+ leftRev: number;
181
+ rightRev: number;
182
+ mode: "unified" | "side-by-side";
183
+ }) {
184
+ const sessionId = useContext(SessionContext);
185
+
186
+ if (mode === "unified") {
187
+ return <InlineUnifiedDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
188
+ }
189
+ return <InlineSideBySideDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
190
+ }
191
+
192
+ function InlineUnifiedDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
193
+ const oldRef = useRef<HTMLDivElement>(null);
194
+ const newRef = useRef<HTMLDivElement>(null);
195
+ const unifiedRef = useRef<HTMLDivElement>(null);
196
+ const [leftReady, setLeftReady] = useState(false);
197
+ const [rightReady, setRightReady] = useState(false);
198
+ const [done, setDone] = useState(false);
199
+ const [error, setError] = useState<string | null>(null);
200
+
201
+ useEffect(() => {
202
+ if (!leftReady || !rightReady || done) return;
203
+ if (!oldRef.current || !newRef.current) return;
204
+
205
+ requestAnimationFrame(() => {
206
+ requestAnimationFrame(() => {
207
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
208
+ try {
209
+ const oldTree = extractBlockTree(oldRef.current);
210
+ const newTree = extractBlockTree(newRef.current);
211
+ const matches = matchBlocks(oldTree, newTree);
212
+ const { fragment } = buildUnifiedDom(matches);
213
+ unifiedRef.current.innerHTML = "";
214
+ unifiedRef.current.appendChild(fragment);
215
+ setDone(true);
216
+ } catch (e) {
217
+ setError(e instanceof Error ? e.message : String(e));
218
+ }
219
+ });
220
+ });
221
+ }, [leftReady, rightReady, done]);
222
+
223
+ return (
224
+ <div>
225
+ {/* Offscreen panels for diff computation */}
226
+ {!done && !error && (
227
+ <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
228
+ <ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={() => setLeftReady(true)} onError={(msg) => setError(msg)} />
229
+ <ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={() => setRightReady(true)} onError={(msg) => setError(msg)} />
230
+ </div>
231
+ )}
232
+ {!done && !error && (
233
+ <div className="flex items-center justify-center h-24 text-text-tertiary font-body text-[13px]">Computing diff...</div>
234
+ )}
235
+ {error && (
236
+ <div className="text-accent-red text-[13px] font-body p-3 bg-accent-red-muted rounded-lg">{error}</div>
237
+ )}
238
+ <div ref={unifiedRef} />
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function InlineSideBySideDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
244
+ const oldRef = useRef<HTMLDivElement>(null);
245
+ const newRef = useRef<HTMLDivElement>(null);
246
+ const [leftReady, setLeftReady] = useState(false);
247
+ const [rightReady, setRightReady] = useState(false);
248
+ const [done, setDone] = useState(false);
249
+
250
+ const handleLeftReady = useCallback(() => setLeftReady(true), []);
251
+ const handleRightReady = useCallback(() => setRightReady(true), []);
252
+
253
+ useEffect(() => {
254
+ if (!leftReady || !rightReady || done) return;
255
+ if (!oldRef.current || !newRef.current) return;
256
+
257
+ requestAnimationFrame(() => {
258
+ requestAnimationFrame(() => {
259
+ if (!oldRef.current || !newRef.current) return;
260
+ runDomDiff(oldRef.current, newRef.current);
261
+ setDone(true);
262
+ });
263
+ });
264
+ }, [leftReady, rightReady, done]);
265
+
266
+ return (
267
+ <div className="flex gap-0 border-y border-border-subtle overflow-hidden w-screen relative left-1/2 -translate-x-1/2">
268
+ <div className="w-1/2 border-r border-border-subtle">
269
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
270
+ Rev {leftRev}
271
+ </div>
272
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
273
+ <ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={handleLeftReady} />
274
+ </div>
275
+ </div>
276
+ <div className="w-1/2">
277
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
278
+ Rev {rightRev}
279
+ </div>
280
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
281
+ <ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={handleRightReady} />
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function UnchangedFileRow({
289
+ change,
290
+ revision,
291
+ onOpenTab,
292
+ }: {
293
+ change: FileChange;
294
+ revision: number;
295
+ onOpenTab: () => void;
296
+ }) {
297
+ const [expanded, setExpanded] = useState(false);
298
+ const label = change.filename.replace(/\.jsx$/, "");
299
+
300
+ return (
301
+ <div className="mb-2">
302
+ <div className="flex items-center gap-2 py-1.5">
303
+ <button
304
+ onClick={() => setExpanded(!expanded)}
305
+ className="text-text-tertiary hover:text-text-secondary transition-colors"
306
+ >
307
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}>
308
+ <path d="M3.5 2L6.5 5L3.5 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
309
+ </svg>
310
+ </button>
311
+ <span className="text-[13px] font-body text-text-secondary">{label}</span>
312
+ <button
313
+ onClick={onOpenTab}
314
+ className="ml-auto text-[11px] font-body text-text-tertiary hover:text-accent-blue transition-colors"
315
+ >
316
+ Open in tab
317
+ </button>
318
+ </div>
319
+ {expanded && (
320
+ <div className="ml-5 mt-1 mb-3" data-canvas-file={change.filename}>
321
+ <PlanRenderer revision={revision} filename={change.filename} />
322
+ </div>
323
+ )}
324
+ </div>
325
+ );
326
+ }
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useState, useRef, useContext, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import { SessionContext } from "#canvas/runtime";
3
+ import { SessionContext, CanvasFileCtx } from "#canvas/runtime";
4
4
  import { useAnnotations } from "./AnnotationProvider";
5
5
  import { extractContext } from "./annotationContext";
6
6
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
@@ -14,15 +14,18 @@ const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='t
14
14
 
15
15
  interface PlanRendererProps {
16
16
  revision: number;
17
+ filename: string;
17
18
  }
18
19
 
19
- export function PlanRenderer({ revision }: PlanRendererProps) {
20
+ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
20
21
  const sessionId = useContext(SessionContext);
21
22
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
22
23
  const [error, setError] = useState<string | null>(null);
23
24
  const [loading, setLoading] = useState(true);
24
25
  const containerRef = useRef<HTMLDivElement>(null);
25
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
26
+ const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
27
+ // Filter annotations to this canvas file
28
+ const annotations = useMemo(() => allAnnotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename)), [allAnnotations, filename]);
26
29
  const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
27
30
 
28
31
  // Block annotation hover state
@@ -37,10 +40,11 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
37
40
  setLoading(true);
38
41
  setError(null);
39
42
  setFocusedBlockIndex(null);
40
- import(`/api/session/${sessionId}/plan.js?rev=${revision}&t=${Date.now()}`)
43
+ const jsName = filename.replace(/\.jsx$/, ".js");
44
+ import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
41
45
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
42
46
  .catch((e) => { setError(e.message); setLoading(false); });
43
- }, [sessionId, revision]);
47
+ }, [sessionId, revision, filename]);
44
48
 
45
49
  const scrollContainer = document.getElementById("plan-scroll-container");
46
50
 
@@ -49,6 +53,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
49
53
  restoreKey: PlanComponent,
50
54
  restoreAnnotations: annotations,
51
55
  extractContext: (range) => extractContext(range, containerRef.current!),
56
+ canvasFile: filename,
52
57
  scrollContainer,
53
58
  });
54
59
 
@@ -257,7 +262,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
257
262
  }
258
263
 
259
264
  return (
260
- <>
265
+ <CanvasFileCtx.Provider value={filename}>
261
266
  <div ref={containerRef} className="plan-content plan-updated">
262
267
  {planElement}
263
268
  </div>
@@ -298,7 +303,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
298
303
  truncateAt={80}
299
304
  onAdd={(note, images) => {
300
305
  const id = generateAnnotationId();
301
- addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images);
306
+ addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images, filename);
302
307
  setBlockPopover(null);
303
308
  }}
304
309
  onCancel={() => setBlockPopover(null)}
@@ -328,7 +333,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
328
333
  />
329
334
  );
330
335
  })()}
331
- </>
336
+ </CanvasFileCtx.Provider>
332
337
  );
333
338
  }
334
339
 
@@ -86,13 +86,22 @@ export function RevisionSelect({ value, onChange, accent = "default", className
86
86
  )}
87
87
  </span>
88
88
  <span className="text-[10px] text-text-tertiary flex-shrink-0 flex items-center gap-1.5">
89
- {r.diffStats && (
90
- <span className="font-mono">
91
- <span className="text-accent-green">+{r.diffStats.added}</span>
92
- {" "}
93
- <span className="text-accent-red">-{r.diffStats.removed}</span>
94
- </span>
95
- )}
89
+ {(() => {
90
+ const stats = r.canvasFiles?.reduce((acc, cf) => {
91
+ if (cf.diffStats) { acc.added += cf.diffStats.added; acc.removed += cf.diffStats.removed; }
92
+ return acc;
93
+ }, { added: 0, removed: 0 });
94
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
95
+ return (
96
+ <span className="font-mono">
97
+ <span className="text-accent-green">+{stats.added}</span>
98
+ {" "}
99
+ <span className="text-accent-red">-{stats.removed}</span>
100
+ </span>
101
+ );
102
+ }
103
+ return null;
104
+ })()}
96
105
  {r.revision === currentRevision ? "current" : r.hasFeedback ? "sent" : relativeTime(r.createdAt)}
97
106
  </span>
98
107
  </button>
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef, useState, useCallback } from "react";
2
2
  import ReactDOM from "react-dom";
3
- import { useAnnotations, Annotation } from "#canvas/runtime";
3
+ import { useAnnotations, useCanvasFile, Annotation } from "#canvas/runtime";
4
4
  import { AnnotationCreatePopover } from "../Popover";
5
5
 
6
6
  interface MermaidProps {
@@ -159,6 +159,7 @@ export function Mermaid({ children }: MermaidProps) {
159
159
  const [svgHtml, setSvgHtml] = useState("");
160
160
  const source = typeof children === "string" ? children : String(children ?? "");
161
161
  const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
162
+ const canvasFile = useCanvasFile();
162
163
  const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
163
164
  const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
164
165
 
@@ -278,7 +279,7 @@ export function Mermaid({ children }: MermaidProps) {
278
279
  }
279
280
  onAdd={(note, images) => {
280
281
  const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
281
- addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images);
282
+ addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images, canvasFile || undefined);
282
283
  setNodePopover(null);
283
284
  }}
284
285
  onCancel={() => setNodePopover(null)}
@@ -13,7 +13,7 @@ export function generateMarkdown(
13
13
  ): string {
14
14
  const parts: string[] = [];
15
15
  const responseAnns = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
16
- const planAnns = annotations.filter((a) => !a.filePath);
16
+ const canvasAnns = annotations.filter((a) => !a.filePath);
17
17
  const fileAnns = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
18
18
  const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
19
19
  const fbEntries = feedbackEntries
@@ -43,12 +43,31 @@ export function generateMarkdown(
43
43
  }
44
44
  }
45
45
 
46
- // Plan annotations grouped by hierarchy
47
- if (planAnns.length > 0) {
46
+ // Canvas annotations grouped by canvas file, then hierarchy
47
+ if (canvasAnns.length > 0) {
48
48
  parts.push("## Canvas annotations");
49
49
  parts.push("");
50
- const grouped = groupByHierarchy(planAnns);
51
- parts.push(renderHierarchyGroup(grouped));
50
+
51
+ // Group by canvasFile
52
+ const byCanvas = new Map<string, Annotation[]>();
53
+ for (const ann of canvasAnns) {
54
+ const key = ann.canvasFile || "";
55
+ if (!byCanvas.has(key)) byCanvas.set(key, []);
56
+ byCanvas.get(key)!.push(ann);
57
+ }
58
+
59
+ const canvasKeys = [...byCanvas.keys()].sort();
60
+ const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== "");
61
+
62
+ for (const canvasKey of canvasKeys) {
63
+ const anns = byCanvas.get(canvasKey)!;
64
+ if (multipleCanvases && canvasKey) {
65
+ parts.push(`### ${canvasKey.replace(/\.jsx$/, "")}`);
66
+ parts.push("");
67
+ }
68
+ const grouped = groupByHierarchy(anns);
69
+ parts.push(renderHierarchyGroup(grouped));
70
+ }
52
71
  }
53
72
 
54
73
  // File annotations grouped by path
@@ -18,6 +18,7 @@ export interface Annotation {
18
18
  note: string;
19
19
  createdAt: string;
20
20
  filePath?: string;
21
+ canvasFile?: string;
21
22
  context?: AnnotationContext;
22
23
  images?: string[];
23
24
  }
@@ -42,7 +43,7 @@ export interface FeedbackEntry {
42
43
  export interface AnnotationContextValue {
43
44
  annotations: Annotation[];
44
45
  addAnnotation: (snippet: string, note: string, filePath?: string) => void;
45
- addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => void;
46
+ addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => void;
46
47
  updateAnnotation: (id: string, note: string) => void;
47
48
  removeAnnotation: (id: string) => void;
48
49
  addAnnotationImage: (id: string, imagePath: string) => void;
@@ -84,8 +85,14 @@ export function useFeedback(
84
85
 
85
86
  export { SessionContext } from "./SessionContext";
86
87
 
88
+ // CanvasFile context — tells components which canvas file they belong to
89
+ export const CanvasFileCtx = createContext<string>("");
90
+ export function useCanvasFile(): string {
91
+ return useContext(CanvasFileCtx);
92
+ }
93
+
87
94
  // ActiveView navigation — allows components (e.g. FilePreview) to open files
88
- export type ActiveView = { type: "plan" } | { type: "file"; path: string };
95
+ export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
89
96
 
90
97
  export const ActiveViewCtx = createContext<{
91
98
  setActiveView: (v: ActiveView) => void;
@@ -250,7 +250,9 @@ function processBlockWithChildren(
250
250
  } else if (cm.kind === "removed") {
251
251
  hasChanges = true;
252
252
  const ghost = ghostBlock(cm.oldBlock!.element);
253
- const insertBefore = findInsertionPoint(childMatches, cm, cloneMap.newMap);
253
+ const refNode = findInsertionPoint(childMatches, cm, cloneMap.newMap);
254
+ // refNode may be deeply nested — walk up to find a direct child of the container
255
+ const insertBefore = refNode ? directChildOf(newContainer, refNode) : null;
254
256
  newContainer.insertBefore(ghost, insertBefore);
255
257
  } else if (cm.kind === "added") {
256
258
  hasChanges = true;
@@ -374,6 +376,15 @@ function findInsertionPoint(
374
376
  return null;
375
377
  }
376
378
 
379
+ /** Walk up from `el` to find the ancestor that is a direct child of `container`. */
380
+ function directChildOf(container: HTMLElement, el: HTMLElement): HTMLElement | null {
381
+ let node: HTMLElement | null = el;
382
+ while (node && node.parentElement !== container) {
383
+ node = node.parentElement;
384
+ }
385
+ return node;
386
+ }
387
+
377
388
  /* ── Element-level diff for blocks with rich internal structure (e.g. markdown) ── */
378
389
 
379
390
  /**
@@ -16,12 +16,14 @@ interface UseTextAnnotationOptions {
16
16
  extractContext: (range: Range) => AnnotationContext | undefined;
17
17
  /** Optional filePath to associate with created annotations */
18
18
  filePath?: string;
19
+ /** Optional canvasFile to associate with created annotations */
20
+ canvasFile?: string;
19
21
  /** Scroll container for popover positioning */
20
22
  scrollContainer?: HTMLElement | null;
21
23
  }
22
24
 
23
25
  export function useTextAnnotation(options: UseTextAnnotationOptions) {
24
- const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, scrollContainer } = options;
26
+ const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, canvasFile, scrollContainer } = options;
25
27
  const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
26
28
 
27
29
  const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
@@ -32,6 +34,8 @@ export function useTextAnnotation(options: UseTextAnnotationOptions) {
32
34
  extractContextRef.current = extractContext;
33
35
  const filePathRef = useRef(filePath);
34
36
  filePathRef.current = filePath;
37
+ const canvasFileRef = useRef(canvasFile);
38
+ canvasFileRef.current = canvasFile;
35
39
  const addAnnotationRef = useRef(addAnnotationWithId);
36
40
  addAnnotationRef.current = addAnnotationWithId;
37
41
  const activeAnnotationIdRef = useRef(activeAnnotationId);
@@ -167,7 +171,7 @@ export function useTextAnnotation(options: UseTextAnnotationOptions) {
167
171
  onAdd={(note, images) => {
168
172
  const id = generateAnnotationId();
169
173
  renameMarkId(createPopover.tempId, id);
170
- addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images);
174
+ addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images, canvasFileRef.current);
171
175
  setCreatePopover(null);
172
176
  }}
173
177
  onCancel={() => {