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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/bin/agent-canvas.ts +26 -353
  4. package/bin/lib/commands/daemon.ts +51 -0
  5. package/bin/lib/commands/fetch.ts +20 -0
  6. package/bin/lib/commands/install.ts +48 -0
  7. package/bin/lib/commands/push.ts +66 -0
  8. package/bin/lib/commands/watch.ts +23 -0
  9. package/bin/lib/config.ts +10 -0
  10. package/bin/lib/daemon-lifecycle.ts +74 -0
  11. package/bin/lib/helpers.ts +78 -0
  12. package/daemon/build.ts +27 -61
  13. package/daemon/client/AnnotationProvider.tsx +2 -2
  14. package/daemon/client/AnnotationSidebar.tsx +199 -31
  15. package/daemon/client/App.tsx +4 -3
  16. package/daemon/client/FileBrowser.tsx +8 -7
  17. package/daemon/client/FileViewer.tsx +153 -16
  18. package/daemon/client/PlanRenderer.tsx +6 -3
  19. package/daemon/client/ResponsePreview.tsx +5 -116
  20. package/daemon/client/SessionSwitcher.tsx +21 -10
  21. package/daemon/client/compat/preact-all.ts +4 -0
  22. package/daemon/client/components/Markdown.tsx +44 -0
  23. package/daemon/client/components/ResponseNote.tsx +3 -8
  24. package/daemon/client/components/UserInput.tsx +2 -4
  25. package/daemon/client/components/index.ts +1 -0
  26. package/daemon/client/domDiff.ts +82 -2
  27. package/daemon/client/generateMarkdown.ts +46 -9
  28. package/daemon/client/highlightRange.ts +8 -6
  29. package/daemon/client/runtime.ts +2 -0
  30. package/daemon/client/styles.css +126 -0
  31. package/daemon/client/unifiedDiff.ts +155 -2
  32. package/daemon/client/utils.ts +10 -0
  33. package/daemon/dist/client.css +1 -1
  34. package/daemon/dist/client.js +83 -36
  35. package/daemon/dist/components.js +63 -5
  36. package/daemon/dist/index.html +5 -12
  37. package/daemon/dist/jsx-runtime.js +3 -0
  38. package/daemon/dist/preact-compat.js +1 -0
  39. package/daemon/src/compiler.ts +28 -2
  40. package/daemon/src/handlers/api.ts +150 -0
  41. package/daemon/src/handlers/files.ts +114 -0
  42. package/daemon/src/handlers/static.ts +61 -0
  43. package/daemon/src/handlers/utils.ts +14 -0
  44. package/daemon/src/router.ts +16 -0
  45. package/daemon/src/server.ts +28 -403
  46. package/daemon/src/session.ts +41 -23
  47. package/daemon/src/websocket.ts +83 -0
  48. package/package.json +3 -3
  49. package/skills/canvas/SKILL.md +32 -22
  50. package/skills/canvas/components.md +31 -0
  51. package/skills/canvas/flows.md +2 -2
  52. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  53. package/daemon/dist/jsx-runtime-shim.js +0 -17
  54. package/daemon/dist/react-dom-client-shim.js +0 -4
  55. package/daemon/dist/react-dom-shim.js +0 -4
  56. 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
- useEffect(() => { updateAllMarkStates(activeAnnotationId); }, [activeAnnotationId]);
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 = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
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
- <div key={i} className="flex hover:bg-bg-elevated-half px-5">
160
- <span className="text-text-tertiary opacity-40 select-none w-10 text-right pr-4 shrink-0 py-px text-[12px]">{i + 1}</span>
161
- {highlightedLines ? (
162
- <code className="py-px" dangerouslySetInnerHTML={{ __html: highlightedLines[i] || " " }} />
163
- ) : (
164
- <code className="py-px">{line || " "}</code>
165
- )}
166
- </div>
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 = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
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 = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
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 = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
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, getMissingRequired, getMissingRequiredFeedback } from "./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 missingResponses = getMissingRequired(responses);
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 elements = useMemo(() => parseMarkdown(text), [text]);
152
- return <div className="font-body text-body text-text-primary leading-relaxed">{elements}</div>;
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
- setSessions(await res.json() as SessionInfo[]);
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
- <button
55
- onClick={() => otherSessions.length > 0 && setOpen(!open)}
56
- className="flex items-center gap-1.5 font-body text-body font-medium text-text-primary hover:text-text-secondary transition-colors"
57
- >
58
- <span>{displayName}</span>
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
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`text-text-tertiary transition-transform duration-200 ${open ? "rotate-180" : ""}`}>
61
- <path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
62
- </svg>
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
- </button>
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,4 @@
1
+ export * from "preact/compat";
2
+ export { default } from "preact/compat";
3
+ export { jsx, jsxs } from "preact/jsx-runtime";
4
+ export { createRoot, hydrateRoot } from "preact/compat/client";
@@ -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
- const t = e.target as HTMLTextAreaElement;
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";
@@ -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 hasChanges =
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 hasChanges;
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
  }