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.
@@ -1,11 +1,11 @@
1
- import React, { useEffect, useState, useRef, useContext, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useState, useRef, useContext, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { SessionContext } from "#canvas/runtime";
4
4
  import { useAnnotations } from "./AnnotationProvider";
5
- import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
6
5
  import { extractContext } from "./annotationContext";
7
6
  import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
8
7
  import { generateAnnotationId } from "./utils";
8
+ import { useTextAnnotation } from "./useTextAnnotation";
9
9
 
10
10
  /** All navigable blocks (keyboard arrows) */
11
11
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -22,13 +22,9 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
22
22
  const [error, setError] = useState<string | null>(null);
23
23
  const [loading, setLoading] = useState(true);
24
24
  const containerRef = useRef<HTMLDivElement>(null);
25
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
25
+ const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
26
26
  const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
27
27
 
28
- // Popover state
29
- const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
30
- const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
31
-
32
28
  // Block annotation hover state
33
29
  const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
34
30
  const [blockPopover, setBlockPopover] = useState<{ anchorEl: HTMLElement; snippet: string; annId?: string } | null>(null);
@@ -46,71 +42,15 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
46
42
  .catch((e) => { setError(e.message); setLoading(false); });
47
43
  }, [sessionId, revision]);
48
44
 
49
- // Restore persisted annotation marks after plan renders
50
- useEffect(() => {
51
- if (!PlanComponent || !containerRef.current) return;
52
- // Small delay to ensure the plan DOM is fully rendered
53
- const timer = setTimeout(() => {
54
- if (containerRef.current) {
55
- restoreMarks(containerRef.current, annotations);
56
- }
57
- }, 50);
58
- return () => clearTimeout(timer);
59
- }, [PlanComponent]);
60
-
61
- // Update mark active states when activeAnnotationId changes
62
- const prevActiveRef = useRef<string | null>(null);
63
- useEffect(() => {
64
- updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
65
- prevActiveRef.current = activeAnnotationId;
66
- }, [activeAnnotationId]);
67
-
68
- // Click handler for marks in plan
69
- useEffect(() => {
70
- const container = containerRef.current;
71
- if (!container) return;
72
-
73
- const handleClick = (e: MouseEvent) => {
74
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
75
- if (!mark) return;
76
- e.stopPropagation();
77
- const annId = mark.getAttribute("data-annotation-id")!;
78
- if (annId === activeAnnotationId) {
79
- setEditPopover({ anchorEl: mark, annId });
80
- } else {
81
- setActiveAnnotationId(annId);
82
- }
83
- };
84
-
85
- // Hover on inline marks → highlight sidebar card
86
- const handleMouseOver = (e: MouseEvent) => {
87
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
88
- if (mark) {
89
- const annId = mark.getAttribute("data-annotation-id")!;
90
- setActiveAnnotationId(annId);
91
- }
92
- };
93
-
94
- const handleMouseOut = (e: MouseEvent) => {
95
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
96
- const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
97
- if (mark && !relatedMark) {
98
- // Left a mark without entering another one — only deactivate if popover isn't open
99
- if (!document.getElementById("ann-inline-popover")) {
100
- setActiveAnnotationId(null);
101
- }
102
- }
103
- };
45
+ const scrollContainer = document.getElementById("plan-scroll-container");
104
46
 
105
- container.addEventListener("click", handleClick);
106
- container.addEventListener("mouseover", handleMouseOver);
107
- container.addEventListener("mouseout", handleMouseOut);
108
- return () => {
109
- container.removeEventListener("click", handleClick);
110
- container.removeEventListener("mouseover", handleMouseOver);
111
- container.removeEventListener("mouseout", handleMouseOut);
112
- };
113
- }, [PlanComponent, annotations, activeAnnotationId]);
47
+ const { popovers: textAnnotationPopovers, isPopoverOpen: textPopoverOpen } = useTextAnnotation({
48
+ containerRef,
49
+ restoreKey: PlanComponent,
50
+ restoreAnnotations: annotations,
51
+ extractContext: (range) => extractContext(range, containerRef.current!),
52
+ scrollContainer,
53
+ });
114
54
 
115
55
  // Build a map of block snippets to annotation IDs for quick lookup
