agent-canvas 0.1.8 → 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,
@@ -6,7 +6,9 @@ import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
6
6
  import { SessionContext } from "#canvas/runtime";
7
7
  import { MarkdownPreview } from "./ResponsePreview";
8
8
  import { FileIcon } from "./FileIcon";
9
- import { autoResizeTextarea } from "./utils";
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;
@@ -166,8 +168,9 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
166
168
  setActiveAnnotationId: (id: string | null) => void;
167
169
  setActiveView: (view: ActiveView) => void;
168
170
  }) {
171
+ const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
169
172
  const planAnnotations = annotations.filter((a) => !a.filePath);
170
- const fileAnnotations = annotations.filter((a) => a.filePath);
173
+ const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
171
174
  const fileGroups: Record<string, Annotation[]> = {};
172
175
  for (const ann of fileAnnotations) {
173
176
  const key = ann.filePath!;
@@ -178,8 +181,6 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
178
181
  const handleMouseEnter = (annId: string) => {
179
182
  setActiveAnnotationId(annId);
180
183
  setMarkActive(annId, true);
181
- const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
182
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
183
184
  };
184
185
 
185
186
  const handleMouseLeave = (annId: string) => {
@@ -195,8 +196,8 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
195
196
  if (ann.filePath) {
196
197
  setActiveView({ type: "file", path: ann.filePath });
197
198
  setTimeout(() => {
198
- const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
199
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
199
+ const el = findAnnotationElement(ann);
200
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
200
201
  }, 150);
201
202
  }
202
203
  }
@@ -205,7 +206,7 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
205
206
  const renderAnnotation = (ann: Annotation) => (
206
207
  <div
207
208
  key={ann.id}
208
- 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 ${
209
210
  activeAnnotationId === ann.id
210
211
  ? "bg-highlight-selected"
211
212
  : "odd:bg-bg-elevated-half hover:bg-bg-input"
@@ -222,16 +223,43 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
222
223
  {ann.note}
223
224
  </div>
224
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>
225
238
  </div>
226
239
  );
227
240
 
241
+ const hasMultipleGroups = [responseAnnotations, planAnnotations, fileAnnotations].filter((g) => g.length > 0).length > 1;
242
+
228
243
  return (
229
244
  <>
230
245
  <div className="flex-1 overflow-y-auto">
231
- {planAnnotations.length > 0 && fileAnnotations.length > 0 && (
232
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
246
+ {responseAnnotations.length > 0 && (
247
+ <>
248
+ {hasMultipleGroups && (
249
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
250
+ )}
251
+ {responseAnnotations.map(renderAnnotation)}
252
+ </>
253
+ )}
254
+
255
+ {planAnnotations.length > 0 && (
256
+ <>
257
+ {hasMultipleGroups && (
258
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
259
+ )}
260
+ {planAnnotations.map(renderAnnotation)}
261
+ </>
233
262
  )}
234
- {planAnnotations.map(renderAnnotation)}
235
263
 
236
264
  {Object.entries(fileGroups).map(([filePath, anns]) => (
237
265
  <div key={filePath}>
@@ -262,17 +290,20 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
262
290
  function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
263
291
  const {
264
292
  annotations, updateAnnotation, removeAnnotation,
293
+ addAnnotationImage, removeAnnotationImage,
265
294
  generalNote, setGeneralNote,
266
295
  activeAnnotationId, setActiveAnnotationId,
267
296
  responses, feedbackEntries,
268
297
  } = useAnnotations();
298
+ const sessionId = useContext(SessionContext);
269
299
  const { setActiveView } = React.useContext(ActiveViewContext);
270
300
 
271
301
  const listRef = useRef<HTMLDivElement>(null);
272
302
  const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
273
303
 
304
+ const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
274
305
  const planAnnotations = annotations.filter((a) => !a.filePath);
275
- const fileAnnotations = annotations.filter((a) => a.filePath);
306
+ const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
276
307
  const fileGroups: Record<string, Annotation[]> = {};
277
308
  for (const ann of fileAnnotations) {
278
309
  const key = ann.filePath!;
@@ -289,15 +320,10 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
289
320
  }
290
321
  }, [activeAnnotationId]);
291
322
 
292
- // Hover on sidebar card → highlight inline mark + block + scroll to it
323
+ // Hover on sidebar card → highlight inline mark + block
293
324
  const handleMouseEnter = useCallback((annId: string) => {
294
325
  setActiveAnnotationId(annId);
295
326
  setMarkActive(annId, true);
296
- // Scroll inline mark into view
297
- const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
298
- if (mark) {
299
- mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
300
- }
301
327
  }, [setActiveAnnotationId]);
302
328
 
303
329
  const handleMouseLeave = useCallback((annId: string) => {
@@ -328,10 +354,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
328
354
  setActiveAnnotationId(ann.id);
329
355
  if (ann.filePath) {
330
356
  setActiveView({ type: "file", path: ann.filePath });
331
- // Scroll to mark after file view renders
332
357
  setTimeout(() => {
333
- const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
334
- if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
358
+ const el = findAnnotationElement(ann);
359
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
335
360
  }, 150);
336
361
  }
337
362
  }
@@ -342,20 +367,38 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
342
367
  {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
343
368
  </div>
344
369
 
345
- {/* Seamless editable note */}
346
- <textarea
347
- value={ann.note}
348
- onChange={(e) => updateAnnotation(ann.id, e.target.value)}
349
- onClick={(e) => e.stopPropagation()}
350
- 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]"
351
- rows={1}
352
- style={{ height: "auto", overflow: "hidden" }}
353
- onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
354
- ref={(el) => { if (el) autoResizeTextarea(el); }}
355
- />
356
-
357
- {/* Delete — top right on hover */}
358
- <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>
359
402
  <button
360
403
  onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
361
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"
@@ -387,12 +430,21 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
387
430
  <div ref={listRef} className="flex-1 overflow-y-auto">
388
431
  {annotations.length === 0 && (
389
432
  <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
390
- Select text in the plan or in files to add annotations.
433
+ Select text in the canvas or in files to add annotations.
391
434
  </p>
392
435
  )}
393
436
 
394
- {planAnnotations.length > 0 && fileAnnotations.length > 0 && (
395
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
437
+ {responseAnnotations.length > 0 && (
438
+ <>
439
+ {(planAnnotations.length > 0 || fileAnnotations.length > 0) && (
440
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
441
+ )}
442
+ {responseAnnotations.map(renderAnnotation)}
443
+ </>
444
+ )}
445
+
446
+ {planAnnotations.length > 0 && (responseAnnotations.length > 0 || fileAnnotations.length > 0) && (
447
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
396
448
  )}
397
449
  {planAnnotations.map(renderAnnotation)}
398
450