agent-canvas 0.1.43 → 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.
@@ -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,7 @@ 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
25
  import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
26
26
 
27
27
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
@@ -297,6 +297,10 @@ function App() {
297
297
 
298
298
  const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
299
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";
300
304
 
301
305
  const setActiveView = useCallback((v: ActiveView) => {
302
306
  // Save current scroll position before switching
@@ -519,10 +523,11 @@ function App() {
519
523
  <RenderErrorContext.Provider value={reportRenderError}>
520
524
  <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
521
525
  <AnnotationProvider
522
- key={`${sessionId}:${selectedRevision}`}
526
+ key={`${sessionId}:${selectedRevision}:${draftPhase}`}
523
527
  sessionId={sessionId}
524
528
  revision={selectedRevision}
525
529
  isReadOnly={isReadOnly}
530
+ draftPhase={draftPhase}
526
531
  remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
527
532
  >
528
533
  <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
@@ -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;