agent-canvas 0.1.42 → 0.1.44

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.
@@ -45,4 +45,34 @@ describe("waitForFeedback", () => {
45
45
 
46
46
  await expect(result).rejects.toThrow("WebSocket closed before feedback was submitted.");
47
47
  });
48
+
49
+ test("reports a browser render error without reconnecting", async () => {
50
+ const sockets: FakeSocket[] = [];
51
+ const result = waitForFeedback("session", {
52
+ createSocket: () => {
53
+ const socket = new FakeSocket();
54
+ sockets.push(socket);
55
+ queueMicrotask(() => socket.onmessage?.(new MessageEvent("message", {
56
+ data: JSON.stringify({
57
+ type: "render-error",
58
+ error: {
59
+ revision: 3,
60
+ filename: "architecture.jsx",
61
+ message: "Table: required prop `headers` must be an array",
62
+ componentStack: "\n at Table",
63
+ },
64
+ }),
65
+ })));
66
+ return socket;
67
+ },
68
+ timeoutMs: 1_000,
69
+ reconnectAttempts: 2,
70
+ reconnectDelayMs: 0,
71
+ });
72
+
73
+ await expect(result).rejects.toThrow(
74
+ "Canvas render failed in architecture.jsx (revision 3):\nTable: required prop `headers` must be an array",
75
+ );
76
+ expect(sockets).toHaveLength(1);
77
+ });
48
78
  });
@@ -47,6 +47,30 @@ interface FeedbackWaitOptions {
47
47
  reconnectDelayMs?: number;
48
48
  }
49
49
 
50
+ interface CanvasRenderError {
51
+ revision: number;
52
+ filename: string;
53
+ message: string;
54
+ stack?: string;
55
+ componentStack?: string;
56
+ }
57
+
58
+ function isCanvasRenderError(value: unknown): value is CanvasRenderError {
59
+ return typeof value === "object" && value !== null
60
+ && "revision" in value && Number.isInteger(value.revision)
61
+ && "filename" in value && typeof value.filename === "string"
62
+ && "message" in value && typeof value.message === "string"
63
+ && (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
64
+ && (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
65
+ }
66
+
67
+ function formatCanvasRenderError(error: CanvasRenderError): string {
68
+ const details = [`Canvas render failed in ${error.filename} (revision ${error.revision}):`, error.message];
69
+ if (error.componentStack?.trim()) details.push(`Component stack:${error.componentStack}`);
70
+ if (error.stack?.trim()) details.push(`Browser stack:\n${error.stack}`);
71
+ return details.join("\n");
72
+ }
73
+
50
74
  class FeedbackWaitError extends Error {
51
75
  constructor(message: string, readonly retryable: boolean) {
52
76
  super(message);
@@ -81,6 +105,9 @@ function waitForFeedbackConnection(
81
105
  if (data.type === "submit") {
82
106
  finish(data.feedback);
83
107
  }
108
+ if (data.type === "render-error" && isCanvasRenderError(data.error)) {
109
+ finish(new FeedbackWaitError(formatCanvasRenderError(data.error), false));
110
+ }
84
111
  } catch {}
85
112
  };
86
113
 
@@ -76,6 +76,11 @@ In Codex, a wait may yield before feedback arrives. Continue waiting on the same
76
76
  process handle; do not start another watcher. Keep user-facing progress updates
77
77
  brief and no more than 60 seconds apart while waiting.
78
78
 
79
+ If the watcher exits with `Canvas render failed`, treat it like a compilation
80
+ error: fix the reported canvas file, push the corrected revision, and start a
81
+ new watcher. The browser reports these failures automatically; do not ask the
82
+ user to transcribe console output.
83
+
79
84
  **Important**: The push → watch sequence is atomic. Never push without starting
80
85
  the host-specific watcher immediately afterward. Do not continue with unrelated
81
86
  work while waiting for feedback.
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationCtx } from "#canvas/runtime";
3
3
  import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
4
- import { clearPersistedDraft } from "./annotationDraft";
4
+ import { annotationDraftKey, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
5
5
  import { generateAnnotationId } from "./utils";
6
6
 
7
7
  // Re-export types for convenience
@@ -12,6 +12,7 @@ interface AnnotationProviderProps {
12
12
  sessionId: string;
13
13
  revision: number;
14
14
  isReadOnly: boolean;
15
+ draftPhase: AnnotationDraftPhase;
15
16
  /** Remote annotations fetched from shared views. Merged read-only into
16
17
  * the annotation list so they render alongside the author's own. */
17
18
  remoteAnnotations?: Annotation[];
@@ -25,13 +26,9 @@ interface PersistedState {
25
26
  feedbackEntries?: [string, FeedbackEntry][];
26
27
  }
27
28
 
28
- function storageKey(sessionId: string, revision: number): string {
29
- return `canvas:${sessionId}:rev:${revision}`;
30
- }
31
-
32
- function loadPersisted(sessionId: string, revision: number): PersistedState | null {
29
+ function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
33
30
  try {
34
- const raw = localStorage.getItem(storageKey(sessionId, revision));
31
+ const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
35
32
  if (!raw) return null;
36
33
  return JSON.parse(raw);
37
34
  } catch {
@@ -39,15 +36,15 @@ function loadPersisted(sessionId: string, revision: number): PersistedState | nu
39
36
  }
40
37
  }
41
38
 
42
- function savePersisted(sessionId: string, revision: number, state: PersistedState) {
39
+ function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
43
40
  try {
44
- localStorage.setItem(storageKey(sessionId, revision), JSON.stringify(state));
41
+ localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
45
42
  } catch {}
46
43
  }
47
44
 
48
- export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnnotations, children }: AnnotationProviderProps) {
45
+ export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, children }: AnnotationProviderProps) {
49
46
  const [localAnnotations, setAnnotations] = useState<Annotation[]>(() => {
50
- const saved = loadPersisted(sessionId, revision);
47
+ const saved = loadPersisted(sessionId, revision, draftPhase);
51
48
  return (saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const }));
52
49
  });
