agent-canvas 0.1.5 → 0.1.8

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 (64) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/.github/workflows/release.yml +36 -0
  3. package/CLAUDE.md +86 -0
  4. package/LICENSE +21 -0
  5. package/README.md +71 -0
  6. package/bin/agent-canvas.ts +26 -353
  7. package/bin/lib/commands/daemon.ts +51 -0
  8. package/bin/lib/commands/fetch.ts +20 -0
  9. package/bin/lib/commands/install.ts +48 -0
  10. package/bin/lib/commands/push.ts +67 -0
  11. package/bin/lib/commands/watch.ts +23 -0
  12. package/bin/lib/config.ts +10 -0
  13. package/bin/lib/daemon-lifecycle.ts +74 -0
  14. package/bin/lib/helpers.ts +78 -0
  15. package/bun.lock +176 -0
  16. package/daemon/build.ts +27 -61
  17. package/daemon/bunfig.toml +2 -0
  18. package/daemon/client/AnnotationProvider.tsx +2 -2
  19. package/daemon/client/AnnotationSidebar.tsx +199 -31
  20. package/daemon/client/App.tsx +39 -3
  21. package/daemon/client/FileBrowser.tsx +8 -7
  22. package/daemon/client/FileViewer.tsx +153 -16
  23. package/daemon/client/PlanRenderer.tsx +6 -3
  24. package/daemon/client/ResponsePreview.tsx +5 -116
  25. package/daemon/client/SessionSwitcher.tsx +21 -10
  26. package/daemon/client/compat/preact-all.ts +4 -0
  27. package/daemon/client/components/Markdown.tsx +44 -0
  28. package/daemon/client/components/ResponseNote.tsx +3 -8
  29. package/daemon/client/components/UserInput.tsx +2 -4
  30. package/daemon/client/components/index.ts +1 -0
  31. package/daemon/client/domDiff.ts +82 -2
  32. package/daemon/client/generateMarkdown.ts +46 -9
  33. package/daemon/client/highlightRange.ts +8 -6
  34. package/daemon/client/runtime.ts +2 -0
  35. package/daemon/client/styles.css +126 -0
  36. package/daemon/client/unifiedDiff.ts +155 -2
  37. package/daemon/client/utils.ts +10 -0
  38. package/daemon/dist/client.css +1 -1
  39. package/daemon/dist/client.js +83 -36
  40. package/daemon/dist/components.js +63 -5
  41. package/daemon/dist/index.html +5 -12
  42. package/daemon/dist/jsx-runtime.js +3 -0
  43. package/daemon/dist/preact-compat.js +1 -0
  44. package/daemon/langMap.ts +8 -0
  45. package/daemon/src/compiler.ts +28 -2
  46. package/daemon/src/handlers/api.ts +150 -0
  47. package/daemon/src/handlers/files.ts +114 -0
  48. package/daemon/src/handlers/static.ts +61 -0
  49. package/daemon/src/handlers/utils.ts +14 -0
  50. package/daemon/src/router.ts +16 -0
  51. package/daemon/src/server.ts +28 -403
  52. package/daemon/src/session.ts +43 -24
  53. package/daemon/src/websocket.ts +83 -0
  54. package/daemon/tsconfig.json +20 -0
  55. package/package.json +4 -14
  56. package/skills/canvas/SKILL.md +47 -22
  57. package/skills/canvas/components.md +31 -0
  58. package/skills/canvas/flows.md +2 -2
  59. package/tsconfig.json +14 -0
  60. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  61. package/daemon/dist/jsx-runtime-shim.js +0 -17
  62. package/daemon/dist/react-dom-client-shim.js +0 -4
  63. package/daemon/dist/react-dom-shim.js +0 -4
  64. package/daemon/dist/react-shim.js +0 -4
@@ -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
  }
@@ -99,6 +99,16 @@ export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>):
99
99
  return missing;
100
100
  }
101
101
 
