agent-canvas 0.1.39 → 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
+ }