53
50
 
@@ -61,35 +58,38 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
61
58
  return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
62
59
  }, [localAnnotations, remoteAnnotations]);
63
60
  const [generalNote, setGeneralNote] = useState(() => {
64
- const saved = loadPersisted(sessionId, revision);
61
+ const saved = loadPersisted(sessionId, revision, draftPhase);
65
62
  return saved?.generalNote ?? "";
66
63
  });
67
64
  const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
68
65
  const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => {
69
- const saved = loadPersisted(sessionId, revision);
66
+ const saved = loadPersisted(sessionId, revision, draftPhase);
70
67
  return saved?.responses ? new Map(saved.responses) : new Map();
71
68
  });
72
69
  const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() => {
73
- const saved = loadPersisted(sessionId, revision);
70
+ const saved = loadPersisted(sessionId, revision, draftPhase);
74
71
  return saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map();
75
72
  });
76
73
 
77
- // Persist to localStorage (debounced)
74
+ // Post-feedback drafts save immediately so a new revision can carry the latest edit.
78
75
  const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
79
76
  useEffect(() => {
80
- if (isReadOnly) return;
77
+ if (isReadOnly && draftPhase !== "next") return;
81
78
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
82
- persistTimerRef.current = setTimeout(() => {
83
- savePersisted(sessionId, revision, {
84
- // Persist only local annotations — remote ones are re-fetched on load.
85
- annotations: localAnnotations,
86
- generalNote,
87
- responses: Array.from(responses.entries()),
88
- feedbackEntries: Array.from(feedbackEntries.entries()),
89
- });
90
- }, 300);
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
91
  return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
92
- }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
92
+ }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase]);
93
93
 
94
94
  const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
95
95
  setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
@@ -159,8 +159,12 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
159
159
  setActiveAnnotationId(null);
160
160
  setResponses(new Map());
161
161
  setFeedbackEntries(new Map());
162
- clearPersistedDraft(localStorage, sessionId, revision);
163
- }, [sessionId, revision]);
162
+ if (draftPhase === "next") {
163
+ localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
164
+ } else {
165
+ clearPersistedDraft(localStorage, sessionId, revision);
166
+ }
167
+ }, [sessionId, revision, draftPhase]);
164
168
 
