agent-canvas 0.1.4 → 0.1.7
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/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/agent-canvas.ts +26 -353
- package/bin/lib/commands/daemon.ts +51 -0
- package/bin/lib/commands/fetch.ts +20 -0
- package/bin/lib/commands/install.ts +48 -0
- package/bin/lib/commands/push.ts +66 -0
- package/bin/lib/commands/watch.ts +23 -0
- package/bin/lib/config.ts +10 -0
- package/bin/lib/daemon-lifecycle.ts +74 -0
- package/bin/lib/helpers.ts +78 -0
- package/daemon/build.ts +27 -61
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/AnnotationSidebar.tsx +199 -31
- package/daemon/client/App.tsx +4 -3
- package/daemon/client/FileBrowser.tsx +8 -7
- package/daemon/client/FileViewer.tsx +153 -16
- package/daemon/client/PlanRenderer.tsx +6 -3
- package/daemon/client/ResponsePreview.tsx +5 -116
- package/daemon/client/SessionSwitcher.tsx +21 -10
- package/daemon/client/compat/preact-all.ts +4 -0
- package/daemon/client/components/Markdown.tsx +44 -0
- package/daemon/client/components/ResponseNote.tsx +3 -8
- package/daemon/client/components/UserInput.tsx +2 -4
- package/daemon/client/components/index.ts +1 -0
- package/daemon/client/domDiff.ts +82 -2
- package/daemon/client/generateMarkdown.ts +46 -9
- package/daemon/client/highlightRange.ts +8 -6
- package/daemon/client/runtime.ts +2 -0
- package/daemon/client/styles.css +126 -0
- package/daemon/client/unifiedDiff.ts +155 -2
- package/daemon/client/utils.ts +10 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +83 -36
- package/daemon/dist/components.js +63 -5
- package/daemon/dist/index.html +5 -12
- package/daemon/dist/jsx-runtime.js +3 -0
- package/daemon/dist/preact-compat.js +1 -0
- package/daemon/src/compiler.ts +28 -2
- package/daemon/src/handlers/api.ts +150 -0
- package/daemon/src/handlers/files.ts +114 -0
- package/daemon/src/handlers/static.ts +61 -0
- package/daemon/src/handlers/utils.ts +14 -0
- package/daemon/src/router.ts +16 -0
- package/daemon/src/server.ts +28 -403
- package/daemon/src/session.ts +41 -23
- package/daemon/src/websocket.ts +83 -0
- package/package.json +3 -3
- package/skills/canvas/SKILL.md +32 -22
- package/skills/canvas/components.md +31 -0
- package/skills/canvas/flows.md +2 -2
- package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
- package/daemon/dist/jsx-runtime-shim.js +0 -17
- package/daemon/dist/react-dom-client-shim.js +0 -4
- package/daemon/dist/react-dom-shim.js +0 -4
- package/daemon/dist/react-shim.js +0 -4
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import React, { useEffect, useState, useContext, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { SessionContext } from "#canvas/runtime";
|
|
3
|
+
import type { AnnotationContext } from "#canvas/runtime";
|
|
3
4
|
import { useAnnotations } from "./AnnotationProvider";
|
|
4
5
|
import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
|
|
5
6
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
6
7
|
import { LANG_MAP } from "../langMap";
|
|
8
|
+
import { generateAnnotationId } from "./utils";
|
|
9
|
+
|
|
10
|
+
/** Walk up from a node to find the parent line div with data-line-num */
|
|
11
|
+
function findLineDiv(node: Node): HTMLElement | null {
|
|
12
|
+
let el: HTMLElement | null = node instanceof HTMLElement ? node : node.parentElement;
|
|
13
|
+
while (el) {
|
|
14
|
+
if (el.hasAttribute("data-line-num")) return el;
|
|
15
|
+
el = el.parentElement;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
interface FileViewerProps {
|
|
9
21
|
path: string;
|
|
@@ -21,9 +33,16 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
21
33
|
const [pendingMarkId, setPendingMarkId] = useState<string | null>(null);
|
|
22
34
|
const pendingSnippetRef = useRef("");
|
|
23
35
|
|
|
36
|
+
// Line gutter selection state
|
|
37
|
+
const [lineSelectStart, setLineSelectStart] = useState<number | null>(null);
|
|
38
|
+
const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null);
|
|
39
|
+
const lineSelectStartRef = useRef<number | null>(null);
|
|
40
|
+
const lineSelectEndRef = useRef<number | null>(null);
|
|
41
|
+
const linesRef = useRef<string[]>([]);
|
|
42
|
+
|
|
24
43
|
// Popover state
|
|
25
44
|
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
26
|
-
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string } | null>(null);
|
|
45
|
+
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
|
|
27
46
|
|
|
28
47
|
const fileAnns = annotations.filter((a) => a.filePath === path);
|
|
29
48
|
|
|
@@ -51,7 +70,11 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
51
70
|
}, [content, path]);
|
|
52
71
|
|
|
53
72
|
// Update mark active states
|
|
54
|
-
|
|
73
|
+
const prevActiveRef = useRef<string | null>(null);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
76
|
+
prevActiveRef.current = activeAnnotationId;
|
|
77
|
+
}, [activeAnnotationId]);
|
|
55
78
|
|
|
56
79
|
// Click handler for marks
|
|
57
80
|
useEffect(() => {
|
|
@@ -108,6 +131,30 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
108
131
|
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
109
132
|
const snippet = sel.toString().trim();
|
|
110
133
|
if (snippet.length < 2) return;
|
|
134
|
+
|
|
135
|
+
// Compute line numbers from DOM
|
|
136
|
+
const startLineDiv = findLineDiv(range.startContainer);
|
|
137
|
+
const endLineDiv = findLineDiv(range.endContainer);
|
|
138
|
+
const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
139
|
+
const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
140
|
+
|
|
141
|
+
// Compute before/after context for short snippets on a single line
|
|
142
|
+
let before = "";
|
|
143
|
+
let after = "";
|
|
144
|
+
if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
|
|
145
|
+
const code = startLineDiv.querySelector("code");
|
|
146
|
+
if (code) {
|
|
147
|
+
const lineText = code.textContent || "";
|
|
148
|
+
const idx = lineText.indexOf(snippet);
|
|
149
|
+
if (idx >= 0) {
|
|
150
|
+
before = lineText.slice(0, idx).trimStart();
|
|
151
|
+
after = lineText.slice(idx + snippet.length).trimEnd();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ctx: AnnotationContext = { before, after, hierarchy: [], lineStart, lineEnd };
|
|
157
|
+
|
|
111
158
|
const tempId = `__pending_${Date.now()}`;
|
|
112
159
|
const cloned = range.cloneRange();
|
|
113
160
|
try { wrapRangeWithMark(cloned, tempId); } catch {}
|
|
@@ -119,7 +166,82 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
119
166
|
|
|
120
167
|
pendingSnippetRef.current = snippet;
|
|
121
168
|
setPendingMarkId(tempId);
|
|
122
|
-
setCreatePopover({ anchorEl: lastMark, tempId, snippet });
|
|
169
|
+
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// Line gutter: mousedown starts line selection
|
|
173
|
+
const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
|
|
174
|
+
e.preventDefault(); // Prevent text selection
|
|
175
|
+
lineSelectStartRef.current = lineNum;
|
|
176
|
+
lineSelectEndRef.current = lineNum;
|
|
177
|
+
setLineSelectStart(lineNum);
|
|
178
|
+
setLineSelectEnd(lineNum);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
// Line gutter: mousemove + mouseup for drag selection
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const handleMouseMoveForLines = (e: MouseEvent) => {
|
|
184
|
+
if (lineSelectStartRef.current === null) return;
|
|
185
|
+
const lineDiv = (e.target as HTMLElement).closest("[data-line-num]") as HTMLElement;
|
|
186
|
+
if (lineDiv) {
|
|
187
|
+
const num = parseInt(lineDiv.getAttribute("data-line-num")!, 10);
|
|
188
|
+
lineSelectEndRef.current = num;
|
|
189
|
+
setLineSelectEnd(num);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleMouseUpForLines = () => {
|
|
194
|
+
const start = lineSelectStartRef.current;
|
|
195
|
+
if (start === null) return;
|
|
196
|
+
lineSelectStartRef.current = null;
|
|
197
|
+
const end = lineSelectEndRef.current ?? start;
|
|
198
|
+
const startLine = Math.min(start, end);
|
|
199
|
+
const endLine = Math.max(start, end);
|
|
200
|
+
const currentLines = linesRef.current;
|
|
201
|
+
const snippet = currentLines.slice(startLine - 1, endLine).join("\n");
|
|
202
|
+
if (!snippet.trim()) {
|
|
203
|
+
setLineSelectStart(null);
|
|
204
|
+
setLineSelectEnd(null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const tempId = `__pending_${Date.now()}`;
|
|
209
|
+
const ctx: AnnotationContext = { before: "", after: "", hierarchy: [], lineStart: startLine, lineEnd: endLine };
|
|
210
|
+
|
|
211
|
+
// Wrap code content of selected lines with marks
|
|
212
|
+
if (contentRef.current) {
|
|
213
|
+
const lineDivs = contentRef.current.querySelectorAll("[data-line-num]");
|
|
214
|
+
const firstDiv = lineDivs[startLine - 1] as HTMLElement;
|
|
215
|
+
const lastDiv = lineDivs[endLine - 1] as HTMLElement;
|
|
216
|
+
if (firstDiv && lastDiv) {
|
|
217
|
+
const firstCode = firstDiv.querySelector("code");
|
|
218
|
+
const lastCode = lastDiv.querySelector("code");
|
|
219
|
+
if (firstCode && lastCode) {
|
|
220
|
+
try {
|
|
221
|
+
const range = document.createRange();
|
|
222
|
+
range.setStartBefore(firstCode.firstChild || firstCode);
|
|
223
|
+
range.setEndAfter(lastCode.lastChild || lastCode);
|
|
224
|
+
wrapRangeWithMark(range, tempId);
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const anchorEl = lineDivs[endLine - 1] as HTMLElement;
|
|
229
|
+
if (anchorEl) {
|
|
230
|
+
pendingSnippetRef.current = snippet;
|
|
231
|
+
setPendingMarkId(tempId);
|
|
232
|
+
setCreatePopover({ anchorEl, tempId, snippet, ctx });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
setLineSelectStart(null);
|
|
236
|
+
setLineSelectEnd(null);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
document.addEventListener("mousemove", handleMouseMoveForLines);
|
|
240
|
+
document.addEventListener("mouseup", handleMouseUpForLines);
|
|
241
|
+
return () => {
|
|
242
|
+
document.removeEventListener("mousemove", handleMouseMoveForLines);
|
|
243
|
+
document.removeEventListener("mouseup", handleMouseUpForLines);
|
|
244
|
+
};
|
|
123
245
|
}, []);
|
|
124
246
|
|
|
125
247
|
// Highlight content
|
|
@@ -140,10 +262,18 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
140
262
|
if (!content) return null;
|
|
141
263
|
|
|
142
264
|
const lines = content.split("\n");
|
|
265
|
+
linesRef.current = lines;
|
|
266
|
+
|
|
267
|
+
// Computed selected line range for highlighting during drag
|
|
268
|
+
const selectedLineRange = useMemo(() => {
|
|
269
|
+
if (lineSelectStart === null) return null;
|
|
270
|
+
const end = lineSelectEnd ?? lineSelectStart;
|
|
271
|
+
return { start: Math.min(lineSelectStart, end), end: Math.max(lineSelectStart, end) };
|
|
272
|
+
}, [lineSelectStart, lineSelectEnd]);
|
|
143
273
|
|
|
144
274
|
const addWholeFileWithPopup = () => {
|
|
145
275
|
const preview = content!.split("\n").slice(0, 3).join("\n") + (lines.length > 3 ? "\n..." : "");
|
|
146
|
-
const id =
|
|
276
|
+
const id = generateAnnotationId();
|
|
147
277
|
addAnnotationWithId(id, preview, "", path);
|
|
148
278
|
// Focus the new annotation's note in sidebar by activating it
|
|
149
279
|
setActiveAnnotationId(id);
|
|
@@ -155,16 +285,23 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
155
285
|
<div>
|
|
156
286
|
{/* Code */}
|
|
157
287
|
<pre ref={contentRef} className="text-code font-mono text-text-code bg-bg-base select-text cursor-text py-3 hljs">
|
|
158
|
-
{lines.map((line, i) =>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
288
|
+
{lines.map((line, i) => {
|
|
289
|
+
const lineNum = i + 1;
|
|
290
|
+
const isSelected = selectedLineRange && lineNum >= selectedLineRange.start && lineNum <= selectedLineRange.end;
|
|
291
|
+
return (
|
|
292
|
+
<div key={i} data-line-num={lineNum} className={`flex px-5 ${isSelected ? "bg-accent-blue/10" : "hover:bg-bg-elevated-half"}`}>
|
|
293
|
+
<span
|
|
294
|
+
className="text-text-tertiary select-none w-10 text-right pr-4 shrink-0 py-px text-[12px] cursor-pointer opacity-40 hover:opacity-100 hover:text-accent-blue"
|
|
295
|
+
onMouseDown={(e) => handleGutterMouseDown(e, lineNum)}
|
|
296
|
+
>{lineNum}</span>
|
|
297
|
+
{highlightedLines ? (
|
|
298
|
+
<code className="py-px" dangerouslySetInnerHTML={{ __html: highlightedLines[i] || " " }} />
|
|
299
|
+
) : (
|
|
300
|
+
<code className="py-px">{line || " "}</code>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
})}
|
|
168
305
|
</pre>
|
|
169
306
|
|
|
170
307
|
{editPopover && (() => {
|
|
@@ -188,9 +325,9 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
188
325
|
scrollContainer={scrollContainer}
|
|
189
326
|
snippet={createPopover.snippet}
|
|
190
327
|
onAdd={(note) => {
|
|
191
|
-
const id =
|
|
328
|
+
const id = generateAnnotationId();
|
|
192
329
|
renameMarkId(createPopover.tempId, id);
|
|
193
|
-
addAnnotationWithId(id, createPopover.snippet, note, path);
|
|
330
|
+
addAnnotationWithId(id, createPopover.snippet, note, path, createPopover.ctx);
|
|
194
331
|
setPendingMarkId(null);
|
|
195
332
|
setCreatePopover(null);
|
|
196
333
|
}}
|
|
@@ -5,6 +5,7 @@ import { useAnnotations } from "./AnnotationProvider";
|
|
|
5
5
|
import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
|
|
6
6
|
import { extractContext } from "./annotationContext";
|
|
7
7
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
8
|
+
import { generateAnnotationId } from "./utils";
|
|
8
9
|
|
|
9
10
|
/** All navigable blocks (keyboard arrows) */
|
|
10
11
|
const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
|
|
@@ -58,8 +59,10 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
58
59
|
}, [PlanComponent]);
|
|
59
60
|
|
|
60
61
|
// Update mark active states when activeAnnotationId changes
|
|
62
|
+
const prevActiveRef = useRef<string | null>(null);
|
|
61
63
|
useEffect(() => {
|
|
62
|
-
updateAllMarkStates(activeAnnotationId);
|
|
64
|
+
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
65
|
+
prevActiveRef.current = activeAnnotationId;
|
|
63
66
|
}, [activeAnnotationId]);
|
|
64
67
|
|
|
65
68
|
// Click handler for marks in plan
|
|
@@ -383,7 +386,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
383
386
|
snippet={createPopover.snippet}
|
|
384
387
|
truncateAt={80}
|
|
385
388
|
onAdd={(note) => {
|
|
386
|
-
const id =
|
|
389
|
+
const id = generateAnnotationId();
|
|
387
390
|
renameMarkId(createPopover.tempId, id);
|
|
388
391
|
addAnnotationWithId(id, createPopover.snippet, note, undefined, createPopover.ctx);
|
|
389
392
|
setCreatePopover(null);
|
|
@@ -421,7 +424,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
421
424
|
snippet={blockPopover.snippet}
|
|
422
425
|
truncateAt={80}
|
|
423
426
|
onAdd={(note) => {
|
|
424
|
-
const id =
|
|
427
|
+
const id = generateAnnotationId();
|
|
425
428
|
addAnnotationWithId(id, blockPopover.snippet, note);
|
|
426
429
|
setBlockPopover(null);
|
|
427
430
|
}}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useState, useRef, useMemo } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
2
3
|
import { useAnnotations } from "./AnnotationProvider";
|
|
3
|
-
import { generateMarkdown,
|
|
4
|
+
import { generateMarkdown, getMissingRequiredLabels } from "./generateMarkdown";
|
|
4
5
|
|
|
5
6
|
interface ResponsePreviewProps {
|
|
6
7
|
open: boolean;
|
|
@@ -119,12 +120,7 @@ export function ResponsePreview({ open, onClose, onSubmit }: ResponsePreviewProp
|
|
|
119
120
|
</button>
|
|
120
121
|
<button
|
|
121
122
|
onClick={() => {
|
|
122
|
-
const
|
|
123
|
-
const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
|
|
124
|
-
const allMissing = [
|
|
125
|
-
...missingResponses.map((r) => r.label),
|
|
126
|
-
...missingFeedback.map((e) => e.label || e.id),
|
|
127
|
-
];
|
|
123
|
+
const allMissing = getMissingRequiredLabels(responses, feedbackEntries);
|
|
128
124
|
if (allMissing.length > 0) {
|
|
129
125
|
setValidationError(`Please answer: ${allMissing.join(", ")}`);
|
|
130
126
|
return;
|
|
@@ -148,113 +144,6 @@ export function ResponsePreview({ open, onClose, onSubmit }: ResponsePreviewProp
|
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
export function MarkdownPreview({ text }: { text: string }) {
|
|
151
|
-
const
|
|
152
|
-
return <div className="font-body text-body text-text-primary leading-relaxed"
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function InlineText({ text }: { text: string }) {
|
|
156
|
-
const parts: React.ReactNode[] = [];
|
|
157
|
-
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g;
|
|
158
|
-
let last = 0;
|
|
159
|
-
let match: RegExpExecArray | null;
|
|
160
|
-
let key = 0;
|
|
161
|
-
|
|
162
|
-
while ((match = regex.exec(text)) !== null) {
|
|
163
|
-
if (match.index > last) parts.push(text.slice(last, match.index));
|
|
164
|
-
if (match[2]) parts.push(<strong key={key++} className="font-semibold text-text-primary">{match[2]}</strong>);
|
|
165
|
-
else if (match[3]) parts.push(<code key={key++} className="font-mono text-code bg-bg-code px-1 py-px rounded">{match[3]}</code>);
|
|
166
|
-
last = regex.lastIndex;
|
|
167
|
-
}
|
|
168
|
-
if (last < text.length) parts.push(text.slice(last));
|
|
169
|
-
return <>{parts}</>;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function parseMarkdown(md: string): React.ReactNode[] {
|
|
173
|
-
const lines = md.split("\n");
|
|
174
|
-
const elements: React.ReactNode[] = [];
|
|
175
|
-
let i = 0;
|
|
176
|
-
let key = 0;
|
|
177
|
-
|
|
178
|
-
while (i < lines.length) {
|
|
179
|
-
const line = lines[i];
|
|
180
|
-
|
|
181
|
-
// Headings
|
|
182
|
-
if (line.startsWith("#### ")) {
|
|
183
|
-
elements.push(<h4 key={key++} className="text-[13px] font-body font-semibold text-text-primary mt-5 mb-1">{line.slice(5)}</h4>);
|
|
184
|
-
i++; continue;
|
|
185
|
-
}
|
|
186
|
-
if (line.startsWith("### ")) {
|
|
187
|
-
elements.push(<h3 key={key++} className="text-[14px] font-body font-semibold text-text-primary mt-5 mb-1">{line.slice(4)}</h3>);
|
|
188
|
-
i++; continue;
|
|
189
|
-
}
|
|
190
|
-
if (line.startsWith("## ")) {
|
|
191
|
-
elements.push(<h2 key={key++} className="text-task-label font-body font-semibold text-text-primary mt-6 mb-2 pb-1 border-b border-border-subtle">{line.slice(3)}</h2>);
|
|
192
|
-
i++; continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Blockquote — collect consecutive > lines
|
|
196
|
-
if (line.startsWith("> ")) {
|
|
197
|
-
const quoteLines: string[] = [];
|
|
198
|
-
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
199
|
-
quoteLines.push(lines[i].slice(2));
|
|
200
|
-
i++;
|
|
201
|
-
}
|
|
202
|
-
elements.push(
|
|
203
|
-
<blockquote key={key++} className="border-l-2 border-border-hover pl-3 my-2 text-text-secondary italic text-[13px]">
|
|
204
|
-
{quoteLines.map((ql, j) => <div key={j}>{ql}</div>)}
|
|
205
|
-
</blockquote>
|
|
206
|
-
);
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Checkbox list — collect consecutive - [x]/- [ ] lines
|
|
211
|
-
if (line.startsWith("- [x] ") || line.startsWith("- [ ] ")) {
|
|
212
|
-
const items: { checked: boolean; label: string }[] = [];
|
|
213
|
-
while (i < lines.length && (lines[i].startsWith("- [x] ") || lines[i].startsWith("- [ ] "))) {
|
|
214
|
-
items.push({ checked: lines[i].startsWith("- [x] "), label: lines[i].slice(6) });
|
|
215
|
-
i++;
|
|
216
|
-
}
|
|
217
|
-
elements.push(
|
|
218
|
-
<ul key={key++} className="my-1 space-y-0.5">
|
|
219
|
-
{items.map((item, j) => (
|
|
220
|
-
<li key={j} className="flex items-center gap-2 text-[13px]">
|
|
221
|
-
<span className={`w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0 ${item.checked ? "bg-accent-amber" : "border border-border-hover"}`}>
|
|
222
|
-
{item.checked && (
|
|
223
|
-
<svg width="8" height="8" viewBox="0 0 10 10" fill="none">
|
|
224
|
-
<path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
225
|
-
</svg>
|
|
226
|
-
)}
|
|
227
|
-
</span>
|
|
228
|
-
<span className={item.checked ? "text-text-primary" : "text-text-tertiary"}>{item.label}</span>
|
|
229
|
-
</li>
|
|
230
|
-
))}
|
|
231
|
-
</ul>
|
|
232
|
-
);
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Regular list — collect consecutive - lines
|
|
237
|
-
if (line.startsWith("- ")) {
|
|
238
|
-
const items: string[] = [];
|
|
239
|
-
while (i < lines.length && lines[i].startsWith("- ")) {
|
|
240
|
-
items.push(lines[i].slice(2));
|
|
241
|
-
i++;
|
|
242
|
-
}
|
|
243
|
-
elements.push(
|
|
244
|
-
<ul key={key++} className="my-1 space-y-0.5 list-disc list-inside">
|
|
245
|
-
{items.map((item, j) => <li key={j} className="text-[13px] text-text-secondary"><InlineText text={item} /></li>)}
|
|
246
|
-
</ul>
|
|
247
|
-
);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Empty line
|
|
252
|
-
if (line.trim() === "") { i++; continue; }
|
|
253
|
-
|
|
254
|
-
// Paragraph
|
|
255
|
-
elements.push(<p key={key++} className="my-1.5 text-[13px]"><InlineText text={line} /></p>);
|
|
256
|
-
i++;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return elements;
|
|
147
|
+
const html = useMemo(() => marked.parse(text, { async: false }) as string, [text]);
|
|
148
|
+
return <div className="prose-canvas font-body text-body text-text-primary leading-relaxed" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
260
149
|
}
|
|
@@ -29,7 +29,11 @@ export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitch
|
|
|
29
29
|
const fetchSessions = async () => {
|
|
30
30
|
try {
|
|
31
31
|
const res = await fetch("/api/sessions");
|
|
32
|
-
|
|
32
|
+
const data = await res.json() as SessionInfo[];
|
|
33
|
+
setSessions((prev) => {
|
|
34
|
+
const next = JSON.stringify(data);
|
|
35
|
+
return JSON.stringify(prev) === next ? prev : data;
|
|
36
|
+
});
|
|
33
37
|
} catch {}
|
|
34
38
|
};
|
|
35
39
|
fetchSessions();
|
|
@@ -51,17 +55,24 @@ export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitch
|
|
|
51
55
|
|
|
52
56
|
return (
|
|
53
57
|
<div className="relative" ref={ref}>
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
<div className="flex items-center gap-1.5 font-body text-body font-medium">
|
|
59
|
+
<a
|
|
60
|
+
href={`/s/${currentSessionId}`}
|
|
61
|
+
className="text-text-primary hover:text-text-secondary transition-colors"
|
|
62
|
+
>
|
|
63
|
+
{displayName}
|
|
64
|
+
</a>
|
|
59
65
|
{otherSessions.length > 0 && (
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => setOpen(!open)}
|
|
68
|
+
className="flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors"
|
|
69
|
+
>
|
|
70
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`transition-transform duration-200 ${open ? "rotate-180" : ""}`}>
|
|
71
|
+
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
63
74
|
)}
|
|
64
|
-
</
|
|
75
|
+
</div>
|
|
65
76
|
|
|
66
77
|
{open && otherSessions.length > 0 && (
|
|
67
78
|
<div className="absolute top-full left-0 mt-2 w-64 bg-bg-elevated border border-border-hover rounded-lg shadow-lg z-50 py-1 overflow-hidden">
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
|
|
4
|
+
interface MarkdownProps {
|
|
5
|
+
/** Inline markdown source (via children) */
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
/** File path (resolved at compile time) */
|
|
8
|
+
file?: string;
|
|
9
|
+
/** Injected by compiler when file prop is used */
|
|
10
|
+
__content?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Configure marked for safe rendering
|
|
14
|
+
marked.setOptions({
|
|
15
|
+
gfm: true,
|
|
16
|
+
breaks: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function Markdown({ children, file, __content }: MarkdownProps) {
|
|
20
|
+
const source = __content ?? (typeof children === "string" ? children : String(children ?? ""));
|
|
21
|
+
|
|
22
|
+
const html = useMemo(() => {
|
|
23
|
+
if (!source) return "";
|
|
24
|
+
return marked.parse(source, { async: false }) as string;
|
|
25
|
+
}, [source]);
|
|
26
|
+
|
|
27
|
+
if (!source) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="text-text-tertiary text-tiny mt-3">
|
|
30
|
+
{file ? `Could not load: ${file}` : "No markdown content"}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className="mt-3 prose-canvas"
|
|
38
|
+
data-md="markdown"
|
|
39
|
+
{...(file ? { "data-md-file": file } : {})}
|
|
40
|
+
data-md-source={source}
|
|
41
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { autoResizeTextarea } from "../utils";
|
|
2
3
|
|
|
3
4
|
/** Seamless note input for response components */
|
|
4
5
|
export function ResponseNote({ note, onChange }: { show?: boolean; note: string; onToggle?: () => void; onChange: (n: string) => void }) {
|
|
@@ -10,14 +11,8 @@ export function ResponseNote({ note, onChange }: { show?: boolean; note: string;
|
|
|
10
11
|
className="w-full bg-transparent text-[12px] font-body text-text-primary resize-none focus:outline-none border-none placeholder:text-text-disabled transition-colors min-h-[20px] p-0 leading-relaxed"
|
|
11
12
|
placeholder="Add a note..."
|
|
12
13
|
rows={1}
|
|
13
|
-
onInput={(e) =>
|
|
14
|
-
|
|
15
|
-
t.style.height = "auto";
|
|
16
|
-
t.style.height = t.scrollHeight + "px";
|
|
17
|
-
}}
|
|
18
|
-
ref={(el) => {
|
|
19
|
-
if (el) { el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }
|
|
20
|
-
}}
|
|
14
|
+
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
|
|
15
|
+
ref={(el) => { if (el) autoResizeTextarea(el); }}
|
|
21
16
|
/>
|
|
22
17
|
</div>
|
|
23
18
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useAnnotations } from "#canvas/runtime";
|
|
3
3
|
import { ResponseNote } from "./ResponseNote";
|
|
4
|
+
import { autoResizeTextarea } from "../utils";
|
|
4
5
|
|
|
5
6
|
/** Free text input from user */
|
|
6
7
|
interface UserInputProps {
|
|
@@ -29,10 +30,7 @@ export function UserInput({ id, label, placeholder, multiline, required }: UserI
|
|
|
29
30
|
setResponse(id, { ...current!, value: v });
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
const autoResize = (el: HTMLTextAreaElement) =>
|
|
33
|
-
el.style.height = "auto";
|
|
34
|
-
el.style.height = Math.max(multiline ? 60 : 32, el.scrollHeight) + "px";
|
|
35
|
-
};
|
|
33
|
+
const autoResize = (el: HTMLTextAreaElement) => autoResizeTextarea(el, multiline ? 60 : 32);
|
|
36
34
|
|
|
37
35
|
const showError = current?.required && !value.trim();
|
|
38
36
|
|
|
@@ -12,4 +12,5 @@ export { Diff } from "./Diff";
|
|
|
12
12
|
export { Choice, MultiChoice } from "./Choice";
|
|
13
13
|
export { UserInput, RangeInput } from "./UserInput";
|
|
14
14
|
export { ImageView } from "./Image";
|
|
15
|
+
export { Markdown } from "./Markdown";
|
|
15
16
|
export { useFeedback } from "#canvas/runtime";
|
package/daemon/client/domDiff.ts
CHANGED
|
@@ -321,16 +321,96 @@ export function runDomDiff(
|
|
|
321
321
|
oldContainer: HTMLElement,
|
|
322
322
|
newContainer: HTMLElement
|
|
323
323
|
): boolean {
|
|
324
|
+
// Handle mermaid blocks separately via data-md-source comparison
|
|
325
|
+
const mermaidChanged = diffMermaidBlocks(oldContainer, newContainer);
|
|
326
|
+
|
|
324
327
|
const oldExtract = extractWords(oldContainer);
|
|
325
328
|
const newExtract = extractWords(newContainer);
|
|
326
329
|
|
|
327
330
|
const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words);
|
|
328
331
|
|
|
329
|
-
const
|
|
332
|
+
const hasWordChanges =
|
|
330
333
|
oldOps.some((op) => op.type === "removed") || newOps.some((op) => op.type === "added");
|
|
331
334
|
|
|
332
335
|
applyHighlightsByNode(oldOps, oldExtract.map, "diff-removed");
|
|
333
336
|
applyHighlightsByNode(newOps, newExtract.map, "diff-added");
|
|
334
337
|
|
|
335
|
-
return
|
|
338
|
+
return hasWordChanges || mermaidChanged;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Compare mermaid blocks between old and new containers by data-md-source.
|
|
343
|
+
* Highlights changed mermaid blocks with a border and appends a source diff.
|
|
344
|
+
*/
|
|
345
|
+
function diffMermaidBlocks(oldContainer: HTMLElement, newContainer: HTMLElement): boolean {
|
|
346
|
+
const oldMermaids = Array.from(oldContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'));
|
|
347
|
+
const newMermaids = Array.from(newContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'));
|
|
348
|
+
let changed = false;
|
|
349
|
+
|
|
350
|
+
// Match by position (simple 1:1 pairing)
|
|
351
|
+
const count = Math.max(oldMermaids.length, newMermaids.length);
|
|
352
|
+
for (let i = 0; i < count; i++) {
|
|
353
|
+
const oldEl = oldMermaids[i];
|
|
354
|
+
const newEl = newMermaids[i];
|
|
355
|
+
|
|
356
|
+
if (!oldEl && newEl) {
|
|
357
|
+
// Added
|
|
358
|
+
newEl.classList.add("diff-block-added");
|
|
359
|
+
newEl.style.position = "relative";
|
|
360
|
+
changed = true;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (oldEl && !newEl) {
|
|
364
|
+
// Removed
|
|
365
|
+
oldEl.classList.add("diff-block-removed");
|
|
366
|
+
oldEl.style.position = "relative";
|
|
367
|
+
changed = true;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!oldEl || !newEl) continue;
|
|
371
|
+
|
|
372
|
+
const oldSource = (oldEl.getAttribute("data-md-source") || "").trim();
|
|
373
|
+
const newSource = (newEl.getAttribute("data-md-source") || "").trim();
|
|
374
|
+
|
|
375
|
+
if (oldSource === newSource) continue;
|
|
376
|
+
changed = true;
|
|
377
|
+
|
|
378
|
+
// Highlight containers
|
|
379
|
+
oldEl.style.borderLeft = "3px solid var(--color-accent-red)";
|
|
380
|
+
oldEl.style.paddingLeft = "8px";
|
|
381
|
+
newEl.style.borderLeft = "3px solid var(--color-accent-green)";
|
|
382
|
+
newEl.style.paddingLeft = "8px";
|
|
383
|
+
|
|
384
|
+
// Append source diffs
|
|
385
|
+
const oldLines = oldSource.split("\n");
|
|
386
|
+
const newLines = newSource.split("\n");
|
|
387
|
+
const lcs = simpleLCS(oldLines, newLines);
|
|
388
|
+
|
|
389
|
+
const buildDiffEl = (lines: string[], lcsArr: string[], side: "old" | "new") => {
|
|
390
|
+
const container = document.createElement("div");
|
|
391
|
+
container.className = "mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono";
|
|
392
|
+
container.setAttribute("data-no-diff", "true");
|
|
393
|
+
|
|
394
|
+
let li = 0, si = 0;
|
|
395
|
+
while (si < lines.length) {
|
|
396
|
+
const row = document.createElement("div");
|
|
397
|
+
row.className = "px-3 py-0.5 whitespace-pre-wrap";
|
|
398
|
+
if (li < lcsArr.length && lines[si] === lcsArr[li]) {
|
|
399
|
+
row.textContent = " " + lines[si];
|
|
400
|
+
si++; li++;
|
|
401
|
+
} else {
|
|
402
|
+
row.classList.add(side === "old" ? "diff-removed" : "diff-added");
|
|
403
|
+
row.textContent = (side === "old" ? "- " : "+ ") + lines[si];
|
|
404
|
+
si++;
|
|
405
|
+
}
|
|
406
|
+
container.appendChild(row);
|
|
407
|
+
}
|
|
408
|
+
return container;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
oldEl.appendChild(buildDiffEl(oldLines, lcs, "old"));
|
|
412
|
+
newEl.appendChild(buildDiffEl(newLines, lcs, "new"));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return changed;
|
|
336
416
|
}
|