agent-canvas 0.1.44 → 0.2.0
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/README.md +14 -0
- package/bin/lib/commands/daemon.ts +3 -6
- package/bin/lib/commands/push.ts +1 -1
- package/bin/lib/daemon-lifecycle.ts +65 -72
- package/bin/lib/helpers.test.ts +1 -0
- package/bin/lib/helpers.ts +30 -74
- package/daemon/build.ts +37 -16
- package/daemon/client/App.tsx +27 -61
- package/daemon/client/CompareView.tsx +4 -4
- package/daemon/client/FileBrowser.tsx +4 -4
- package/daemon/client/FileViewer.tsx +4 -4
- package/daemon/client/OverviewView.tsx +2 -2
- package/daemon/client/RevisionSelect.tsx +1 -1
- package/daemon/client/canvas-runtime.ts +1 -0
- package/daemon/client/clientApi.ts +18 -0
- package/daemon/client/components/index.ts +3 -16
- package/daemon/client/styles.css +4 -457
- package/daemon/dist/annotations-runtime.js +1 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +76 -76
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/index.html +1 -0
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/components.ts +13 -0
- package/daemon/src/handlers/api.ts +13 -9
- package/daemon/src/handlers/files.ts +2 -3
- package/daemon/src/handlers/static.ts +1 -1
- package/daemon/src/handlers/uploads.ts +2 -3
- package/daemon/src/paths.ts +10 -24
- package/daemon/src/remote-feedback.test.ts +1 -1
- package/daemon/src/remote-feedback.ts +1 -1
- package/daemon/src/secrets-api.test.ts +2 -2
- package/daemon/src/server.ts +11 -10
- package/daemon/src/share-e2e.test.ts +1 -1
- package/daemon/src/share.test.ts +1 -1
- package/daemon/src/share.ts +1 -1
- package/daemon/tsconfig.json +1 -3
- package/package.json +11 -4
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/.github/workflows/ci.yml +0 -48
- package/.github/workflows/deploy-worker-prod.yml +0 -19
- package/.github/workflows/deploy-worker-stage.yml +0 -21
- package/.github/workflows/deploy-worker.yml +0 -72
- package/.github/workflows/release.yml +0 -36
- package/CLAUDE.md +0 -86
- package/bun.lock +0 -377
- package/daemon/client/AnnotationEditor.tsx +0 -315
- package/daemon/client/AnnotationProvider.tsx +0 -195
- package/daemon/client/AnnotationSidebar.tsx +0 -627
- package/daemon/client/FileIcon.tsx +0 -76
- package/daemon/client/PlanRenderer.tsx +0 -614
- package/daemon/client/Popover.tsx +0 -350
- package/daemon/client/RenderErrorContext.ts +0 -11
- package/daemon/client/ResponsePreview.tsx +0 -186
- package/daemon/client/SessionContext.tsx +0 -3
- package/daemon/client/annotationContext.ts +0 -114
- package/daemon/client/annotationDom.ts +0 -104
- package/daemon/client/annotationDraft.test.ts +0 -188
- package/daemon/client/annotationDraft.ts +0 -130
- package/daemon/client/compat/preact-all.ts +0 -4
- package/daemon/client/components/Callout.tsx +0 -26
- package/daemon/client/components/Checklist.tsx +0 -37
- package/daemon/client/components/Choice.tsx +0 -176
- package/daemon/client/components/CodeBlock.tsx +0 -35
- package/daemon/client/components/Diff.tsx +0 -65
- package/daemon/client/components/FilePreview.tsx +0 -88
- package/daemon/client/components/Image.tsx +0 -45
- package/daemon/client/components/Markdown.tsx +0 -44
- package/daemon/client/components/Mermaid.tsx +0 -565
- package/daemon/client/components/Note.tsx +0 -14
- package/daemon/client/components/Priority.tsx +0 -19
- package/daemon/client/components/ResponseNote.tsx +0 -19
- package/daemon/client/components/Section.tsx +0 -33
- package/daemon/client/components/Table.tsx +0 -35
- package/daemon/client/components/Task.tsx +0 -76
- package/daemon/client/components/UserInput.tsx +0 -174
- package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
- package/daemon/client/generateMarkdown.ts +0 -273
- package/daemon/client/highlightRange.ts +0 -224
- package/daemon/client/popoverPosition.ts +0 -31
- package/daemon/client/runtime.ts +0 -123
- package/daemon/client/theme.css +0 -66
- package/daemon/client/useTextAnnotation.tsx +0 -188
- package/daemon/client/utils.ts +0 -13
- package/daemon/src/compiler.test.ts +0 -57
- package/daemon/src/compiler.ts +0 -360
- package/daemon/src/handlers/utils.ts +0 -14
- package/daemon/src/router.ts +0 -16
- package/daemon/src/session-secrets.test.ts +0 -83
- package/daemon/src/session.ts +0 -612
- package/daemon/src/watcher.ts +0 -71
- package/daemon/src/websocket.test.ts +0 -99
- package/daemon/src/websocket.ts +0 -211
- package/tsconfig.json +0 -14
- package/workers/canvas-share/README.md +0 -159
- package/workers/canvas-share/oblaka.ts +0 -56
- package/workers/canvas-share/package.json +0 -25
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +0 -2
- package/workers/canvas-share/public/assets/client.js +0 -91
- package/workers/canvas-share/public/assets/components.js +0 -65
- package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
- package/workers/canvas-share/public/assets/preact-compat.js +0 -1
- package/workers/canvas-share/public/assets/runtime.js +0 -1
- package/workers/canvas-share/public/index.html +0 -385
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +0 -209
- package/workers/canvas-share/src/index.ts +0 -518
- package/workers/canvas-share/src/types.ts +0 -119
- package/workers/canvas-share/src/util.ts +0 -156
- package/workers/canvas-share/src/validation.ts +0 -160
- package/workers/canvas-share/tsconfig.json +0 -16
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { Annotation } from "./AnnotationProvider";
|
|
2
|
-
import type { ActiveView } from "./App";
|
|
3
|
-
|
|
4
|
-
/** All blocks that can be annotated via block-level comments */
|
|
5
|
-
const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='image']";
|
|
6
|
-
|
|
7
|
-
/** Extract snippet identifier for a block element (mirrors PlanRenderer's getBlockSnippet) */
|
|
8
|
-
function getBlockSnippet(block: HTMLElement): string | null {
|
|
9
|
-
const md = block.getAttribute("data-md");
|
|
10
|
-
if (md === "item") {
|
|
11
|
-
const label = block.getAttribute("data-md-label");
|
|
12
|
-
return label ? `[Item] ${label}` : null;
|
|
13
|
-
}
|
|
14
|
-
if (md === "section") {
|
|
15
|
-
const title = block.getAttribute("data-md-title");
|
|
16
|
-
return title ? `[Section] ${title}` : null;
|
|
17
|
-
}
|
|
18
|
-
if (md === "callout") {
|
|
19
|
-
const type = block.getAttribute("data-md-type") || "info";
|
|
20
|
-
const text = block.textContent?.trim().slice(0, 60) || "Callout";
|
|
21
|
-
return `[Callout:${type}] ${text}`;
|
|
22
|
-
}
|
|
23
|
-
if (md === "note") {
|
|
24
|
-
const text = block.textContent?.trim().slice(0, 60) || "Note";
|
|
25
|
-
return `[Note] ${text}`;
|
|
26
|
-
}
|
|
27
|
-
if (block.tagName === "TR") {
|
|
28
|
-
const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
|
|
29
|
-
return cells.length ? `[Row] ${cells.join(" | ")}` : null;
|
|
30
|
-
}
|
|
31
|
-
if (md === "checklist-item") {
|
|
32
|
-
const label = block.getAttribute("data-md-label");
|
|
33
|
-
return label ? `[Checklist] ${label}` : null;
|
|
34
|
-
}
|
|
35
|
-
if (md === "choice-option" || md === "multichoice-option") {
|
|
36
|
-
const label = block.getAttribute("data-md-label");
|
|
37
|
-
return label ? `[Option] ${label}` : null;
|
|
38
|
-
}
|
|
39
|
-
if (md === "userinput") {
|
|
40
|
-
const label = block.getAttribute("data-md-label");
|
|
41
|
-
return label ? `[Input] ${label}` : null;
|
|
42
|
-
}
|
|
43
|
-
if (md === "rangeinput") {
|
|
44
|
-
const label = block.getAttribute("data-md-label");
|
|
45
|
-
return label ? `[Range] ${label}` : null;
|
|
46
|
-
}
|
|
47
|
-
if (md === "image") {
|
|
48
|
-
const src = block.getAttribute("data-md-src");
|
|
49
|
-
return src ? `[Image] ${src}` : null;
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Find the DOM element for an annotation — either an inline mark (data-annotation-id)
|
|
56
|
-
* or a block element matched by snippet.
|
|
57
|
-
*/
|
|
58
|
-
export function findAnnotationElement(ann: Annotation): HTMLElement | null {
|
|
59
|
-
// Try inline mark first
|
|
60
|
-
const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
|
|
61
|
-
if (mark) return mark;
|
|
62
|
-
|
|
63
|
-
// For block annotations (snippet starts with "["), find by matching snippet against blocks
|
|
64
|
-
if (ann.snippet.startsWith("[")) {
|
|
65
|
-
for (const el of document.querySelectorAll(ANNOTATABLE_SELECTOR)) {
|
|
66
|
-
if (getBlockSnippet(el as HTMLElement) === ann.snippet) {
|
|
67
|
-
return el as HTMLElement;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Flash/pulse the element briefly — blinks bg via CSS animation */
|
|
76
|
-
function flashElement(el: HTMLElement) {
|
|
77
|
-
el.classList.remove("ann-flash");
|
|
78
|
-
void el.offsetWidth; // force reflow to restart
|
|
79
|
-
el.classList.add("ann-flash");
|
|
80
|
-
el.addEventListener("animationend", () => { el.classList.remove("ann-flash"); }, { once: true });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Scroll the canvas/file view to an annotation element and flash it.
|
|
85
|
-
* Handles file annotations by switching view first.
|
|
86
|
-
*/
|
|
87
|
-
export function scrollToAnnotation(ann: Annotation, setActiveView: (view: ActiveView) => void) {
|
|
88
|
-
if (ann.filePath) {
|
|
89
|
-
setActiveView({ type: "file", path: ann.filePath });
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
const el = findAnnotationElement(ann);
|
|
92
|
-
if (el) {
|
|
93
|
-
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
94
|
-
flashElement(el);
|
|
95
|
-
}
|
|
96
|
-
}, 150);
|
|
97
|
-
} else {
|
|
98
|
-
const el = findAnnotationElement(ann);
|
|
99
|
-
if (el) {
|
|
100
|
-
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
101
|
-
flashElement(el);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { annotationDraftKey, carryUnsubmittedDraft, clearPersistedDraft } 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
|
-
removeItem(key: string): void {
|
|
16
|
-
this.values.delete(key);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const sessionId = "session";
|
|
21
|
-
const key = (revision: number) => annotationDraftKey(sessionId, revision);
|
|
22
|
-
const nextKey = (revision: number) => annotationDraftKey(sessionId, revision, "next");
|
|
23
|
-
|
|
24
|
-
describe("carryUnsubmittedDraft", () => {
|
|
25
|
-
test("clears a submitted draft and its carry marker", () => {
|
|
26
|
-
const storage = new MemoryStorage();
|
|
27
|
-
storage.setItem(key(2), JSON.stringify({ generalNote: "Submitted" }));
|
|
28
|
-
storage.setItem(`${key(2)}:draft-handled`, "1");
|
|
29
|
-
|
|
30
|
-
clearPersistedDraft(storage, sessionId, 2);
|
|
31
|
-
|
|
32
|
-
expect(storage.getItem(key(2))).toBeNull();
|
|
33
|
-
expect(storage.getItem(`${key(2)}:draft-handled`)).toBeNull();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("carries the latest unsent draft into a new revision", () => {
|
|
37
|
-
const storage = new MemoryStorage();
|
|
38
|
-
storage.setItem(key(2), JSON.stringify({
|
|
39
|
-
annotations: [{ id: "annotation-1" }],
|
|
40
|
-
generalNote: "Review in progress",
|
|
41
|
-
responses: [["choice", { value: "keep" }]],
|
|
42
|
-
feedbackEntries: [["old", { markdown: "stale generated content" }]],
|
|
43
|
-
}));
|
|
44
|
-
storage.setItem(key(3), JSON.stringify({
|
|
45
|
-
annotations: [],
|
|
46
|
-
generalNote: "",
|
|
47
|
-
responses: [["new-choice", { value: null }]],
|
|
48
|
-
feedbackEntries: [["new", { markdown: "generated content" }]],
|
|
49
|
-
}));
|
|
50
|
-
|
|
51
|
-
const sourceRevision = carryUnsubmittedDraft(storage, sessionId, 3, [
|
|
52
|
-
{ revision: 1, hasFeedback: true },
|
|
53
|
-
{ revision: 2, hasFeedback: false },
|
|
54
|
-
{ revision: 3, hasFeedback: false },
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
expect(sourceRevision).toBe(2);
|
|
58
|
-
expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
|
|
59
|
-
annotations: [{ id: "annotation-1" }],
|
|
60
|
-
generalNote: "Review in progress",
|
|
61
|
-
responses: [["choice", { value: "keep" }]],
|
|
62
|
-
feedbackEntries: [],
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("does not cross a submitted revision", () => {
|
|
67
|
-
const storage = new MemoryStorage();
|
|
68
|
-
storage.setItem(key(1), JSON.stringify({
|
|
69
|
-
annotations: [{ id: "stale-annotation" }],
|
|
70
|
-
generalNote: "",
|
|
71
|
-
responses: [],
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 3, [
|
|
75
|
-
{ revision: 1, hasFeedback: false },
|
|
76
|
-
{ revision: 2, hasFeedback: true },
|
|
77
|
-
{ revision: 3, hasFeedback: false },
|
|
78
|
-
])).toBeNull();
|
|
79
|
-
expect(storage.getItem(key(3))).toBeNull();
|
|
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
|
-
|
|
120
|
-
test("does not overwrite meaningful target data", () => {
|
|
121
|
-
const storage = new MemoryStorage();
|
|
122
|
-
storage.setItem(key(2), JSON.stringify({
|
|
123
|
-
annotations: [{ id: "old-annotation" }],
|
|
124
|
-
generalNote: "",
|
|
125
|
-
responses: [],
|
|
126
|
-
}));
|
|
127
|
-
const current = JSON.stringify({
|
|
128
|
-
annotations: [{ id: "current-annotation" }],
|
|
129
|
-
generalNote: "",
|
|
130
|
-
responses: [],
|
|
131
|
-
});
|
|
132
|
-
storage.setItem(key(3), current);
|
|
133
|
-
|
|
134
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 3, [
|
|
135
|
-
{ revision: 2, hasFeedback: false },
|
|
136
|
-
{ revision: 3, hasFeedback: false },
|
|
137
|
-
])).toBeNull();
|
|
138
|
-
expect(storage.getItem(key(3))).toBe(current);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("skips empty intermediate revision state", () => {
|
|
142
|
-
const storage = new MemoryStorage();
|
|
143
|
-
storage.setItem(key(2), JSON.stringify({
|
|
144
|
-
annotations: [],
|
|
145
|
-
generalNote: "Still drafting",
|
|
146
|
-
responses: [],
|
|
147
|
-
}));
|
|
148
|
-
storage.setItem(key(3), JSON.stringify({
|
|
149
|
-
annotations: [],
|
|
150
|
-
generalNote: "",
|
|
151
|
-
responses: [["choice", { value: [] }]],
|
|
152
|
-
feedbackEntries: [["generated", { markdown: "not user input" }]],
|
|
153
|
-
}));
|
|
154
|
-
|
|
155
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 4, [
|
|
156
|
-
{ revision: 1, hasFeedback: true },
|
|
157
|
-
{ revision: 2, hasFeedback: false },
|
|
158
|
-
{ revision: 3, hasFeedback: false },
|
|
159
|
-
{ revision: 4, hasFeedback: false },
|
|
160
|
-
])).toBe(2);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("does not resurrect a carried draft after it is cleared", () => {
|
|
164
|
-
const storage = new MemoryStorage();
|
|
165
|
-
storage.setItem(key(2), JSON.stringify({
|
|
166
|
-
annotations: [{ id: "annotation-1" }],
|
|
167
|
-
generalNote: "",
|
|
168
|
-
responses: [],
|
|
169
|
-
}));
|
|
170
|
-
const revisions = [
|
|
171
|
-
{ revision: 1, hasFeedback: true },
|
|
172
|
-
{ revision: 2, hasFeedback: false },
|
|
173
|
-
{ revision: 3, hasFeedback: false },
|
|
174
|
-
];
|
|
175
|
-
|
|
176
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBe(2);
|
|
177
|
-
const cleared = JSON.stringify({ annotations: [], generalNote: "", responses: [] });
|
|
178
|
-
storage.setItem(key(3), cleared);
|
|
179
|
-
|
|
180
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBeNull();
|
|
181
|
-
expect(storage.getItem(key(3))).toBe(cleared);
|
|
182
|
-
expect(carryUnsubmittedDraft(storage, sessionId, 4, [
|
|
183
|
-
...revisions,
|
|
184
|
-
{ revision: 4, hasFeedback: false },
|
|
185
|
-
])).toBeNull();
|
|
186
|
-
expect(storage.getItem(key(4))).toBeNull();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
removeItem(key: string): void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface RemovableAnnotationStorage {
|
|
13
|
-
removeItem(key: string): void;
|
|
14
|
-
}
|
|
15
|
-
|
|
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;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function handledKey(sessionId: string, revision: number): string {
|
|
28
|
-
return `${annotationDraftKey(sessionId, revision)}:draft-handled`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function clearPersistedDraft(
|
|
32
|
-
storage: RemovableAnnotationStorage,
|
|
33
|
-
sessionId: string,
|
|
34
|
-
revision: number,
|
|
35
|
-
): void {
|
|
36
|
-
try {
|
|
37
|
-
storage.removeItem(annotationDraftKey(sessionId, revision));
|
|
38
|
-
storage.removeItem(handledKey(sessionId, revision));
|
|
39
|
-
} catch {}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function parseState(raw: string | null): object | null {
|
|
43
|
-
if (!raw) return null;
|
|
44
|
-
try {
|
|
45
|
-
const value: unknown = JSON.parse(raw);
|
|
46
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
47
|
-
? value
|
|
48
|
-
: null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function hasResponseValue(entry: unknown): boolean {
|
|
55
|
-
if (!Array.isArray(entry) || entry.length < 2) return false;
|
|
56
|
-
const response: unknown = entry[1];
|
|
57
|
-
if (typeof response !== "object" || response === null || Array.isArray(response)) return false;
|
|
58
|
-
|
|
59
|
-
const note = Reflect.get(response, "note");
|
|
60
|
-
if (typeof note === "string" && note.trim().length > 0) return true;
|
|
61
|
-
|
|
62
|
-
const value = Reflect.get(response, "value");
|
|
63
|
-
if (typeof value === "string") return value.trim().length > 0;
|
|
64
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
65
|
-
return value !== null && value !== undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function hasDraftContent(state: object | null): boolean {
|
|
69
|
-
if (!state) return false;
|
|
70
|
-
const annotations = Reflect.get(state, "annotations");
|
|
71
|
-
if (Array.isArray(annotations) && annotations.length > 0) return true;
|
|
72
|
-
|
|
73
|
-
const generalNote = Reflect.get(state, "generalNote");
|
|
74
|
-
if (typeof generalNote === "string" && generalNote.trim().length > 0) return true;
|
|
75
|
-
|
|
76
|
-
const responses = Reflect.get(state, "responses");
|
|
77
|
-
return Array.isArray(responses) && responses.some(hasResponseValue);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Carry an unsent draft forward without crossing submitted revisions. */
|
|
81
|
-
export function carryUnsubmittedDraft(
|
|
82
|
-
storage: AnnotationStorage,
|
|
83
|
-
sessionId: string,
|
|
84
|
-
targetRevision: number,
|
|
85
|
-
revisions: RevisionState[],
|
|
86
|
-
): number | null {
|
|
87
|
-
try {
|
|
88
|
-
const targetKey = annotationDraftKey(sessionId, targetRevision);
|
|
89
|
-
if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
|
|
90
|
-
storage.setItem(handledKey(sessionId, targetRevision), "1");
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
if (storage.getItem(handledKey(sessionId, targetRevision)) !== null) return null;
|
|
94
|
-
|
|
95
|
-
const previous = revisions
|
|
96
|
-
.filter((revision) => revision.revision < targetRevision)
|
|
97
|
-
.sort((left, right) => right.revision - left.revision);
|
|
98
|
-
|
|
99
|
-
for (const revision of previous) {
|
|
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
|
-
}
|
|
113
|
-
|
|
114
|
-
const source = parseState(storage.getItem(annotationDraftKey(sessionId, revision.revision)));
|
|
115
|
-
if (!hasDraftContent(source)) {
|
|
116
|
-
if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
storage.setItem(targetKey, JSON.stringify({
|
|
121
|
-
...source,
|
|
122
|
-
feedbackEntries: [],
|
|
123
|
-
}));
|
|
124
|
-
storage.setItem(handledKey(sessionId, targetRevision), "1");
|
|
125
|
-
return revision.revision;
|
|
126
|
-
}
|
|
127
|
-
} catch {}
|
|
128
|
-
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { renderMarkdownChildren } from "./renderMarkdownChildren";
|
|
3
|
-
|
|
4
|
-
interface CalloutProps {
|
|
5
|
-
type?: "info" | "warning" | "danger" | "tip";
|
|
6
|
-
children?: React.ReactNode;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const config = {
|
|
10
|
-
info: { bg: "bg-accent-blue-muted", icon: "i", iconColor: "text-accent-blue" },
|
|
11
|
-
warning: { bg: "bg-accent-amber-muted", icon: "!", iconColor: "text-accent-amber" },
|
|
12
|
-
danger: { bg: "bg-accent-red-muted", icon: "\u2715", iconColor: "text-accent-red" },
|
|
13
|
-
tip: { bg: "bg-accent-green-muted", icon: "\u2713", iconColor: "text-accent-green" },
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function Callout({ type = "info", children }: CalloutProps) {
|
|
17
|
-
const cfg = config[type];
|
|
18
|
-
return (
|
|
19
|
-
<div className={`${cfg.bg} relative rounded-lg px-5 py-4 mt-3 flex gap-3 items-start`} data-md="callout" data-md-type={type}>
|
|
20
|
-
<span className={`${cfg.iconColor} opacity-70 text-base leading-relaxed flex-shrink-0 mt-px`}>
|
|
21
|
-
{cfg.icon}
|
|
22
|
-
</span>
|
|
23
|
-
<div className="text-body text-text-secondary leading-relaxed">{renderMarkdownChildren(children)}</div>
|
|
24
|
-
</div>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
|
|
3
|
-
interface ChecklistItem {
|
|
4
|
-
label: string;
|
|
5
|
-
checked: boolean;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface ChecklistProps {
|
|
9
|
-
items: ChecklistItem[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function Checklist({ items }: ChecklistProps) {
|
|
13
|
-
return (
|
|
14
|
-
<ul className="space-y-1 mt-3" data-md="checklist">
|
|
15
|
-
{items.map((item, i) => (
|
|
16
|
-
<li key={i} data-md="checklist-item" data-md-label={item.label} className="relative flex items-start gap-2.5 py-1">
|
|
17
|
-
<span className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
|
|
18
|
-
item.checked
|
|
19
|
-
? "bg-accent-green"
|
|
20
|
-
: "border-[1.5px] border-border-hover"
|
|
21
|
-
}`}>
|
|
22
|
-
{item.checked && (
|
|
23
|
-
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
24
|
-
<path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
25
|
-
</svg>
|
|
26
|
-
)}
|
|
27
|
-
</span>
|
|
28
|
-
<span className={`text-body font-body ${
|
|
29
|
-
item.checked ? "text-text-tertiary line-through decoration-border-hover" : "text-text-secondary"
|
|
30
|
-
}`}>
|
|
31
|
-
{item.label}
|
|
32
|
-
</span>
|
|
33
|
-
</li>
|
|
34
|
-
))}
|
|
35
|
-
</ul>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { useAnnotations } from "#canvas/runtime";
|
|
3
|
-
import { ResponseNote } from "./ResponseNote";
|
|
4
|
-
|
|
5
|
-
export type ChoiceOption = string | { value: string; label?: string };
|
|
6
|
-
|
|
7
|
-
type NormalizedOption = { value: string; label: string };
|
|
8
|
-
|
|
9
|
-
function normalizeOptions(options: ChoiceOption[]): NormalizedOption[] {
|
|
10
|
-
return options.map((opt) =>
|
|
11
|
-
typeof opt === "string"
|
|
12
|
-
? { value: opt, label: opt }
|
|
13
|
-
: { value: opt.value, label: opt.label ?? opt.value },
|
|
14
|
-
);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Radio — pick one option */
|
|
18
|
-
interface ChoiceProps {
|
|
19
|
-
id: string;
|
|
20
|
-
label: string;
|
|
21
|
-
options: ChoiceOption[];
|
|
22
|
-
required?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function Choice({ id, label, options, required }: ChoiceProps) {
|
|
26
|
-
const { responses, setResponse } = useAnnotations();
|
|
27
|
-
const normalized = useMemo(() => normalizeOptions(options), [options]);
|
|
28
|
-
const current = responses.get(id);
|
|
29
|
-
const selected = current?.value as string | undefined;
|
|
30
|
-
const note = current?.note || "";
|
|
31
|
-
const [showNote, setShowNote] = useState(false);
|
|
32
|
-
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
33
|
-
|
|
34
|
-
if (!responses.has(id)) {
|
|
35
|
-
setResponse(id, { id, type: "radio", label, value: null, options: normalized.map((o) => o.value), required });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const handleSelect = (value: string) => {
|
|
39
|
-
setResponse(id, { ...current!, value });
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Keyboard selection via custom event from PlanRenderer
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
const el = wrapperRef.current;
|
|
45
|
-
if (!el) return;
|
|
46
|
-
const handler = (e: Event) => {
|
|
47
|
-
const target = (e.target as HTMLElement).closest("[data-md-label]");
|
|
48
|
-
const labelAttr = target?.getAttribute("data-md-label");
|
|
49
|
-
const match = normalized.find((o) => o.label === labelAttr);
|
|
50
|
-
if (match) handleSelect(match.value);
|
|
51
|
-
};
|
|
52
|
-
el.addEventListener("kb-select", handler);
|
|
53
|
-
return () => el.removeEventListener("kb-select", handler);
|
|
54
|
-
}, [normalized, handleSelect]);
|
|
55
|
-
|
|
56
|
-
const showError = current?.required && !selected;
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div ref={wrapperRef} data-md="choice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
60
|
-
<div className="flex items-baseline gap-2 mb-2">
|
|
61
|
-
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
62
|
-
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
63
|
-
</div>
|
|
64
|
-
<div className="space-y-1">
|
|
65
|
-
{normalized.map(({ value, label: optLabel }) => (
|
|
66
|
-
<label
|
|
67
|
-
key={value}
|
|
68
|
-
data-md="choice-option"
|
|
69
|
-
data-md-label={optLabel}
|
|
70
|
-
onClick={() => handleSelect(value)}
|
|
71
|
-
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
72
|
-
selected === value
|
|
73
|
-
? "bg-highlight-selected"
|
|
74
|
-
: "hover:bg-bg-elevated"
|
|
75
|
-
}`}
|
|
76
|
-
>
|
|
77
|
-
<span className={`w-4 h-4 rounded-full border-[1.5px] flex items-center justify-center flex-shrink-0 transition-all ${
|
|
78
|
-
selected === value
|
|
79
|
-
? "border-accent-amber bg-accent-amber"
|
|
80
|
-
: "border-border-strong"
|
|
81
|
-
}`}>
|
|
82
|
-
{selected === value && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
|
|
83
|
-
</span>
|
|
84
|
-
<span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
|
|
85
|
-
</label>
|
|
86
|
-
))}
|
|
87
|
-
</div>
|
|
88
|
-
<ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
|
|
89
|
-
{showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select an option.</p>}
|
|
90
|
-
</div>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Checkbox — pick multiple options */
|
|
95
|
-
interface MultiChoiceProps {
|
|
96
|
-
id: string;
|
|
97
|
-
label: string;
|
|
98
|
-
options: ChoiceOption[];
|
|
99
|
-
required?: boolean;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function MultiChoice({ id, label, options, required }: MultiChoiceProps) {
|
|
103
|
-
const { responses, setResponse } = useAnnotations();
|
|
104
|
-
const normalized = useMemo(() => normalizeOptions(options), [options]);
|
|
105
|
-
const current = responses.get(id);
|
|
106
|
-
const selected: string[] = (current?.value as string[]) || [];
|
|
107
|
-
const note = current?.note || "";
|
|
108
|
-
const [showNote, setShowNote] = useState(false);
|
|
109
|
-
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
110
|
-
|
|
111
|
-
if (!responses.has(id)) {
|
|
112
|
-
setResponse(id, { id, type: "checkbox", label, value: [], options: normalized.map((o) => o.value), required });
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const toggle = (value: string) => {
|
|
116
|
-
const next = selected.includes(value) ? selected.filter((o) => o !== value) : [...selected, value];
|
|
117
|
-
setResponse(id, { ...current!, value: next });
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// Keyboard selection via custom event from PlanRenderer
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
const el = wrapperRef.current;
|
|
123
|
-
if (!el) return;
|
|
124
|
-
const handler = (e: Event) => {
|
|
125
|
-
const target = (e.target as HTMLElement).closest("[data-md-label]");
|
|
126
|
-
const labelAttr = target?.getAttribute("data-md-label");
|
|
127
|
-
const match = normalized.find((o) => o.label === labelAttr);
|
|
128
|
-
if (match) toggle(match.value);
|
|
129
|
-
};
|
|
130
|
-
el.addEventListener("kb-select", handler);
|
|
131
|
-
return () => el.removeEventListener("kb-select", handler);
|
|
132
|
-
}, [normalized, toggle]);
|
|
133
|
-
|
|
134
|
-
const showError = current?.required && selected.length === 0;
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<div ref={wrapperRef} data-md="multichoice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
138
|
-
<div className="flex items-baseline gap-2 mb-2">
|
|
139
|
-
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
140
|
-
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
141
|
-
</div>
|
|
142
|
-
<div className="space-y-1">
|
|
143
|
-
{normalized.map(({ value, label: optLabel }) => {
|
|
144
|
-
const checked = selected.includes(value);
|
|
145
|
-
return (
|
|
146
|
-
<label
|
|
147
|
-
key={value}
|
|
148
|
-
data-md="multichoice-option"
|
|
149
|
-
data-md-label={optLabel}
|
|
150
|
-
onClick={() => toggle(value)}
|
|
151
|
-
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
152
|
-
checked ? "bg-highlight-selected" : "hover:bg-bg-elevated"
|
|
153
|
-
}`}
|
|
154
|
-
>
|
|
155
|
-
<span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 transition-all ${
|
|
156
|
-
checked
|
|
157
|
-
? "bg-accent-amber"
|
|
158
|
-
: "border-[1.5px] border-border-strong"
|
|
159
|
-
}`}>
|
|
160
|
-
{checked && (
|
|
161
|
-
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
162
|
-
<path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
163
|
-
</svg>
|
|
164
|
-
)}
|
|
165
|
-
</span>
|
|
166
|
-
<span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
|
|
167
|
-
</label>
|
|
168
|
-
);
|
|
169
|
-
})}
|
|
170
|
-
</div>
|
|
171
|
-
<ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
|
|
172
|
-
{showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select at least one option.</p>}
|
|
173
|
-
</div>
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|