165
169
  return (
166
170
  <AnnotationCtx.Provider
@@ -21,7 +21,8 @@ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
21
21
  import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
22
22
  import type { Annotation } from "#canvas/runtime";
23
23
  import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
24
- import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
24
+ import { carryUnsubmittedDraft, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
25
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
25
26
 
26
27
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
27
28
 
@@ -232,6 +233,8 @@ function App() {
232
233
  const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
233
234
  const [openFiles, setOpenFiles] = useState<string[]>([]);
234
235
  const wsRef = useRef<WebSocket | null>(null);
236
+ const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([]);
237
+ const reportedRenderErrorsRef = useRef(new Set<string>());
235
238
 
236
239
  const [projectRoot, setProjectRoot] = useState<string | undefined>();
237
240
  const [mobileSidebar, setMobileSidebar] = useState(false);
@@ -247,6 +250,21 @@ function App() {
247
250
  const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
248
251
  const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
249
252
  const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
253
+
254
+ const reportRenderError = useCallback((error: CanvasRenderError) => {
255
+ if (isSharedMode) return;
256
+ const signature = `${error.revision}\0${error.filename}\0${error.message}`;
257
+ if (reportedRenderErrorsRef.current.has(signature)) return;
258
+ reportedRenderErrorsRef.current.add(signature);
259
+
260
+ const payload = JSON.stringify({ type: "render-error", error });
261
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
262
+ wsRef.current.send(payload);
263
+ } else {
264
+ pendingRenderErrorsRef.current.push(error);
265
+ }
266
+ }, [isSharedMode]);
267
+
250
268
  useEffect(() => {
251
269
  if (!toast) return;
252
270
  const t = setTimeout(() => setToast(null), 4000);
@@ -279,6 +297,10 @@ function App() {
279
297
 
280
298
  const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
281
299
  const isReadOnly = selectedRevision !== currentRevision || !!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted;
300
+ // Keep feedback added after submission separate from the already-sent draft.
301
+ const draftPhase: AnnotationDraftPhase = selectedRevision === currentRevision && (!!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted)
302
+ ? "next"
303
+ : "current";
282
304
 
283
305
  const setActiveView = useCallback((v: ActiveView) => {
284
306
  // Save current scroll position before switching
@@ -378,7 +400,13 @@ function App() {
378
400
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
379
401
  const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
380
402
  wsRef.current = ws;
381
- ws.onopen = () => setConnected(true);
403
+ ws.onopen = () => {
404
+ setConnected(true);
405
+ for (const error of pendingRenderErrorsRef.current) {
406
+ ws.send(JSON.stringify({ type: "render-error", error }));
407
+ }
408
+ pendingRenderErrorsRef.current = [];
409
+ };
382
410
  ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
383
411
  ws.onmessage = (event) => {
384
412
  try {
@@ -492,14 +520,16 @@ function App() {
492
520
 
493
521
  return (
494
522
  <SessionContext.Provider value={sessionId}>
495
- <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
496
- <AnnotationProvider
497
- key={`${sessionId}:${selectedRevision}`}
498
- sessionId={sessionId}
499
- revision={selectedRevision}
500
- isReadOnly={isReadOnly}
501
- remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
502
- >
523
+ <RenderErrorContext.Provider value={reportRenderError}>
524
+ <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
525
+ <AnnotationProvider
526
+ key={`${sessionId}:${selectedRevision}:${draftPhase}`}
527
+ sessionId={sessionId}
528
+ revision={selectedRevision}
529
+ isReadOnly={isReadOnly}
530
+ draftPhase={draftPhase}
531
+ remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
532
+ >
503
533
  <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
504
534
  <ActiveViewCtx.Provider value={{ setActiveView }}>
505
535
  <div className="min-h-screen bg-bg-base">
@@ -673,8 +703,9 @@ function App() {
673
703
  </div>
674
704
  </ActiveViewCtx.Provider>
675
705
  </ActiveViewContext.Provider>
676
- </AnnotationProvider>
677
- </RevisionContext.Provider>
706
+ </AnnotationProvider>
707
+ </RevisionContext.Provider>
708
+ </RenderErrorContext.Provider>
678
709
  </SessionContext.Provider>
679
710
  );
680
711
  }
@@ -7,6 +7,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
9
  import { loadCanvasModule } from "./clientApi";
10
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
10
11
 
11
12
  /** All navigable blocks (keyboard arrows) */
12
13
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -18,8 +19,53 @@ interface PlanRendererProps {
18
19
  filename: string;
19
20
  }
20
21
 
22
+ interface CanvasErrorBoundaryProps {
23
+ revision: number;
24
+ filename: string;
25
+ reportError: (error: CanvasRenderError) => void;
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ interface CanvasErrorBoundaryState {
30
+ error: Error | null;
31
+ }
32
+
33
+ class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
34
+ state: CanvasErrorBoundaryState = { error: null };
35
+
36
+ static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
37
+ return { error };
38
+ }
39
+
40
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
41
+ this.props.reportError({
42
+ revision: this.props.revision,
43
+ filename: this.props.filename,
44
+ message: error.message,
45
+ ...(error.stack ? { stack: error.stack } : {}),
46
+ ...(info.componentStack ? { componentStack: info.componentStack } : {}),
47
+ });
48
+ }
49
+
50
+ render() {
51
+ if (!this.state.error) return this.props.children;
52
+ return <CanvasErrorDisplay message={this.state.error.message} />;
53
+ }
54
+ }
55
+
56
+ function CanvasErrorDisplay({ message }: { message: string }) {
57
+ return (
58
+ <div className="bg-accent-red-muted rounded-lg p-5">
59
+ <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
60
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
61
+ <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
62
+ </div>
63
+ );
64
+ }
65
+
21
66
  export function PlanRenderer({ revision, filename }: PlanRendererProps) {
22
67
  const sessionId = useContext(SessionContext);
68
+ const reportError = useContext(RenderErrorContext);
23
69
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
24
70
  const [error, setError] = useState<string | null>(null);
25
71
  const [loading, setLoading] = useState(true);
@@ -44,8 +90,18 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
44
90
  void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45
91
  loadCanvasModule(filename, revision)
46
92
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
47
- .catch((e) => { setError(e.message); setLoading(false); });
48
- }, [sessionId, revision, filename]);
93
+ .catch((cause: unknown) => {
94
+ const moduleError = cause instanceof Error ? cause : new Error(String(cause));
95
+ setError(moduleError.message);
96
+ setLoading(false);
97
+ reportError({
98
+ revision,
99
+ filename,
100
+ message: moduleError.message,
101
+ ...(moduleError.stack ? { stack: moduleError.stack } : {}),
102
+ });
103
+ });
104
+ }, [sessionId, revision, filename, reportError]);
49
105
 
50
106
  const scrollContainer = document.getElementById("plan-scroll-container");
51
107
 
@@ -250,12 +306,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
250
306
  }
251
307
 
252
308
  if (error) {
253
- return (
254
- <div className="bg-accent-red-muted rounded-lg p-5">
255
- <h3 className="font-body font-semibold text-accent-red mb-2">Compilation Error</h3>
256
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
257
- </div>
258
- );
309
+ return <CanvasErrorDisplay message={error} />;
259
310
  }
260
311
 
261
312
  if (!PlanComponent) {
@@ -265,7 +316,14 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
265
316
  return (
266
317
  <CanvasFileCtx.Provider value={filename}>
267
318
  <div ref={containerRef} className="plan-content plan-updated">
268
- {planElement}
319
+ <CanvasErrorBoundary
320
+ key={`${revision}:${filename}`}
321
+ revision={revision}
322
+ filename={filename}
323
+ reportError={reportError}
324
+ >
325
+ {planElement}
326
+ </CanvasErrorBoundary>
269
327
  </div>
270
328
  {editingAnn && (
271
329
  <EditAnnotationModal
@@ -554,4 +612,3 @@ function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave:
554
612
  </div>
555
613
  );
556
614
  }
557
-
@@ -0,0 +1,11 @@
1
+ import { createContext } from "react";
2
+
3
+ export interface CanvasRenderError {
4
+ revision: number;
5
+ filename: string;
6
+ message: string;
7
+ stack?: string;
8
+ componentStack?: string;
9
+ }
10
+
11
+ export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
2
+ import { annotationDraftKey, carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
3
3
 
4
4
  class MemoryStorage {
5
5
  private readonly values = new Map<string, string>();
@@ -18,7 +18,8 @@ class MemoryStorage {
18
18
  }
19
19
 
20
20
  const sessionId = "session";
21
- const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
21
+ const key = (revision: number) => annotationDraftKey(sessionId, revision);
22
+ const nextKey = (revision: number) => annotationDraftKey(sessionId, revision, "next");
22
23
 
23
24
  describe("carryUnsubmittedDraft", () => {
24
25
  test("clears a submitted draft and its carry marker", () => {
@@ -78,6 +79,44 @@ describe("carryUnsubmittedDraft", () => {
78
79
  expect(storage.getItem(key(3))).toBeNull();
79
80
  });
80
81
 
82
+ test("carries a post-feedback draft into the next revision", () => {
83
+ const storage = new MemoryStorage();
84
+ storage.setItem(key(2), JSON.stringify({
85
+ annotations: [{ id: "submitted-annotation" }],
86
+ generalNote: "Submitted feedback",
87
+ responses: [],
88
+ }));
89
+ storage.setItem(nextKey(2), JSON.stringify({
90
+ annotations: [{ id: "next-annotation" }],
91
+ generalNote: "",
92
+ responses: [],
93
+ feedbackEntries: [["generated", { markdown: "stale generated content" }]],
94
+ }));
95
+
96
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, [
97
+ { revision: 2, hasFeedback: true },
98
+ { revision: 3, hasFeedback: false },
99
+ ])).toBe(2);
100
+ expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
101
+ annotations: [{ id: "next-annotation" }],
102
+ generalNote: "",
103
+ responses: [],
104
+ feedbackEntries: [],
105
+ });
106
+ expect(storage.getItem(nextKey(2))).toBeNull();
107
+ });
108
+
109
+ test("clearing submitted feedback preserves its next-revision draft", () => {
110
+ const storage = new MemoryStorage();
111
+ storage.setItem(key(2), JSON.stringify({ annotations: [{ id: "submitted" }] }));
112
+ storage.setItem(nextKey(2), JSON.stringify({ annotations: [{ id: "next" }] }));
113
+
114
+ clearPersistedDraft(storage, sessionId, 2);
115
+
116
+ expect(storage.getItem(key(2))).toBeNull();
117
+ expect(storage.getItem(nextKey(2))).not.toBeNull();
118
+ });
119
+
81
120
  test("does not overwrite meaningful target data", () => {
82
121
  const storage = new MemoryStorage();
83
122
  storage.setItem(key(2), JSON.stringify({
@@ -6,18 +6,26 @@ interface RevisionState {
6
6
  interface AnnotationStorage {
7
7
  getItem(key: string): string | null;
8
8
  setItem(key: string, value: string): void;
9
+ removeItem(key: string): void;
9
10
  }
10
11
 
11
12
  interface RemovableAnnotationStorage {
12
13
  removeItem(key: string): void;
13
14
  }
14
15
 
15
- function storageKey(sessionId: string, revision: number): string {
16
- return `canvas:${sessionId}:rev:${revision}`;
16
+ export type AnnotationDraftPhase = "current" | "next";
17
+
18
+ export function annotationDraftKey(
19
+ sessionId: string,
20
+ revision: number,
21
+ phase: AnnotationDraftPhase = "current",
22
+ ): string {
23
+ const base = `canvas:${sessionId}:rev:${revision}`;
24
+ return phase === "next" ? `${base}:next-draft` : base;
17
25
  }
18
26
 
19
27
  function handledKey(sessionId: string, revision: number): string {
20
- return `${storageKey(sessionId, revision)}:draft-handled`;
28
+ return `${annotationDraftKey(sessionId, revision)}:draft-handled`;
21
29
  }
22
30
 
23
31
  export function clearPersistedDraft(
@@ -26,7 +34,7 @@ export function clearPersistedDraft(
26
34
  revision: number,
27
35
  ): void {
28
36
  try {
29
- storage.removeItem(storageKey(sessionId, revision));
37
+ storage.removeItem(annotationDraftKey(sessionId, revision));
30
38
  storage.removeItem(handledKey(sessionId, revision));
31
39
  } catch {}
32
40
  }
@@ -77,7 +85,7 @@ export function carryUnsubmittedDraft(
77
85
  revisions: RevisionState[],
78
86
  ): number | null {
79
87
  try {
80
- const targetKey = storageKey(sessionId, targetRevision);
88
+ const targetKey = annotationDraftKey(sessionId, targetRevision);
81
89
  if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
82
90
  storage.setItem(handledKey(sessionId, targetRevision), "1");
83
91
  return null;
@@ -89,9 +97,21 @@ export function carryUnsubmittedDraft(
89
97
  .sort((left, right) => right.revision - left.revision);
90
98
 
91
99
  for (const revision of previous) {
92
- if (revision.hasFeedback) return null;
100
+ if (revision.hasFeedback) {
101
+ const sourceKey = annotationDraftKey(sessionId, revision.revision, "next");
102
+ const source = parseState(storage.getItem(sourceKey));
103
+ if (!hasDraftContent(source)) return null;
104
+
105
+ storage.setItem(targetKey, JSON.stringify({
106
+ ...source,
107
+ feedbackEntries: [],
108
+ }));
109
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
110
+ storage.removeItem(sourceKey);
111
+ return revision.revision;
112
+ }
93
113
 
94
- const source = parseState(storage.getItem(storageKey(sessionId, revision.revision)));
114
+ const source = parseState(storage.getItem(annotationDraftKey(sessionId, revision.revision)));
95
115
  if (!hasDraftContent(source)) {
96
116
  if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
97
117
  continue;