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.
- package/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- 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
|
+
});
|