agent-canvas 0.1.8 → 0.1.9

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.
@@ -6,7 +6,7 @@ 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
+ import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
10
10
 
11
11
  interface AnnotationSidebarProps {
12
12
  onPreview: () => void;
@@ -166,8 +166,9 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
166
166
  setActiveAnnotationId: (id: string | null) => void;
167
167
  setActiveView: (view: ActiveView) => void;
168
168
  }) {
169
+ const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
169
170
  const planAnnotations = annotations.filter((a) => !a.filePath);
170
- const fileAnnotations = annotations.filter((a) => a.filePath);
171
+ const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
171
172
  const fileGroups: Record<string, Annotation[]> = {};
172
173
  for (const ann of fileAnnotations) {
173
174
  const key = ann.filePath!;
@@ -225,13 +226,28 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
225
226
  </div>
226
227
  );
227
228
 
229
+ const hasMultipleGroups = [responseAnnotations, planAnnotations, fileAnnotations].filter((g) => g.length > 0).length > 1;
230
+
228
231
  return (
229
232
  <>
230
233
  <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>
234
+ {responseAnnotations.length > 0 && (
235
+ <>
236
+ {hasMultipleGroups && (
237
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
238
+ )}
239
+ {responseAnnotations.map(renderAnnotation)}
240
+ </>
241
+ )}
242
+
243
+ {planAnnotations.length > 0 && (
244
+ <>
245
+ {hasMultipleGroups && (
246
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
247
+ )}
248
+ {planAnnotations.map(renderAnnotation)}
249
+ </>
233
250
  )}
234
- {planAnnotations.map(renderAnnotation)}
235
251
 
236
252
  {Object.entries(fileGroups).map(([filePath, anns]) => (
237
253
  <div key={filePath}>
@@ -271,8 +287,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
271
287
  const listRef = useRef<HTMLDivElement>(null);
272
288
  const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
273
289
 
290
+ const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
274
291
  const planAnnotations = annotations.filter((a) => !a.filePath);
275
- const fileAnnotations = annotations.filter((a) => a.filePath);
292
+ const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
276
293
  const fileGroups: Record<string, Annotation[]> = {};
277
294
  for (const ann of fileAnnotations) {
278
295
  const key = ann.filePath!;
@@ -387,12 +404,21 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
387
404
  <div ref={listRef} className="flex-1 overflow-y-auto">
388
405
  {annotations.length === 0 && (
389
406
  <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
390
- Select text in the plan or in files to add annotations.
407
+ Select text in the canvas or in files to add annotations.
391
408
  </p>
392
409
  )}
393
410
 
394
- {planAnnotations.length > 0 && fileAnnotations.length > 0 && (
395
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
411
+ {responseAnnotations.length > 0 && (
412
+ <>
413
+ {(planAnnotations.length > 0 || fileAnnotations.length > 0) && (
414
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
415
+ )}
416
+ {responseAnnotations.map(renderAnnotation)}
417
+ </>
418
+ )}
419
+
420
+ {planAnnotations.length > 0 && (responseAnnotations.length > 0 || fileAnnotations.length > 0) && (
421
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
396
422
  )}
397
423
  {planAnnotations.map(renderAnnotation)}
398
424
 
@@ -12,7 +12,10 @@ import { SessionSwitcher } from "./SessionSwitcher";
12
12
  import { exportCanvasToMarkdown } from "./exportMarkdown";
13
13
  import { CompareView } from "./CompareView";
14
14
  import { RevisionSelect } from "./RevisionSelect";
15
- import { generateAnnotationId } from "./utils";
15
+ import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
16
+ import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
17
+ import { extractContext } from "./annotationContext";
18
+ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
16
19
 
17
20
  export type ActiveView = { type: "plan" } | { type: "file"; path: string };
18
21
 
@@ -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, 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,42 @@ 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) => {
644
+ const id = generateAnnotationId();
645
+ renameMarkId(createPopover.tempId, id);
646
+ addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx);
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
+ onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
665
+ onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
666
+ onClose={() => setEditPopover(null)}
667
+ />
668
+ );
669
+ })()}
550
670
  </div>
551
671
  );
552
672
  }
@@ -574,7 +694,7 @@ function ContentTabs() {
574
694
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
575
695
  <path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
576
696
  </svg>
577
- Plan
697
+ Canvas
578
698
  {activeView.type === "plan" && (
579
699
  <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
580
700
  )}
@@ -321,7 +321,7 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
321
321
  }
322
322
 
323
323
  if (!PlanComponent) {
324
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No plan loaded</div>;
324
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
325
325
  }
326
326
 
327
327
  return (
@@ -333,7 +333,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
333
333
  const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
334
334
 
335
335
  if (loading) {
336
- return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading plan...</div>;
336
+ return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading canvas...</div>;
337
337
  }
338
338
 
339
339
  if (error) {
@@ -346,7 +346,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
346
346
  }
347
347
 
348
348
  if (!PlanComponent) {
349
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No plan loaded</div>;
349
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
350
350
  }
351
351
 
352
352
  const scrollContainer = document.getElementById("plan-scroll-container");
@@ -105,7 +105,7 @@ export function formatSnippetInContext(ann: { snippet: string; context?: Annotat
105
105
  if (ctx && isShort && (ctx.before || ctx.after)) {
106
106
  const parts: string[] = [];
107
107
  if (ctx.before) parts.push(`...${ctx.before} `);
108
- parts.push(snippet);
108
+ parts.push(`**${snippet}**`);
109
109
  if (ctx.after) parts.push(` ${ctx.after}...`);
110
110
  return parts.join("");
111
111
  }
@@ -1,5 +1,6 @@
1
1
  import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
2
2
  import { formatSnippetInContext } from "./annotationContext";
3
+ import { RESPONSE_ANNOTATION_PATH } from "./utils";
3
4
 
4
5
  /**
5
6
  * Generate human-readable markdown feedback from annotations and responses.
@@ -11,8 +12,9 @@ export function generateMarkdown(
11
12
  feedbackEntries?: Map<string, FeedbackEntry>,
12
13
  ): string {
13
14
  const parts: string[] = [];
15
+ const responseAnns = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
14
16
  const planAnns = annotations.filter((a) => !a.filePath);
15
- const fileAnns = annotations.filter((a) => a.filePath);
17
+ const fileAnns = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
16
18
  const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
17
19
  const fbEntries = feedbackEntries
18
20
  ? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
@@ -32,9 +34,18 @@ export function generateMarkdown(
32
34
  }
33
35
  }
34
36
 
37
+ // Response annotations (on agent response banner)
38
+ if (responseAnns.length > 0) {
39
+ parts.push("## Annotations on agent response");
40
+ parts.push("");
41
+ for (const ann of responseAnns) {
42
+ parts.push(renderAnnotation(ann));
43
+ }
44
+ }
45
+
35
46
  // Plan annotations grouped by hierarchy
36
47
  if (planAnns.length > 0) {
37
- parts.push("## Plan annotations");
48
+ parts.push("## Canvas annotations");
38
49
  parts.push("");
39
50
  const grouped = groupByHierarchy(planAnns);
40
51
  parts.push(renderHierarchyGroup(grouped));
@@ -1,3 +1,6 @@
1
+ /** Sentinel filePath for annotations on the agent response banner */
2
+ export const RESPONSE_ANNOTATION_PATH = "__agent-response__";
3
+
1
4
  /** Generate a unique annotation ID */
2
5
  export function generateAnnotationId(): string {
3
6
  return `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;