agent-canvas 0.1.9 → 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.
@@ -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
-