116
56
  const blockAnnotationMap = useMemo(() => {
@@ -130,7 +70,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
130
70
 
131
71
  const handleMove = (e: MouseEvent) => {
132
72
  // Don't change hover when a popover is open
133
- if (blockPopover || createPopover || editPopover) return;
73
+ if (blockPopover || textPopoverOpen) return;
134
74
  const target = e.target as HTMLElement;
135
75
  // Ignore events on the block comment button itself
136
76
  if (target.closest("[data-block-comment-btn]")) return;
@@ -159,7 +99,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
159
99
  };
160
100
 
161
101
  const handleLeave = (e: MouseEvent) => {
162
- if (blockPopover || createPopover || editPopover) return;
102
+ if (blockPopover || textPopoverOpen) return;
163
103
  // Don't clear if moving onto the block comment button
164
104
  const related = e.relatedTarget as HTMLElement | null;
165
105
  if (related?.closest("[data-block-comment-btn]")) return;
@@ -173,7 +113,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
173
113
  container.removeEventListener("mousemove", handleMove);
174
114
  container.removeEventListener("mouseleave", handleLeave);
175
115
  };
176
- }, [PlanComponent, blockPopover, createPopover, editPopover, blockAnnotationMap, activeAnnotationId]);
116
+ }, [PlanComponent, blockPopover, textPopoverOpen, blockAnnotationMap, activeAnnotationId]);
177
117
 
178
118
  // Keyboard navigation: document-level key listener
179
119
  useEffect(() => {
@@ -181,7 +121,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
181
121
  // Guard: skip if input/textarea focused or popover open
182
122
  const tag = (e.target as HTMLElement).tagName;
183
123
  if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
184
- if (blockPopover || createPopover || editPopover) return;
124
+ if (blockPopover || textPopoverOpen) return;
185
125
 
186
126
  const container = containerRef.current;
187
127
  if (!container) return;
@@ -268,7 +208,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
268
208
 
269
209
  document.addEventListener("keydown", handleKeyDown);
270
210
  return () => document.removeEventListener("keydown", handleKeyDown);
271
- }, [focusedBlockIndex, blockPopover, createPopover, editPopover, blockAnnotationMap]);
211
+ }, [focusedBlockIndex, blockPopover, textPopoverOpen, blockAnnotationMap]);
272
212
 
273
213
  // Keyboard navigation: visual focus outline + scroll into view + sidebar sync
274
214
  useEffect(() => {
@@ -295,39 +235,6 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
295
235
  };
296
236
  }, [focusedBlockIndex, keyboardNav, blockAnnotationMap]);
297
237
 
298
- // Use document-level mouseup so selections that end outside the container still work
299
- useEffect(() => {
300
- const handler = () => handleMouseUp();
301
- document.addEventListener("mouseup", handler);
302
- return () => document.removeEventListener("mouseup", handler);
303
- }, []);
304
-
305
- const handleMouseUp = useCallback(() => {
306
- const sel = window.getSelection();
307
- if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
308
- if (!containerRef.current) return;
309
- const range = sel.getRangeAt(0);
310
- // Selection must have started inside the plan container
311
- if (!containerRef.current.contains(range.startContainer)) return;
312
- // Don't create new annotation if clicking inside existing mark
313
- if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
314
- const snippet = sel.toString().trim();
315
- if (snippet.length < 2) return;
316
-
317
- // Capture context before popover disturbs the DOM
318
- const savedRange = range.cloneRange();
319
- const ctx = extractContext(range, containerRef.current);
320
- const tempId = `__pending_${Date.now()}`;
321
- try { wrapRangeWithMark(savedRange, tempId); } catch {}
322
- window.getSelection()?.removeAllRanges();
323
-
324
- const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
325
- const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
326
- if (!lastMark) return;
327
-
328
- setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
329
- }, [addAnnotationWithId]);
330
-
331
238
  // Memoize so context-triggered re-renders of PlanRenderer don't re-render the plan tree.
332
239
  // This prevents remounting custom components defined inside the Plan function.
333
240
  const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
@@ -349,8 +256,6 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
349
256
  return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
350
257
  }
351
258
 
352
- const scrollContainer = document.getElementById("plan-scroll-container");
353
-
354
259
  return (
355
260
  <>
356
261
  <div ref={containerRef} className="plan-content plan-updated">
@@ -364,39 +269,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
364
269
  />
365
270
  )}
366
271
 
