agent-canvas 0.1.9 → 0.1.11

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 (36) hide show
  1. package/.github/dark.png +0 -0
  2. package/.github/light.png +0 -0
  3. package/README.md +10 -0
  4. package/daemon/build.ts +1 -1
  5. package/daemon/client/AnnotationEditor.tsx +307 -0
  6. package/daemon/client/AnnotationProvider.tsx +15 -3
  7. package/daemon/client/AnnotationSidebar.tsx +54 -28
  8. package/daemon/client/App.tsx +64 -24
  9. package/daemon/client/CompareView.tsx +53 -22
  10. package/daemon/client/FileBrowser.tsx +2 -1
  11. package/daemon/client/FileViewer.tsx +41 -161
  12. package/daemon/client/PlanRenderer.tsx +30 -147
  13. package/daemon/client/Popover.tsx +168 -108
  14. package/daemon/client/ResponsePreview.tsx +7 -1
  15. package/daemon/client/RevisionSelect.tsx +8 -1
  16. package/daemon/client/annotationDom.ts +104 -0
  17. package/daemon/client/components/Mermaid.tsx +2 -2
  18. package/daemon/client/generateMarkdown.ts +8 -0
  19. package/daemon/client/highlightRange.ts +1 -22
  20. package/daemon/client/index.html +1 -1
  21. package/daemon/client/runtime.ts +4 -1
  22. package/daemon/client/styles.css +28 -2
  23. package/daemon/client/theme.css +1 -1
  24. package/daemon/client/useTextAnnotation.tsx +183 -0
  25. package/daemon/dist/client.css +1 -1
  26. package/daemon/dist/client.js +63 -87
  27. package/daemon/dist/components.js +54 -54
  28. package/daemon/dist/index.html +2 -2
  29. package/daemon/src/handlers/uploads.ts +62 -0
  30. package/daemon/src/paths.ts +4 -0
  31. package/daemon/src/server.ts +2 -0
  32. package/daemon/src/session.ts +39 -1
  33. package/package.json +1 -1
  34. package/skills/canvas/SKILL.md +15 -7
  35. package/skills/canvas/flows.md +1 -1
  36. package/hooks/session-start.sh +0 -13
@@ -7,6 +7,7 @@ import { PlanRenderer } from "./PlanRenderer";
7
7
  import { AnnotationSidebar } from "./AnnotationSidebar";
8
8
  import { ResponsePreview } from "./ResponsePreview";
9
9
  import { FileBrowser } from "./FileBrowser";
10
+ import { FileIcon } from "./FileIcon";
10
11
  import { FileViewer } from "./FileViewer";
11
12
  import { SessionSwitcher } from "./SessionSwitcher";
12
13
  import { exportCanvasToMarkdown } from "./exportMarkdown";
@@ -26,6 +27,7 @@ export interface RevisionInfo {
26
27
  createdAt: string;
27
28
  hasFeedback: boolean;
28
29
  response?: string;
30
+ diffStats?: { added: number; removed: number };
29
31
  }
30
32
 
31
33
  export const ActiveViewContext = createContext<{
@@ -58,23 +60,56 @@ export const RevisionContext = createContext<{
58
60
  setCompareRevision: () => {},
59
61
  });
60
62
 