102
+ /** Returns list of missing required field labels, or empty if all valid */
103
+ export function getMissingRequiredLabels(responses: Map<string, PlanResponse>, feedbackEntries: Map<string, FeedbackEntry>): string[] {
104
+ const missingResponses = getMissingRequired(responses);
105
+ const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
106
+ return [
107
+ ...missingResponses.map((r) => r.label),
108
+ ...missingFeedback.map((e) => e.label || e.id),
109
+ ];
110
+ }
111
+
102
112
  function renderResponse(r: PlanResponse): string {
103
113
  const lines: string[] = [];
104
114
  lines.push(`**${r.label}**`);
@@ -134,17 +144,44 @@ function renderResponse(r: PlanResponse): string {
134
144
  function renderAnnotation(ann: Annotation): string {
135
145
  const lines: string[] = [];
136
146
  const snippet = ann.snippet.trim();
137
- const context = formatSnippetInContext(ann);
147
+ const ctx = ann.context;
148
+ const hasLineInfo = ctx?.lineStart != null;
138
149
 
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
150
+ if (ann.filePath && hasLineInfo) {
151
+ // File annotation with line numbers
152
+ const lineStart = ctx!.lineStart!;
153
+ const lineEnd = ctx!.lineEnd ?? lineStart;
144
154
  const snippetLines = snippet.split("\n");
145
- lines.push(`> ${snippetLines[0]}`);
146
- lines.push(`> ... (${snippetLines.length} lines)`);
147
- lines.push(`> ${snippetLines[snippetLines.length - 1]}`);
155
+ const isShort = snippet.length < 30 && lineStart === lineEnd;
156
+
157
+ if (isShort && (ctx!.before || ctx!.after)) {
158
+ // Short snippet on single line — show full line context
159
+ const expanded = formatSnippetInContext(ann);
160
+ lines.push(`> L${lineStart}: ${expanded}`);
161
+ } else if (snippetLines.length <= 6) {
162
+ for (let i = 0; i < snippetLines.length; i++) {
163
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
164
+ }
165
+ } else {
166
+ for (let i = 0; i < 3; i++) {
167
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
168
+ }
169
+ lines.push(`> ... (${snippetLines.length} lines)`);
170
+ for (let i = snippetLines.length - 3; i < snippetLines.length; i++) {
171
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
172
+ }
173
+ }
174
+ } else {
175
+ // Plan annotations or file annotations without line info
176
+ const context = formatSnippetInContext(ann);
177
+ if (snippet.split("\n").length <= 3) {
178
+ lines.push(`> ${context.split("\n").join("\n> ")}`);
179
+ } else {
180
+ const snippetLines = snippet.split("\n");
181
+ lines.push(`> ${snippetLines[0]}`);
182
+ lines.push(`> ... (${snippetLines.length} lines)`);
183
+ lines.push(`> ${snippetLines[snippetLines.length - 1]}`);
184
+ }
148
185
  }
149
186
 
150
187
  // Comment
@@ -99,13 +99,15 @@ export function setMarkActive(annotationId: string, active: boolean) {
99
99
  }
100
100
 
101
101
  /**
102
- * Update active state for all annotation marks.
102
+ * Update active state for annotation marks.
103
+ * Only touches marks for the previous and current active IDs to avoid full DOM scans.
103
104
  */
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;
105
+ export function updateAllMarkStates(activeId: string | null, prevActiveId?: string | null) {
106
+ if (prevActiveId != null && prevActiveId !== activeId) {
107
+ setMarkActive(prevActiveId, false);
108
+ }
109
+ if (activeId != null) {
110
+ setMarkActive(activeId, true);
109
111
  }
110
112
  }
111
113
 
@@ -8,6 +8,8 @@ export interface AnnotationContext {
8
8
  before: string;
9
9
  after: string;
10
10
  hierarchy: string[];
11
+ lineStart?: number;
12
+ lineEnd?: number;
11
13
  }
12
14
 
13
15
  export interface Annotation {
@@ -258,6 +258,132 @@ input[type="range"]::-moz-range-track {
258
258
  background: transparent;
259
259
  }
260
260
 
261
+ /* Prose (Markdown component) */
262
+ .prose-canvas {
263
+ color: var(--color-text-secondary);
264
+ font-size: 0.875rem;
265
+ line-height: 1.7;
266
+ }
267
+
268
+ .prose-canvas h1, .prose-canvas h2, .prose-canvas h3,
269
+ .prose-canvas h4, .prose-canvas h5, .prose-canvas h6 {
270
+ color: var(--color-text-primary);
271
+ font-weight: 600;
272
+ margin-top: 1.5em;
273
+ margin-bottom: 0.5em;
274
+ line-height: 1.3;
275
+ }
276
+
277
+ .prose-canvas h1 { font-size: 1.5rem; }
278
+ .prose-canvas h2 { font-size: 1.25rem; }
279
+ .prose-canvas h3 { font-size: 1.1rem; }
280
+
281
+ .prose-canvas h1:first-child, .prose-canvas h2:first-child,
282
+ .prose-canvas h3:first-child {
283
+ margin-top: 0;
284
+ }
285
+
286
+ .prose-canvas p {
287
+ margin-top: 0.75em;
288
+ margin-bottom: 0.75em;
289
+ }
290
+
291
+ .prose-canvas p:first-child {
292
+ margin-top: 0;
293
+ }
294
+
295
+ .prose-canvas a {
296
+ color: var(--color-accent-blue);
297
+ text-decoration: underline;
298
+ text-decoration-color: color-mix(in srgb, var(--color-accent-blue) 40%, transparent);
299
+ text-underline-offset: 2px;
300
+ }
301
+
302
+ .prose-canvas a:hover {
303
+ text-decoration-color: var(--color-accent-blue);
304
+ }
305
+
306
+ .prose-canvas strong {
307
+ color: var(--color-text-primary);
308
+ font-weight: 600;
309
+ }
310
+
311
+ .prose-canvas code {
312
+ font-size: 0.8em;
313
+ background: var(--color-bg-code);
314
+ color: var(--color-text-code);
315
+ padding: 0.15em 0.4em;
316
+ border-radius: 4px;
317
+ }
318
+
319
+ .prose-canvas pre {
320
+ background: var(--color-bg-code);
321
+ border-radius: 8px;
322
+ padding: 1em;
323
+ overflow-x: auto;
324
+ margin-top: 0.75em;
325
+ margin-bottom: 0.75em;
326
+ }
327
+
328
+ .prose-canvas pre code {
329
+ background: transparent;
330
+ padding: 0;
331
+ font-size: 0.8rem;
332
+ }
333
+
334
+ .prose-canvas ul, .prose-canvas ol {
335
+ padding-left: 1.5em;
336
+ margin-top: 0.5em;
337
+ margin-bottom: 0.5em;
338
+ }
339
+
340
+ .prose-canvas ul { list-style-type: disc; }
341
+ .prose-canvas ol { list-style-type: decimal; }
342
+ .prose-canvas li { margin-top: 0.25em; }
343
+ .prose-canvas li > ul, .prose-canvas li > ol { margin-top: 0.25em; margin-bottom: 0; }
344
+
345
+ .prose-canvas blockquote {
346
+ border-left: 3px solid var(--color-border-strong);
347
+ padding-left: 1em;
348
+ margin-left: 0;
349
+ margin-top: 0.75em;
350
+ margin-bottom: 0.75em;
351
+ color: var(--color-text-tertiary);
352
+ }
353
+
354
+ .prose-canvas hr {
355
+ border: none;
356
+ border-top: 1px solid var(--color-border-subtle);
357
+ margin: 1.5em 0;
358
+ }
359
+
360
+ .prose-canvas table {
361
+ width: 100%;
362
+ border-collapse: collapse;
363
+ margin-top: 0.75em;
364
+ margin-bottom: 0.75em;
365
+ font-size: 0.8rem;
366
+ }
367
+
368
+ .prose-canvas th, .prose-canvas td {
369
+ border: 1px solid var(--color-border-subtle);
370
+ padding: 0.5em 0.75em;
371
+ text-align: left;
372
+ }
373
+
374
+ .prose-canvas th {
375
+ background: var(--color-bg-elevated);
376
+ color: var(--color-text-primary);
377
+ font-weight: 600;
378
+ }
379
+
380
+ .prose-canvas img {
381
+ max-width: 100%;
382
+ border-radius: 8px;
383
+ margin-top: 0.75em;
384
+ margin-bottom: 0.75em;
385
+ }
386
+
261
387
  /* highlight.js overrides */
262
388
  .hljs {
263
389
  background: transparent !important;