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