@fabrika/canvas-kernel 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 (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -58
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,55 +1,57 @@
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 { canPruneResponses, pruneStaleResponses } from "./generateMarkdown";
6
- import { generateAnnotationId } from "./utils";
1
+ import { AnnotationCtx } from '#canvas/runtime'
2
+ import type { Annotation, AnnotationContext, AnnotationContextValue, FeedbackEntry, PlanResponse } from '#canvas/runtime'
3
+ import { useAnnotationState } from '@fabrika/annotations'
4
+ import { AnnotationCtx as SurfaceAnnotationCtx, AnnotationHostContext } from '@fabrika/annotations/runtime'
5
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { annotationDraftKey, type AnnotationDraftPhase, clearPersistedDraft } from './annotationDraft'
7
+ import { canPruneResponses, pruneStaleResponses } from './generateMarkdown'
8
+ import { useCanvasHost } from './hostContext'
7
9
 
8
10
  // Re-export types for convenience
9
- export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
10
- export { useAnnotations } from "#canvas/runtime";
11
+ export type { Annotation, AnnotationContext, AnnotationContextValue, FeedbackEntry, PlanResponse }
12
+ export { useAnnotations } from '#canvas/runtime'
11
13
 
12
14
  export interface PersistedState {
13
- annotations: Annotation[];
14
- generalNote: string;
15
- responses: [string, PlanResponse][];
16
- feedbackEntries?: [string, FeedbackEntry][];
15
+ annotations: Annotation[]
16
+ generalNote: string
17
+ responses: [string, PlanResponse][]
18
+ feedbackEntries?: [string, FeedbackEntry][]
17
19
  }
18
20
 
19
21
  interface AnnotationProviderProps {
20
- sessionId: string;
21
- revision: number;
22
- isReadOnly: boolean;
23
- draftPhase: AnnotationDraftPhase;
24
- /** Remote annotations fetched from shared views. Merged read-only into
25
- * the annotation list so they render alongside the author's own. */
26
- remoteAnnotations?: Annotation[];
27
- /** Canvas files this revision is made of. Supplying it enables pruning of
28
- * answers to questions the revision no longer asks; omit it and every
29
- * answer is submitted, which is what a host without canvas tabs wants. */
30
- canvasFiles?: readonly string[];
31
- /** Server-authoritative persistence. When supplied, the draft is loaded and
32
- * saved through these instead of localStorage, so it survives reloads and
33
- * follows the author across browsers. Omit for the localStorage default. */
34
- loadState?: (sessionId: string, revision: number, phase: AnnotationDraftPhase) => Promise<PersistedState | null>;
35
- saveState?: (sessionId: string, revision: number, state: PersistedState, phase: AnnotationDraftPhase) => void;
36
- children: React.ReactNode;
22
+ sessionId: string
23
+ revision: number
24
+ isReadOnly: boolean
25
+ draftPhase: AnnotationDraftPhase
26
+ /** Remote annotations fetched from shared views. Merged read-only into
27
+ * the annotation list so they render alongside the author's own. */
28
+ remoteAnnotations?: Annotation[]
29
+ /** Canvas files this revision is made of. Supplying it enables pruning of
30
+ * answers to questions the revision no longer asks; omit it and every
31
+ * answer is submitted, which is what a host without canvas tabs wants. */
32
+ canvasFiles?: readonly string[]
33
+ /** Server-authoritative persistence. When supplied, the draft is loaded and
34
+ * saved through these instead of localStorage, so it survives reloads and
35
+ * follows the author across browsers. Omit for the localStorage default. */
36
+ loadState?: (sessionId: string, revision: number, phase: AnnotationDraftPhase) => Promise<PersistedState | null>
37
+ saveState?: (sessionId: string, revision: number, state: PersistedState, phase: AnnotationDraftPhase) => void
38
+ children: React.ReactNode
37
39
  }
38
40
 
39
41
  function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
40
- try {
41
- const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
42
- if (!raw) return null;
43
- return JSON.parse(raw);
44
- } catch {
45
- return null;
46
- }
42
+ try {
43
+ const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase))
44
+ if (!raw) return null
45
+ return JSON.parse(raw)
46
+ } catch {
47
+ return null
48
+ }
47
49
  }
48
50
 
49
51
  function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