367
- {editPopover && (() => {
368
- const ann = annotations.find((a) => a.id === editPopover.annId);
369
- if (!ann) return null;
370
- return (
371
- <AnnotationEditPopover
372
- anchorEl={editPopover.anchorEl}
373
- scrollContainer={scrollContainer}
374
- initialNote={ann.note}
375
- onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
376
- onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
377
- onClose={() => setEditPopover(null)}
378
- />
379
- );
380
- })()}
381
-
382
- {createPopover && (
383
- <AnnotationCreatePopover
384
- anchorEl={createPopover.anchorEl}
385
- scrollContainer={scrollContainer}
386
- snippet={createPopover.snippet}
387
- truncateAt={80}
388
- onAdd={(note) => {
389
- const id = generateAnnotationId();
390
- renameMarkId(createPopover.tempId, id);
391
- addAnnotationWithId(id, createPopover.snippet, note, undefined, createPopover.ctx);
392
- setCreatePopover(null);
393
- }}
394
- onCancel={() => {
395
- unwrapMarks(createPopover.tempId);
396
- setCreatePopover(null);
397
- }}
398
- />
399
- )}
272
+ {textAnnotationPopovers}
400
273
 
401
274
  {/* Block annotation floating buttons */}
402
275
  <BlockCommentButtons
@@ -423,9 +296,9 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
423
296
  scrollContainer={scrollContainer}
424
297
  snippet={blockPopover.snippet}
425
298
  truncateAt={80}
426
- onAdd={(note) => {
299
+ onAdd={(note, images) => {
427
300
  const id = generateAnnotationId();
428
- addAnnotationWithId(id, blockPopover.snippet, note);
301
+ addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images);
429
302
  setBlockPopover(null);
430
303
  }}
431
304
  onCancel={() => setBlockPopover(null)}
@@ -439,7 +312,17 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
439
312
  anchorEl={blockPopover.anchorEl}
440
313
  scrollContainer={scrollContainer}
441
314
  initialNote={ann.note}
442
- onUpdate={(note) => updateAnnotation(blockPopover.annId!, note)}
315
+ initialImages={ann.images}
316
+ onUpdate={(note, images) => {
317
+ updateAnnotation(blockPopover.annId!, note);
318
+ const current = ann.images || [];
319
+ for (const img of images) {
320
+ if (!current.includes(img)) addAnnotationImage(blockPopover.annId!, img);
321
+ }
322
+ for (const img of current) {
323
+ if (!images.includes(img)) removeAnnotationImage(blockPopover.annId!, img);
324
+ }
325
+ }}
443
326
  onDelete={() => { removeAnnotation(blockPopover.annId!); setActiveAnnotationId(null); setBlockPopover(null); }}
444
327
  onClose={() => setBlockPopover(null)}
445
328
  />
@@ -1,6 +1,8 @@
1
- import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useRef, useState, useCallback, useMemo, useContext } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { getPopoverPosition } from "./popoverPosition";
4
+ import { AnnotationEditor, AttachButton } from "./AnnotationEditor";
5
+ import { SessionContext } from "#canvas/runtime";
4
6
 
