agent-canvas 0.1.9 → 0.1.10

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.
Binary file
Binary file
package/README.md CHANGED
@@ -5,6 +5,8 @@ Interactive browser-based visual canvas for [Claude Code](https://docs.anthropic
5
5
  ![License](https://img.shields.io/npm/l/agent-canvas)
6
6
  ![npm](https://img.shields.io/npm/v/agent-canvas)
7
7
 
8
+ ![Agent Canvas](.github/light.png)
9
+
8
10
  ## What it does
9
11
 
10
12
  When Claude needs to present a plan, review, explanation, or decision — instead of dumping markdown in the terminal — it renders a full interactive canvas in your browser. You can:
@@ -53,6 +55,14 @@ Claude uses different canvas flows depending on the task:
53
55
  | **Review** | Code/architecture review | Findings → Fix Plan → Summary |
54
56
  | **Decision** | Choosing between options | Single canvas with comparison |
55
57
 
58
+ ## Theming
59
+
60
+ Agent Canvas supports light and dark themes, automatically matching your system preference.
61
+
62
+ **Dark mode**
63
+
64
+ ![](.github/dark.png)
65
+
56
66
  ## Development
57
67
 
58
68
  ```bash
@@ -0,0 +1,307 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from "react";
2
+ import { autoResizeTextarea } from "./utils";
3
+
4
+ interface AnnotationEditorProps {
5
+ note: string;
6
+ onNoteChange: (note: string) => void;
7
+ images: string[];
8
+ onAddImage: (path: string) => void;
9
+ onRemoveImage: (path: string) => void;
10
+ sessionId: string;
11
+
12
+ placeholder?: string;
13
+ autoFocus?: boolean;
14
+ autoResize?: boolean;
15
+ readOnly?: boolean;
16
+ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
17
+ onTextareaClick?: (e: React.MouseEvent) => void;
18
+ textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
19
+ textareaClassName?: string;
20
+ textareaStyle?: React.CSSProperties;
21
+ minHeight?: number;
22
+ /** "always" (default), "on-focus" (sidebar), or "none" (popover — use openFilePickerRef) */
23
+ attachButton?: "always" | "on-focus" | "none";
24
+ /** Parent can store the open-file-picker function for external trigger */
25
+ openFilePickerRef?: React.MutableRefObject<(() => void) | null>;
26
+ }
27
+
28
+ export function imageToUrl(path: string): string {
29
+ const filename = path.split("/").pop();
30
+ return `/api/uploads/${filename}`;
31
+ }
32
+
33
+ async function uploadImage(sessionId: string, file: File): Promise<string | null> {
34
+ const formData = new FormData();
35
+ formData.append("image", file);
36
+ try {
37
+ const resp = await fetch(`/api/session/${sessionId}/upload`, { method: "POST", body: formData });
38
+ const data = await resp.json();
39
+ return data.path || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function AnnotationEditor({
46
+ note, onNoteChange, images, onAddImage, onRemoveImage, sessionId,
47
+ placeholder = "Add your note...", autoFocus = false, autoResize = false,
48
+ readOnly = false, onKeyDown, onTextareaClick, textareaRef: externalRef,
49
+ textareaClassName, textareaStyle, minHeight,
50
+ attachButton = "always", openFilePickerRef,
51
+ }: AnnotationEditorProps) {
52
+ const [isDragging, setIsDragging] = useState(false);
53
+ const [isUploading, setIsUploading] = useState(false);
54
+ const [isFocused, setIsFocused] = useState(false);
55
+ const dragCounter = useRef(0);
56
+ const internalRef = useRef<HTMLTextAreaElement | null>(null);
57
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
58
+
59
+ const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
60
+
61
+ // Expose file picker to parent
62
+ useEffect(() => {
63
+ if (openFilePickerRef) openFilePickerRef.current = openFilePicker;
64
+ }, [openFilePickerRef, openFilePicker]);
65
+
66
+ const setRef = useCallback((el: HTMLTextAreaElement | null) => {
67
+ internalRef.current = el;
68
+ if (externalRef) externalRef.current = el;
69
+ if (el && autoResize) autoResizeTextarea(el, minHeight);
70
+ }, [externalRef, autoResize, minHeight]);
71
+
72
+ useEffect(() => {
73
+ if (autoFocus && internalRef.current) {
74
+ const ta = internalRef.current;
75
+ ta.focus();
76
+ if (ta.value) ta.setSelectionRange(ta.value.length, ta.value.length);
77
+ }
78
+ }, [autoFocus]);
79
+
80
+ const doUpload = useCallback(async (file: File) => {
81
+ if (!file.type.startsWith("image/")) return;
82
+ setIsUploading(true);
83
+ try {
84
+ const path = await uploadImage(sessionId, file);
85
+ if (path) onAddImage(path);
86
+ } finally {
87
+ setIsUploading(false);
88
+ }
89
+ }, [sessionId, onAddImage]);
90
+
91
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
92
+ const items = e.clipboardData.items;
93
+ for (const item of items) {
94
+ if (item.type.startsWith("image/")) {
95
+ e.preventDefault();
96
+ const file = item.getAsFile();
97
+ if (file) doUpload(file);
98
+ return;
99
+ }
100
+ }
101
+ }, [doUpload]);
102
+
103
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
104
+ e.preventDefault();
105
+ dragCounter.current++;
106
+ if (dragCounter.current === 1) setIsDragging(true);
107
+ }, []);
108
+
109
+ const handleDragOver = useCallback((e: React.DragEvent) => {
110
+ e.preventDefault();
111
+ }, []);
112
+
113
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
114
+ e.preventDefault();
115
+ dragCounter.current--;
116
+ if (dragCounter.current === 0) setIsDragging(false);
117
+ }, []);
118
+
119
+ const handleDrop = useCallback((e: React.DragEvent) => {
120
+ e.preventDefault();
121
+ dragCounter.current = 0;
122
+ setIsDragging(false);
123
+ for (const file of e.dataTransfer.files) {
124
+ if (file.type.startsWith("image/")) doUpload(file);
125
+ }
126
+ }, [doUpload]);
127
+
128
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
129
+ onNoteChange(e.target.value);
130
+ if (autoResize) {
131
+ e.target.style.height = "auto";
132
+ e.target.style.height = (minHeight ? Math.max(minHeight, e.target.scrollHeight) : e.target.scrollHeight) + "px";
133
+ }
134
+ }, [onNoteChange, autoResize, minHeight]);
135
+
136
+ const defaultClassName = "w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none";
137
+ const defaultStyle: React.CSSProperties = {
138
+ width: "100%", background: "transparent", border: "none",
139
+ color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
140
+ fontSize: "13px", lineHeight: "1.5", outline: "none",
141
+ ...(autoResize ? { resize: "none" as const, overflow: "hidden" } : { resize: "vertical" as const }),
142
+ };
143
+
144
+ const showButton = !readOnly && (
145
+ attachButton === "always" ||
146
+ (attachButton === "on-focus" && isFocused)
147
+ );
148
+
149
+ return (
150
+ <div
151
+ style={{ position: "relative" }}
152
+ onDragEnter={readOnly ? undefined : handleDragEnter}
153
+ onDragOver={readOnly ? undefined : handleDragOver}
154
+ onDragLeave={readOnly ? undefined : handleDragLeave}
155
+ onDrop={readOnly ? undefined : handleDrop}
156
+ >
157
+ <textarea
158
+ ref={setRef}
159
+ value={note}
160
+ onChange={readOnly ? undefined : handleChange}
161
+ onPaste={readOnly ? undefined : handlePaste}
162
+ onKeyDown={onKeyDown}
163
+ onClick={onTextareaClick}
164
+ onFocus={() => setIsFocused(true)}
165
+ onBlur={() => {
166
+ // Delay so click on attach button registers before hiding
167
+ setTimeout(() => setIsFocused(false), 150);
168
+ }}
169
+ readOnly={readOnly}
170
+ placeholder={placeholder}
171
+ className={textareaClassName ?? defaultClassName}
172
+ style={textareaStyle ?? defaultStyle}
173
+ rows={autoResize ? 1 : undefined}
174
+ />
175
+
176
+ {/* Hidden file input — always rendered for paste/drop + external trigger */}
177
+ {!readOnly && (
178
+ <input
179
+ ref={fileInputRef}
180
+ type="file"
181
+ accept="image/*"
182
+ multiple
183
+ style={{ display: "none" }}
184
+ onChange={(e) => {
185
+ const files = e.target.files;
186
+ if (files) for (const f of files) doUpload(f);
187
+ e.target.value = "";
188
+ }}
189
+ />
190
+ )}
191
+
192
+ {/* Image thumbnails + inline attach button */}
193
+ {(images.length > 0 || showButton) && (
194
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px", alignItems: "center" }}>
195
+ {images.map((img) => (
196
+ <div
197
+ key={img}
198
+ style={{
199
+ position: "relative", width: "56px", height: "56px",
200
+ borderRadius: "6px", overflow: "hidden",
201
+ border: "1px solid var(--color-border-subtle)",
202
+ flexShrink: 0,
203
+ }}
204
+ >
205
+ <img
206
+ src={imageToUrl(img)}
207
+ alt=""
208
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
209
+ />
210
+ {!readOnly && (
211
+ <button
212
+ onClick={(e) => { e.stopPropagation(); onRemoveImage(img); }}
213
+ style={{
214
+ position: "absolute", top: "2px", right: "2px",
215
+ width: "16px", height: "16px", borderRadius: "50%",
216
+ background: "rgba(0,0,0,0.6)", color: "white",
217
+ border: "none", cursor: "pointer", fontSize: "11px",
218
+ display: "flex", alignItems: "center", justifyContent: "center",
219
+ lineHeight: 1, padding: 0,
220
+ }}
221
+ title="Remove image"
222
+ >
223
+ &times;
224
+ </button>
225
+ )}
226
+ </div>
227
+ ))}
228
+ {showButton && <AttachButton onClick={openFilePicker} />}
229
+ </div>
230
+ )}
231
+
232
+ {isUploading && (
233
+ <div style={{
234
+ fontSize: "11px", color: "var(--color-text-tertiary)",
235
+ fontFamily: "'Inter', sans-serif", marginTop: "4px",
236
+ }}>
237
+ Uploading...
238
+ </div>
239
+ )}
240
+
241
+ {isDragging && (
242
+ <div style={{
243
+ position: "absolute", inset: 0, display: "flex",
244
+ alignItems: "center", justifyContent: "center",
245
+ background: "var(--color-highlight-bg)", opacity: 0.9,
246
+ border: "2px dashed var(--color-highlight-border)",
247
+ borderRadius: "6px", zIndex: 10, pointerEvents: "none",
248
+ fontSize: "12px", fontFamily: "'Inter', sans-serif",
249
+ color: "var(--color-text-secondary)",
250
+ }}>
251
+ Drop image
252
+ </div>
253
+ )}
254
+ </div>
255
+ );
256
+ }
257
+
258
+ /** Small attach-image button — reusable in editor and popover button bar */
259
+ export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
260
+ return (
261
+ <button
262
+ onClick={(e) => { e.stopPropagation(); onClick(); }}
263
+ style={{
264
+ width: `${size * 2}px`, height: `${size * 2}px`, borderRadius: "6px",
265
+ border: "none",
266
+ background: "transparent", cursor: "pointer",
267
+ display: "flex", alignItems: "center", justifyContent: "center",
268
+ color: "var(--color-text-tertiary)", flexShrink: 0,
269
+ transition: "color 0.15s",
270
+ }}
271
+ title="Attach image"
272
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--color-text-secondary)"; }}
273
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--color-text-tertiary)"; }}
274
+ >
275
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
276
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
277
+ <circle cx="8.5" cy="8.5" r="1.5" />
278
+ <polyline points="21 15 16 10 5 21" />
279
+ </svg>
280
+ </button>
281
+ );
282
+ }
283
+
284
+ /** Read-only image thumbnails for display in read-only annotations */
285
+ export function ImageThumbnails({ images }: { images: string[] }) {
286
+ if (!images.length) return null;
287
+ return (
288
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px" }}>
289
+ {images.map((img) => (
290
+ <div
291
+ key={img}
292
+ style={{
293
+ width: "56px", height: "56px", borderRadius: "6px",
294
+ overflow: "hidden", border: "1px solid var(--color-border-subtle)",
295
+ flexShrink: 0,
296
+ }}
297
+ >
298
+ <img
299
+ src={imageToUrl(img)}
300
+ alt=""
301
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
302
+ />
303
+ </div>
304
+ ))}
305
+ </div>
306
+ );
307
+ }
@@ -97,8 +97,8 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
97
97
  return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
98
98
  }, [annotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
99
99
 
100
- const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext) => {
101
- setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context }]);
100
+ const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => {
101
+ setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}) }]);
102
102
  }, []);
103
103
 
104
104
  const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
@@ -115,6 +115,18 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
115
115
  setActiveAnnotationId((prev) => (prev === id ? null : prev));
116
116
  }, []);
117
117
 
118
+ const addAnnotationImage = useCallback((id: string, imagePath: string) => {
119
+ setAnnotations((prev) => prev.map((a) =>
120
+ a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
121
+ ));
122
+ }, []);
123
+
124
+ const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
125
+ setAnnotations((prev) => prev.map((a) =>
126
+ a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
127
+ ));
128
+ }, []);
129
+
118
130
  const setResponse = useCallback((id: string, response: PlanResponse) => {
119
131
  setResponses((prev) => {
120
132
  const next = new Map(prev);
@@ -159,7 +171,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
159
171
  return (
160
172
  <AnnotationCtx.Provider
161
173
  value={{
162
- annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation,
174
+ annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
163
175
  generalNote, setGeneralNote, clearAll,
164
176
  activeAnnotationId, setActiveAnnotationId,
165
177
  responses, setResponse,
@@ -7,6 +7,8 @@ import { SessionContext } from "#canvas/runtime";
7
7
  import { MarkdownPreview } from "./ResponsePreview";
8
8
  import { FileIcon } from "./FileIcon";
9
9
  import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
10
+ import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
11
+ import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
10
12
 
11
13
  interface AnnotationSidebarProps {
12
14
  onPreview: () => void;
@@ -179,8 +181,6 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
179
181
  const handleMouseEnter = (annId: string) => {
180
182
  setActiveAnnotationId(annId);
181
183
  setMarkActive(annId, true);
182
- const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
183
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
184
184
  };
185
185
 
186
186
  const handleMouseLeave = (annId: string) => {
@@ -196,8 +196,8 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
196
196
  if (ann.filePath) {
197
197
  setActiveView({ type: "file", path: ann.filePath });
198
198
  setTimeout(() => {
199
- const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
200
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
199
+ const el = findAnnotationElement(ann);
200
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
201
201
  }, 150);
202
202
  }
203
203
  }
@@ -206,7 +206,7 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
206
206
  const renderAnnotation = (ann: Annotation) => (
207
207
  <div
208
208
  key={ann.id}
209
- className={`relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
209
+ className={`group/ann relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
210
210
  activeAnnotationId === ann.id
211
211
  ? "bg-highlight-selected"
212
212
  : "odd:bg-bg-elevated-half hover:bg-bg-input"
@@ -223,6 +223,18 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
223
223
  {ann.note}
224
224
  </div>
225
225
  )}
226
+ <ImageThumbnails images={ann.images || []} />
227
+ <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
228
+ <button
229
+ onClick={(e) => { e.stopPropagation(); scrollToAnnotation(ann, setActiveView); }}
230
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
231
+ title="Scroll to annotation"
232
+ >
233
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
234
+ <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
235
+ </svg>
236
+ </button>
237
+ </div>
226
238
  </div>
227
239
  );
228
240
 
@@ -278,10 +290,12 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
278
290
  function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
279
291
  const {
280
292
  annotations, updateAnnotation, removeAnnotation,
293
+ addAnnotationImage, removeAnnotationImage,
281
294
  generalNote, setGeneralNote,
282
295
  activeAnnotationId, setActiveAnnotationId,
283
296
  responses, feedbackEntries,
284
297
  } = useAnnotations();
298
+ const sessionId = useContext(SessionContext);
285
299
  const { setActiveView } = React.useContext(ActiveViewContext);
286
300
 
287
301
  const listRef = useRef<HTMLDivElement>(null);
@@ -306,15 +320,10 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
306
320
  }
307
321
  }, [activeAnnotationId]);
308
322
 
309
- // Hover on sidebar card → highlight inline mark + block + scroll to it
323
+ // Hover on sidebar card → highlight inline mark + block
310
324
  const handleMouseEnter = useCallback((annId: string) => {
311
325
  setActiveAnnotationId(annId);
312
326
  setMarkActive(annId, true);
313
- // Scroll inline mark into view
314
- const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
315
- if (mark) {
316
- mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
317
- }
318
327
  }, [setActiveAnnotationId]);
319
328
 
320
329
  const handleMouseLeave = useCallback((annId: string) => {
@@ -345,10 +354,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
345
354
  setActiveAnnotationId(ann.id);
346
355
  if (ann.filePath) {
347
356
  setActiveView({ type: "file", path: ann.filePath });
348
- // Scroll to mark after file view renders
349
357
  setTimeout(() => {
350
- const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
351
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
358
+ const el = findAnnotationElement(ann);
359
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
352
360
  }, 150);
353
361
  }
354
362
  }
@@ -359,20 +367,38 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
359
367
  {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
360
368
  </div>
361
369
 
362
- {/* Seamless editable note */}
363
- <textarea
364
- value={ann.note}
365
- onChange={(e) => updateAnnotation(ann.id, e.target.value)}
366
- onClick={(e) => e.stopPropagation()}
367
- className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
368
- rows={1}
369
- style={{ height: "auto", overflow: "hidden" }}
370
- onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
371
- ref={(el) => { if (el) autoResizeTextarea(el); }}
372
- />
373
-
374
- {/* Delete — top right on hover */}
375
- <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
370
+ {/* Editable note + images */}
371
+ <div onClick={(e) => e.stopPropagation()}>
372
+ <AnnotationEditor
373
+ note={ann.note}
374
+ onNoteChange={(note) => updateAnnotation(ann.id, note)}
375
+ images={ann.images || []}
376
+ onAddImage={(path) => addAnnotationImage(ann.id, path)}
377
+ onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
378
+ sessionId={sessionId}
379
+ autoResize
380
+ minHeight={20}
381
+ textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
382
+ textareaStyle={{ height: "auto", overflow: "hidden" }}
383
+ placeholder="Add your note..."
384
+ attachButton="on-focus"
385
+ />
386
+ </div>
387
+
388
+ {/* Actions — top right on hover */}
389
+ <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
390
+ <button
391
+ onClick={(e) => {
392
+ e.stopPropagation();
393
+ scrollToAnnotation(ann, setActiveView);
394
+ }}
395
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
396
+ title="Scroll to annotation"
397
+ >
398
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
399
+ <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
400
+ </svg>
401
+ </button>
376
402
  <button
377
403
  onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
378
404
  className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
@@ -7,6 +7,7 @@ import { PlanRenderer } from "./PlanRenderer";
7
7
  import { AnnotationSidebar } from "./AnnotationSidebar";
8
8
  import { ResponsePreview } from "./ResponsePreview";
9
9
  import { FileBrowser } from "./FileBrowser";
10
+ import { FileIcon } from "./FileIcon";
10
11
  import { FileViewer } from "./FileViewer";
11
12
  import { SessionSwitcher } from "./SessionSwitcher";
12
13
  import { exportCanvasToMarkdown } from "./exportMarkdown";
@@ -26,6 +27,7 @@ export interface RevisionInfo {
26
27
  createdAt: string;
27
28
  hasFeedback: boolean;
28
29
  response?: string;
30
+ diffStats?: { added: number; removed: number };
29
31
  }
30
32
 
31
33
  export const ActiveViewContext = createContext<{
@@ -274,16 +276,14 @@ function App() {
274
276
  {/* Left panel — fixed to viewport */}
275
277
  <LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
276
278
 
277
- {/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
278
- {compareRevision === null && (
279
- <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
280
- <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
281
- </ResizableSidebar>
282
- )}
279
+ {/* Right panel — fixed to viewport, resizable */}
280
+ <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
281
+ <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
282
+ </ResizableSidebar>
283
283
 
284
284
  {/* Center content — normal document flow, browser scroll */}
285
285
  {compareRevision !== null ? (
286
- <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
286
+ <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
287
287
  <CompareView
288
288
  initialLeft={compareRevision.left}
289
289
  initialRight={compareRevision.right}
@@ -528,7 +528,7 @@ function ResponseBanner({ markdown }: { markdown: string }) {
528
528
  const [dismissed, setDismissed] = useState(false);
529
529
  const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
530
530
  const contentRef = useRef<HTMLDivElement>(null);
531
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
531
+ const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
532
532
  const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
533
533
  const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
534
534
 
@@ -640,10 +640,10 @@ function ResponseBanner({ markdown }: { markdown: string }) {
640
640
  scrollContainer={scrollContainer}
641
641
  snippet={createPopover.snippet}
642
642
  truncateAt={80}
643
- onAdd={(note) => {
643
+ onAdd={(note, images) => {
644
644
  const id = generateAnnotationId();
645
645
  renameMarkId(createPopover.tempId, id);
646
- addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx);
646
+ addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
647
647
  setCreatePopover(null);
648
648
  }}
649
649
  onCancel={() => {
@@ -661,7 +661,17 @@ function ResponseBanner({ markdown }: { markdown: string }) {
661
661
  anchorEl={editPopover.anchorEl}
662
662
  scrollContainer={scrollContainer}
663
663
  initialNote={ann.note}
664
- onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
664
+ initialImages={ann.images}
665
+ onUpdate={(note, images) => {
666
+ updateAnnotation(editPopover.annId, note);
667
+ const current = ann.images || [];
668
+ for (const img of images) {
669
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
670
+ }
671
+ for (const img of current) {
672
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
673
+ }
674
+ }}
665
675
  onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
666
676
  onClose={() => setEditPopover(null)}
667
677
  />
@@ -691,8 +701,8 @@ function ContentTabs() {
691
701
  : "text-text-tertiary hover:text-text-secondary"
692
702
  }`}
693
703
  >
694
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
695
- <path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
704
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
705
+ <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
696
706
  </svg>
697
707
  Canvas
698
708
  {activeView.type === "plan" && (
@@ -714,12 +724,13 @@ function ContentTabs() {
714
724
  <div className="flex items-center">
715
725
  <button
716
726
  onClick={() => setActiveView({ type: "file", path })}
717
- className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-mono whitespace-nowrap transition-colors relative ${
727
+ className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
718
728
  isActive
719
729
  ? "text-text-primary"
720
730
  : "text-text-tertiary hover:text-text-secondary"
721
731
  }`}
722
732
  >
733
+ <FileIcon name={name} type="file" />
723
734
  {name}
724
735
  {isActive && (
725
736
  <span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />