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.
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/README.md +10 -0
- package/daemon/client/AnnotationEditor.tsx +307 -0
- package/daemon/client/AnnotationProvider.tsx +15 -3
- package/daemon/client/AnnotationSidebar.tsx +54 -28
- package/daemon/client/App.tsx +25 -14
- package/daemon/client/CompareView.tsx +53 -22
- package/daemon/client/FileBrowser.tsx +2 -1
- package/daemon/client/FileViewer.tsx +41 -161
- package/daemon/client/PlanRenderer.tsx +30 -147
- package/daemon/client/Popover.tsx +168 -108
- package/daemon/client/ResponsePreview.tsx +7 -1
- package/daemon/client/RevisionSelect.tsx +8 -1
- package/daemon/client/annotationDom.ts +104 -0
- package/daemon/client/components/Mermaid.tsx +2 -2
- package/daemon/client/generateMarkdown.ts +8 -0
- package/daemon/client/highlightRange.ts +1 -22
- package/daemon/client/runtime.ts +4 -1
- package/daemon/client/styles.css +28 -2
- package/daemon/client/theme.css +1 -1
- package/daemon/client/useTextAnnotation.tsx +183 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +61 -85
- package/daemon/dist/components.js +54 -54
- package/daemon/dist/index.html +1 -1
- package/daemon/src/handlers/uploads.ts +62 -0
- package/daemon/src/paths.ts +4 -0
- package/daemon/src/server.ts +2 -0
- package/daemon/src/session.ts +39 -1
- package/package.json +1 -1
- package/hooks/session-start.sh +0 -13
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import React, { useEffect, useState, useRef, useContext,
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 ||
|
|
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 ||
|
|
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,
|
|
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 ||
|
|
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,
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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(
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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())
|
|
105
|
+
if (note.trim() || images.length > 0) onConfirm(note.trim(), images);
|
|
106
|
+
else if (isEditMode && onDelete) onDelete();
|
|
90
107
|
else onCancel();
|
|
91
|
-
}, [note,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
{
|
|
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
|
“{truncated}”
|
|
146
210
|
</div>
|
|
147
|
-
)}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
/* ──
|
|
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
|
-
|
|
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
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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) => ``);
|
|
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
|
}
|