5
7
  interface PopoverProps {
6
8
  anchorEl: HTMLElement;
@@ -56,9 +58,9 @@ export function Popover({ anchorEl, scrollContainer, onClose, ignoreSelector, zI
56
58
  );
57
59
  }
58
60
 
59
- /* ── Create annotation popover ─────────────────────────────── */
61
+ /* ── Create/Edit annotation popover ────────────────────────── */
60
62
 
61
- interface AnnotationCreatePopoverProps {
63
+ interface AnnotationPopoverProps {
62
64
  anchorEl: HTMLElement;
63
65
  scrollContainer?: HTMLElement | null;
64
66
  snippet: string;
@@ -66,8 +68,16 @@ interface AnnotationCreatePopoverProps {
66
68
  truncateAt?: number;
67
69
  /** Custom header instead of the default quoted snippet */
68
70
  header?: React.ReactNode;
69
- onAdd: (note: string) => void;
71
+ /** Initial note text (for edit mode) */
72
+ initialNote?: string;
73
+ /** Initial images (for edit mode) */
74
+ initialImages?: string[];
75
+ /** Called on confirm — passes note and images */
76
+ onConfirm: (note: string, images: string[]) => void;
77
+ /** Called on cancel / close without saving */
70
78
  onCancel: () => void;
79
+ /** Called when annotation should be deleted (edit mode) */
80
+ onDelete?: () => void;
71
81
  }
72
82
 
73
83
  const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
@@ -77,36 +87,50 @@ const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
77
87
  ];
78
88
  const ALL_SUGGESTIONS = SUGGESTION_GROUPS.flatMap((g) => g.items);
79
89
 
80
- export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt = 100, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
81
- const [note, setNote] = useState("");
90
+ export function AnnotationPopover({
91
+ anchorEl, scrollContainer, snippet, truncateAt = 100, header,
92
+ initialNote = "", initialImages, onConfirm, onCancel, onDelete,
93
+ }: AnnotationPopoverProps) {
94
+ const sessionId = useContext(SessionContext);
95
+ const isEditMode = initialNote !== "" || !!onDelete;
96
+ const [note, setNote] = useState(initialNote);
97
+ const [images, setImages] = useState<string[]>(initialImages || []);
82
98
  const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
83
- const [suppressed, setSuppressed] = useState(false);
84
- const textareaRef = useRef<HTMLTextAreaElement>(null);
85
-
86
- useEffect(() => { textareaRef.current?.focus(); }, []);
99
+ const [suppressed, setSuppressed] = useState(isEditMode);
100
+ const [deleteHover, setDeleteHover] = useState(false);
101
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
102
+ const openFilePickerRef = useRef<(() => void) | null>(null);
87
103
 
88
104
  const submit = useCallback(() => {
89
- if (note.trim()) onAdd(note.trim());
105
+ if (note.trim() || images.length > 0) onConfirm(note.trim(), images);
106
+ else if (isEditMode && onDelete) onDelete();
90
107
  else onCancel();
91
- }, [note, onAdd, onCancel]);
108
+ }, [note, images, onConfirm, onCancel, isEditMode, onDelete]);
92
109
 
110
+ const handleClose = useCallback(() => {
111
+ if (isEditMode) {
112
+ if (!note.trim() && images.length === 0 && onDelete) { onDelete(); onCancel(); return; }
113
+ onConfirm(note, images);
114
+ return;
115
+ }
116
+ onCancel();
117
+ }, [note, images, isEditMode, onConfirm, onCancel, onDelete]);
118
+
119
+ // Suggestions (only in create mode)
120
+ const showSuggestions = !isEditMode;
93
121
  const matches = note.trim().length > 0
94
122
  ? ALL_SUGGESTIONS.filter((s) => s.toLowerCase().startsWith(note.trim().toLowerCase()))
95
123
  : ALL_SUGGESTIONS;
96
- // Don't show if the input already exactly matches a suggestion, or if suppressed
97
- const filtered = suppressed ? [] : matches.length === 1 && matches[0].toLowerCase() === note.trim().toLowerCase() ? [] : matches;
124
+ const filtered = !showSuggestions || suppressed ? [] : matches.length === 1 && matches[0].toLowerCase() === note.trim().toLowerCase() ? [] : matches;
98
125
  const matchSet = new Set(filtered);
99
126
 
100
- // Build filtered groups for grouped display
101
127
  const filteredGroups = SUGGESTION_GROUPS
102
128
  .map((g) => ({ label: g.label, items: g.items.filter((s) => matchSet.has(s)) }))
103
129
  .filter((g) => g.items.length > 0);
104
130
 
105
- // Each group = one column
106
131
  const colItems = filteredGroups.map((g) => g.items);
107
132
  const flatFiltered = colItems.flat();
108
133
 
109
- // Navigation state as [col, row]
110
134
  const selectedCol = useMemo(() => {
111
135
  if (selectedIdx === null) return 0;
112
136
  let count = 0;
@@ -129,8 +153,7 @@ export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, tr
129
153
  return idx + row;
130
154
  };
131
155
 
132
- // Reset selection and unsuppress when input changes
133
- useEffect(() => { setSelectedIdx(null); setSuppressed(false); }, [note]);
156
+ useEffect(() => { setSelectedIdx(null); setSuppressed(isEditMode); }, [note]);
134
157
 
135
158
  const applySuggestion = useCallback((s: string) => {
136
159
  setNote(s);
@@ -138,55 +161,102 @@ export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, tr
138
161
 
139
162
  const truncated = snippet.length > truncateAt ? snippet.slice(0, truncateAt) + "..." : snippet;
140
163
 
164
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
165
+ if (flatFiltered.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight")) {
166
+ e.preventDefault();
167
+ if (selectedIdx === null) { setSelectedIdx(0); return; }
168
+ if (e.key === "ArrowDown") {
169
+ const newRow = Math.min(selectedRow + 1, colItems[selectedCol].length - 1);
170
+ setSelectedIdx(colRowToFlat(selectedCol, newRow));
171
+ } else if (e.key === "ArrowUp") {
172
+ const newRow = Math.max(selectedRow - 1, 0);
173
+ setSelectedIdx(colRowToFlat(selectedCol, newRow));
174
+ } else if (e.key === "ArrowRight") {
175
+ const newCol = Math.min(selectedCol + 1, colItems.length - 1);
176
+ const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
177
+ setSelectedIdx(colRowToFlat(newCol, newRow));
178
+ } else if (e.key === "ArrowLeft") {
179
+ const newCol = Math.max(selectedCol - 1, 0);
180
+ const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
181
+ setSelectedIdx(colRowToFlat(newCol, newRow));
182
+ }
183
+ return;
184
+ }
185
+ if (selectedIdx !== null && flatFiltered.length > 0) {
186
+ if (e.key === "Tab") { e.preventDefault(); applySuggestion(flatFiltered[selectedIdx]); return; }
187
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onConfirm(flatFiltered[selectedIdx], images); return; }
188
+ }
189
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
190
+ if (e.key === "Escape") {
191
+ if (flatFiltered.length > 0 && !suppressed) { setSuppressed(true); }
192
+ else if (isEditMode) { handleClose(); }
193
+ else { onCancel(); }
194
+ }
195
+ }, [flatFiltered, selectedIdx, selectedRow, selectedCol, colItems, colRowToFlat, applySuggestion, onConfirm, images, submit, suppressed, isEditMode, handleClose, onCancel]);
196
+
141
197
  return (
142
- <Popover anchorEl={anchorEl} scrollContainer={scrollContainer} onClose={onCancel} width="420px">
143
- {header ?? (
198
+ <Popover
199
+ anchorEl={anchorEl}
200
+ scrollContainer={scrollContainer}
201
+ onClose={isEditMode ? handleClose : onCancel}
202
+ ignoreSelector={isEditMode ? "[data-annotation-id]" : undefined}
203
+ zIndex={isEditMode ? "60" : "50"}
204
+ padding={isEditMode ? "10px 12px" : "12px"}
205
+ width="420px"
206
+ >
207
+ {!isEditMode && (header ?? (
144
208
  <div style={{ fontSize: "11px", color: "var(--color-text-tertiary)", fontStyle: "italic", marginBottom: "8px", lineHeight: "1.4", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
145
209
  &ldquo;{truncated}&rdquo;
146
210
  </div>
147
- )}
148
- <textarea
149
- ref={textareaRef}
150
- value={note}
151
- onChange={(e) => setNote(e.target.value)}
152
- onKeyDown={(e) => {
153
- if (flatFiltered.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight")) {
154
- e.preventDefault();
155
- if (selectedIdx === null) {
156
- setSelectedIdx(0);
157
- return;
158
- }
159
- if (e.key === "ArrowDown") {
160
- const newRow = Math.min(selectedRow + 1, colItems[selectedCol].length - 1);
161
- setSelectedIdx(colRowToFlat(selectedCol, newRow));
162
- } else if (e.key === "ArrowUp") {
163
- const newRow = Math.max(selectedRow - 1, 0);
164
- setSelectedIdx(colRowToFlat(selectedCol, newRow));
165
- } else if (e.key === "ArrowRight") {
166
- const newCol = Math.min(selectedCol + 1, colItems.length - 1);
167
- const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
168
- setSelectedIdx(colRowToFlat(newCol, newRow));
169
- } else if (e.key === "ArrowLeft") {
170
- const newCol = Math.max(selectedCol - 1, 0);
171
- const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
172
- setSelectedIdx(colRowToFlat(newCol, newRow));
173
- }
174
- return;
175
- }
176
- if (selectedIdx !== null && flatFiltered.length > 0) {
177
- if (e.key === "Tab") { e.preventDefault(); applySuggestion(flatFiltered[selectedIdx]); return; }
178
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onAdd(flatFiltered[selectedIdx]); return; }
179
- }
180
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
181
- if (e.key === "Escape") { if (flatFiltered.length > 0 && !suppressed) { setSuppressed(true); } else { onCancel(); } }
211
+ ))}
212
+
213
+ <AnnotationEditor
214
+ note={note}
215
+ onNoteChange={setNote}
216
+ images={images}
217
+ onAddImage={(path) => setImages((prev) => [...prev, path])}
218
+ onRemoveImage={(path) => setImages((prev) => prev.filter((p) => p !== path))}
219
+ sessionId={sessionId}
220
+ autoFocus
221
+ autoResize={isEditMode}
222
+ textareaRef={textareaRef}
223
+ onKeyDown={handleKeyDown}
224
+ attachButton="none"
225
+ openFilePickerRef={openFilePickerRef}
226
+ textareaStyle={isEditMode ? {
227
+ width: "100%", background: "transparent", border: "none",
228
+ color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
229
+ fontSize: "13px", lineHeight: "1.5", resize: "none", outline: "none",
230
+ overflow: "hidden",
231
+ } : {
232
+ width: "100%", minHeight: "60px", background: "transparent", border: "none",
233
+ color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
234
+ fontSize: "13px", lineHeight: "1.5", resize: "vertical", outline: "none",
182
235
  }}
183
- style={{ width: "100%", minHeight: "60px", background: "transparent", border: "none", color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif", fontSize: "13px", lineHeight: "1.5", resize: "vertical", outline: "none" }}
184
236
  placeholder="Add your note..."
185
237
  />
186
- <div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "8px" }}>
187
- <button onClick={onCancel} style={{ fontSize: "11px", color: "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "4px 12px", fontFamily: "'Inter', sans-serif" }}>Cancel</button>
188
- <button onClick={submit} style={{ fontSize: "11px", fontWeight: 500, padding: "4px 12px", borderRadius: "6px", background: "var(--color-highlight-bg)", color: "var(--color-text-primary)", border: "1px solid var(--color-highlight-border)", cursor: "pointer", fontFamily: "'Inter', sans-serif" }}>Add</button>
238
+
239
+ {/* Action buttons */}
240
+ {/* Action buttons */}
241
+ <div style={{ display: "flex", alignItems: "center", gap: "8px", marginTop: isEditMode ? "6px" : "8px" }}>
242
+ <AttachButton onClick={() => openFilePickerRef.current?.()} size={14} />
243
+ <div style={{ flex: 1 }} />
244
+ {isEditMode ? (
245
+ <button
246
+ onClick={() => { if (onDelete) onDelete(); onCancel(); }}
247
+ onMouseEnter={() => setDeleteHover(true)}
248
+ onMouseLeave={() => setDeleteHover(false)}
249
+ style={{ fontSize: "11px", color: deleteHover ? "var(--color-accent-red)" : "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "2px 0", fontFamily: "'Inter', sans-serif" }}
250
+ >Delete</button>
251
+ ) : (
252
+ <>
253
+ <button onClick={onCancel} style={{ fontSize: "11px", color: "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "4px 12px", fontFamily: "'Inter', sans-serif" }}>Cancel</button>
254
+ <button onClick={submit} style={{ fontSize: "11px", fontWeight: 500, padding: "4px 12px", borderRadius: "6px", background: "var(--color-highlight-bg)", color: "var(--color-text-primary)", border: "1px solid var(--color-highlight-border)", cursor: "pointer", fontFamily: "'Inter', sans-serif" }}>Add</button>
255
+ </>
256
+ )}
189
257
  </div>
258
+
259
+ {/* Suggestions (create mode only) */}
190
260
  {flatFiltered.length > 0 && (
191
261
  <div style={{ marginTop: "6px", borderTop: "1px solid var(--color-border-subtle)", paddingTop: "8px", display: "flex", gap: "4px" }}>
192
262
  {filteredGroups.map((group) => (
@@ -223,63 +293,53 @@ export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, tr
223
293
  );
224
294
  }
225
295
 
226
- /* ── Edit annotation popover ───────────────────────────────── */
296
+ /* ── Legacy exports for backwards compatibility ───────────── */
297
+
298
+ interface AnnotationCreatePopoverProps {
299
+ anchorEl: HTMLElement;
300
+ scrollContainer?: HTMLElement | null;
301
+ snippet: string;
302
+ truncateAt?: number;
303
+ header?: React.ReactNode;
304
+ onAdd: (note: string, images?: string[]) => void;
305
+ onCancel: () => void;
306
+ }
307
+
308
+ export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
309
+ return (
310
+ <AnnotationPopover
311
+ anchorEl={anchorEl}
312
+ scrollContainer={scrollContainer}
313
+ snippet={snippet}
314
+ truncateAt={truncateAt}
315
+ header={header}
316
+ onConfirm={(note, images) => onAdd(note, images)}
317
+ onCancel={onCancel}
318
+ />
319
+ );
320
+ }
227
321
 
228
322
  interface AnnotationEditPopoverProps {
229
323
  anchorEl: HTMLElement;
230
324
  scrollContainer?: HTMLElement | null;
231
325
  initialNote: string;
232
- onUpdate: (note: string) => void;
326
+ initialImages?: string[];
327
+ onUpdate: (note: string, images: string[]) => void;
233
328
  onDelete: () => void;
234
329
  onClose: () => void;
235
330
  }
236
331
 
237
- export function AnnotationEditPopover({ anchorEl, scrollContainer, initialNote, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps) {
238
- const [note, setNote] = useState(initialNote);
239
- const textareaRef = useRef<HTMLTextAreaElement>(null);
240
- const [deleteHover, setDeleteHover] = useState(false);
241
-
242
- useEffect(() => {
243
- const ta = textareaRef.current;
244
- if (ta) {
245
- ta.style.height = "auto";
246
- ta.style.height = ta.scrollHeight + "px";
247
- ta.focus();
248
- ta.setSelectionRange(ta.value.length, ta.value.length);
249
- }
250
- }, []);
251
-
252
- const handleClose = useCallback(() => {
253
- if (!note.trim()) { onDelete(); onClose(); return; }
254
- onUpdate(note);
255
- onClose();
256
- }, [note, onUpdate, onDelete, onClose]);
257
-
332
+ export function AnnotationEditPopover({ anchorEl, scrollContainer, initialNote, initialImages, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps) {
258
333
  return (
259
- <Popover anchorEl={anchorEl} scrollContainer={scrollContainer} onClose={handleClose} ignoreSelector="[data-annotation-id]" zIndex="60" padding="10px 12px">
260
- <textarea
261
- ref={textareaRef}
262
- value={note}
263
- onChange={(e) => {
264
- setNote(e.target.value);
265
- onUpdate(e.target.value);
266
- e.target.style.height = "auto";
267
- e.target.style.height = e.target.scrollHeight + "px";
268
- }}
269
- onKeyDown={(e) => {
270
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleClose(); }
271
- if (e.key === "Escape") handleClose();
272
- }}
273
- style={{ width: "100%", background: "transparent", border: "none", color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif", fontSize: "13px", lineHeight: "1.5", resize: "none", outline: "none", overflow: "hidden" }}
274
- />
275
- <div style={{ display: "flex", justifyContent: "flex-end", marginTop: "6px" }}>
276
- <button
277
- onClick={() => { onDelete(); onClose(); }}
278
- onMouseEnter={() => setDeleteHover(true)}
279
- onMouseLeave={() => setDeleteHover(false)}
280
- style={{ fontSize: "11px", color: deleteHover ? "var(--color-accent-red)" : "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "2px 0", fontFamily: "'Inter', sans-serif" }}
281
- >Delete</button>
282
- </div>
283
- </Popover>
334
+ <AnnotationPopover
335
+ anchorEl={anchorEl}
336
+ scrollContainer={scrollContainer}
337
+ snippet=""
338
+ initialNote={initialNote}
339
+ initialImages={initialImages}
340
+ onConfirm={(note, images) => { onUpdate(note, images); onClose(); }}
341
+ onDelete={onDelete}
342
+ onCancel={onClose}
343
+ />
284
344
  );
285
345
  }
@@ -143,7 +143,13 @@ export function ResponsePreview({ open, onClose, onSubmit }: ResponsePreviewProp
143
143
  );
144
144
  }
145
145
 
146
+ const UPLOADS_PATH_RE = /!\[([^\]]*)\]\((\/tmp\/agent-canvas\/uploads\/([^)]+))\)/g;
147
+
148
+ function rewriteUploadPaths(md: string): string {
149
+ return md.replace(UPLOADS_PATH_RE, (_match, alt, _fullPath, filename) => `![${alt}](/api/uploads/${filename})`);
150
+ }
151
+
146
152
  export function MarkdownPreview({ text }: { text: string }) {
147
- const html = useMemo(() => marked.parse(text, { async: false }) as string, [text]);
153
+ const html = useMemo(() => marked.parse(rewriteUploadPaths(text), { async: false }) as string, [text]);
148
154
  return <div className="prose-canvas font-body text-body text-text-primary leading-relaxed" dangerouslySetInnerHTML={{ __html: html }} />;
149
155
  }