63
+ function resolveTheme(pref: string): "light" | "dark" {
64
+ if (pref === "auto") return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
65
+ return pref as "light" | "dark";
66
+ }
67
+
68
+ function applyTheme(pref: string) {
69
+ document.documentElement.dataset.theme = resolveTheme(pref);
70
+ }
71
+
61
72
  function ThemeSwitcher() {
62
- const [theme, setTheme] = useState(() => localStorage.getItem("canvas-theme") || document.documentElement.dataset.theme || "dark");
73
+ const [pref, setPref] = useState<"auto" | "light" | "dark">(() => {
74
+ const stored = localStorage.getItem("canvas-theme");
75
+ if (stored === "light" || stored === "dark" || stored === "auto") return stored;
76
+ return "auto";
77
+ });
78
+
79
+ useEffect(() => {
80
+ applyTheme(pref);
81
+ if (pref !== "auto") return;
82
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
83
+ const handler = () => applyTheme("auto");
84
+ mq.addEventListener("change", handler);
85
+ return () => mq.removeEventListener("change", handler);
86
+ }, [pref]);
63
87
 
64
88
  const toggle = () => {
65
- const next = theme === "dark" ? "light" : "dark";
66
- document.documentElement.dataset.theme = next;
89
+ const next = pref === "auto" ? "light" : pref === "light" ? "dark" : "auto";
67
90
  localStorage.setItem("canvas-theme", next);
68
- setTheme(next);
91
+ setPref(next);
69
92
  };
70
93
 
94
+ const titles = { auto: "Theme: system (click for light)", light: "Theme: light (click for dark)", dark: "Theme: dark (click for system)" };
95
+
71
96
  return (
72
97
  <button
73
98
  onClick={toggle}
74
99
  className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
75
- title={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
100
+ title={titles[pref]}
76
101
  >
77
- {theme === "dark" ? (
102
+ {pref === "auto" ? (
103
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
104
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
105
+ <line x1="8" y1="21" x2="16" y2="21" />
106
+ <line x1="12" y1="17" x2="12" y2="21" />
107
+ </svg>
108
+ ) : pref === "dark" ? (
109
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
110
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
111
+ </svg>
112
+ ) : (
78
113
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
79
114
  <circle cx="12" cy="12" r="5" />
80
115
  <line x1="12" y1="1" x2="12" y2="3" />
@@ -86,10 +121,6 @@ function ThemeSwitcher() {
86
121
  <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
87
122
  <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
88
123
  </svg>
89
- ) : (
90
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
91
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
92
- </svg>
93
124
  )}
94
125
  </button>
95
126
  );
@@ -274,16 +305,14 @@ function App() {
274
305
  {/* Left panel — fixed to viewport */}
275
306
  <LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
276
307
 
277
- {/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
278
- {compareRevision === null && (
279
- <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
280
- <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
281
- </ResizableSidebar>
282
- )}
308
+ {/* Right panel — fixed to viewport, resizable */}
309
+ <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
310
+ <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
311
+ </ResizableSidebar>
283
312
 
284
313
  {/* Center content — normal document flow, browser scroll */}
285
314
  {compareRevision !== null ? (
286
- <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
315
+ <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
287
316
  <CompareView
288
317
  initialLeft={compareRevision.left}
289
318
  initialRight={compareRevision.right}
@@ -528,7 +557,7 @@ function ResponseBanner({ markdown }: { markdown: string }) {
528
557
  const [dismissed, setDismissed] = useState(false);
529
558
  const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
530
559
  const contentRef = useRef<HTMLDivElement>(null);
531
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
560
+ const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
532
561
  const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
533
562
  const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
534
563
 
@@ -640,10 +669,10 @@ function ResponseBanner({ markdown }: { markdown: string }) {
640
669
  scrollContainer={scrollContainer}
641
670
  snippet={createPopover.snippet}
642
671
  truncateAt={80}
643
- onAdd={(note) => {
672
+ onAdd={(note, images) => {
644
673
  const id = generateAnnotationId();
645
674
  renameMarkId(createPopover.tempId, id);
646
- addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx);
675
+ addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
647
676
  setCreatePopover(null);
648
677
  }}
649
678
  onCancel={() => {
@@ -661,7 +690,17 @@ function ResponseBanner({ markdown }: { markdown: string }) {
661
690
  anchorEl={editPopover.anchorEl}
662
691
  scrollContainer={scrollContainer}
663
692
  initialNote={ann.note}
664
- onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
693
+ initialImages={ann.images}
694
+ onUpdate={(note, images) => {
695
+ updateAnnotation(editPopover.annId, note);
696
+ const current = ann.images || [];
697
+ for (const img of images) {
698
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
699
+ }
700
+ for (const img of current) {
701
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
702
+ }
703
+ }}
665
704
  onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
666
705
  onClose={() => setEditPopover(null)}
667
706
  />
@@ -691,8 +730,8 @@ function ContentTabs() {
691
730
  : "text-text-tertiary hover:text-text-secondary"
692
731
  }`}
693
732
  >
694
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
695
- <path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
733
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
734
+ <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
696
735
  </svg>
697
736
  Canvas
698
737
  {activeView.type === "plan" && (
@@ -714,12 +753,13 @@ function ContentTabs() {
714
753
  <div className="flex items-center">
715
754
  <button
716
755
  onClick={() => setActiveView({ type: "file", path })}
717
- className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-mono whitespace-nowrap transition-colors relative ${
756
+ className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
718
757
  isActive
719
758
  ? "text-text-primary"
720
759
  : "text-text-tertiary hover:text-text-secondary"
721
760
  }`}
722
761
  >
762
+ <FileIcon name={name} type="file" />
723
763
  {name}
724
764
  {isActive && (
725
765
  <span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />
@@ -1,7 +1,10 @@
1
- import React, { useEffect, useState, useRef, useCallback } from "react";
1
+ import React, { useEffect, useState, useRef, useCallback, useMemo } from "react";
2
2
  import { RevisionSelect } from "./RevisionSelect";
3
3
  import { runDomDiff } from "./domDiff";
4
4
  import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
5
+ import { useAnnotations } from "./AnnotationProvider";
6
+ import { useTextAnnotation } from "./useTextAnnotation";
7
+ import { extractContext } from "./annotationContext";
5
8
 
6
9
  interface CompareViewProps {
7
10
  initialLeft: number;
@@ -132,6 +135,15 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
132
135
  const [rightReady, setRightReady] = useState(false);
133
136
  const [done, setDone] = useState(false);
134
137
 
138
+ const { annotations } = useAnnotations();
139
+ const planAnnotations = annotations.filter(a => !a.filePath);
140
+ const { popovers } = useTextAnnotation({
141
+ containerRef: unifiedRef,
142
+ restoreKey: done,
143
+ restoreAnnotations: planAnnotations,
144
+ extractContext: (range) => extractContext(range, unifiedRef.current!),
145
+ });
146
+
135
147
  useEffect(() => {
136
148
  if (!leftReady || !rightReady || done) return;
137
149
  if (!oldRef.current || !newRef.current) return;
@@ -155,21 +167,23 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
155
167
 
156
168
  return (
157
169
  <div className="flex-1 min-h-0 overflow-y-auto">
158
- {/* Offscreen source panels */}
159
- <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
160
- <ComparePanelRenderer
161
- ref={oldRef}
162
- sessionId={sessionId}
163
- revision={leftRev}
164
- onReady={() => setLeftReady(true)}
165
- />
166
- <ComparePanelRenderer
167
- ref={newRef}
168
- sessionId={sessionId}
169
- revision={rightRev}
170
- onReady={() => setRightReady(true)}
171
- />
172
- </div>
170
+ {/* Offscreen source panels — unmount after diff is built */}
171
+ {!done && (
172
+ <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
173
+ <ComparePanelRenderer
174
+ ref={oldRef}
175
+ sessionId={sessionId}
176
+ revision={leftRev}
177
+ onReady={() => setLeftReady(true)}
178
+ />
179
+ <ComparePanelRenderer
180
+ ref={newRef}
181
+ sessionId={sessionId}
182
+ revision={rightRev}
183
+ onReady={() => setRightReady(true)}
184
+ />
185
+ </div>
186
+ )}
173
187
 
174
188
  {/* Visible output */}
175
189
  <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
@@ -180,6 +194,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
180
194
  )}
181
195
  <div ref={unifiedRef} />
182
196
  </div>
197
+ {popovers}
183
198
  </div>
184
199
  );
185
200
  }
@@ -193,10 +208,23 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
193
208
  const [rightReady, setRightReady] = useState(false);
194
209
  const [done, setDone] = useState(false);
195
210
 
211
+ const viewRef = useRef<HTMLDivElement>(null);
196
212
  const leftPanelRef = useRef<HTMLDivElement>(null);
197
213
  const rightPanelRef = useRef<HTMLDivElement>(null);
198
214
  const isSyncing = useRef(false);
199
215
 
216
+ const handleLeftReady = useCallback(() => setLeftReady(true), []);
217
+ const handleRightReady = useCallback(() => setRightReady(true), []);
218
+
219
+ const { annotations } = useAnnotations();
220
+ const planAnnotations = annotations.filter(a => !a.filePath);
221
+ const { popovers } = useTextAnnotation({
222
+ containerRef: viewRef,
223
+ restoreKey: done,
224
+ restoreAnnotations: planAnnotations,
225
+ extractContext: (range) => extractContext(range, viewRef.current!),
226
+ });
227
+
200
228
  // Run diff
201
229
  useEffect(() => {
202
230
  if (!leftReady || !rightReady || done) return;
@@ -241,14 +269,14 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
241
269
  }, []);
242
270
 
243
271
  return (
244
- <div className="flex flex-1 min-h-0">
272
+ <div ref={viewRef} className="flex flex-1 min-h-0">
245
273
  <div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
246
274
  <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
247
275
  <ComparePanelRenderer
248
276
  ref={oldRef}
249
277
  sessionId={sessionId}
250
278
  revision={leftRev}
251
- onReady={() => setLeftReady(true)}
279
+ onReady={handleLeftReady}
252
280
  />
253
281
  </div>
254
282
  </div>
@@ -258,10 +286,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
258
286
  ref={newRef}
259
287
  sessionId={sessionId}
260
288
  revision={rightRev}
261
- onReady={() => setRightReady(true)}
289
+ onReady={handleRightReady}
262
290
  />
263
291
  </div>
264
292
  </div>
293
+ {popovers}
265
294
  </div>
266
295
  );
267
296
  }
@@ -274,7 +303,7 @@ interface ComparePanelRendererProps {
274
303
  onReady: () => void;
275
304
  }
276
305
 
277
- const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
306
+ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
278
307
  ({ sessionId, revision, onReady }, ref) => {
279
308
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
280
309
  const [error, setError] = useState<string | null>(null);
@@ -303,6 +332,8 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
303
332
  });
304
333
  }, [PlanComponent]);
305
334
 
335
+ const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
336
+
306
337
  if (loading) {
307
338
  return (
308
339
  <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
@@ -326,8 +357,8 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
326
357
 
327
358
  return (
328
359
  <div ref={ref}>
329
- <PlanComponent />
360
+ {planElement}
330
361
  </div>
331
362
  );
332
363
  }
333
- );
364
+ ));
@@ -3,6 +3,7 @@ import { SessionContext } from "#canvas/runtime";
3
3
  import { useAnnotations } from "./AnnotationProvider";
4
4
  import { ActiveViewContext } from "./App";
5
5
  import { FileIcon } from "./FileIcon";
6
+ import { RESPONSE_ANNOTATION_PATH } from "./utils";
6
7
 
7
8
  interface TreeEntry { name: string; type: "file" | "dir"; }
8
9
  interface DirState { entries: TreeEntry[]; expanded: boolean; loaded: boolean; }
@@ -17,7 +18,7 @@ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
17
18
  const { annCountMap, annotatedFiles } = useMemo(() => {
18
19
  const map = new Map<string, number>();
19
20
  for (const a of annotations) {
20
- if (a.filePath) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
21
+ if (a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
21
22
  }
22
23
  return { annCountMap: map, annotatedFiles: [...map.keys()] };
23
24
  }, [annotations]);
@@ -2,10 +2,10 @@ import React, { useEffect, useState, useContext, useCallback, useRef, useMemo }
2
2
  import { SessionContext } from "#canvas/runtime";
3
3
  import type { AnnotationContext } from "#canvas/runtime";
4
4
  import { useAnnotations } from "./AnnotationProvider";
5
- import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
6
- import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
5
+ import { wrapRangeWithMark } from "./highlightRange";
7
6
  import { LANG_MAP } from "../langMap";
8
7
  import { generateAnnotationId } from "./utils";
8
+ import { useTextAnnotation } from "./useTextAnnotation";
9
9
 
10
10
  /** Walk up from a node to find the parent line div with data-line-num */
11
11
  function findLineDiv(node: Node): HTMLElement | null {
@@ -17,22 +17,44 @@ function findLineDiv(node: Node): HTMLElement | null {
17
17
  return null;
18
18
  }
19
19
 
20
+ /** Build AnnotationContext from a text selection Range inside file content */
21
+ function buildFileContext(range: Range): AnnotationContext {
22
+ const snippet = range.toString().trim();
23
+ const startLineDiv = findLineDiv(range.startContainer);
24
+ const endLineDiv = findLineDiv(range.endContainer);
25
+ const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
26
+ const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
27
+
28
+ let before = "";
29
+ let after = "";
30
+ if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
31
+ const code = startLineDiv.querySelector("code");
32
+ if (code) {
33
+ const lineText = code.textContent || "";
34
+ const idx = lineText.indexOf(snippet);
35
+ if (idx >= 0) {
36
+ before = lineText.slice(0, idx).trimStart();
37
+ after = lineText.slice(idx + snippet.length).trimEnd();
38
+ }
39
+ }
40
+ }
41
+
42
+ return { before, after, hierarchy: [], lineStart, lineEnd };
43
+ }
44
+
20
45
  interface FileViewerProps {
21
46
  path: string;
22
47
  }
23
48
 
24
49
  export function FileViewer({ path }: FileViewerProps) {
25
50
  const sessionId = useContext(SessionContext);
26
- const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
51
+ const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
27
52
  const [content, setContent] = useState<string | null>(null);
28
53
  const [language, setLanguage] = useState("text");
29
54
  const [loading, setLoading] = useState(true);
30
55
  const [error, setError] = useState<string | null>(null);
31
56
  const contentRef = useRef<HTMLPreElement>(null);
32
57
 
33
- const [pendingMarkId, setPendingMarkId] = useState<string | null>(null);
34
- const pendingSnippetRef = useRef("");
35
-
36
58
  // Line gutter selection state
37
59
  const [lineSelectStart, setLineSelectStart] = useState<number | null>(null);
38
60
  const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null);
@@ -40,12 +62,19 @@ export function FileViewer({ path }: FileViewerProps) {
40
62
  const lineSelectEndRef = useRef<number | null>(null);
41
63
  const linesRef = useRef<string[]>([]);
42
64
 
43
- // Popover state
44
- const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
45
- const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
46
-
47
65
  const fileAnns = annotations.filter((a) => a.filePath === path);
48
66
 
67
+ const scrollContainer = contentRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
68
+
69
+ const { popovers, openCreatePopover } = useTextAnnotation({
70
+ containerRef: contentRef,
71
+ restoreKey: `${content}:${path}`,
72
+ restoreAnnotations: fileAnns,
73
+ extractContext: buildFileContext,
74
+ filePath: path,
75
+ scrollContainer,
76
+ });
77
+
49
78
  useEffect(() => {
50
79
  setLoading(true);
51
80
  setError(null);
@@ -58,117 +87,6 @@ export function FileViewer({ path }: FileViewerProps) {
58
87
  .catch(() => { setError("Failed to fetch file"); setLoading(false); });
59
88
  }, [sessionId, path]);
60
89
 
61
- // Restore persisted annotation marks after file content renders
62
- useEffect(() => {
63
- if (!content || !contentRef.current) return;
64
- const timer = setTimeout(() => {
65
- if (contentRef.current) {
66
- restoreMarks(contentRef.current, fileAnns);
67
- }
68
- }, 50);
69
- return () => clearTimeout(timer);
70
- }, [content, path]);
71
-
72
- // Update mark active states
73
- const prevActiveRef = useRef<string | null>(null);
74
- useEffect(() => {
75
- updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
76
- prevActiveRef.current = activeAnnotationId;
77
- }, [activeAnnotationId]);
78
-
79
- // Click handler for marks
80
- useEffect(() => {
81
- const container = contentRef.current;
82
- if (!container) return;
83
- const handleClick = (e: MouseEvent) => {
84
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
85
- if (!mark) return;
86
- e.stopPropagation();
87
- const annId = mark.getAttribute("data-annotation-id")!;
88
- if (annId === activeAnnotationId) {
89
- setEditPopover({ anchorEl: mark, annId });
90
- } else {
91
- setActiveAnnotationId(annId);
92
- }
93
- };
94
- const handleMouseOver = (e: MouseEvent) => {
95
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
96
- if (mark) setActiveAnnotationId(mark.getAttribute("data-annotation-id")!);
97
- };
98
- const handleMouseOut = (e: MouseEvent) => {
99
- const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
100
- const related = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
101
- if (mark && !related && !document.getElementById("ann-inline-popover")) setActiveAnnotationId(null);
102
- };
103
- container.addEventListener("click", handleClick);
104
- container.addEventListener("mouseover", handleMouseOver);
105
- container.addEventListener("mouseout", handleMouseOut);
106
- return () => {
107
- container.removeEventListener("click", handleClick);
108
- container.removeEventListener("mouseover", handleMouseOver);
109
- container.removeEventListener("mouseout", handleMouseOut);
110
- };
111
- }, [content, annotations, activeAnnotationId]);
112
-
113
- useEffect(() => {
114
- const handler = () => handleMouseUp();
115
- document.addEventListener("mouseup", handler);
116
- return () => document.removeEventListener("mouseup", handler);
117
- }, []);
118
-
119
- // Use refs so the document-level mouseup always has current values
120
- const pathRef = useRef(path);
121
- pathRef.current = path;
122
- const addAnnotationRef = useRef(addAnnotationWithId);
123
- addAnnotationRef.current = addAnnotationWithId;
124
-
125
- const handleMouseUp = useCallback(() => {
126
- const sel = window.getSelection();
127
- if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
128
- if (!contentRef.current) return;
129
- const range = sel.getRangeAt(0);
130
- if (!contentRef.current.contains(range.startContainer)) return;
131
- if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
132
- const snippet = sel.toString().trim();
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
-
158
- const tempId = `__pending_${Date.now()}`;
159
- const cloned = range.cloneRange();
160
- try { wrapRangeWithMark(cloned, tempId); } catch {}
161
- sel.removeAllRanges();
162
-
163
- const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
164
- const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
165
- if (!lastMark) return;
166
-
167
- pendingSnippetRef.current = snippet;
168
- setPendingMarkId(tempId);
169
- setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
170
- }, []);
171
-
172
90
  // Line gutter: mousedown starts line selection
173
91
  const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
174
92
  e.preventDefault(); // Prevent text selection
@@ -227,9 +145,7 @@ export function FileViewer({ path }: FileViewerProps) {
227
145
  }
228
146
  const anchorEl = lineDivs[endLine - 1] as HTMLElement;
229
147
  if (anchorEl) {
230
- pendingSnippetRef.current = snippet;
231
- setPendingMarkId(tempId);
232
- setCreatePopover({ anchorEl, tempId, snippet, ctx });
148
+ openCreatePopover(anchorEl, tempId, snippet, ctx);
233
149
  }
234
150
  }
235
151
  setLineSelectStart(null);
@@ -279,8 +195,6 @@ export function FileViewer({ path }: FileViewerProps) {
279
195
  setActiveAnnotationId(id);
280
196
  };
281
197
 
282
- const scrollContainer = contentRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
283
-
284
198
  return (
285
199
  <div>
286
200
  {/* Code */}
@@ -304,41 +218,7 @@ export function FileViewer({ path }: FileViewerProps) {
304
218
  })}
305
219
  </pre>
306
220
 
307
- {editPopover && (() => {
308
- const ann = annotations.find((a) => a.id === editPopover.annId);
309
- if (!ann) return null;
310
- return (
311
- <AnnotationEditPopover
312
- anchorEl={editPopover.anchorEl}
313
- scrollContainer={scrollContainer}
314
- initialNote={ann.note}
315
- onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
316
- onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
317
- onClose={() => setEditPopover(null)}
318
- />
319
- );
320
- })()}
321
-
322
- {createPopover && (
323
- <AnnotationCreatePopover
324
- anchorEl={createPopover.anchorEl}
325
- scrollContainer={scrollContainer}
326
- snippet={createPopover.snippet}
327
- onAdd={(note) => {
328
- const id = generateAnnotationId();
329
- renameMarkId(createPopover.tempId, id);
330
- addAnnotationWithId(id, createPopover.snippet, note, path, createPopover.ctx);
331
- setPendingMarkId(null);
332
- setCreatePopover(null);
333
- }}
334
- onCancel={() => {
335
- unwrapMarks(createPopover.tempId);
336
- setPendingMarkId(null);
337
- setCreatePopover(null);
338
- }}
339
- />
340
- )}
221
+ {popovers}
341
222
  </div>
342
223
  );
343
224
  }
344
-