agent-canvas 0.1.44 → 0.1.45

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.
Files changed (110) hide show
  1. package/bin/lib/commands/daemon.ts +3 -6
  2. package/bin/lib/commands/push.ts +1 -1
  3. package/bin/lib/daemon-lifecycle.ts +65 -72
  4. package/bin/lib/helpers.test.ts +1 -0
  5. package/bin/lib/helpers.ts +30 -74
  6. package/daemon/build.ts +16 -13
  7. package/daemon/client/App.tsx +32 -61
  8. package/daemon/client/CompareView.tsx +4 -4
  9. package/daemon/client/FileBrowser.tsx +4 -4
  10. package/daemon/client/FileViewer.tsx +4 -4
  11. package/daemon/client/OverviewView.tsx +2 -2
  12. package/daemon/client/RevisionSelect.tsx +1 -1
  13. package/daemon/client/clientApi.ts +18 -0
  14. package/daemon/client/components/index.ts +3 -16
  15. package/daemon/client/styles.css +4 -457
  16. package/daemon/dist/client.css +1 -1
  17. package/daemon/dist/client.js +76 -76
  18. package/daemon/dist/components.js +29 -29
  19. package/daemon/dist/runtime.js +1 -1
  20. package/daemon/src/components.ts +13 -0
  21. package/daemon/src/handlers/api.ts +14 -9
  22. package/daemon/src/handlers/files.ts +1 -3
  23. package/daemon/src/handlers/static.ts +1 -1
  24. package/daemon/src/handlers/uploads.ts +1 -3
  25. package/daemon/src/paths.ts +10 -24
  26. package/daemon/src/remote-feedback.test.ts +1 -1
  27. package/daemon/src/remote-feedback.ts +1 -1
  28. package/daemon/src/secrets-api.test.ts +1 -2
  29. package/daemon/src/server.ts +13 -10
  30. package/daemon/src/share-e2e.test.ts +1 -1
  31. package/daemon/src/share.test.ts +1 -1
  32. package/daemon/src/share.ts +1 -1
  33. package/daemon/tsconfig.json +1 -3
  34. package/package.json +10 -2
  35. package/.github/dark.png +0 -0
  36. package/.github/light.png +0 -0
  37. package/.github/workflows/ci.yml +0 -48
  38. package/.github/workflows/deploy-worker-prod.yml +0 -19
  39. package/.github/workflows/deploy-worker-stage.yml +0 -21
  40. package/.github/workflows/deploy-worker.yml +0 -72
  41. package/.github/workflows/release.yml +0 -36
  42. package/CLAUDE.md +0 -86
  43. package/bun.lock +0 -377
  44. package/daemon/client/AnnotationEditor.tsx +0 -315
  45. package/daemon/client/AnnotationProvider.tsx +0 -195
  46. package/daemon/client/AnnotationSidebar.tsx +0 -627
  47. package/daemon/client/FileIcon.tsx +0 -76
  48. package/daemon/client/PlanRenderer.tsx +0 -614
  49. package/daemon/client/Popover.tsx +0 -350
  50. package/daemon/client/RenderErrorContext.ts +0 -11
  51. package/daemon/client/ResponsePreview.tsx +0 -186
  52. package/daemon/client/SessionContext.tsx +0 -3
  53. package/daemon/client/annotationContext.ts +0 -114
  54. package/daemon/client/annotationDom.ts +0 -104
  55. package/daemon/client/annotationDraft.test.ts +0 -188
  56. package/daemon/client/annotationDraft.ts +0 -130
  57. package/daemon/client/compat/preact-all.ts +0 -4
  58. package/daemon/client/components/Callout.tsx +0 -26
  59. package/daemon/client/components/Checklist.tsx +0 -37
  60. package/daemon/client/components/Choice.tsx +0 -176
  61. package/daemon/client/components/CodeBlock.tsx +0 -35
  62. package/daemon/client/components/Diff.tsx +0 -65
  63. package/daemon/client/components/FilePreview.tsx +0 -88
  64. package/daemon/client/components/Image.tsx +0 -45
  65. package/daemon/client/components/Markdown.tsx +0 -44
  66. package/daemon/client/components/Mermaid.tsx +0 -565
  67. package/daemon/client/components/Note.tsx +0 -14
  68. package/daemon/client/components/Priority.tsx +0 -19
  69. package/daemon/client/components/ResponseNote.tsx +0 -19
  70. package/daemon/client/components/Section.tsx +0 -33
  71. package/daemon/client/components/Table.tsx +0 -35
  72. package/daemon/client/components/Task.tsx +0 -76
  73. package/daemon/client/components/UserInput.tsx +0 -174
  74. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  75. package/daemon/client/generateMarkdown.ts +0 -273
  76. package/daemon/client/highlightRange.ts +0 -224
  77. package/daemon/client/popoverPosition.ts +0 -31
  78. package/daemon/client/runtime.ts +0 -123
  79. package/daemon/client/theme.css +0 -66
  80. package/daemon/client/useTextAnnotation.tsx +0 -188
  81. package/daemon/client/utils.ts +0 -13
  82. package/daemon/src/compiler.test.ts +0 -57
  83. package/daemon/src/compiler.ts +0 -360
  84. package/daemon/src/handlers/utils.ts +0 -14
  85. package/daemon/src/router.ts +0 -16
  86. package/daemon/src/session-secrets.test.ts +0 -83
  87. package/daemon/src/session.ts +0 -612
  88. package/daemon/src/watcher.ts +0 -71
  89. package/daemon/src/websocket.test.ts +0 -99
  90. package/daemon/src/websocket.ts +0 -211
  91. package/tsconfig.json +0 -14
  92. package/workers/canvas-share/README.md +0 -159
  93. package/workers/canvas-share/oblaka.ts +0 -56
  94. package/workers/canvas-share/package.json +0 -25
  95. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  96. package/workers/canvas-share/public/assets/client.css +0 -2
  97. package/workers/canvas-share/public/assets/client.js +0 -91
  98. package/workers/canvas-share/public/assets/components.js +0 -65
  99. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  100. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  101. package/workers/canvas-share/public/assets/runtime.js +0 -1
  102. package/workers/canvas-share/public/index.html +0 -385
  103. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  104. package/workers/canvas-share/public/screenshot-light.png +0 -0
  105. package/workers/canvas-share/src/index.test.ts +0 -209
  106. package/workers/canvas-share/src/index.ts +0 -518
  107. package/workers/canvas-share/src/types.ts +0 -119
  108. package/workers/canvas-share/src/util.ts +0 -156
  109. package/workers/canvas-share/src/validation.ts +0 -160
  110. 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
- &times;
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
- }