agent-canvas 0.1.8 → 0.1.10

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.
@@ -7,12 +7,16 @@ 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";
13
14
  import { CompareView } from "./CompareView";
14
15
  import { RevisionSelect } from "./RevisionSelect";
15
- import { generateAnnotationId } from "./utils";
16
+ import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
17
+ import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
18
+ import { extractContext } from "./annotationContext";
19
+ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
16
20
 
17
21
  export type ActiveView = { type: "plan" } | { type: "file"; path: string };
18
22
 
@@ -23,6 +27,7 @@ export interface RevisionInfo {
23
27
  createdAt: string;
24
28
  hasFeedback: boolean;
25
29
  response?: string;
30
+ diffStats?: { added: number; removed: number };
26
31
  }
27
32
 
28
33
  export const ActiveViewContext = createContext<{
@@ -271,16 +276,14 @@ function App() {
271
276
  {/* Left panel — fixed to viewport */}
272
277
  <LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
273
278
 
274
- {/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
275
- {compareRevision === null && (
276
- <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
277
- <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
278
- </ResizableSidebar>
279
- )}
279
+ {/* Right panel — fixed to viewport, resizable */}
280
+ <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
281
+ <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
282
+ </ResizableSidebar>
280
283
 
281
284
  {/* Center content — normal document flow, browser scroll */}
282
285
  {compareRevision !== null ? (
283
- <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
286
+ <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
284
287
  <CompareView
285
288
  initialLeft={compareRevision.left}
286
289
  initialRight={compareRevision.right}
@@ -524,12 +527,95 @@ function ResizableSidebar({ children, collapsed, onToggle }: { children: React.R
524
527
  function ResponseBanner({ markdown }: { markdown: string }) {
525
528
  const [dismissed, setDismissed] = useState(false);
526
529
  const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
530
+ const contentRef = useRef<HTMLDivElement>(null);
531
+ const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
532
+ const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
533
+ const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
534
+
535
+ const responseAnnotations = useMemo(() => annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH), [annotations]);
527
536
 
528
537
  // Reset dismissed state when markdown changes
529
538
  useEffect(() => { setDismissed(false); }, [markdown]);
530
539
 
540
+ // Restore marks after content renders
541
+ useEffect(() => {
542
+ if (!contentRef.current || responseAnnotations.length === 0) return;
543
+ const timer = setTimeout(() => {
544
+ if (contentRef.current) restoreMarks(contentRef.current, responseAnnotations);
545
+ }, 50);
546
+ return () => clearTimeout(timer);
547
+ }, [html, responseAnnotations.length]);
548
+
549
+ // Update mark active states
550
+ const prevActiveRef = useRef<string | null>(null);
551
+ useEffect(() => {
552
+ updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
553
+ prevActiveRef.current = activeAnnotationId;
554
+ }, [activeAnnotationId]);
555
+
556
+ // Click/hover handlers on marks
557
+ useEffect(() => {
558
+ const container = contentRef.current;
559
+ if (!container) return;
560
+ const handleClick = (e: MouseEvent) => {
561
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
562
+ if (!mark) return;
563
+ e.stopPropagation();
564
+ const annId = mark.getAttribute("data-annotation-id")!;
565
+ if (annId === activeAnnotationId) {
566
+ setEditPopover({ anchorEl: mark, annId });
567
+ } else {
568
+ setActiveAnnotationId(annId);
569
+ }
570
+ };
571
+ const handleMouseOver = (e: MouseEvent) => {
572
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
573
+ if (mark) setActiveAnnotationId(mark.getAttribute("data-annotation-id")!);
574
+ };
575
+ const handleMouseOut = (e: MouseEvent) => {
576
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
577
+ const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
578
+ if (mark && !relatedMark) setActiveAnnotationId(null);
579
+ };
580
+ container.addEventListener("click", handleClick);
581
+ container.addEventListener("mouseover", handleMouseOver);
582
+ container.addEventListener("mouseout", handleMouseOut);
583
+ return () => {
584
+ container.removeEventListener("click", handleClick);
585
+ container.removeEventListener("mouseover", handleMouseOver);
586
+ container.removeEventListener("mouseout", handleMouseOut);
587
+ };
588
+ }, [html, activeAnnotationId]);
589
+
590
+ // Text selection → annotation
591
+ useEffect(() => {
592
+ const handler = () => {
593
+ const sel = window.getSelection();
594
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
595
+ if (!contentRef.current) return;
596
+ const range = sel.getRangeAt(0);
597
+ if (!contentRef.current.contains(range.startContainer)) return;
598
+ if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
599
+ const snippet = sel.toString().trim();
600
+ if (snippet.length < 2) return;
601
+ const savedRange = range.cloneRange();
602
+ const ctx = extractContext(range, contentRef.current);
603
+ const tempId = `__pending_${Date.now()}`;
604
+ try { wrapRangeWithMark(savedRange, tempId); } catch {}
605
+ window.getSelection()?.removeAllRanges();
606
+ const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
607
+ const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
608
+ if (!lastMark) return;
609
+ setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
610
+ };
611
+ document.addEventListener("mouseup", handler);
612
+ return () => document.removeEventListener("mouseup", handler);
613
+ }, []);
614
+
531
615
  if (dismissed) return null;
532
616
 
617
+ const scrollContainer = document.getElementById("plan-scroll-container");
618
+
533
619
  return (
534
620
  <div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
535
621
  <button
@@ -545,8 +631,52 @@ function ResponseBanner({ markdown }: { markdown: string }) {
545
631
  </svg>
546
632
  <span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
547
633
  </div>
548
- <div className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
634
+ <div ref={contentRef} className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
549
635
  </div>
636
+
637
+ {createPopover && (
638
+ <AnnotationCreatePopover
639
+ anchorEl={createPopover.anchorEl}
640
+ scrollContainer={scrollContainer}
641
+ snippet={createPopover.snippet}
642
+ truncateAt={80}
643
+ onAdd={(note, images) => {
644
+ const id = generateAnnotationId();
645
+ renameMarkId(createPopover.tempId, id);
646
+ addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
647
+ setCreatePopover(null);
648
+ }}
649
+ onCancel={() => {
650
+ unwrapMarks(createPopover.tempId);
651
+ setCreatePopover(null);
652
+ }}
653
+ />
654
+ )}
655
+
656
+ {editPopover && (() => {
657
+ const ann = annotations.find((a) => a.id === editPopover.annId);
658
+ if (!ann) return null;
659
+ return (
660
+ <AnnotationEditPopover
661
+ anchorEl={editPopover.anchorEl}
662
+ scrollContainer={scrollContainer}
663
+ initialNote={ann.note}
664
+ initialImages={ann.images}
665
+ onUpdate={(note, images) => {
666
+ updateAnnotation(editPopover.annId, note);
667
+ const current = ann.images || [];
668
+ for (const img of images) {
669
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
670
+ }
671
+ for (const img of current) {
672
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
673
+ }
674
+ }}
675
+ onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
676
+ onClose={() => setEditPopover(null)}
677
+ />
678
+ );
679
+ })()}
550
680
  </div>
551
681
  );
552
682
  }
@@ -571,10 +701,10 @@ function ContentTabs() {
571
701
  : "text-text-tertiary hover:text-text-secondary"
572
702
  }`}
573
703
  >
574
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
575
- <path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
704
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
705
+ <path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
576
706
  </svg>
577
- Plan
707
+ Canvas
578
708
  {activeView.type === "plan" && (
579
709
  <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
580
710
  )}
@@ -594,12 +724,13 @@ function ContentTabs() {
594
724
  <div className="flex items-center">
595
725
  <button
596
726
  onClick={() => setActiveView({ type: "file", path })}
597
- className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-mono whitespace-nowrap transition-colors relative ${
727
+ className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
598
728
  isActive
599
729
  ? "text-text-primary"
600
730
  : "text-text-tertiary hover:text-text-secondary"
601
731
  }`}
602
732
  >
733
+ <FileIcon name={name} type="file" />
603
734
  {name}
604
735
  {isActive && (
605
736
  <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">
@@ -321,13 +352,13 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
321
352
  }
322
353
 
323
354
  if (!PlanComponent) {
324
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No plan loaded</div>;
355
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
325
356
  }
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
-