agent-canvas 0.1.9 → 0.1.11
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/build.ts +1 -1
- 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 +64 -24
- 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/index.html +1 -1
- 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 +63 -87
- package/daemon/dist/components.js +54 -54
- package/daemon/dist/index.html +2 -2
- 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/skills/canvas/SKILL.md +15 -7
- package/skills/canvas/flows.md +1 -1
- package/hooks/session-start.sh +0 -13
package/.github/dark.png
ADDED
|
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
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
+

|
|
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
|
+

|
|
65
|
+
|
|
56
66
|
## Development
|
|
57
67
|
|
|
58
68
|
```bash
|
package/daemon/build.ts
CHANGED
|
@@ -96,7 +96,7 @@ async function build() {
|
|
|
96
96
|
<meta charset="utf-8" />
|
|
97
97
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
98
98
|
<title>Agent Canvas</title>
|
|
99
|
-
<script
|
|
99
|
+
<script>!function(){var p=localStorage.getItem('canvas-theme')||'auto';document.documentElement.dataset.theme=p==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):p}()</script>
|
|
100
100
|
<link rel="stylesheet" href="/assets/client.css" />
|
|
101
101
|
<link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/styles/github-dark-dimmed.min.css" />
|
|
102
102
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
@@ -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
|
+
×
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
{showButton && <AttachButton onClick={openFilePicker} />}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{isUploading && (
|
|
233
|
+
<div style={{
|
|
234
|
+
fontSize: "11px", color: "var(--color-text-tertiary)",
|
|
235
|
+
fontFamily: "'Inter', sans-serif", marginTop: "4px",
|
|
236
|
+
}}>
|
|
237
|
+
Uploading...
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{isDragging && (
|
|
242
|
+
<div style={{
|
|
243
|
+
position: "absolute", inset: 0, display: "flex",
|
|
244
|
+
alignItems: "center", justifyContent: "center",
|
|
245
|
+
background: "var(--color-highlight-bg)", opacity: 0.9,
|
|
246
|
+
border: "2px dashed var(--color-highlight-border)",
|
|
247
|
+
borderRadius: "6px", zIndex: 10, pointerEvents: "none",
|
|
248
|
+
fontSize: "12px", fontFamily: "'Inter', sans-serif",
|
|
249
|
+
color: "var(--color-text-secondary)",
|
|
250
|
+
}}>
|
|
251
|
+
Drop image
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Small attach-image button — reusable in editor and popover button bar */
|
|
259
|
+
export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
|
|
260
|
+
return (
|
|
261
|
+
<button
|
|
262
|
+
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
|
263
|
+
style={{
|
|
264
|
+
width: `${size * 2}px`, height: `${size * 2}px`, borderRadius: "6px",
|
|
265
|
+
border: "none",
|
|
266
|
+
background: "transparent", cursor: "pointer",
|
|
267
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
268
|
+
color: "var(--color-text-tertiary)", flexShrink: 0,
|
|
269
|
+
transition: "color 0.15s",
|
|
270
|
+
}}
|
|
271
|
+
title="Attach image"
|
|
272
|
+
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--color-text-secondary)"; }}
|
|
273
|
+
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--color-text-tertiary)"; }}
|
|
274
|
+
>
|
|
275
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
276
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
277
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
278
|
+
<polyline points="21 15 16 10 5 21" />
|
|
279
|
+
</svg>
|
|
280
|
+
</button>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Read-only image thumbnails for display in read-only annotations */
|
|
285
|
+
export function ImageThumbnails({ images }: { images: string[] }) {
|
|
286
|
+
if (!images.length) return null;
|
|
287
|
+
return (
|
|
288
|
+
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px" }}>
|
|
289
|
+
{images.map((img) => (
|
|
290
|
+
<div
|
|
291
|
+
key={img}
|
|
292
|
+
style={{
|
|
293
|
+
width: "56px", height: "56px", borderRadius: "6px",
|
|
294
|
+
overflow: "hidden", border: "1px solid var(--color-border-subtle)",
|
|
295
|
+
flexShrink: 0,
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<img
|
|
299
|
+
src={imageToUrl(img)}
|
|
300
|
+
alt=""
|
|
301
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -97,8 +97,8 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
|
|
|
97
97
|
return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
|
|
98
98
|
}, [annotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
|
|
99
99
|
|
|
100
|
-
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext) => {
|
|
101
|
-
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context }]);
|
|
100
|
+
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => {
|
|
101
|
+
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}) }]);
|
|
102
102
|
}, []);
|
|
103
103
|
|
|
104
104
|
const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
|
|
@@ -115,6 +115,18 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
|
|
|
115
115
|
setActiveAnnotationId((prev) => (prev === id ? null : prev));
|
|
116
116
|
}, []);
|
|
117
117
|
|
|
118
|
+
const addAnnotationImage = useCallback((id: string, imagePath: string) => {
|
|
119
|
+
setAnnotations((prev) => prev.map((a) =>
|
|
120
|
+
a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
|
|
121
|
+
));
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
|
|
125
|
+
setAnnotations((prev) => prev.map((a) =>
|
|
126
|
+
a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
|
|
127
|
+
));
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
118
130
|
const setResponse = useCallback((id: string, response: PlanResponse) => {
|
|
119
131
|
setResponses((prev) => {
|
|
120
132
|
const next = new Map(prev);
|
|
@@ -159,7 +171,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
|
|
|
159
171
|
return (
|
|
160
172
|
<AnnotationCtx.Provider
|
|
161
173
|
value={{
|
|
162
|
-
annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation,
|
|
174
|
+
annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
|
|
163
175
|
generalNote, setGeneralNote, clearAll,
|
|
164
176
|
activeAnnotationId, setActiveAnnotationId,
|
|
165
177
|
responses, setResponse,
|
|
@@ -7,6 +7,8 @@ import { SessionContext } from "#canvas/runtime";
|
|
|
7
7
|
import { MarkdownPreview } from "./ResponsePreview";
|
|
8
8
|
import { FileIcon } from "./FileIcon";
|
|
9
9
|
import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
10
|
+
import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
|
|
11
|
+
import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
|
|
10
12
|
|
|
11
13
|
interface AnnotationSidebarProps {
|
|
12
14
|
onPreview: () => void;
|
|
@@ -179,8 +181,6 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
179
181
|
const handleMouseEnter = (annId: string) => {
|
|
180
182
|
setActiveAnnotationId(annId);
|
|
181
183
|
setMarkActive(annId, true);
|
|
182
|
-
const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
|
|
183
|
-
if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
184
184
|
};
|
|
185
185
|
|
|
186
186
|
const handleMouseLeave = (annId: string) => {
|
|
@@ -196,8 +196,8 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
196
196
|
if (ann.filePath) {
|
|
197
197
|
setActiveView({ type: "file", path: ann.filePath });
|
|
198
198
|
setTimeout(() => {
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
199
|
+
const el = findAnnotationElement(ann);
|
|
200
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
201
201
|
}, 150);
|
|
202
202
|
}
|
|
203
203
|
}
|
|
@@ -206,7 +206,7 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
206
206
|
const renderAnnotation = (ann: Annotation) => (
|
|
207
207
|
<div
|
|
208
208
|
key={ann.id}
|
|
209
|
-
className={`relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
|
|
209
|
+
className={`group/ann relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
|
|
210
210
|
activeAnnotationId === ann.id
|
|
211
211
|
? "bg-highlight-selected"
|
|
212
212
|
: "odd:bg-bg-elevated-half hover:bg-bg-input"
|
|
@@ -223,6 +223,18 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
223
223
|
{ann.note}
|
|
224
224
|
</div>
|
|
225
225
|
)}
|
|
226
|
+
<ImageThumbnails images={ann.images || []} />
|
|
227
|
+
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
|
|
228
|
+
<button
|
|
229
|
+
onClick={(e) => { e.stopPropagation(); scrollToAnnotation(ann, setActiveView); }}
|
|
230
|
+
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
|
|
231
|
+
title="Scroll to annotation"
|
|
232
|
+
>
|
|
233
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
234
|
+
<circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
235
|
+
</svg>
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
226
238
|
</div>
|
|
227
239
|
);
|
|
228
240
|
|
|
@@ -278,10 +290,12 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
278
290
|
function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
|
|
279
291
|
const {
|
|
280
292
|
annotations, updateAnnotation, removeAnnotation,
|
|
293
|
+
addAnnotationImage, removeAnnotationImage,
|
|
281
294
|
generalNote, setGeneralNote,
|
|
282
295
|
activeAnnotationId, setActiveAnnotationId,
|
|
283
296
|
responses, feedbackEntries,
|
|
284
297
|
} = useAnnotations();
|
|
298
|
+
const sessionId = useContext(SessionContext);
|
|
285
299
|
const { setActiveView } = React.useContext(ActiveViewContext);
|
|
286
300
|
|
|
287
301
|
const listRef = useRef<HTMLDivElement>(null);
|
|
@@ -306,15 +320,10 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
306
320
|
}
|
|
307
321
|
}, [activeAnnotationId]);
|
|
308
322
|
|
|
309
|
-
// Hover on sidebar card → highlight inline mark + block
|
|
323
|
+
// Hover on sidebar card → highlight inline mark + block
|
|
310
324
|
const handleMouseEnter = useCallback((annId: string) => {
|
|
311
325
|
setActiveAnnotationId(annId);
|
|
312
326
|
setMarkActive(annId, true);
|
|
313
|
-
// Scroll inline mark into view
|
|
314
|
-
const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
|
|
315
|
-
if (mark) {
|
|
316
|
-
mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
317
|
-
}
|
|
318
327
|
}, [setActiveAnnotationId]);
|
|
319
328
|
|
|
320
329
|
const handleMouseLeave = useCallback((annId: string) => {
|
|
@@ -345,10 +354,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
345
354
|
setActiveAnnotationId(ann.id);
|
|
346
355
|
if (ann.filePath) {
|
|
347
356
|
setActiveView({ type: "file", path: ann.filePath });
|
|
348
|
-
// Scroll to mark after file view renders
|
|
349
357
|
setTimeout(() => {
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
358
|
+
const el = findAnnotationElement(ann);
|
|
359
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
352
360
|
}, 150);
|
|
353
361
|
}
|
|
354
362
|
}
|
|
@@ -359,20 +367,38 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
359
367
|
{ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
|
|
360
368
|
</div>
|
|
361
369
|
|
|
362
|
-
{/*
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
370
|
+
{/* Editable note + images */}
|
|
371
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
372
|
+
<AnnotationEditor
|
|
373
|
+
note={ann.note}
|
|
374
|
+
onNoteChange={(note) => updateAnnotation(ann.id, note)}
|
|
375
|
+
images={ann.images || []}
|
|
376
|
+
onAddImage={(path) => addAnnotationImage(ann.id, path)}
|
|
377
|
+
onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
|
|
378
|
+
sessionId={sessionId}
|
|
379
|
+
autoResize
|
|
380
|
+
minHeight={20}
|
|
381
|
+
textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
|
|
382
|
+
textareaStyle={{ height: "auto", overflow: "hidden" }}
|
|
383
|
+
placeholder="Add your note..."
|
|
384
|
+
attachButton="on-focus"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{/* Actions — top right on hover */}
|
|
389
|
+
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
|
|
390
|
+
<button
|
|
391
|
+
onClick={(e) => {
|
|
392
|
+
e.stopPropagation();
|
|
393
|
+
scrollToAnnotation(ann, setActiveView);
|
|
394
|
+
}}
|
|
395
|
+
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
|
|
396
|
+
title="Scroll to annotation"
|
|
397
|
+
>
|
|
398
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
399
|
+
<circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
400
|
+
</svg>
|
|
401
|
+
</button>
|
|
376
402
|
<button
|
|
377
403
|
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
|
|
378
404
|
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
|