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
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useCallback, useEffect, useRef } from "react";
2
2
  import { AnnotationCtx } from "#canvas/runtime";
3
3
  import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
4
+ import { generateAnnotationId } from "./utils";
4
5
 
5
6
  // Re-export types for convenience
6
7
  export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
@@ -101,8 +102,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
101
102
  }, []);
102
103
 
103
104
  const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
104
- const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
105
- addAnnotationWithId(id, snippet, note, filePath);
105
+ addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
106
106
  }, [addAnnotationWithId]);
107
107
 
108
108
  const updateAnnotation = useCallback((id: string, note: string) => {
@@ -1,11 +1,12 @@
1
1
  import React, { useRef, useEffect, useCallback, useState, useContext } from "react";
2
2
  import { useAnnotations, Annotation } from "./AnnotationProvider";
3
3
  import { setMarkActive } from "./highlightRange";
4
- import { generateMarkdown, hasValue, getMissingRequired, getMissingRequiredFeedback } from "./generateMarkdown";
5
- import { RevisionContext, ActiveViewContext } from "./App";
4
+ import { generateMarkdown, hasValue, getMissingRequiredLabels } from "./generateMarkdown";
5
+ import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
6
6
  import { SessionContext } from "#canvas/runtime";
7
7
  import { MarkdownPreview } from "./ResponsePreview";
8
8
  import { FileIcon } from "./FileIcon";
9
+ import { autoResizeTextarea } from "./utils";
9
10
 
10
11
  interface AnnotationSidebarProps {
11
12
  onPreview: () => void;
@@ -21,7 +22,7 @@ export function AnnotationSidebar({ onPreview, onSubmit, collapseButton }: Annot
21
22
  const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
22
23
 
23
24
  if (isReadOnly) {
24
- return <FeedbackDisplay sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
25
+ return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
25
26
  }
26
27
 
27
28
  return <AnnotationSidebarInner onPreview={onPreview} onSubmit={onSubmit} collapseButton={collapseButton} />;
@@ -68,6 +69,196 @@ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collaps
68
69
  );
69
70
  }
70
71
 
72
+ type ReadOnlyTab = "feedback" | "annotations";
73
+
74
+ function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; collapseButton?: React.ReactNode }) {
75
+ const { annotations, generalNote, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
76
+ const { setActiveView } = useContext(ActiveViewContext);
77
+ const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0;
78
+ const [activeTab, setActiveTab] = useState<ReadOnlyTab>("feedback");
79
+
80
+ // If no annotations in localStorage, just show feedback
81
+ if (!hasAnnotations) {
82
+ return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} collapseButton={collapseButton} />;
83
+ }
84
+
85
+ return (
86
+ <div className="flex flex-col h-full">
87
+ {/* Header with tab switcher */}
88
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
89
+ <div className="flex items-center gap-1 bg-bg-input rounded-md p-0.5">
90
+ <button
91
+ onClick={() => setActiveTab("feedback")}
92
+ className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors ${
93
+ activeTab === "feedback"
94
+ ? "bg-bg-elevated text-text-primary shadow-sm"
95
+ : "text-text-tertiary hover:text-text-secondary"
96
+ }`}
97
+ >
98
+ Feedback
99
+ </button>
100
+ <button
101
+ onClick={() => setActiveTab("annotations")}
102
+ className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors flex items-center gap-1.5 ${
103
+ activeTab === "annotations"
104
+ ? "bg-bg-elevated text-text-primary shadow-sm"
105
+ : "text-text-tertiary hover:text-text-secondary"
106
+ }`}
107
+ >
108
+ Annotations
109
+ <span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 rounded-full bg-border-subtle text-[10px] font-medium">{annotations.length}</span>
110
+ </button>
111
+ </div>
112
+ {collapseButton}
113
+ </div>
114
+
115
+ {waitingForUpdate && (
116
+ <div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-green-muted flex items-center gap-2 flex-shrink-0">
117
+ <span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
118
+ <span className="text-[12px] font-body text-accent-green">Waiting for next revision...</span>
119
+ </div>
120
+ )}
121
+
122
+ {activeTab === "feedback" ? (
123
+ <FeedbackDisplayContent sessionId={sessionId} revision={revision} />
124
+ ) : (
125
+ <ReadOnlyAnnotationList
126
+ annotations={annotations}
127
+ generalNote={generalNote}
128
+ activeAnnotationId={activeAnnotationId}
129
+ setActiveAnnotationId={setActiveAnnotationId}
130
+ setActiveView={setActiveView}
131
+ />
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; revision: number }) {
138
+ const [feedback, setFeedback] = useState<string | null>(null);
139
+ const [loading, setLoading] = useState(true);
140
+
141
+ useEffect(() => {
142
+ setLoading(true);
143
+ fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
144
+ .then((r) => r.json())
145
+ .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
146
+ .catch(() => { setFeedback(null); setLoading(false); });
147
+ }, [sessionId, revision]);
148
+
149
+ return (
150
+ <div className="flex-1 overflow-y-auto px-4">
151
+ {loading ? (
152
+ <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
153
+ ) : feedback ? (
154
+ <MarkdownPreview text={feedback} />
155
+ ) : (
156
+ <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
157
+ )}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId, setActiveAnnotationId, setActiveView }: {
163
+ annotations: Annotation[];
164
+ generalNote: string;
165
+ activeAnnotationId: string | null;
166
+ setActiveAnnotationId: (id: string | null) => void;
167
+ setActiveView: (view: ActiveView) => void;
168
+ }) {
169
+ const planAnnotations = annotations.filter((a) => !a.filePath);
170
+ const fileAnnotations = annotations.filter((a) => a.filePath);
171
+ const fileGroups: Record<string, Annotation[]> = {};
172
+ for (const ann of fileAnnotations) {
173
+ const key = ann.filePath!;
174
+ if (!fileGroups[key]) fileGroups[key] = [];
175
+ fileGroups[key].push(ann);
176
+ }
177
+
178
+ const handleMouseEnter = (annId: string) => {
179
+ setActiveAnnotationId(annId);
180
+ setMarkActive(annId, true);
181
+ const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
182
+ if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
183
+ };
184
+
185
+ const handleMouseLeave = (annId: string) => {
186
+ setActiveAnnotationId(null);
187
+ setMarkActive(annId, false);
188
+ };
189
+
190
+ const handleClick = (ann: Annotation) => {
191
+ if (ann.id === activeAnnotationId) {
192
+ setActiveAnnotationId(null);
193
+ } else {
194
+ setActiveAnnotationId(ann.id);
195
+ if (ann.filePath) {
196
+ setActiveView({ type: "file", path: ann.filePath });
197
+ setTimeout(() => {
198
+ const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
199
+ if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
200
+ }, 150);
201
+ }
202
+ }
203
+ };
204
+
205
+ const renderAnnotation = (ann: Annotation) => (
206
+ <div
207
+ key={ann.id}
208
+ className={`relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
209
+ activeAnnotationId === ann.id
210
+ ? "bg-highlight-selected"
211
+ : "odd:bg-bg-elevated-half hover:bg-bg-input"
212
+ }`}
213
+ onMouseEnter={() => handleMouseEnter(ann.id)}
214
+ onMouseLeave={() => handleMouseLeave(ann.id)}
215
+ onClick={() => handleClick(ann)}
216
+ >
217
+ <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
218
+ {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
219
+ </div>
220
+ {ann.note.trim() && (
221
+ <div className="text-[13px] font-body text-text-primary leading-relaxed">
222
+ {ann.note}
223
+ </div>
224
+ )}
225
+ </div>
226
+ );
227
+
228
+ return (
229
+ <>
230
+ <div className="flex-1 overflow-y-auto">
231
+ {planAnnotations.length > 0 && fileAnnotations.length > 0 && (
232
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
233
+ )}
234
+ {planAnnotations.map(renderAnnotation)}
235
+
236
+ {Object.entries(fileGroups).map(([filePath, anns]) => (
237
+ <div key={filePath}>
238
+ <button
239
+ onClick={() => setActiveView({ type: "file", path: filePath })}
240
+ className="flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-text-tertiary hover:text-text-secondary font-body px-3 pt-3 pb-1 truncate transition-colors w-full text-left"
241
+ title={filePath}
242
+ >
243
+ <FileIcon name={filePath.split("/").pop() || filePath} type="file" />
244
+ <span className="truncate">{filePath}</span>
245
+ </button>
246
+ {anns.map(renderAnnotation)}
247
+ </div>
248
+ ))}
249
+ </div>
250
+
251
+ {generalNote.trim() && (
252
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
253
+ <div className="text-[13px] font-body text-text-primary leading-relaxed">
254
+ {generalNote}
255
+ </div>
256
+ </div>
257
+ )}
258
+ </>
259
+ );
260
+ }
261
+
71
262
  function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
72
263
  const {
73
264
  annotations, updateAnnotation, removeAnnotation,
@@ -159,17 +350,8 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
159
350
  className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
160
351
  rows={1}
161
352
  style={{ height: "auto", overflow: "hidden" }}
162
- onInput={(e) => {
163
- const t = e.target as HTMLTextAreaElement;
164
- t.style.height = "auto";
165
- t.style.height = t.scrollHeight + "px";
166
- }}
167
- ref={(el) => {
168
- if (el) {
169
- el.style.height = "auto";
170
- el.style.height = el.scrollHeight + "px";
171
- }
172
- }}
353
+ onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
354
+ ref={(el) => { if (el) autoResizeTextarea(el); }}
173
355
  />
174
356
 
175
357
  {/* Delete — top right on hover */}
@@ -236,17 +418,8 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
236
418
  onChange={(e) => setGeneralNote(e.target.value)}
237
419
  className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
238
420
  placeholder="General notes..."
239
- onInput={(e) => {
240
- const t = e.target as HTMLTextAreaElement;
241
- t.style.height = "auto";
242
- t.style.height = Math.max(40, t.scrollHeight) + "px";
243
- }}
244
- ref={(el) => {
245
- if (el) {
246
- el.style.height = "auto";
247
- el.style.height = Math.max(40, el.scrollHeight) + "px";
248
- }
249
- }}
421
+ onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement, 40)}
422
+ ref={(el) => { if (el) autoResizeTextarea(el, 40); }}
250
423
  />
251
424
  </div>
252
425
 
@@ -279,12 +452,7 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
279
452
  </button>
280
453
  <button
281
454
  onClick={() => {
282
- const missingResponses = getMissingRequired(responses);
283
- const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
284
- const allMissing = [
285
- ...missingResponses.map((r) => r.label),
286
- ...missingFeedback.map((e) => e.label || e.id),
287
- ];
455
+ const allMissing = getMissingRequiredLabels(responses, feedbackEntries);
288
456
  if (allMissing.length > 0) {
289
457
  setValidationError(`Please answer: ${allMissing.join(", ")}`);
290
458
  return;
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useLayoutEffect, useState, useCallback, useRef, createContext, useMemo } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import { marked } from "marked";
3
4
  import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
4
5
  import { AnnotationProvider, useAnnotations } from "./AnnotationProvider";
5
6
  import { PlanRenderer } from "./PlanRenderer";
@@ -11,6 +12,7 @@ import { SessionSwitcher } from "./SessionSwitcher";
11
12
  import { exportCanvasToMarkdown } from "./exportMarkdown";
12
13
  import { CompareView } from "./CompareView";
13
14
  import { RevisionSelect } from "./RevisionSelect";
15
+ import { generateAnnotationId } from "./utils";
14
16
 
15
17
  export type ActiveView = { type: "plan" } | { type: "file"; path: string };
16
18
 
@@ -20,6 +22,7 @@ export interface RevisionInfo {
20
22
  sourceFile?: string;
21
23
  createdAt: string;
22
24
  hasFeedback: boolean;
25
+ response?: string;
23
26
  }
24
27
 
25
28
  export const ActiveViewContext = createContext<{
@@ -101,7 +104,7 @@ function RevisionSelector() {
101
104
  const isLatest = selectedRevision === currentRevision;
102
105
 
103
106
  return (
104
- <div className="flex items-center gap-1.5">
107
+ <div className="flex flex-col gap-1">
105
108
  <RevisionSelect
106
109
  value={selectedRevision}
107
110
  onChange={setSelectedRevision}
@@ -123,7 +126,7 @@ function RevisionSelector() {
123
126
  }
124
127
  setCompareRevision({ left, right: currentRevision });
125
128
  }}
126
- className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors"
129
+ className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors self-start"
127
130
  >
128
131
  Compare
129
132
  </button>
@@ -337,6 +340,9 @@ function App() {
337
340
  <polyline points="20 6 9 17 4 12"/>
338
341
  </svg>
339
342
  </button>
343
+ {selectedRevInfo?.response && (
344
+ <ResponseBanner markdown={selectedRevInfo.response} />
345
+ )}
340
346
  <PlanRenderer revision={selectedRevision} />
341
347
  </div>
342
348
  </div>
@@ -515,6 +521,36 @@ function ResizableSidebar({ children, collapsed, onToggle }: { children: React.R
515
521
  );
516
522
  }
517
523
 
524
+ function ResponseBanner({ markdown }: { markdown: string }) {
525
+ const [dismissed, setDismissed] = useState(false);
526
+ const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
527
+
528
+ // Reset dismissed state when markdown changes
529
+ useEffect(() => { setDismissed(false); }, [markdown]);
530
+
531
+ if (dismissed) return null;
532
+
533
+ return (
534
+ <div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
535
+ <button
536
+ onClick={() => setDismissed(true)}
537
+ className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
538
+ >
539
+ <span className="text-xs">&#x2715;</span>
540
+ </button>
541
+ <div className="px-4 py-3 pr-10">
542
+ <div className="flex items-center gap-2 mb-1.5">
543
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-blue flex-shrink-0">
544
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
545
+ </svg>
546
+ <span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
547
+ </div>
548
+ <div className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
549
+ </div>
550
+ </div>
551
+ );
552
+ }
553
+
518
554
  function ContentTabs() {
519
555
  const { activeView, setActiveView, openFiles, closeFile } = React.useContext(ActiveViewContext);
520
556
  const sessionId = React.useContext(SessionContext);
@@ -595,7 +631,7 @@ function ContentTabs() {
595
631
  const data = await res.json() as any;
596
632
  if (!data.error) {
597
633
  const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
598
- const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
634
+ const id = generateAnnotationId();
599
635
  addAnnotationWithId(id, preview, "", path);
600
636
  setActiveAnnotationId(id);
601
637
  }
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState, useContext, useCallback } from "react";
1
+ import React, { useEffect, useState, useContext, useCallback, useMemo } from "react";
2
2
  import { SessionContext } from "#canvas/runtime";
3
3
  import { useAnnotations } from "./AnnotationProvider";
4
4
  import { ActiveViewContext } from "./App";
@@ -14,12 +14,13 @@ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
14
14
  const [collapsed, setCollapsed] = useState(false);
15
15
  const [dirs, setDirs] = useState<Record<string, DirState>>({});
16
16
 
17
- const fileAnnotations = annotations.filter((a) => a.filePath);
18
- const annCountMap = new Map<string, number>();
19
- for (const a of fileAnnotations) {
20
- annCountMap.set(a.filePath!, (annCountMap.get(a.filePath!) || 0) + 1);
21
- }
22
- const annotatedFiles = [...annCountMap.keys()];
17
+ const { annCountMap, annotatedFiles } = useMemo(() => {
18
+ const map = new Map<string, number>();
19
+ for (const a of annotations) {
20
+ if (a.filePath) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
21
+ }
22
+ return { annCountMap: map, annotatedFiles: [...map.keys()] };
23
+ }, [annotations]);
23
24
 
24
25
  const loadDir = async (path: string) => {
25
26
  const res = await fetch(`/api/tree?session=${sessionId}&path=${encodeURIComponent(path)}`);
@@ -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
  }}