50
- try {
51
- localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
52
- } catch {}
52
+ try {
53
+ localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state))
54
+ } catch {}
53
55
  }
54
56
 
55
57
  /** What the reader has been shown for one draft. Stamped with the draft it
@@ -57,238 +59,195 @@ function savePersisted(sessionId: string, revision: number, phase: AnnotationDra
57
59
  * their own effects, which run before the provider's, so an effect-based reset
58
60
  * would wipe registrations that already happened. */
59
61
  interface SeenState {
60
- draft: string;
61
- responseIds: Set<string>;
62
- canvases: Set<string>;
62
+ draft: string
63
+ responseIds: Set<string>
64
+ canvases: Set<string>
63
65
  }
64
66
 
65
67
  function seenFor(draft: string): SeenState {
66
- return { draft, responseIds: new Set(), canvases: new Set() };
68
+ return { draft, responseIds: new Set(), canvases: new Set() }
67
69
  }
68
70
 
69
- export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, canvasFiles, loadState, saveState, children }: AnnotationProviderProps) {
70
- // Server-authoritative persistence loads asynchronously, so that mode starts
71
- // empty and hydrates in an effect. localStorage mode seeds synchronously.
72
- const serverMode = !!loadState;
73
- const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase);
74
-
75
- const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
76
- (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })),
77
- );
78
-
79
- // Merge local + remote annotations. Remote annotations are always
80
- // rendered read-only; mutation helpers below operate on localAnnotations
81
- // only so they silently no-op on remote ids.
82
- const annotations = useMemo<Annotation[]>(() => {
83
- if (!remoteAnnotations || remoteAnnotations.length === 0) return localAnnotations;
84
- const localIds = new Set(localAnnotations.map((a) => a.id));
85
- const filtered = remoteAnnotations.filter((a) => !localIds.has(a.id));
86
- return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
87
- }, [localAnnotations, remoteAnnotations]);
88
- const [generalNote, setGeneralNote] = useState(() => seed?.generalNote ?? "");
89
- const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
90
- const [responses, setResponses] = useState<Map<string, PlanResponse>>(() =>
91
- seed?.responses ? new Map(seed.responses) : new Map(),
92
- );
93
- const draftId = `${sessionId}:${revision}:${draftPhase}`;
94
- const draftIdRef = useRef(draftId);
95
- draftIdRef.current = draftId;
96
- const [seen, setSeen] = useState<SeenState>(() => seenFor(draftId));
97
- const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() =>
98
- seed?.feedbackEntries ? new Map(seed.feedbackEntries) : new Map(),
99
- );
100
-
101
- // Re-hydrate when the draft identity changes. Hosts that remount the provider
102
- // per revision (via a key) get this for free from the initializers, but hosts
103
- // that keep it mounted need it here — otherwise one revision's answers leak
104
- // into the next and the persist effect writes them over the new revision's
105
- // saved draft. `hydrated` gates that effect so an async server load cannot be
106
- // clobbered by the empty initial state.
107
- const [hydrated, setHydrated] = useState(!serverMode);
108
- useEffect(() => {
109
- const apply = (saved: PersistedState | null) => {
110
- setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })));
111
- setGeneralNote(saved?.generalNote ?? "");
112
- setResponses(saved?.responses ? new Map(saved.responses) : new Map());
113
- setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map());
114
- setActiveAnnotationId(null);
115
- };
116
- if (serverMode) {
117
- let cancelled = false;
118
- setHydrated(false);
119
- loadState!(sessionId, revision, draftPhase).then((saved) => {
120
- if (cancelled) return;
121
- apply(saved);
122
- setHydrated(true);
123
- });
124
- return () => { cancelled = true; };
125
- }
126
- apply(loadPersisted(sessionId, revision, draftPhase));
127
- setHydrated(true);
128
- return undefined;
129
- }, [sessionId, revision, draftPhase, serverMode, loadState]);
130
-
131
- // Post-feedback drafts save immediately so a new revision can carry the latest edit.
132
- const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
- useEffect(() => {
134
- if (isReadOnly && draftPhase !== "next") return;
135
- if (!hydrated) return;
136
- if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
137
- const state: PersistedState = {
138
- // Persist only local annotations — remote ones are re-fetched on load.
139
- annotations: localAnnotations,
140
- generalNote,
141
- responses: Array.from(responses.entries()),
142
- feedbackEntries: Array.from(feedbackEntries.entries()),
143
- };
144
- const persist = () => {
145
- if (saveState) saveState(sessionId, revision, state, draftPhase);
146
- else savePersisted(sessionId, revision, draftPhase, state);
147
- };
148
- if (draftPhase === "next") {
149
- persist();
150
- return;
151
- }
152
- persistTimerRef.current = setTimeout(persist, 300);
153
- return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
154
- }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase, hydrated, saveState]);
155
-
156
- const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
157
- setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
158
- }, []);
159
-
160
- const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
161
- addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
162
- }, [addAnnotationWithId]);
163
-
164
- const updateAnnotation = useCallback((id: string, note: string) => {
165
- setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, note } : a)));
166
- }, []);
167
-
168
- const removeAnnotation = useCallback((id: string) => {
169
- removeMarksFromDom(id);
170
- setAnnotations((prev) => prev.filter((a) => a.id !== id));
171
- setActiveAnnotationId((prev) => (prev === id ? null : prev));
172
- }, []);
173
-
174
- const addAnnotationImage = useCallback((id: string, imagePath: string) => {
175
- setAnnotations((prev) => prev.map((a) =>
176
- a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
177
- ));
178
- }, []);
179
-
180
- const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
181
- setAnnotations((prev) => prev.map((a) =>
182
- a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
183
- ));
184
- }, []);
185
-
186
- const setResponse = useCallback((id: string, response: PlanResponse) => {
187
- setResponses((prev) => {
188
- const next = new Map(prev);
189
- next.set(id, response);
190
- return next;
191
- });
192
- }, []);
193
-
194
- // Both registrations accumulate for the life of one draft and are idempotent,
195
- // so re-running the caller's effect cannot loop.
196
- const registerResponse = useCallback((id: string) => {
197
- setSeen((prev) => {
198
- const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
199
- if (base.responseIds.has(id)) return base;
200
- return { ...base, responseIds: new Set(base.responseIds).add(id) };
201
- });
202
- }, []);
203
-
204
- const registerCanvasRendered = useCallback((filename: string) => {
205
- setSeen((prev) => {
206
- const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
207
- if (base.canvases.has(filename)) return base;
208
- return { ...base, canvases: new Set(base.canvases).add(filename) };
209
- });
210
- }, []);
211
-
212
- // Answers survive until a canvas proves the question is gone. `seen` can lag
213
- // the draft by a render when the host keeps the provider mounted across
214
- // revisions, and stale evidence must never prune.
215
- const current = seen.draft === draftId ? seen : null;
216
- const submittableResponses = useMemo(() => {
217
- if (!current || !canPruneResponses(canvasFiles, current.canvases)) return responses;
218
- return pruneStaleResponses(responses, current.responseIds);
219
- }, [current, canvasFiles, responses]);
220
-
221
- const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
222
- setFeedbackEntries((prev) => {
223
- const existing = prev.get(id);
224
- if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
225
- return prev; // same reference → no re-render
226
- }
227
- const next = new Map(prev);
228
- next.set(id, entry);
229
- return next;
230
- });
231
- }, []);
232
-
233
- const removeFeedbackEntry = useCallback((id: string) => {
234
- setFeedbackEntries((prev) => {
235
- if (!prev.has(id)) return prev;
236
- const next = new Map(prev);
237
- next.delete(id);
238
- return next;
239
- });
240
- }, []);
241
-
242
- const clearAll = useCallback(() => {
243
- for (const mark of document.querySelectorAll("[data-annotation-id]")) {
244
- unwrapMark(mark as HTMLElement);
245
- }
246
- setAnnotations([]);
247
- setGeneralNote("");
248
- setActiveAnnotationId(null);
249
- setResponses(new Map());
250
- setFeedbackEntries(new Map());
251
- if (saveState) {
252
- saveState(sessionId, revision, { annotations: [], generalNote: "", responses: [], feedbackEntries: [] }, draftPhase);
253
- } else if (draftPhase === "next") {
254
- localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
255
- } else {
256
- clearPersistedDraft(localStorage, sessionId, revision);
257
- }
258
- }, [sessionId, revision, draftPhase, saveState]);
259
-
260
- return (
261
- <AnnotationCtx.Provider
262
- value={{
263
- annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
264
- generalNote, setGeneralNote, clearAll,
265
- activeAnnotationId, setActiveAnnotationId,
266
- responses, setResponse, submittableResponses,
267
- registerResponse, registerCanvasRendered,
268
- feedbackEntries, setFeedbackEntry, removeFeedbackEntry,
269
- isReadOnly,
270
- }}
271
- >
272
- {children}
273
- </AnnotationCtx.Provider>
274
- );
275
- }
276
-
277
- function removeMarksFromDom(id: string) {
278
- const marks = document.querySelectorAll(`[data-annotation-id="${id}"]`);
279
- for (const mark of marks) unwrapMark(mark as HTMLElement);
280
- }
281
-
282
- function unwrapMark(mark: HTMLElement) {
283
- const parent = mark.parentNode;
284
- if (!parent) return;
285
- // A region overlay wraps no text; swapping it for its text content would
286
- // leave a stray empty node inside the image.
287
- if (mark.tagName !== "MARK") {
288
- parent.removeChild(mark);
289
- return;
290
- }
291
- const text = document.createTextNode(mark.textContent || "");
292
- parent.replaceChild(text, mark);
293
- parent.normalize();
71
+ export function AnnotationProvider(
72
+ { sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, canvasFiles, loadState, saveState, children }: AnnotationProviderProps,
73
+ ) {
74
+ // Server-authoritative persistence loads asynchronously, so that mode starts
75
+ // empty and hydrates in an effect. localStorage mode seeds synchronously.
76
+ const serverMode = !!loadState
77
+ const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase)
78
+
79
+ const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
80
+ (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const }))
81
+ )
82
+
83
+ const [generalNote, setGeneralNote] = useState(() => seed?.generalNote ?? '')
84
+ const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => seed?.responses ? new Map(seed.responses) : new Map())
85
+ const draftId = `${sessionId}:${revision}:${draftPhase}`
86
+ const draftIdRef = useRef(draftId)
87
+ draftIdRef.current = draftId
88
+ const [seen, setSeen] = useState<SeenState>(() => seenFor(draftId))
89
+ const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() =>
90
+ seed?.feedbackEntries ? new Map(seed.feedbackEntries) : new Map()
91
+ )
92
+
93
+ // Re-hydrate when the draft identity changes. Hosts that remount the provider
94
+ // per revision (via a key) get this for free from the initializers, but hosts
95
+ // that keep it mounted need it here — otherwise one revision's answers leak
96
+ // into the next and the persist effect writes them over the new revision's
97
+ // saved draft. `hydrated` gates that effect so an async server load cannot be
98
+ // clobbered by the empty initial state.
99
+ const [hydrated, setHydrated] = useState(!serverMode)
100
+ useEffect(() => {
101
+ const apply = (saved: PersistedState | null) => {
102
+ setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const })))
103
+ setGeneralNote(saved?.generalNote ?? '')
104
+ setResponses(saved?.responses ? new Map(saved.responses) : new Map())
105
+ setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map())
106
+ setActiveAnnotationId(null)
107
+ }
108
+ if (serverMode) {
109
+ let cancelled = false
110
+ setHydrated(false)
111
+ loadState!(sessionId, revision, draftPhase).then((saved) => {
112
+ if (cancelled) return
113
+ apply(saved)
114
+ setHydrated(true)
115
+ })
116
+ return () => {
117
+ cancelled = true
118
+ }
119
+ }
120
+ apply(loadPersisted(sessionId, revision, draftPhase))
121
+ setHydrated(true)
122
+ return undefined
123
+ }, [sessionId, revision, draftPhase, serverMode, loadState])
124
+
125
+ // Post-feedback drafts save immediately so a new revision can carry the latest edit.
126
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
127
+ useEffect(() => {
128
+ if (isReadOnly && draftPhase !== 'next') return
129
+ if (!hydrated) return
130
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
131
+ const state: PersistedState = {
132
+ // Persist only local annotations — remote ones are re-fetched on load.
133
+ annotations: localAnnotations,
134
+ generalNote,
135
+ responses: Array.from(responses.entries()),
136
+ feedbackEntries: Array.from(feedbackEntries.entries()),
137
+ }
138
+ const persist = () => {
139
+ if (saveState) saveState(sessionId, revision, state, draftPhase)
140
+ else savePersisted(sessionId, revision, draftPhase, state)
141
+ }
142
+ if (draftPhase === 'next') {
143
+ persist()
144
+ return
145
+ }
146
+ persistTimerRef.current = setTimeout(persist, 300)
147
+ return () => {
148
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
149
+ }
150
+ }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase, hydrated, saveState])
151
+
152
+ const setResponse = useCallback((id: string, response: PlanResponse) => {
153
+ setResponses((prev) => {
154
+ const next = new Map(prev)
155
+ next.set(id, response)
156
+ return next
157
+ })
158
+ }, [])
159
+
160
+ // Both registrations accumulate for the life of one draft and are idempotent,
161
+ // so re-running the caller's effect cannot loop.
162
+ const registerResponse = useCallback((id: string) => {
163
+ setSeen((prev) => {
164
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current)
165
+ if (base.responseIds.has(id)) return base
166
+ return { ...base, responseIds: new Set(base.responseIds).add(id) }
167
+ })
168
+ }, [])
169
+
170
+ const registerCanvasRendered = useCallback((filename: string) => {
171
+ setSeen((prev) => {
172
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current)
173
+ if (base.canvases.has(filename)) return base
174
+ return { ...base, canvases: new Set(base.canvases).add(filename) }
175
+ })
176
+ }, [])
177
+
178
+ // Answers survive until a canvas proves the question is gone. `seen` can lag
179
+ // the draft by a render when the host keeps the provider mounted across
180
+ // revisions, and stale evidence must never prune.
181
+ const current = seen.draft === draftId ? seen : null
182
+ const submittableResponses = useMemo(() => {
183
+ if (!current || !canPruneResponses(canvasFiles, current.canvases)) return responses
184
+ return pruneStaleResponses(responses, current.responseIds)
185
+ }, [current, canvasFiles, responses])
186
+
187
+ const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
188
+ setFeedbackEntries((prev) => {
189
+ const existing = prev.get(id)
190
+ if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
191
+ return prev // same reference → no re-render
192
+ }
193
+ const next = new Map(prev)
194
+ next.set(id, entry)
195
+ return next
196
+ })
197
+ }, [])
198
+
199
+ const removeFeedbackEntry = useCallback((id: string) => {
200
+ setFeedbackEntries((prev) => {
201
+ if (!prev.has(id)) return prev
202
+ const next = new Map(prev)
203
+ next.delete(id)
204
+ return next
205
+ })
206
+ }, [])
207
+
208
+ const clearCanvasState = useCallback(() => {
209
+ setResponses(new Map())
210
+ setFeedbackEntries(new Map())
211
+ if (saveState) {
212
+ saveState(sessionId, revision, { annotations: [], generalNote: '', responses: [], feedbackEntries: [] }, draftPhase)
213
+ } else if (draftPhase === 'next') {
214
+ localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase))
215
+ } else {
216
+ clearPersistedDraft(localStorage, sessionId, revision)
217
+ }
218
+ }, [sessionId, revision, draftPhase, saveState])
219
+
220
+ const annotationState = useAnnotationState({
221
+ annotations: localAnnotations,
222
+ setAnnotations,
223
+ generalNote,
224
+ setGeneralNote,
225
+ remoteAnnotations,
226
+ isReadOnly,
227
+ onClear: clearCanvasState,
228
+ })
229
+ const { setActiveAnnotationId } = annotationState
230
+ const host = useCanvasHost()
231
+
232
+ return (
233
+ <AnnotationHostContext.Provider value={host}>
234
+ <SurfaceAnnotationCtx.Provider value={annotationState}>
235
+ <AnnotationCtx.Provider
236
+ value={{
237
+ ...annotationState,
238
+ responses,
239
+ setResponse,
240
+ submittableResponses,
241
+ registerResponse,
242
+ registerCanvasRendered,
243
+ feedbackEntries,
244
+ setFeedbackEntry,
245
+ removeFeedbackEntry,
246
+ }}
247
+ >
248
+ {children}
249
+ </AnnotationCtx.Provider>
250
+ </SurfaceAnnotationCtx.Provider>
251
+ </AnnotationHostContext.Provider>
252
+ )
294
253
  }