agent-canvas 0.1.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.
Files changed (65) hide show
  1. package/bin/agent-canvas.js +14 -0
  2. package/bin/agent-canvas.ts +377 -0
  3. package/daemon/build.ts +185 -0
  4. package/daemon/client/AnnotationProvider.tsx +185 -0
  5. package/daemon/client/AnnotationSidebar.tsx +314 -0
  6. package/daemon/client/App.tsx +611 -0
  7. package/daemon/client/CompareView.tsx +333 -0
  8. package/daemon/client/FileBrowser.tsx +188 -0
  9. package/daemon/client/FileIcon.tsx +76 -0
  10. package/daemon/client/FileViewer.tsx +207 -0
  11. package/daemon/client/PlanRenderer.tsx +665 -0
  12. package/daemon/client/Popover.tsx +285 -0
  13. package/daemon/client/ResponsePreview.tsx +260 -0
  14. package/daemon/client/RevisionSelect.tsx +97 -0
  15. package/daemon/client/SessionContext.tsx +3 -0
  16. package/daemon/client/SessionSwitcher.tsx +72 -0
  17. package/daemon/client/annotationContext.ts +114 -0
  18. package/daemon/client/components/Callout.tsx +25 -0
  19. package/daemon/client/components/Checklist.tsx +37 -0
  20. package/daemon/client/components/Choice.tsx +164 -0
  21. package/daemon/client/components/CodeBlock.tsx +35 -0
  22. package/daemon/client/components/Diff.tsx +65 -0
  23. package/daemon/client/components/FilePreview.tsx +88 -0
  24. package/daemon/client/components/Image.tsx +45 -0
  25. package/daemon/client/components/Mermaid.tsx +277 -0
  26. package/daemon/client/components/Note.tsx +13 -0
  27. package/daemon/client/components/Priority.tsx +19 -0
  28. package/daemon/client/components/ResponseNote.tsx +24 -0
  29. package/daemon/client/components/Section.tsx +32 -0
  30. package/daemon/client/components/Table.tsx +35 -0
  31. package/daemon/client/components/Task.tsx +75 -0
  32. package/daemon/client/components/UserInput.tsx +180 -0
  33. package/daemon/client/components/index.ts +15 -0
  34. package/daemon/client/domDiff.ts +336 -0
  35. package/daemon/client/exportMarkdown.ts +267 -0
  36. package/daemon/client/generateMarkdown.ts +194 -0
  37. package/daemon/client/highlightRange.ts +233 -0
  38. package/daemon/client/index.html +26 -0
  39. package/daemon/client/lcs.ts +19 -0
  40. package/daemon/client/popoverPosition.ts +31 -0
  41. package/daemon/client/runtime.ts +89 -0
  42. package/daemon/client/styles.css +306 -0
  43. package/daemon/client/theme.css +66 -0
  44. package/daemon/client/unifiedDiff.ts +604 -0
  45. package/daemon/dist/client.css +2 -0
  46. package/daemon/dist/client.js +68 -0
  47. package/daemon/dist/components.js +5 -0
  48. package/daemon/dist/index.html +108 -0
  49. package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
  50. package/daemon/dist/jsx-runtime-shim.js +17 -0
  51. package/daemon/dist/react-dom-client-shim.js +4 -0
  52. package/daemon/dist/react-dom-shim.js +4 -0
  53. package/daemon/dist/react-shim.js +4 -0
  54. package/daemon/dist/runtime.js +1 -0
  55. package/daemon/dist/theme.css +66 -0
  56. package/daemon/src/compiler.ts +92 -0
  57. package/daemon/src/paths.ts +22 -0
  58. package/daemon/src/server.ts +448 -0
  59. package/daemon/src/session.ts +274 -0
  60. package/daemon/src/watcher.ts +59 -0
  61. package/hooks/session-start.sh +11 -0
  62. package/package.json +39 -0
  63. package/skills/canvas/SKILL.md +174 -0
  64. package/skills/canvas/components.md +349 -0
  65. package/skills/canvas/flows.md +406 -0
