agent-canvas 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +89 -37
- package/daemon/client/App.tsx +144 -13
- package/daemon/client/CompareView.tsx +54 -23
- package/daemon/client/FileBrowser.tsx +2 -1
- package/daemon/client/FileViewer.tsx +41 -161
- package/daemon/client/PlanRenderer.tsx +32 -149
- package/daemon/client/Popover.tsx +168 -108
- package/daemon/client/ResponsePreview.tsx +7 -1
- package/daemon/client/RevisionSelect.tsx +8 -1
- package/daemon/client/annotationContext.ts +1 -1
- package/daemon/client/annotationDom.ts +104 -0
- package/daemon/client/components/Mermaid.tsx +2 -2
- package/daemon/client/generateMarkdown.ts +21 -2
- 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/client/utils.ts +3 -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/skills/canvas/SKILL.md +5 -6
- 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
|
|
@@ -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,
|
|
@@ -6,7 +6,9 @@ import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
|
|
|
6
6
|
import { SessionContext } from "#canvas/runtime";
|
|
7
7
|
import { MarkdownPreview } from "./ResponsePreview";
|
|
8
8
|
import { FileIcon } from "./FileIcon";
|
|
9
|
-
import { autoResizeTextarea } from "./utils";
|
|
9
|
+
import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
10
|
+
import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
|
|
11
|
+
import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
|
|
10
12
|
|
|
11
13
|
interface AnnotationSidebarProps {
|
|
12
14
|
onPreview: () => void;
|
|
@@ -166,8 +168,9 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
166
168
|
setActiveAnnotationId: (id: string | null) => void;
|
|
167
169
|
setActiveView: (view: ActiveView) => void;
|
|
168
170
|
}) {
|
|
171
|
+
const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
169
172
|
const planAnnotations = annotations.filter((a) => !a.filePath);
|
|
170
|
-
const fileAnnotations = annotations.filter((a) => a.filePath);
|
|
173
|
+
const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
171
174
|
const fileGroups: Record<string, Annotation[]> = {};
|
|
172
175
|
for (const ann of fileAnnotations) {
|
|
173
176
|
const key = ann.filePath!;
|
|
@@ -178,8 +181,6 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
178
181
|
const handleMouseEnter = (annId: string) => {
|
|
179
182
|
setActiveAnnotationId(annId);
|
|
180
183
|
setMarkActive(annId, true);
|
|
181
|
-
const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
|
|
182
|
-
if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
183
184
|
};
|
|
184
185
|
|
|
185
186
|
const handleMouseLeave = (annId: string) => {
|
|
@@ -195,8 +196,8 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
195
196
|
if (ann.filePath) {
|
|
196
197
|
setActiveView({ type: "file", path: ann.filePath });
|
|
197
198
|
setTimeout(() => {
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
199
|
+
const el = findAnnotationElement(ann);
|
|
200
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
200
201
|
}, 150);
|
|
201
202
|
}
|
|
202
203
|
}
|
|
@@ -205,7 +206,7 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
205
206
|
const renderAnnotation = (ann: Annotation) => (
|
|
206
207
|
<div
|
|
207
208
|
key={ann.id}
|
|
208
|
-
className={`relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
|
|
209
|
+
className={`group/ann relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
|
|
209
210
|
activeAnnotationId === ann.id
|
|
210
211
|
? "bg-highlight-selected"
|
|
211
212
|
: "odd:bg-bg-elevated-half hover:bg-bg-input"
|
|
@@ -222,16 +223,43 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
222
223
|
{ann.note}
|
|
223
224
|
</div>
|
|
224
225
|
)}
|
|
226
|
+
<ImageThumbnails images={ann.images || []} />
|
|
227
|
+
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
|
|
228
|
+
<button
|
|
229
|
+
onClick={(e) => { e.stopPropagation(); scrollToAnnotation(ann, setActiveView); }}
|
|
230
|
+
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
|
|
231
|
+
title="Scroll to annotation"
|
|
232
|
+
>
|
|
233
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
234
|
+
<circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
235
|
+
</svg>
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
225
238
|
</div>
|
|
226
239
|
);
|
|
227
240
|
|
|
241
|
+
const hasMultipleGroups = [responseAnnotations, planAnnotations, fileAnnotations].filter((g) => g.length > 0).length > 1;
|
|
242
|
+
|
|
228
243
|
return (
|
|
229
244
|
<>
|
|
230
245
|
<div className="flex-1 overflow-y-auto">
|
|
231
|
-
{
|
|
232
|
-
|
|
246
|
+
{responseAnnotations.length > 0 && (
|
|
247
|
+
<>
|
|
248
|
+
{hasMultipleGroups && (
|
|
249
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
|
|
250
|
+
)}
|
|
251
|
+
{responseAnnotations.map(renderAnnotation)}
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{planAnnotations.length > 0 && (
|
|
256
|
+
<>
|
|
257
|
+
{hasMultipleGroups && (
|
|
258
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
|
|
259
|
+
)}
|
|
260
|
+
{planAnnotations.map(renderAnnotation)}
|
|
261
|
+
</>
|
|
233
262
|
)}
|
|
234
|
-
{planAnnotations.map(renderAnnotation)}
|
|
235
263
|
|
|
236
264
|
{Object.entries(fileGroups).map(([filePath, anns]) => (
|
|
237
265
|
<div key={filePath}>
|
|
@@ -262,17 +290,20 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
262
290
|
function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
|
|
263
291
|
const {
|
|
264
292
|
annotations, updateAnnotation, removeAnnotation,
|
|
293
|
+
addAnnotationImage, removeAnnotationImage,
|
|
265
294
|
generalNote, setGeneralNote,
|
|
266
295
|
activeAnnotationId, setActiveAnnotationId,
|
|
267
296
|
responses, feedbackEntries,
|
|
268
297
|
} = useAnnotations();
|
|
298
|
+
const sessionId = useContext(SessionContext);
|
|
269
299
|
const { setActiveView } = React.useContext(ActiveViewContext);
|
|
270
300
|
|
|
271
301
|
const listRef = useRef<HTMLDivElement>(null);
|
|
272
302
|
const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
273
303
|
|
|
304
|
+
const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
274
305
|
const planAnnotations = annotations.filter((a) => !a.filePath);
|
|
275
|
-
const fileAnnotations = annotations.filter((a) => a.filePath);
|
|
306
|
+
const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
276
307
|
const fileGroups: Record<string, Annotation[]> = {};
|
|
277
308
|
for (const ann of fileAnnotations) {
|
|
278
309
|
const key = ann.filePath!;
|
|
@@ -289,15 +320,10 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
289
320
|
}
|
|
290
321
|
}, [activeAnnotationId]);
|
|
291
322
|
|
|
292
|
-
// Hover on sidebar card → highlight inline mark + block
|
|
323
|
+
// Hover on sidebar card → highlight inline mark + block
|
|
293
324
|
const handleMouseEnter = useCallback((annId: string) => {
|
|
294
325
|
setActiveAnnotationId(annId);
|
|
295
326
|
setMarkActive(annId, true);
|
|
296
|
-
// Scroll inline mark into view
|
|
297
|
-
const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
|
|
298
|
-
if (mark) {
|
|
299
|
-
mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
300
|
-
}
|
|
301
327
|
}, [setActiveAnnotationId]);
|
|
302
328
|
|
|
303
329
|
const handleMouseLeave = useCallback((annId: string) => {
|
|
@@ -328,10 +354,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
328
354
|
setActiveAnnotationId(ann.id);
|
|
329
355
|
if (ann.filePath) {
|
|
330
356
|
setActiveView({ type: "file", path: ann.filePath });
|
|
331
|
-
// Scroll to mark after file view renders
|
|
332
357
|
setTimeout(() => {
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
358
|
+
const el = findAnnotationElement(ann);
|
|
359
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
335
360
|
}, 150);
|
|
336
361
|
}
|
|
337
362
|
}
|
|
@@ -342,20 +367,38 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
342
367
|
{ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
|
|
343
368
|
</div>
|
|
344
369
|
|
|
345
|
-
{/*
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
370
|
+
{/* Editable note + images */}
|
|
371
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
372
|
+
<AnnotationEditor
|
|
373
|
+
note={ann.note}
|
|
374
|
+
onNoteChange={(note) => updateAnnotation(ann.id, note)}
|
|
375
|
+
images={ann.images || []}
|
|
376
|
+
onAddImage={(path) => addAnnotationImage(ann.id, path)}
|
|
377
|
+
onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
|
|
378
|
+
sessionId={sessionId}
|
|
379
|
+
autoResize
|
|
380
|
+
minHeight={20}
|
|
381
|
+
textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
|
|
382
|
+
textareaStyle={{ height: "auto", overflow: "hidden" }}
|
|
383
|
+
placeholder="Add your note..."
|
|
384
|
+
attachButton="on-focus"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{/* Actions — top right on hover */}
|
|
389
|
+
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
|
|
390
|
+
<button
|
|
391
|
+
onClick={(e) => {
|
|
392
|
+
e.stopPropagation();
|
|
393
|
+
scrollToAnnotation(ann, setActiveView);
|
|
394
|
+
}}
|
|
395
|
+
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
|
|
396
|
+
title="Scroll to annotation"
|
|
397
|
+
>
|
|
398
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
399
|
+
<circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
400
|
+
</svg>
|
|
401
|
+
</button>
|
|
359
402
|
<button
|
|
360
403
|
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
|
|
361
404
|
className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
|
|
@@ -387,12 +430,21 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
387
430
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
388
431
|
{annotations.length === 0 && (
|
|
389
432
|
<p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
|
|
390
|
-
Select text in the
|
|
433
|
+
Select text in the canvas or in files to add annotations.
|
|
391
434
|
</p>
|
|
392
435
|
)}
|
|
393
436
|
|
|
394
|
-
{
|
|
395
|
-
|
|
437
|
+
{responseAnnotations.length > 0 && (
|
|
438
|
+
<>
|
|
439
|
+
{(planAnnotations.length > 0 || fileAnnotations.length > 0) && (
|
|
440
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
|
|
441
|
+
)}
|
|
442
|
+
{responseAnnotations.map(renderAnnotation)}
|
|
443
|
+
</>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{planAnnotations.length > 0 && (responseAnnotations.length > 0 || fileAnnotations.length > 0) && (
|
|
447
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
|
|
396
448
|
)}
|
|
397
449
|
{planAnnotations.map(renderAnnotation)}
|
|
398
450
|
|