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.
- package/daemon/client/AnnotationProvider.tsx +32 -28
- package/daemon/client/App.tsx +7 -2
- package/daemon/client/annotationDraft.test.ts +41 -2
- package/daemon/client/annotationDraft.ts +27 -7
- package/daemon/dist/client.js +31 -31
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +31 -31
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
package/daemon/client/App.tsx
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
16
|
-
|
|
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 `${
|
|
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(
|
|
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 =
|
|
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)
|
|
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(
|
|
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;
|