@@ -0,0 +1,194 @@
1
+ import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
2
+ import { formatSnippetInContext } from "./annotationContext";
3
+
4
+ /**
5
+ * Generate human-readable markdown feedback from annotations and responses.
6
+ */
7
+ export function generateMarkdown(
8
+ annotations: Annotation[],
9
+ generalNote: string,
10
+ responses?: Map<string, PlanResponse>,
11
+ feedbackEntries?: Map<string, FeedbackEntry>,
12
+ ): string {
13
+ const parts: string[] = [];
14
+ const planAnns = annotations.filter((a) => !a.filePath);
15
+ const fileAnns = annotations.filter((a) => a.filePath);
16
+ const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
17
+ const fbEntries = feedbackEntries
18
+ ? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
19
+ : [];
20
+
21
+ // Responses section
22
+ const hasResponseSection = resps.length > 0 || fbEntries.length > 0;
23
+ if (hasResponseSection) {
24
+ parts.push("## Responses");
25
+ parts.push("");
26
+ for (const r of resps) {
27
+ parts.push(renderResponse(r));
28
+ }
29
+ for (const entry of fbEntries) {
30
+ parts.push(entry.markdown.trim());
31
+ parts.push("");
32
+ }
33
+ }
34
+
35
+ // Plan annotations grouped by hierarchy
36
+ if (planAnns.length > 0) {
37
+ parts.push("## Plan annotations");
38
+ parts.push("");
39
+ const grouped = groupByHierarchy(planAnns);
40
+ parts.push(renderHierarchyGroup(grouped));
41
+ }
42
+
43
+ // File annotations grouped by path
44
+ if (fileAnns.length > 0) {
45
+ const groups: Record<string, Annotation[]> = {};
46
+ for (const ann of fileAnns) {
47
+ const key = ann.filePath!;
48
+ if (!groups[key]) groups[key] = [];
49
+ groups[key].push(ann);
50
+ }
51
+ parts.push("## File annotations");
52
+ parts.push("");
53
+ for (const [filePath, anns] of Object.entries(groups)) {
54
+ parts.push(`### ${filePath}`);
55
+ parts.push("");
56
+ for (const ann of anns) {
57
+ parts.push(renderAnnotation(ann));
58
+ }
59
+ }
60
+ }
61
+
62
+ // General notes
63
+ if (generalNote.trim()) {
64
+ parts.push("## General notes");
65
+ parts.push("");
66
+ parts.push(generalNote.trim());
67
+ parts.push("");
68
+ }
69
+
70
+ return parts.join("\n").trim() + "\n";
71
+ }
72
+
73
+ export function hasValue(r: PlanResponse): boolean {
74
+ if (r.note?.trim()) return true;
75
+ if (r.value === null || r.value === undefined) return false;
76
+ if (r.type === "text" && !(r.value as string).trim()) return false;
77
+ if (r.type === "checkbox" && (r.value as string[]).length === 0) return false;
78
+ return true;
79
+ }
80
+
81
+ export function getMissingRequired(responses: Map<string, PlanResponse>): PlanResponse[] {
82
+ const missing: PlanResponse[] = [];
83
+ for (const r of responses.values()) {
84
+ if (!r.required) continue;
85
+ if (r.value === null || r.value === undefined) { missing.push(r); continue; }
86
+ if (r.type === "text" && !(r.value as string).trim()) { missing.push(r); continue; }
87
+ if (r.type === "checkbox" && (r.value as string[]).length === 0) { missing.push(r); continue; }
88
+ }
89
+ return missing;
90
+ }
91
+
92
+ export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>): FeedbackEntry[] {
93
+ const missing: FeedbackEntry[] = [];
94
+ for (const entry of entries.values()) {
95
+ if (entry.required && !entry.markdown.trim()) {
96
+ missing.push(entry);
97
+ }
98
+ }
99
+ return missing;
100
+ }
101
+
102
+ function renderResponse(r: PlanResponse): string {
103
+ const lines: string[] = [];
104
+ lines.push(`**${r.label}**`);
105
+
106
+ switch (r.type) {
107
+ case "radio":
108
+ case "select":
109
+ lines.push(`Answer: ${r.value}`);
110
+ break;
111
+ case "checkbox":
112
+ for (const v of r.value as string[]) {
113
+ lines.push(`- [x] ${v}`);
114
+ }
115
+ break;
116
+ case "text":
117
+ lines.push("");
118
+ lines.push((r.value as string).trim());
119
+ break;
120
+ case "range":
121
+ lines.push(`Answer: ${r.value}`);
122
+ break;
123
+ }
124
+
125
+ if (r.note?.trim()) {
126
+ lines.push("");
127
+ lines.push(`Note: ${r.note.trim()}`);
128
+ }
129
+
130
+ lines.push("");
131
+ return lines.join("\n");
132
+ }
133
+
134
+ function renderAnnotation(ann: Annotation): string {
135
+ const lines: string[] = [];
136
+ const snippet = ann.snippet.trim();
137
+ const context = formatSnippetInContext(ann);
138
+
139
+ // Show selected text as a quote
140
+ if (snippet.split("\n").length <= 3) {
141
+ lines.push(`> ${context.split("\n").join("\n> ")}`);
142
+ } else {
143
+ // Longer selections — just first/last line
144
+ const snippetLines = snippet.split("\n");
145
+ lines.push(`> ${snippetLines[0]}`);
146
+ lines.push(`> ... (${snippetLines.length} lines)`);
147
+ lines.push(`> ${snippetLines[snippetLines.length - 1]}`);
148
+ }
149
+
150
+ // Comment
151
+ if (ann.note.trim()) {
152
+ lines.push("");
153
+ lines.push(ann.note.trim());
154
+ }
155
+
156
+ lines.push("");
157
+ return lines.join("\n");
158
+ }
159
+
160
+ interface HierarchyNode {
161
+ label: string;
162
+ annotations: Annotation[];
163
+ children: Map<string, HierarchyNode>;
164
+ }
165
+
166
+ function groupByHierarchy(annotations: Annotation[]): HierarchyNode {
167
+ const root: HierarchyNode = { label: "", annotations: [], children: new Map() };
168
+ for (const ann of annotations) {
169
+ const path = ann.context?.hierarchy || [];
170
+ let current = root;
171
+ for (const segment of path) {
172
+ if (!current.children.has(segment)) {
173
+ current.children.set(segment, { label: segment, annotations: [], children: new Map() });
174
+ }
175
+ current = current.children.get(segment)!;
176
+ }
177
+ current.annotations.push(ann);
178
+ }
179
+ return root;
180
+ }
181
+
182
+ function renderHierarchyGroup(node: HierarchyNode, depth: number = 0): string {
183
+ const parts: string[] = [];
184
+ for (const ann of node.annotations) {
185
+ parts.push(renderAnnotation(ann));
186
+ }
187
+ for (const [, child] of node.children) {
188
+ const prefix = depth === 0 ? "###" : "####";
189
+ parts.push(`${prefix} ${child.label}`);
190
+ parts.push("");
191
+ parts.push(renderHierarchyGroup(child, depth + 1));
192
+ }
193
+ return parts.join("\n");
194
+ }
@@ -0,0 +1,233 @@
1
+ const MARK_STYLE_INACTIVE = `
2
+ background: var(--color-highlight-annotation);
3
+ border-bottom: 1.5px solid var(--color-highlight-bg);
4
+ border-radius: 2px;
5
+ cursor: pointer;
6
+ transition: background 150ms ease;
7
+ color: inherit;
8
+ padding: 1px 0;
9
+ `;
10
+
11
+ const MARK_STYLE_ACTIVE = `
12
+ background: var(--color-highlight-bg);
13
+ border-bottom: 1.5px solid var(--color-highlight-border);
14
+ border-radius: 2px;
15
+ cursor: pointer;
16
+ transition: background 150ms ease;
17
+ color: inherit;
18
+ padding: 1px 0;
19
+ `;
20
+
21
+ /**
22
+ * Wraps the current browser Range in <mark> elements with the given annotation ID.
23
+ * Works for multiline / cross-element selections.
24
+ */
25
+ export function wrapRangeWithMark(range: Range, annotationId: string): HTMLElement[] {
26
+ const marks: HTMLElement[] = [];
27
+
28
+ // Collect all text nodes within the range
29
+ const textNodes = getTextNodesInRange(range);
30
+
31
+ for (const tn of textNodes) {
32
+ const nodeRange = document.createRange();
33
+ nodeRange.selectNodeContents(tn);
34
+
35
+ // Clamp to the actual selection range
36
+ if (tn === range.startContainer) {
37
+ nodeRange.setStart(tn, range.startOffset);
38
+ }
39
+ if (tn === range.endContainer) {
40
+ nodeRange.setEnd(tn, range.endOffset);
41
+ }
42
+
43
+ const text = nodeRange.toString();
44
+ if (!text || !text.trim()) continue;
45
+
46
+ // Wrap this portion in a <mark>
47
+ const mark = document.createElement("mark");
48
+ mark.setAttribute("data-annotation-id", annotationId);
49
+ mark.style.cssText = MARK_STYLE_INACTIVE;
50
+
51
+ try {
52
+ nodeRange.surroundContents(mark);
53
+ } catch {
54
+ // surroundContents fails if range crosses element boundaries within a single text node
55
+ // (shouldn't happen for a single text node, but handle gracefully)
56
+ const fragment = nodeRange.extractContents();
57
+ mark.appendChild(fragment);
58
+ nodeRange.insertNode(mark);
59
+ }
60
+
61
+ marks.push(mark);
62
+ }
63
+
64
+ return marks;
65
+ }
66
+
67
+ function getTextNodesInRange(range: Range): Text[] {
68
+ const nodes: Text[] = [];
69
+ const ancestor = range.commonAncestorContainer;
70
+
71
+ if (ancestor.nodeType === Node.TEXT_NODE) {
72
+ nodes.push(ancestor as Text);
73
+ return nodes;
74
+ }
75
+
76
+ const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT);
77
+ let node: Text | null;
78
+ while ((node = walker.nextNode() as Text | null)) {
79
+ // Skip line numbers and existing marks
80
+ const parent = node.parentElement;
81
+ if (parent?.classList.contains("select-none")) continue;
82
+
83
+ if (range.intersectsNode(node)) {
84
+ nodes.push(node);
85
+ }
86
+ }
87
+
88
+ return nodes;
89
+ }
90
+
91
+ /**
92
+ * Update the visual state of marks for a given annotation ID.
93
+ */
94
+ export function setMarkActive(annotationId: string, active: boolean) {
95
+ const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
96
+ for (const mark of marks) {
97
+ (mark as HTMLElement).style.cssText = active ? MARK_STYLE_ACTIVE : MARK_STYLE_INACTIVE;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Update active state for all annotation marks.
103
+ */
104
+ export function updateAllMarkStates(activeId: string | null) {
105
+ const allMarks = document.querySelectorAll("[data-annotation-id]");
106
+ for (const mark of allMarks) {
107
+ const id = mark.getAttribute("data-annotation-id");
108
+ (mark as HTMLElement).style.cssText = id === activeId ? MARK_STYLE_ACTIVE : MARK_STYLE_INACTIVE;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Rename annotation ID on existing marks (e.g. temp → real ID).
114
+ */
115
+ export function renameMarkId(oldId: string, newId: string) {
116
+ const marks = document.querySelectorAll(`[data-annotation-id="${oldId}"]`);
117
+ for (const mark of marks) {
118
+ mark.setAttribute("data-annotation-id", newId);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Restore marks for persisted annotations by finding their snippet text in the DOM.
124
+ * Uses before/after context for disambiguation when the same text appears multiple times.
125
+ */
126
+ export function restoreMarks(
127
+ container: HTMLElement,
128
+ annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[]
129
+ ) {
130
+ // Skip annotations that already have marks in the DOM
131
+ const toRestore = annotations.filter(
132
+ (a) => !document.querySelector(`[data-annotation-id="${a.id}"]`)
133
+ );
134
+ if (!toRestore.length) return;
135
+
136
+ // Build full text content + mapping from text offset to (textNode, offsetInNode)
137
+ const textNodes: { node: Text; start: number }[] = [];
138
+ let fullText = "";
139
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
140
+ let tn: Text | null;
141
+ while ((tn = walker.nextNode() as Text | null)) {
142
+ if (tn.parentElement?.classList.contains("select-none")) continue;
143
+ textNodes.push({ node: tn, start: fullText.length });
144
+ fullText += tn.textContent || "";
145
+ }
146
+
147
+ for (const ann of toRestore) {
148
+ const snippet = ann.snippet;
149
+ if (!snippet) continue;
150
+
151
+ // Find all occurrences of the snippet in the full text
152
+ const occurrences: number[] = [];
153
+ let searchFrom = 0;
154
+ while (true) {
155
+ const idx = fullText.indexOf(snippet, searchFrom);
156
+ if (idx === -1) break;
157
+ occurrences.push(idx);
158
+ searchFrom = idx + 1;
159
+ }
160
+
161
+ if (occurrences.length === 0) continue;
162
+
163
+ // Pick the best occurrence using context
164
+ let bestIdx = occurrences[0];
165
+ if (occurrences.length > 1 && ann.context) {
166
+ let bestScore = -1;
167
+ for (const idx of occurrences) {
168
+ let score = 0;
169
+ if (ann.context.before) {
170
+ const preceding = fullText.slice(Math.max(0, idx - 80), idx);
171
+ if (preceding.includes(ann.context.before)) score += 2;
172
+ else if (ann.context.before.length > 10 && preceding.includes(ann.context.before.slice(-10))) score += 1;
173
+ }
174
+ if (ann.context.after) {
175
+ const following = fullText.slice(idx + snippet.length, idx + snippet.length + 80);
176
+ if (following.includes(ann.context.after)) score += 2;
177
+ else if (ann.context.after.length > 10 && following.includes(ann.context.after.slice(0, 10))) score += 1;
178
+ }
179
+ if (score > bestScore) {
180
+ bestScore = score;
181
+ bestIdx = idx;
182
+ }
183
+ }
184
+ }
185
+
186
+ // Map text offsets to DOM positions
187
+ const startOffset = bestIdx;
188
+ const endOffset = bestIdx + snippet.length;
189
+
190
+ const startPos = offsetToNode(textNodes, startOffset);
191
+ const endPos = offsetToNode(textNodes, endOffset);
192
+ if (!startPos || !endPos) continue;
193
+
194
+ try {
195
+ const range = document.createRange();
196
+ range.setStart(startPos.node, startPos.offset);
197
+ range.setEnd(endPos.node, endPos.offset);
198
+ wrapRangeWithMark(range, ann.id);
199
+ } catch {}
200
+ }
201
+ }
202
+
203
+ function offsetToNode(
204
+ textNodes: { node: Text; start: number }[],
205
+ offset: number
206
+ ): { node: Text; offset: number } | null {
207
+ for (let i = textNodes.length - 1; i >= 0; i--) {
208
+ if (textNodes[i].start <= offset) {
209
+ const localOffset = offset - textNodes[i].start;
210
+ const nodeLen = textNodes[i].node.textContent?.length ?? 0;
211
+ if (localOffset <= nodeLen) {
212
+ return { node: textNodes[i].node, offset: localOffset };
213
+ }
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /**
220
+ * Remove marks for a given annotation ID, restoring the original text nodes.
221
+ */
222
+ export function unwrapMarks(annotationId: string) {
223
+ const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
224
+ for (const mark of marks) {
225
+ const parent = mark.parentNode;
226
+ if (!parent) continue;
227
+ while (mark.firstChild) {
228
+ parent.insertBefore(mark.firstChild, mark);
229
+ }
230
+ parent.removeChild(mark);
231
+ parent.normalize();
232
+ }
233
+ }
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Agent Canvas</title>
7
+ <script>document.documentElement.dataset.theme = localStorage.getItem('canvas-theme') || 'dark';</script>
8
+ <link rel="stylesheet" href="/assets/client.css" />
9
+ </head>
10
+ <body class="min-h-screen">
11
+ <div id="root"></div>
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "react": "/assets/react.js",
16
+ "react-dom": "/assets/react-dom.js",
17
+ "react-dom/client": "/assets/react-dom-client.js",
18
+ "react/jsx-runtime": "/assets/react-jsx-runtime.js",
19
+ "react/jsx-dev-runtime": "/assets/react-jsx-dev-runtime.js",
20
+ "#canvas/components": "/assets/components.js"
21
+ }
22
+ }
23
+ </script>
24
+ <script type="module" src="/assets/client.js"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,19 @@
1
+ /** Simple O(mn) space LCS for small inputs. */
2
+ export function simpleLCS(a: string[], b: string[]): string[] {
3
+ const m = a.length, n = b.length;
4
+ if (m === 0 || n === 0) return [];
5
+
6
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
7
+ for (let i = 1; i <= m; i++)
8
+ for (let j = 1; j <= n; j++)
9
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
10
+
11
+ const result: string[] = [];
12
+ let i = m, j = n;
13
+ while (i > 0 && j > 0) {
14
+ if (a[i - 1] === b[j - 1]) { result.unshift(a[i - 1]); i--; j--; }
15
+ else if (dp[i - 1][j] > dp[i][j - 1]) i--;
16
+ else j--;
17
+ }
18
+ return result;
19
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Calculate popover position relative to an anchor element.
3
+ * Returns absolute position within the scroll container (or body).
4
+ */
5
+ export function getPopoverPosition(
6
+ anchor: HTMLElement,
7
+ scrollContainer?: HTMLElement | null,
8
+ ): { style: Record<string, string>; parent: HTMLElement } {
9
+ const popoverWidth = 280;
10
+ const gap = 8;
11
+ const parent = scrollContainer || document.body;
12
+
13
+ // Use getBoundingClientRect for both anchor and parent,
14
+ // then add scroll offsets to get absolute position within parent
15
+ const anchorRect = anchor.getBoundingClientRect();
16
+ const parentRect = parent.getBoundingClientRect();
17
+
18
+ // Vertical: anchor bottom relative to parent top + any scroll
19
+ const scrollTop = parent === document.body ? window.scrollY : parent.scrollTop;
20
+ const top = anchorRect.bottom - parentRect.top + scrollTop + gap;
21
+
22
+ // Horizontal: clamp to parent width
23
+ const left = Math.max(0, Math.min(anchorRect.left - parentRect.left, parentRect.width - popoverWidth - 12));
24
+
25
+ parent.style.position = "relative";
26
+
27
+ return {
28
+ style: { position: "absolute", top: `${top}px`, left: `${left}px` },
29
+ parent,
30
+ };
31
+ }
@@ -0,0 +1,89 @@
1
+ // #canvas/runtime — shared context between client app and components bundle
2
+ // The React.createContext call MUST live here (not in AnnotationProvider)
3
+ // so that both bundles reference the same context object.
4
+
5
+ import { createContext, useContext, useEffect } from "react";
6
+
7
+ export interface AnnotationContext {
8
+ before: string;
9
+ after: string;
10
+ hierarchy: string[];
11
+ }
12
+
13
+ export interface Annotation {
14
+ id: string;
15
+ snippet: string;
16
+ note: string;
17
+ createdAt: string;
18
+ filePath?: string;
19
+ context?: AnnotationContext;
20
+ }
21
+
22
+ export interface PlanResponse {
23
+ id: string;
24
+ type: "select" | "radio" | "checkbox" | "text" | "range";
25
+ label: string;
26
+ value: any;
27
+ options?: string[];
28
+ required?: boolean;
29
+ note?: string;
30
+ }
31
+
32
+ export interface FeedbackEntry {
33
+ id: string;
34
+ markdown: string;
35
+ label?: string;
36
+ required?: boolean;
37
+ }
38
+
39
+ export interface AnnotationContextValue {
40
+ annotations: Annotation[];
41
+ addAnnotation: (snippet: string, note: string, filePath?: string) => void;
42
+ addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext) => void;
43
+ updateAnnotation: (id: string, note: string) => void;
44
+ removeAnnotation: (id: string) => void;
45
+ generalNote: string;
46
+ setGeneralNote: (text: string) => void;
47
+ clearAll: () => void;
48
+ activeAnnotationId: string | null;
49
+ setActiveAnnotationId: (id: string | null) => void;
50
+ responses: Map<string, PlanResponse>;
51
+ setResponse: (id: string, response: PlanResponse) => void;
52
+ feedbackEntries: Map<string, FeedbackEntry>;
53
+ setFeedbackEntry: (id: string, entry: FeedbackEntry) => void;
54
+ removeFeedbackEntry: (id: string) => void;
55
+ }
56
+
57
+ export const AnnotationCtx = createContext<AnnotationContextValue>(null!);
58
+
59
+ export function useAnnotations(): AnnotationContextValue {
60
+ return useContext(AnnotationCtx);
61
+ }
62
+
63
+ export function useFeedback(
64
+ id: string,
65
+ markdown: string,
66
+ options?: { label?: string; required?: boolean },
67
+ ): void {
68
+ const { setFeedbackEntry, removeFeedbackEntry } = useAnnotations();
69
+ useEffect(() => {
70
+ setFeedbackEntry(id, {
71
+ id,
72
+ markdown,
73
+ label: options?.label,
74
+ required: options?.required,
75
+ });
76
+ return () => removeFeedbackEntry(id);
77
+ }, [id, markdown, options?.label, options?.required]);
78
+ }
79
+
80
+ export { SessionContext } from "./SessionContext";
81
+
82
+ // ActiveView navigation — allows components (e.g. FilePreview) to open files
83
+ export type ActiveView = { type: "plan" } | { type: "file"; path: string };
84
+
85
+ export const ActiveViewCtx = createContext<{
86
+ setActiveView: (v: ActiveView) => void;
87
+ }>({
88
+ setActiveView: () => {},
89
+ });