agent-canvas 0.1.44 → 0.2.0
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/README.md +14 -0
- package/bin/lib/commands/daemon.ts +3 -6
- package/bin/lib/commands/push.ts +1 -1
- package/bin/lib/daemon-lifecycle.ts +65 -72
- package/bin/lib/helpers.test.ts +1 -0
- package/bin/lib/helpers.ts +30 -74
- package/daemon/build.ts +37 -16
- package/daemon/client/App.tsx +27 -61
- package/daemon/client/CompareView.tsx +4 -4
- package/daemon/client/FileBrowser.tsx +4 -4
- package/daemon/client/FileViewer.tsx +4 -4
- package/daemon/client/OverviewView.tsx +2 -2
- package/daemon/client/RevisionSelect.tsx +1 -1
- package/daemon/client/canvas-runtime.ts +1 -0
- package/daemon/client/clientApi.ts +18 -0
- package/daemon/client/components/index.ts +3 -16
- package/daemon/client/styles.css +4 -457
- package/daemon/dist/annotations-runtime.js +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +76 -76
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/index.html +1 -0
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/components.ts +13 -0
- package/daemon/src/handlers/api.ts +13 -9
- package/daemon/src/handlers/files.ts +2 -3
- package/daemon/src/handlers/static.ts +1 -1
- package/daemon/src/handlers/uploads.ts +2 -3
- package/daemon/src/paths.ts +10 -24
- package/daemon/src/remote-feedback.test.ts +1 -1
- package/daemon/src/remote-feedback.ts +1 -1
- package/daemon/src/secrets-api.test.ts +2 -2
- package/daemon/src/server.ts +11 -10
- package/daemon/src/share-e2e.test.ts +1 -1
- package/daemon/src/share.test.ts +1 -1
- package/daemon/src/share.ts +1 -1
- package/daemon/tsconfig.json +1 -3
- package/package.json +11 -4
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/.github/workflows/ci.yml +0 -48
- package/.github/workflows/deploy-worker-prod.yml +0 -19
- package/.github/workflows/deploy-worker-stage.yml +0 -21
- package/.github/workflows/deploy-worker.yml +0 -72
- package/.github/workflows/release.yml +0 -36
- package/CLAUDE.md +0 -86
- package/bun.lock +0 -377
- package/daemon/client/AnnotationEditor.tsx +0 -315
- package/daemon/client/AnnotationProvider.tsx +0 -195
- package/daemon/client/AnnotationSidebar.tsx +0 -627
- package/daemon/client/FileIcon.tsx +0 -76
- package/daemon/client/PlanRenderer.tsx +0 -614
- package/daemon/client/Popover.tsx +0 -350
- package/daemon/client/RenderErrorContext.ts +0 -11
- package/daemon/client/ResponsePreview.tsx +0 -186
- package/daemon/client/SessionContext.tsx +0 -3
- package/daemon/client/annotationContext.ts +0 -114
- package/daemon/client/annotationDom.ts +0 -104
- package/daemon/client/annotationDraft.test.ts +0 -188
- package/daemon/client/annotationDraft.ts +0 -130
- package/daemon/client/compat/preact-all.ts +0 -4
- package/daemon/client/components/Callout.tsx +0 -26
- package/daemon/client/components/Checklist.tsx +0 -37
- package/daemon/client/components/Choice.tsx +0 -176
- package/daemon/client/components/CodeBlock.tsx +0 -35
- package/daemon/client/components/Diff.tsx +0 -65
- package/daemon/client/components/FilePreview.tsx +0 -88
- package/daemon/client/components/Image.tsx +0 -45
- package/daemon/client/components/Markdown.tsx +0 -44
- package/daemon/client/components/Mermaid.tsx +0 -565
- package/daemon/client/components/Note.tsx +0 -14
- package/daemon/client/components/Priority.tsx +0 -19
- package/daemon/client/components/ResponseNote.tsx +0 -19
- package/daemon/client/components/Section.tsx +0 -33
- package/daemon/client/components/Table.tsx +0 -35
- package/daemon/client/components/Task.tsx +0 -76
- package/daemon/client/components/UserInput.tsx +0 -174
- package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
- package/daemon/client/generateMarkdown.ts +0 -273
- package/daemon/client/highlightRange.ts +0 -224
- package/daemon/client/popoverPosition.ts +0 -31
- package/daemon/client/runtime.ts +0 -123
- package/daemon/client/theme.css +0 -66
- package/daemon/client/useTextAnnotation.tsx +0 -188
- package/daemon/client/utils.ts +0 -13
- package/daemon/src/compiler.test.ts +0 -57
- package/daemon/src/compiler.ts +0 -360
- package/daemon/src/handlers/utils.ts +0 -14
- package/daemon/src/router.ts +0 -16
- package/daemon/src/session-secrets.test.ts +0 -83
- package/daemon/src/session.ts +0 -612
- package/daemon/src/watcher.ts +0 -71
- package/daemon/src/websocket.test.ts +0 -99
- package/daemon/src/websocket.ts +0 -211
- package/tsconfig.json +0 -14
- package/workers/canvas-share/README.md +0 -159
- package/workers/canvas-share/oblaka.ts +0 -56
- package/workers/canvas-share/package.json +0 -25
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +0 -2
- package/workers/canvas-share/public/assets/client.js +0 -91
- package/workers/canvas-share/public/assets/components.js +0 -65
- package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
- package/workers/canvas-share/public/assets/preact-compat.js +0 -1
- package/workers/canvas-share/public/assets/runtime.js +0 -1
- package/workers/canvas-share/public/index.html +0 -385
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +0 -209
- package/workers/canvas-share/src/index.ts +0 -518
- package/workers/canvas-share/src/types.ts +0 -119
- package/workers/canvas-share/src/util.ts +0 -156
- package/workers/canvas-share/src/validation.ts +0 -160
- package/workers/canvas-share/tsconfig.json +0 -16
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
-
import { autoResizeTextarea } from "./utils";
|
|
3
|
-
import { uploadUrl, MODE } from "./clientApi";
|
|
4
|
-
|
|
5
|
-
interface AnnotationEditorProps {
|
|
6
|
-
note: string;
|
|
7
|
-
onNoteChange: (note: string) => void;
|
|
8
|
-
images: string[];
|
|
9
|
-
onAddImage: (path: string) => void;
|
|
10
|
-
onRemoveImage: (path: string) => void;
|
|
11
|
-
sessionId: string;
|
|
12
|
-
|
|
13
|
-
placeholder?: string;
|
|
14
|
-
autoFocus?: boolean;
|
|
15
|
-
autoResize?: boolean;
|
|
16
|
-
readOnly?: boolean;
|
|
17
|
-
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
18
|
-
onTextareaClick?: (e: React.MouseEvent) => void;
|
|
19
|
-
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
|
|
20
|
-
textareaClassName?: string;
|
|
21
|
-
textareaStyle?: React.CSSProperties;
|
|
22
|
-
minHeight?: number;
|
|
23
|
-
/** "always" (default), "on-focus" (sidebar), or "none" (popover — use openFilePickerRef) */
|
|
24
|
-
attachButton?: "always" | "on-focus" | "none";
|
|
25
|
-
/** Parent can store the open-file-picker function for external trigger */
|
|
26
|
-
openFilePickerRef?: React.MutableRefObject<(() => void) | null>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function imageToUrl(path: string): string {
|
|
30
|
-
// Shared-mode uploads come back from the worker as absolute URLs under
|
|
31
|
-
// /s/:shareId/uploads/... — return them as-is. Local daemon uploads are
|
|
32
|
-
// stored as absolute filesystem paths; translate to the served URL.
|
|
33
|
-
if (path.startsWith("/s/") || path.startsWith("http://") || path.startsWith("https://")) {
|
|
34
|
-
return path;
|
|
35
|
-
}
|
|
36
|
-
const filename = path.split("/").pop();
|
|
37
|
-
return `/api/uploads/${filename}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function uploadImage(_sessionId: string, file: File): Promise<string | null> {
|
|
41
|
-
const formData = new FormData();
|
|
42
|
-
formData.append("image", file);
|
|
43
|
-
try {
|
|
44
|
-
const resp = await fetch(uploadUrl(), { method: "POST", body: formData });
|
|
45
|
-
const data = await resp.json();
|
|
46
|
-
// Local daemon returns { path }; worker returns { url }. Normalise to
|
|
47
|
-
// a single string that `imageToUrl` can resolve.
|
|
48
|
-
if (MODE.isShared) return data.url || null;
|
|
49
|
-
return data.path || null;
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function AnnotationEditor({
|
|
56
|
-
note, onNoteChange, images, onAddImage, onRemoveImage, sessionId,
|
|
57
|
-
placeholder = "Add your note...", autoFocus = false, autoResize = false,
|
|
58
|
-
readOnly = false, onKeyDown, onTextareaClick, textareaRef: externalRef,
|
|
59
|
-
textareaClassName, textareaStyle, minHeight,
|
|
60
|
-
attachButton = "always", openFilePickerRef,
|
|
61
|
-
}: AnnotationEditorProps) {
|
|
62
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
63
|
-
const [isUploading, setIsUploading] = useState(false);
|
|
64
|
-
const [isFocused, setIsFocused] = useState(false);
|
|
65
|
-
const dragCounter = useRef(0);
|
|
66
|
-
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
|
67
|
-
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
68
|
-
|
|
69
|
-
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
|
70
|
-
|
|
71
|
-
// Expose file picker to parent
|
|
72
|
-
if (openFilePickerRef) openFilePickerRef.current = openFilePicker;
|
|
73
|
-
|
|
74
|
-
const setRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
75
|
-
internalRef.current = el;
|
|
76
|
-
if (externalRef) externalRef.current = el;
|
|
77
|
-
if (el && autoResize) autoResizeTextarea(el, minHeight);
|
|
78
|
-
}, [externalRef, autoResize, minHeight]);
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (autoFocus && internalRef.current) {
|
|
82
|
-
const ta = internalRef.current;
|
|
83
|
-
ta.focus();
|
|
84
|
-
if (ta.value) ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
85
|
-
}
|
|
86
|
-
}, [autoFocus]);
|
|
87
|
-
|
|
88
|
-
const doUpload = useCallback(async (file: File) => {
|
|
89
|
-
if (!file.type.startsWith("image/")) return;
|
|
90
|
-
setIsUploading(true);
|
|
91
|
-
try {
|
|
92
|
-
const path = await uploadImage(sessionId, file);
|
|
93
|
-
if (path) onAddImage(path);
|
|
94
|
-
} finally {
|
|
95
|
-
setIsUploading(false);
|
|
96
|
-
}
|
|
97
|
-
}, [sessionId, onAddImage]);
|
|
98
|
-
|
|
99
|
-
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
100
|
-
const items = e.clipboardData.items;
|
|
101
|
-
for (const item of items) {
|
|
102
|
-
if (item.type.startsWith("image/")) {
|
|
103
|
-
e.preventDefault();
|
|
104
|
-
const file = item.getAsFile();
|
|
105
|
-
if (file) doUpload(file);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}, [doUpload]);
|
|
110
|
-
|
|
111
|
-
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
dragCounter.current++;
|
|
114
|
-
if (dragCounter.current === 1) setIsDragging(true);
|
|
115
|
-
}, []);
|
|
116
|
-
|
|
117
|
-
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
118
|
-
e.preventDefault();
|
|
119
|
-
}, []);
|
|
120
|
-
|
|
121
|
-
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
dragCounter.current--;
|
|
124
|
-
if (dragCounter.current === 0) setIsDragging(false);
|
|
125
|
-
}, []);
|
|
126
|
-
|
|
127
|
-
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
128
|
-
e.preventDefault();
|
|
129
|
-
dragCounter.current = 0;
|
|
130
|
-
setIsDragging(false);
|
|
131
|
-
for (const file of e.dataTransfer.files) {
|
|
132
|
-
if (file.type.startsWith("image/")) doUpload(file);
|
|
133
|
-
}
|
|
134
|
-
}, [doUpload]);
|
|
135
|
-
|
|
136
|
-
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
137
|
-
onNoteChange(e.target.value);
|
|
138
|
-
if (autoResize) {
|
|
139
|
-
e.target.style.height = "auto";
|
|
140
|
-
e.target.style.height = (minHeight ? Math.max(minHeight, e.target.scrollHeight) : e.target.scrollHeight) + "px";
|
|
141
|
-
}
|
|
142
|
-
}, [onNoteChange, autoResize, minHeight]);
|
|
143
|
-
|
|
144
|
-
const defaultClassName = "w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none";
|
|
145
|
-
const defaultStyle: React.CSSProperties = {
|
|
146
|
-
width: "100%", background: "transparent", border: "none",
|
|
147
|
-
color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
|
|
148
|
-
fontSize: "13px", lineHeight: "1.5", outline: "none",
|
|
149
|
-
...(autoResize ? { resize: "none" as const, overflow: "hidden" } : { resize: "vertical" as const }),
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const showButton = !readOnly && (
|
|
153
|
-
attachButton === "always" ||
|
|
154
|
-
(attachButton === "on-focus" && isFocused)
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
return (
|
|
158
|
-
<div
|
|
159
|
-
style={{ position: "relative" }}
|
|
160
|
-
onDragEnter={readOnly ? undefined : handleDragEnter}
|
|
161
|
-
onDragOver={readOnly ? undefined : handleDragOver}
|
|
162
|
-
onDragLeave={readOnly ? undefined : handleDragLeave}
|
|
163
|
-
onDrop={readOnly ? undefined : handleDrop}
|
|
164
|
-
>
|
|
165
|
-
<textarea
|
|
166
|
-
ref={setRef}
|
|
167
|
-
value={note}
|
|
168
|
-
onChange={readOnly ? undefined : handleChange}
|
|
169
|
-
onPaste={readOnly ? undefined : handlePaste}
|
|
170
|
-
onKeyDown={onKeyDown}
|
|
171
|
-
onClick={onTextareaClick}
|
|
172
|
-
onFocus={() => setIsFocused(true)}
|
|
173
|
-
onBlur={() => {
|
|
174
|
-
// Delay so click on attach button registers before hiding
|
|
175
|
-
setTimeout(() => setIsFocused(false), 150);
|
|
176
|
-
}}
|
|
177
|
-
readOnly={readOnly}
|
|
178
|
-
placeholder={placeholder}
|
|
179
|
-
className={textareaClassName ?? defaultClassName}
|
|
180
|
-
style={textareaStyle ?? defaultStyle}
|
|
181
|
-
rows={autoResize ? 1 : undefined}
|
|
182
|
-
/>
|
|
183
|
-
|
|
184
|
-
{/* Hidden file input — always rendered for paste/drop + external trigger */}
|
|
185
|
-
{!readOnly && (
|
|
186
|
-
<input
|
|
187
|
-
ref={fileInputRef}
|
|
188
|
-
type="file"
|
|
189
|
-
accept="image/*"
|
|
190
|
-
multiple
|
|
191
|
-
style={{ display: "none" }}
|
|
192
|
-
onChange={(e) => {
|
|
193
|
-
const files = e.target.files;
|
|
194
|
-
if (files) for (const f of files) doUpload(f);
|
|
195
|
-
e.target.value = "";
|
|
196
|
-
}}
|
|
197
|
-
/>
|
|
198
|
-
)}
|
|
199
|
-
|
|
200
|
-
{/* Image thumbnails + inline attach button */}
|
|
201
|
-
{(images.length > 0 || showButton) && (
|
|
202
|
-
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px", alignItems: "center" }}>
|
|
203
|
-
{images.map((img) => (
|
|
204
|
-
<div
|
|
205
|
-
key={img}
|
|
206
|
-
style={{
|
|
207
|
-
position: "relative", width: "56px", height: "56px",
|
|
208
|
-
borderRadius: "6px", overflow: "hidden",
|
|
209
|
-
border: "1px solid var(--color-border-subtle)",
|
|
210
|
-
flexShrink: 0,
|
|
211
|
-
}}
|
|
212
|
-
>
|
|
213
|
-
<img
|
|
214
|
-
src={imageToUrl(img)}
|
|
215
|
-
alt=""
|
|
216
|
-
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
217
|
-
/>
|
|
218
|
-
{!readOnly && (
|
|
219
|
-
<button
|
|
220
|
-
onClick={(e) => { e.stopPropagation(); onRemoveImage(img); }}
|
|
221
|
-
style={{
|
|
222
|
-
position: "absolute", top: "2px", right: "2px",
|
|
223
|
-
width: "16px", height: "16px", borderRadius: "50%",
|
|
224
|
-
background: "rgba(0,0,0,0.6)", color: "white",
|
|
225
|
-
border: "none", cursor: "pointer", fontSize: "11px",
|
|
226
|
-
display: "flex", alignItems: "center", justifyContent: "center",
|
|
227
|
-
lineHeight: 1, padding: 0,
|
|
228
|
-
}}
|
|
229
|
-
title="Remove image"
|
|
230
|
-
>
|
|
231
|
-
×
|
|
232
|
-
</button>
|
|
233
|
-
)}
|
|
234
|
-
</div>
|
|
235
|
-
))}
|
|
236
|
-
{showButton && <AttachButton onClick={openFilePicker} />}
|
|
237
|
-
</div>
|
|
238
|
-
)}
|
|
239
|
-
|
|
240
|
-
{isUploading && (
|
|
241
|
-
<div style={{
|
|
242
|
-
fontSize: "11px", color: "var(--color-text-tertiary)",
|
|
243
|
-
fontFamily: "'Inter', sans-serif", marginTop: "4px",
|
|
244
|
-
}}>
|
|
245
|
-
Uploading...
|
|
246
|
-
</div>
|
|
247
|
-
)}
|
|
248
|
-
|
|
249
|
-
{isDragging && (
|
|
250
|
-
<div style={{
|
|
251
|
-
position: "absolute", inset: 0, display: "flex",
|
|
252
|
-
alignItems: "center", justifyContent: "center",
|
|
253
|
-
background: "var(--color-highlight-bg)", opacity: 0.9,
|
|
254
|
-
border: "2px dashed var(--color-highlight-border)",
|
|
255
|
-
borderRadius: "6px", zIndex: 10, pointerEvents: "none",
|
|
256
|
-
fontSize: "12px", fontFamily: "'Inter', sans-serif",
|
|
257
|
-
color: "var(--color-text-secondary)",
|
|
258
|
-
}}>
|
|
259
|
-
Drop image
|
|
260
|
-
</div>
|
|
261
|
-
)}
|
|
262
|
-
</div>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Small attach-image button — reusable in editor and popover button bar */
|
|
267
|
-
export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
|
|
268
|
-
return (
|
|
269
|
-
<button
|
|
270
|
-
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
|
271
|
-
style={{
|
|
272
|
-
width: `${size * 2}px`, height: `${size * 2}px`, borderRadius: "6px",
|
|
273
|
-
border: "none",
|
|
274
|
-
background: "transparent", cursor: "pointer",
|
|
275
|
-
display: "flex", alignItems: "center", justifyContent: "center",
|
|
276
|
-
color: "var(--color-text-tertiary)", flexShrink: 0,
|
|
277
|
-
transition: "color 0.15s",
|
|
278
|
-
}}
|
|
279
|
-
title="Attach image"
|
|
280
|
-
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--color-text-secondary)"; }}
|
|
281
|
-
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--color-text-tertiary)"; }}
|
|
282
|
-
>
|
|
283
|
-
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
284
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
285
|
-
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
286
|
-
<polyline points="21 15 16 10 5 21" />
|
|
287
|
-
</svg>
|
|
288
|
-
</button>
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Read-only image thumbnails for display in read-only annotations */
|
|
293
|
-
export function ImageThumbnails({ images }: { images: string[] }) {
|
|
294
|
-
if (!images.length) return null;
|
|
295
|
-
return (
|
|
296
|
-
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", marginTop: "6px" }}>
|
|
297
|
-
{images.map((img) => (
|
|
298
|
-
<div
|
|
299
|
-
key={img}
|
|
300
|
-
style={{
|
|
301
|
-
width: "56px", height: "56px", borderRadius: "6px",
|
|
302
|
-
overflow: "hidden", border: "1px solid var(--color-border-subtle)",
|
|
303
|
-
flexShrink: 0,
|
|
304
|
-
}}
|
|
305
|
-
>
|
|
306
|
-
<img
|
|
307
|
-
src={imageToUrl(img)}
|
|
308
|
-
alt=""
|
|
309
|
-
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
|
310
|
-
/>
|
|
311
|
-
</div>
|
|
312
|
-
))}
|
|
313
|
-
</div>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
|
-
import { AnnotationCtx } from "#canvas/runtime";
|
|
3
|
-
import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
|
|
4
|
-
import { annotationDraftKey, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
|
|
5
|
-
import { generateAnnotationId } from "./utils";
|
|
6
|
-
|
|
7
|
-
// Re-export types for convenience
|
|
8
|
-
export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
|
|
9
|
-
export { useAnnotations } from "#canvas/runtime";
|
|
10
|
-
|
|
11
|
-
interface AnnotationProviderProps {
|
|
12
|
-
sessionId: string;
|
|
13
|
-
revision: number;
|
|
14
|
-
isReadOnly: boolean;
|
|
15
|
-
draftPhase: AnnotationDraftPhase;
|
|
16
|
-
/** Remote annotations fetched from shared views. Merged read-only into
|
|
17
|
-
* the annotation list so they render alongside the author's own. */
|
|
18
|
-
remoteAnnotations?: Annotation[];
|
|
19
|
-
children: React.ReactNode;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface PersistedState {
|
|
23
|
-
annotations: Annotation[];
|
|
24
|
-
generalNote: string;
|
|
25
|
-
responses: [string, PlanResponse][];
|
|
26
|
-
feedbackEntries?: [string, FeedbackEntry][];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
|
|
30
|
-
try {
|
|
31
|
-
const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
|
|
32
|
-
if (!raw) return null;
|
|
33
|
-
return JSON.parse(raw);
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
|
|
40
|
-
try {
|
|
41
|
-
localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
|
|
42
|
-
} catch {}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, children }: AnnotationProviderProps) {
|
|
46
|
-
const [localAnnotations, setAnnotations] = useState<Annotation[]>(() => {
|
|
47
|
-
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
48
|
-
return (saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const }));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Merge local + remote annotations. Remote annotations are always
|
|
52
|
-
// rendered read-only; mutation helpers below operate on localAnnotations
|
|
53
|
-
// only so they silently no-op on remote ids.
|
|
54
|
-
const annotations = useMemo<Annotation[]>(() => {
|
|
55
|
-
if (!remoteAnnotations || remoteAnnotations.length === 0) return localAnnotations;
|
|
56
|
-
const localIds = new Set(localAnnotations.map((a) => a.id));
|
|
57
|
-
const filtered = remoteAnnotations.filter((a) => !localIds.has(a.id));
|
|
58
|
-
return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
|
|
59
|
-
}, [localAnnotations, remoteAnnotations]);
|
|
60
|
-
const [generalNote, setGeneralNote] = useState(() => {
|
|
61
|
-
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
62
|
-
return saved?.generalNote ?? "";
|
|
63
|
-
});
|
|
64
|
-
const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
|
|
65
|
-
const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => {
|
|
66
|
-
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
67
|
-
return saved?.responses ? new Map(saved.responses) : new Map();
|
|
68
|
-
});
|
|
69
|
-
const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() => {
|
|
70
|
-
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
71
|
-
return saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// Post-feedback drafts save immediately so a new revision can carry the latest edit.
|
|
75
|
-
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (isReadOnly && draftPhase !== "next") return;
|
|
78
|
-
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
|
79
|
-
const state = {
|
|
80
|
-
// Persist only local annotations — remote ones are re-fetched on load.
|
|
81
|
-
annotations: localAnnotations,
|
|
82
|
-
generalNote,
|
|
83
|
-
responses: Array.from(responses.entries()),
|
|
84
|
-
feedbackEntries: Array.from(feedbackEntries.entries()),
|
|
85
|
-
};
|
|
86
|
-
if (draftPhase === "next") {
|
|
87
|
-
savePersisted(sessionId, revision, draftPhase, state);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
persistTimerRef.current = setTimeout(() => savePersisted(sessionId, revision, draftPhase, state), 300);
|
|
91
|
-
return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
|
|
92
|
-
}, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase]);
|
|
93
|
-
|
|
94
|
-
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
|
|
95
|
-
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
|
|
96
|
-
}, []);
|
|
97
|
-
|
|
98
|
-
const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
|
|
99
|
-
addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
|
|
100
|
-
}, [addAnnotationWithId]);
|
|
101
|
-
|
|
102
|
-
const updateAnnotation = useCallback((id: string, note: string) => {
|
|
103
|
-
setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, note } : a)));
|
|
104
|
-
}, []);
|
|
105
|
-
|
|
106
|
-
const removeAnnotation = useCallback((id: string) => {
|
|
107
|
-
removeMarksFromDom(id);
|
|
108
|
-
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
|
109
|
-
setActiveAnnotationId((prev) => (prev === id ? null : prev));
|
|
110
|
-
}, []);
|
|
111
|
-
|
|
112
|
-
const addAnnotationImage = useCallback((id: string, imagePath: string) => {
|
|
113
|
-
setAnnotations((prev) => prev.map((a) =>
|
|
114
|
-
a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
|
|
115
|
-
));
|
|
116
|
-
}, []);
|
|
117
|
-
|
|
118
|
-
const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
|
|
119
|
-
setAnnotations((prev) => prev.map((a) =>
|
|
120
|
-
a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
|
|
121
|
-
));
|
|
122
|
-
}, []);
|
|
123
|
-
|
|
124
|
-
const setResponse = useCallback((id: string, response: PlanResponse) => {
|
|
125
|
-
setResponses((prev) => {
|
|
126
|
-
const next = new Map(prev);
|
|
127
|
-
next.set(id, response);
|
|
128
|
-
return next;
|
|
129
|
-
});
|
|
130
|
-
}, []);
|
|
131
|
-
|
|
132
|
-
const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
|
|
133
|
-
setFeedbackEntries((prev) => {
|
|
134
|
-
const existing = prev.get(id);
|
|
135
|
-
if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
|
|
136
|
-
return prev; // same reference → no re-render
|
|
137
|
-
}
|
|
138
|
-
const next = new Map(prev);
|
|
139
|
-
next.set(id, entry);
|
|
140
|
-
return next;
|
|
141
|
-
});
|
|
142
|
-
}, []);
|
|
143
|
-
|
|
144
|
-
const removeFeedbackEntry = useCallback((id: string) => {
|
|
145
|
-
setFeedbackEntries((prev) => {
|
|
146
|
-
if (!prev.has(id)) return prev;
|
|
147
|
-
const next = new Map(prev);
|
|
148
|
-
next.delete(id);
|
|
149
|
-
return next;
|
|
150
|
-
});
|
|
151
|
-
}, []);
|
|
152
|
-
|
|
153
|
-
const clearAll = useCallback(() => {
|
|
154
|
-
for (const mark of document.querySelectorAll("[data-annotation-id]")) {
|
|
155
|
-
unwrapMark(mark as HTMLElement);
|
|
156
|
-
}
|
|
157
|
-
setAnnotations([]);
|
|
158
|
-
setGeneralNote("");
|
|
159
|
-
setActiveAnnotationId(null);
|
|
160
|
-
setResponses(new Map());
|
|
161
|
-
setFeedbackEntries(new Map());
|
|
162
|
-
if (draftPhase === "next") {
|
|
163
|
-
localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
|
|
164
|
-
} else {
|
|
165
|
-
clearPersistedDraft(localStorage, sessionId, revision);
|
|
166
|
-
}
|
|
167
|
-
}, [sessionId, revision, draftPhase]);
|
|
168
|
-
|
|
169
|
-
return (
|
|
170
|
-
<AnnotationCtx.Provider
|
|
171
|
-
value={{
|
|
172
|
-
annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
|
|
173
|
-
generalNote, setGeneralNote, clearAll,
|
|
174
|
-
activeAnnotationId, setActiveAnnotationId,
|
|
175
|
-
responses, setResponse,
|
|
176
|
-
feedbackEntries, setFeedbackEntry, removeFeedbackEntry,
|
|
177
|
-
}}
|
|
178
|
-
>
|
|
179
|
-
{children}
|
|
180
|
-
</AnnotationCtx.Provider>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function removeMarksFromDom(id: string) {
|
|
185
|
-
const marks = document.querySelectorAll(`[data-annotation-id="${id}"]`);
|
|
186
|
-
for (const mark of marks) unwrapMark(mark as HTMLElement);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function unwrapMark(mark: HTMLElement) {
|
|
190
|
-
const parent = mark.parentNode;
|
|
191
|
-
if (!parent) return;
|
|
192
|
-
const text = document.createTextNode(mark.textContent || "");
|
|
193
|
-
parent.replaceChild(text, mark);
|
|
194
|
-
parent.normalize();
|
|
195
|
-
}
|