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.
package/daemon/client/App.tsx
CHANGED
|
@@ -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(
|
|
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 (
|
|
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
|
+
}
|