agent-canvas 0.1.38 → 0.1.40

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.
@@ -21,6 +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 } from "./annotationDraft";
24
25
 
25
26
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
26
27
 
@@ -312,9 +313,11 @@ function App() {
312
313
  fetchMeta()
313
314
  .then((data: any) => {
314
315
  if (data.currentRevision) {
316
+ const nextRevisions: RevisionInfo[] = data.revisions || [];
317
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
315
318
  setCurrentRevision(data.currentRevision);
316
319
  setSelectedRevision(data.currentRevision);
317
- setRevisions(data.revisions || []);
320
+ setRevisions(nextRevisions);
318
321
  }
319
322
  if (data.canvasFiles) {
320
323
  const files = (data.canvasFiles as string[]).sort();
@@ -378,11 +381,12 @@ function App() {
378
381
  try {
379
382
  const data = JSON.parse(event.data);
380
383
  if (data.type === "plan-updated") {
384
+ const allRevisions: RevisionInfo[] = Array.isArray(data.revisions) ? data.revisions : [];
385
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, allRevisions);
381
386
  setCurrentRevision(data.currentRevision);
382
387
  setSelectedRevision(data.currentRevision);
383
388
  setCompareRevision(null);
384
- if (data.revisions) {
385
- const allRevisions = data.revisions as RevisionInfo[];
389
+ if (allRevisions.length > 0) {
386
390
  setRevisions(allRevisions);
387
391
  const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision);
388
392
  if (latest?.canvasFiles) {
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { carryUnsubmittedDraft } from "./annotationDraft";
3
+
4
+ class MemoryStorage {
5
+ private readonly values = new Map<string, string>();
6
+
7
+ getItem(key: string): string | null {
8
+ return this.values.get(key) ?? null;
9
+ }
10
+
11
+ setItem(key: string, value: string): void {
12
+ this.values.set(key, value);
13
+ }
14
+ }
15
+
16
+ const sessionId = "session";
17
+ const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
18
+
19
+ describe("carryUnsubmittedDraft", () => {
20
+ test("carries the latest unsent draft into a new revision", () => {
21
+ const storage = new MemoryStorage();
22
+ storage.setItem(key(2), JSON.stringify({
23
+ annotations: [{ id: "annotation-1" }],
24
+ generalNote: "Review in progress",
25
+ responses: [["choice", { value: "keep" }]],
26
+ feedbackEntries: [["old", { markdown: "stale generated content" }]],
27
+ }));
28
+ storage.setItem(key(3), JSON.stringify({
29
+ annotations: [],
30
+ generalNote: "",
31
+ responses: [["new-choice", { value: null }]],
32
+ feedbackEntries: [["new", { markdown: "generated content" }]],
33
+ }));
34
+
35
+ const sourceRevision = carryUnsubmittedDraft(storage, sessionId, 3, [
36
+ { revision: 1, hasFeedback: true },
37
+ { revision: 2, hasFeedback: false },
38
+ { revision: 3, hasFeedback: false },
39
+ ]);
40
+
41
+ expect(sourceRevision).toBe(2);
42
+ expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
43
+ annotations: [{ id: "annotation-1" }],
44
+ generalNote: "Review in progress",
45
+ responses: [["choice", { value: "keep" }]],
46
+ feedbackEntries: [],
47
+ });
48
+ });
49
+
50
+ test("does not cross a submitted revision", () => {
51
+ const storage = new MemoryStorage();
52
+ storage.setItem(key(1), JSON.stringify({
53
+ annotations: [{ id: "stale-annotation" }],
54
+ generalNote: "",
55
+ responses: [],
56
+ }));
57
+
58
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, [
59
+ { revision: 1, hasFeedback: false },
60
+ { revision: 2, hasFeedback: true },
61
+ { revision: 3, hasFeedback: false },
62
+ ])).toBeNull();
63
+ expect(storage.getItem(key(3))).toBeNull();
64
+ });
65
+
66
+ test("does not overwrite meaningful target data", () => {
67
+ const storage = new MemoryStorage();
68
+ storage.setItem(key(2), JSON.stringify({
69
+ annotations: [{ id: "old-annotation" }],
70
+ generalNote: "",
71
+ responses: [],
72
+ }));
73
+ const current = JSON.stringify({
74
+ annotations: [{ id: "current-annotation" }],
75
+ generalNote: "",
76
+ responses: [],
77
+ });
78
+ storage.setItem(key(3), current);
79
+
80
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, [
81
+ { revision: 2, hasFeedback: false },
82
+ { revision: 3, hasFeedback: false },
83
+ ])).toBeNull();
84
+ expect(storage.getItem(key(3))).toBe(current);
85
+ });
86
+
87
+ test("skips empty intermediate revision state", () => {
88
+ const storage = new MemoryStorage();
89
+ storage.setItem(key(2), JSON.stringify({
90
+ annotations: [],
91
+ generalNote: "Still drafting",
92
+ responses: [],
93
+ }));
94
+ storage.setItem(key(3), JSON.stringify({
95
+ annotations: [],
96
+ generalNote: "",
97
+ responses: [["choice", { value: [] }]],
98
+ feedbackEntries: [["generated", { markdown: "not user input" }]],
99
+ }));
100
+
101
+ expect(carryUnsubmittedDraft(storage, sessionId, 4, [
102
+ { revision: 1, hasFeedback: true },
103
+ { revision: 2, hasFeedback: false },
104
+ { revision: 3, hasFeedback: false },
105
+ { revision: 4, hasFeedback: false },
106
+ ])).toBe(2);
107
+ });
108
+
109
+ test("does not resurrect a carried draft after it is cleared", () => {
110
+ const storage = new MemoryStorage();
111
+ storage.setItem(key(2), JSON.stringify({
112
+ annotations: [{ id: "annotation-1" }],
113
+ generalNote: "",
114
+ responses: [],
115
+ }));
116
+ const revisions = [
117
+ { revision: 1, hasFeedback: true },
118
+ { revision: 2, hasFeedback: false },
119
+ { revision: 3, hasFeedback: false },
120
+ ];
121
+
122
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBe(2);
123
+ const cleared = JSON.stringify({ annotations: [], generalNote: "", responses: [] });
124
+ storage.setItem(key(3), cleared);
125
+
126
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBeNull();
127
+ expect(storage.getItem(key(3))).toBe(cleared);
128
+ expect(carryUnsubmittedDraft(storage, sessionId, 4, [
129
+ ...revisions,
130
+ { revision: 4, hasFeedback: false },
131
+ ])).toBeNull();
132
+ expect(storage.getItem(key(4))).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,95 @@
1
+ interface RevisionState {
2
+ revision: number;
3
+ hasFeedback: boolean;
4
+ }
5
+
6
+ interface AnnotationStorage {
7
+ getItem(key: string): string | null;
8
+ setItem(key: string, value: string): void;
9
+ }
10
+
11
+ function storageKey(sessionId: string, revision: number): string {
12
+ return `canvas:${sessionId}:rev:${revision}`;
13
+ }
14
+
15
+ function handledKey(sessionId: string, revision: number): string {
16
+ return `${storageKey(sessionId, revision)}:draft-handled`;
17
+ }
18
+
19
+ function parseState(raw: string | null): object | null {
20
+ if (!raw) return null;
21
+ try {
22
+ const value: unknown = JSON.parse(raw);
23
+ return typeof value === "object" && value !== null && !Array.isArray(value)
24
+ ? value
25
+ : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function hasResponseValue(entry: unknown): boolean {
32
+ if (!Array.isArray(entry) || entry.length < 2) return false;
33
+ const response: unknown = entry[1];
34
+ if (typeof response !== "object" || response === null || Array.isArray(response)) return false;
35
+
36
+ const note = Reflect.get(response, "note");
37
+ if (typeof note === "string" && note.trim().length > 0) return true;
38
+
39
+ const value = Reflect.get(response, "value");
40
+ if (typeof value === "string") return value.trim().length > 0;
41
+ if (Array.isArray(value)) return value.length > 0;
42
+ return value !== null && value !== undefined;
43
+ }
44
+
45
+ function hasDraftContent(state: object | null): boolean {
46
+ if (!state) return false;
47
+ const annotations = Reflect.get(state, "annotations");
48
+ if (Array.isArray(annotations) && annotations.length > 0) return true;
49
+
50
+ const generalNote = Reflect.get(state, "generalNote");
51
+ if (typeof generalNote === "string" && generalNote.trim().length > 0) return true;
52
+
53
+ const responses = Reflect.get(state, "responses");
54
+ return Array.isArray(responses) && responses.some(hasResponseValue);
55
+ }
56
+
57
+ /** Carry an unsent draft forward without crossing submitted revisions. */
58
+ export function carryUnsubmittedDraft(
59
+ storage: AnnotationStorage,
60
+ sessionId: string,
61
+ targetRevision: number,
62
+ revisions: RevisionState[],
63
+ ): number | null {
64
+ try {
65
+ const targetKey = storageKey(sessionId, targetRevision);
66
+ if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
67
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
68
+ return null;
69
+ }
70
+ if (storage.getItem(handledKey(sessionId, targetRevision)) !== null) return null;
71
+
72
+ const previous = revisions
73
+ .filter((revision) => revision.revision < targetRevision)
74
+ .sort((left, right) => right.revision - left.revision);
75
+
76
+ for (const revision of previous) {
77
+ if (revision.hasFeedback) return null;
78
+
79
+ const source = parseState(storage.getItem(storageKey(sessionId, revision.revision)));
80
+ if (!hasDraftContent(source)) {
81
+ if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
82
+ continue;
83
+ }
84
+
85
+ storage.setItem(targetKey, JSON.stringify({
86
+ ...source,
87
+ feedbackEntries: [],
88
+ }));
89
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
90
+ return revision.revision;
91
+ }
92
+ } catch {}
93
+
94
+ return null;
95
+ }
@@ -58,6 +58,7 @@ function getTextNodesInRange(range: Range): Text[] {
58
58
  // Skip line numbers and existing marks
59
59
  const parent = node.parentElement;
60
60
  if (parent?.classList.contains("select-none")) continue;
61
+ if (parent?.closest("[data-annotation-id]")) continue;
61
62
 
62
63
  if (range.intersectsNode(node)) {
63
64
  nodes.push(node);
@@ -108,27 +109,18 @@ export function restoreMarks(
108
109
  container: HTMLElement,
109
110
  annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[]
110
111
  ) {
111
- // Skip annotations that already have marks in the DOM
112
- const toRestore = annotations.filter(
113
- (a) => !document.querySelector(`[data-annotation-id="${a.id}"]`)
114
- );
115
- if (!toRestore.length) return;
112
+ for (const ann of annotations) {
113
+ // A canvas can be rendered more than once (overview, compare, hidden
114
+ // source panes), so restore decisions must be scoped to this container.
115
+ if (container.querySelector(`[data-annotation-id="${ann.id}"]`)) continue;
116
116
 
117
- // Build full text content + mapping from text offset to (textNode, offsetInNode)
118
- const textNodes: { node: Text; start: number }[] = [];
119
- let fullText = "";
120
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
121
- let tn: Text | null;
122
- while ((tn = walker.nextNode() as Text | null)) {
123
- if (tn.parentElement?.classList.contains("select-none")) continue;
124
- textNodes.push({ node: tn, start: fullText.length });
125
- fullText += tn.textContent || "";
126
- }
127
-
128
- for (const ann of toRestore) {
129
117
  const snippet = ann.snippet;
130
118
  if (!snippet) continue;
131
119
 
120
+ // Rebuild after every inserted mark. Wrapping text splits and moves text
121
+ // nodes, so a cached offset map can point at stale DOM after the first hit.
122
+ const { fullText, textNodes } = buildTextIndex(container);
123
+
132
124
  // Find all occurrences of the snippet in the full text
133
125
  const occurrences: number[] = [];
134
126
  let searchFrom = 0;
@@ -181,6 +173,24 @@ export function restoreMarks(
181
173
  }
182
174
  }
183
175
 
176
+ function buildTextIndex(container: HTMLElement): {
177
+ fullText: string;
178
+ textNodes: { node: Text; start: number }[];
179
+ } {
180
+ const textNodes: { node: Text; start: number }[] = [];
181
+ let fullText = "";
182
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
183
+ let tn: Text | null;
184
+ while ((tn = walker.nextNode() as Text | null)) {
185
+ const parent = tn.parentElement;
186
+ if (parent?.classList.contains("select-none")) continue;
187
+ if (parent?.closest("[data-annotation-id]")) continue;
188
+ textNodes.push({ node: tn, start: fullText.length });
189
+ fullText += tn.textContent || "";
190
+ }
191
+ return { fullText, textNodes };
192
+ }
193
+
184
194
  function offsetToNode(
185
195
  textNodes: { node: Text; start: number }[],
186
196
  offset: number