From dc8c398d468158b6ee25289656fbc15f8663483a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 15 Mar 2026 11:57:52 -0700 Subject: [PATCH 1/6] feat: annotatable diff view with diff context in feedback Add annotation support to the plan clean diff view so users can select text in added/removed/modified sections and leave feedback. Annotations carry a diffContext field that flows through export, sharing, and drafts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/editor/App.tsx | 27 +- packages/ui/components/AnnotationPanel.tsx | 9 + .../plan-diff/PlanCleanDiffView.tsx | 475 ++++++++++++++++-- .../components/plan-diff/PlanDiffViewer.tsx | 21 +- packages/ui/hooks/useAnnotationDraft.ts | 5 +- packages/ui/hooks/useSharing.ts | 6 +- packages/ui/types.ts | 1 + packages/ui/utils/parser.ts | 10 + packages/ui/utils/sharing.ts | 18 +- 9 files changed, 508 insertions(+), 64 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index e1f0f0bc9..8d0ab12fc 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1152,18 +1152,16 @@ const App: React.FC = () => { showCancel />
- {/* Annotation Toolstrip (hidden during plan diff) */} - {!isPlanDiffActive && ( -
- -
- )} + {/* Annotation Toolstrip */} +
+ +
{/* Plan Diff View or Normal Plan View */} {isPlanDiffActive && planDiff.diffBlocks && planDiff.diffStats ? ( @@ -1177,6 +1175,11 @@ const App: React.FC = () => { baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined} baseVersion={planDiff.diffBaseVersion ?? undefined} maxWidth={planMaxWidth} + annotations={annotations} + onAddAnnotation={handleAddAnnotation} + onSelectAnnotation={handleSelectAnnotation} + selectedAnnotationId={selectedAnnotationId} + mode={editorMode} /> ) : (
+ {annotation.diffContext && ( + + {annotation.diffContext} + + )} {formatTimestamp(annotation.createdA)} diff --git a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx index 2c31217a8..654ac967d 100644 --- a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx +++ b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx @@ -8,27 +8,430 @@ * - Modified: old content (red, struck through) above new content (green) * - Unchanged: normal rendering, slightly dimmed * + * Supports annotation via web-highlighter — same pattern as Viewer.tsx. * Reuses parseMarkdownToBlocks() for rendering consistency with the plan view. */ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState, useCallback } from "react"; import hljs from "highlight.js"; +import Highlighter from "@plannotator/web-highlighter"; import { parseMarkdownToBlocks } from "../../utils/parser"; -import type { Block } from "../../types"; +import { getIdentity } from "../../utils/identity"; +import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types"; +import { AnnotationType } from "../../types"; import type { PlanDiffBlock } from "../../utils/planDiffEngine"; +import type { QuickLabel } from "../../utils/quickLabels"; +import { AnnotationToolbar } from "../AnnotationToolbar"; +import { CommentPopover } from "../CommentPopover"; +import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker"; interface PlanCleanDiffViewProps { blocks: PlanDiffBlock[]; + // Annotation props (all optional for backwards compat) + annotations?: Annotation[]; + onAddAnnotation?: (ann: Annotation) => void; + onSelectAnnotation?: (id: string | null) => void; + selectedAnnotationId?: string | null; + mode?: EditorMode; } export const PlanCleanDiffView: React.FC = ({ blocks, + annotations, + onAddAnnotation, + onSelectAnnotation, + selectedAnnotationId, + mode = "selection", }) => { + const containerRef = useRef(null); + const highlighterRef = useRef(null); + const modeRef = useRef(mode); + const onAddAnnotationRef = useRef(onAddAnnotation); + const pendingSourceRef = useRef(null); + const justCreatedIdRef = useRef(null); + const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const [toolbarState, setToolbarState] = useState<{ + element: HTMLElement; + source: any; + selectionText: string; + } | null>(null); + + const [commentPopover, setCommentPopover] = useState<{ + anchorEl: HTMLElement; + contextText: string; + initialText?: string; + isGlobal: boolean; + source?: any; + } | null>(null); + + const [quickLabelPicker, setQuickLabelPicker] = useState<{ + anchorEl: HTMLElement; + cursorHint?: { x: number; y: number }; + source?: any; + } | null>(null); + + // Keep refs in sync + useEffect(() => { modeRef.current = mode; }, [mode]); + useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); + + // Track mouse position for quick label picker + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + lastMousePosRef.current = { x: e.clientX, y: e.clientY }; + }; + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, []); + + // Resolve diff context from DOM — walks up to find data-diff-type + const resolveDiffContext = (el: HTMLElement): Annotation["diffContext"] => { + let node: HTMLElement | null = el; + while (node && node !== containerRef.current) { + const dt = node.dataset.diffType; + if (dt === "added" || dt === "removed" || dt === "modified") return dt; + node = node.parentElement; + } + return undefined; + }; + + // Create annotation from web-highlighter source + const createAnnotationFromSource = ( + highlighter: Highlighter, + source: any, + type: AnnotationType, + text?: string, + images?: ImageAttachment[], + isQuickLabel?: boolean, + quickLabelTip?: string, + ) => { + const doms = highlighter.getDoms(source.id); + let blockId = ""; + let startOffset = 0; + let diffContext: Annotation["diffContext"]; + + if (doms?.length > 0) { + const el = doms[0] as HTMLElement; + // Walk up to find data-block-id + let parent = el.parentElement; + while (parent && !parent.dataset.blockId) { + parent = parent.parentElement; + } + if (parent?.dataset.blockId) { + blockId = parent.dataset.blockId; + const blockText = parent.textContent || ""; + const beforeText = blockText.split(source.text)[0]; + startOffset = beforeText?.length || 0; + } + // Resolve diff context + diffContext = resolveDiffContext(el); + } + + const newAnnotation: Annotation = { + id: source.id, + blockId, + startOffset, + endOffset: startOffset + source.text.length, + type, + text, + originalText: source.text, + createdA: Date.now(), + author: getIdentity(), + startMeta: source.startMeta, + endMeta: source.endMeta, + images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), + ...(diffContext ? { diffContext } : {}), + }; + + if (type === AnnotationType.DELETION) { + highlighter.addClass("deletion", source.id); + } else if (type === AnnotationType.COMMENT) { + highlighter.addClass("comment", source.id); + } + + justCreatedIdRef.current = newAnnotation.id; + onAddAnnotationRef.current?.(newAnnotation); + }; + + // Initialize web-highlighter + useEffect(() => { + if (!containerRef.current || !onAddAnnotation) return; + + const highlighter = new Highlighter({ + $root: containerRef.current, + exceptSelectors: [".annotation-toolbar", "button"], + wrapTag: "mark", + style: { className: "annotation-highlight" }, + }); + + highlighterRef.current = highlighter; + + highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => { + if (sources.length > 0) { + const source = sources[0]; + const doms = highlighter.getDoms(source.id); + if (doms?.length > 0) { + // Clean up previous pending + if (pendingSourceRef.current) { + highlighter.remove(pendingSourceRef.current.id); + pendingSourceRef.current = null; + } + setCommentPopover(null); + setQuickLabelPicker(null); + + if (modeRef.current === "redline") { + createAnnotationFromSource(highlighter, source, AnnotationType.DELETION); + window.getSelection()?.removeAllRanges(); + } else if (modeRef.current === "comment") { + pendingSourceRef.current = source; + setCommentPopover({ + anchorEl: doms[0] as HTMLElement, + contextText: source.text.slice(0, 80), + isGlobal: false, + source, + }); + } else if (modeRef.current === "quickLabel") { + pendingSourceRef.current = source; + setQuickLabelPicker({ + anchorEl: doms[0] as HTMLElement, + cursorHint: lastMousePosRef.current, + source, + }); + } else { + // Selection mode — show toolbar + pendingSourceRef.current = source; + setToolbarState({ + element: doms[0] as HTMLElement, + source, + selectionText: source.text, + }); + } + } + } + }); + + highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => { + onSelectAnnotation?.(id); + }); + + highlighter.run(); + + // Mobile bridge + const isTouchPrimary = window.matchMedia("(pointer: coarse)").matches; + let selectionTimer: ReturnType; + const handleSelectionChange = isTouchPrimary + ? () => { + clearTimeout(selectionTimer); + selectionTimer = setTimeout(() => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) return; + if (!containerRef.current?.contains(sel.anchorNode)) return; + highlighter.fromRange(sel.getRangeAt(0)); + }, 400); + } + : null; + + if (handleSelectionChange) { + document.addEventListener("selectionchange", handleSelectionChange); + } + + return () => { + if (handleSelectionChange) { + clearTimeout(selectionTimer); + document.removeEventListener("selectionchange", handleSelectionChange); + } + highlighter.dispose(); + }; + }, [onAddAnnotation, onSelectAnnotation]); + + // Apply CSS classes to existing annotations + useEffect(() => { + const highlighter = highlighterRef.current; + if (!highlighter || !annotations) return; + + annotations.forEach((ann) => { + try { + const doms = highlighter.getDoms(ann.id); + if (doms && doms.length > 0) { + if (ann.type === AnnotationType.DELETION) { + highlighter.addClass("deletion", ann.id); + } else if (ann.type === AnnotationType.COMMENT) { + highlighter.addClass("comment", ann.id); + } + } + } catch {} + }); + }, [annotations]); + + // Scroll to selected annotation + useEffect(() => { + if (!selectedAnnotationId || !containerRef.current) return; + + // Skip scroll if we just created this annotation + if (justCreatedIdRef.current === selectedAnnotationId) { + justCreatedIdRef.current = null; + return; + } + + const highlighter = highlighterRef.current; + let targetElements: Element[] = []; + + if (highlighter) { + try { + const doms = highlighter.getDoms(selectedAnnotationId); + if (doms && doms.length > 0) targetElements = Array.from(doms); + } catch {} + } + + if (targetElements.length === 0) { + const manualMarks = containerRef.current.querySelectorAll( + `[data-bind-id="${selectedAnnotationId}"]` + ); + if (manualMarks.length > 0) targetElements = Array.from(manualMarks); + } + + if (targetElements.length === 0) return; + + targetElements.forEach((el) => el.classList.add("focused")); + targetElements[0].scrollIntoView({ behavior: "smooth", block: "center" }); + + // Remove focused class after animation + const timer = setTimeout(() => { + targetElements.forEach((el) => el.classList.remove("focused")); + }, 2000); + return () => clearTimeout(timer); + }, [selectedAnnotationId]); + + // Toolbar handlers + const handleAnnotate = (type: AnnotationType) => { + const highlighter = highlighterRef.current; + if (!toolbarState || !highlighter) return; + createAnnotationFromSource(highlighter, toolbarState.source, type); + pendingSourceRef.current = null; + setToolbarState(null); + window.getSelection()?.removeAllRanges(); + }; + + const handleQuickLabel = (label: QuickLabel) => { + const highlighter = highlighterRef.current; + if (!toolbarState || !highlighter) return; + createAnnotationFromSource( + highlighter, toolbarState.source, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true, label.tip + ); + pendingSourceRef.current = null; + setToolbarState(null); + window.getSelection()?.removeAllRanges(); + }; + + const handleToolbarClose = () => { + if (toolbarState && highlighterRef.current) { + highlighterRef.current.remove(toolbarState.source.id); + } + pendingSourceRef.current = null; + setToolbarState(null); + window.getSelection()?.removeAllRanges(); + }; + + const handleRequestComment = (initialChar?: string) => { + if (!toolbarState) return; + setCommentPopover({ + anchorEl: toolbarState.element, + contextText: toolbarState.selectionText.slice(0, 80), + initialText: initialChar, + isGlobal: false, + source: toolbarState.source, + }); + setToolbarState(null); + }; + + const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => { + if (!commentPopover) return; + if (commentPopover.source && highlighterRef.current) { + createAnnotationFromSource( + highlighterRef.current, commentPopover.source, + AnnotationType.COMMENT, text, images + ); + pendingSourceRef.current = null; + window.getSelection()?.removeAllRanges(); + } + setCommentPopover(null); + }; + + const handleCommentClose = useCallback(() => { + setCommentPopover((prev) => { + if (prev?.source && highlighterRef.current) { + highlighterRef.current.remove(prev.source.id); + pendingSourceRef.current = null; + } + return null; + }); + window.getSelection()?.removeAllRanges(); + }, []); + + const handleFloatingQuickLabel = useCallback((label: QuickLabel) => { + if (!quickLabelPicker?.source || !highlighterRef.current) return; + createAnnotationFromSource( + highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true, label.tip + ); + pendingSourceRef.current = null; + setQuickLabelPicker(null); + window.getSelection()?.removeAllRanges(); + }, [quickLabelPicker]); + + const handleQuickLabelPickerDismiss = useCallback(() => { + if (quickLabelPicker?.source && highlighterRef.current) { + highlighterRef.current.remove(quickLabelPicker.source.id); + pendingSourceRef.current = null; + } + setQuickLabelPicker(null); + window.getSelection()?.removeAllRanges(); + }, [quickLabelPicker]); + return ( -
+
{blocks.map((block, index) => ( ))} + + {/* Text selection toolbar */} + {toolbarState && ( + + )} + + {/* Comment popover */} + {commentPopover && ( + + )} + + {/* Quick label picker */} + {quickLabelPicker && ( + + )}
); }; @@ -37,27 +440,35 @@ const DiffBlockRenderer: React.FC<{ block: PlanDiffBlock }> = ({ block }) => { switch (block.type) { case "unchanged": return ( -
+
); case "added": return ( -
+
); case "removed": - return ; + return ( +
+ +
+ ); case "modified": return ( - +
+
+ +
+
+ +
+
); default: @@ -65,36 +476,6 @@ const DiffBlockRenderer: React.FC<{ block: PlanDiffBlock }> = ({ block }) => { } }; -/** - * Removed content — always visible with red styling and strikethrough. - */ -const RemovedBlock: React.FC<{ content: string }> = ({ content }) => { - return ( -
- -
- ); -}; - -/** - * Modified content — shows old content (red, struck through) above new content (green border). - */ -const ModifiedBlock: React.FC<{ - content: string; - oldContent: string; -}> = ({ content, oldContent }) => { - return ( -
-
- -
-
- -
-
- ); -}; - /** * Renders a markdown string chunk using parseMarkdownToBlocks + simplified block rendering. * Reuses the same visual output as the Viewer component. @@ -116,7 +497,8 @@ const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => { /** * Simplified block renderer — same visual output as Viewer's BlockRenderer - * but without annotations, code block hover, or mermaid support. + * but without code block hover, mermaid, or linked doc support. + * Adds data-block-id for annotation anchoring. */ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { switch (block.type) { @@ -130,7 +512,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { }[block.level || 1] || "text-base font-semibold mb-2 mt-4"; return ( - + ); @@ -138,7 +520,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { case "blockquote": return ( -
+
); @@ -150,6 +532,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => {
{isCheckbox ? ( @@ -216,7 +599,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { rows.push(parseRow(line)); } return ( -
+
@@ -245,7 +628,7 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { default: return ( -

+

); @@ -267,7 +650,7 @@ const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => { }, [block.content, block.language]); return ( -
+
          void;
+  onSelectAnnotation?: (id: string | null) => void;
+  selectedAnnotationId?: string | null;
+  mode?: EditorMode;
 }
 
 export const PlanDiffViewer: React.FC = ({
@@ -39,6 +46,11 @@ export const PlanDiffViewer: React.FC = ({
   baseVersionLabel,
   baseVersion,
   maxWidth,
+  annotations,
+  onAddAnnotation,
+  onSelectAnnotation,
+  selectedAnnotationId,
+  mode,
 }) => {
   const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false);
   const [vscodeDiffError, setVscodeDiffError] = useState(null);
@@ -166,7 +178,14 @@ export const PlanDiffViewer: React.FC = ({
 
         {/* Diff content */}
         {diffMode === "clean" ? (
-          
+          
         ) : (
           
         )}
diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts
index 535ecd74f..b7ac949ca 100644
--- a/packages/ui/hooks/useAnnotationDraft.ts
+++ b/packages/ui/hooks/useAnnotationDraft.ts
@@ -15,6 +15,7 @@ const DEBOUNCE_MS = 500;
 interface DraftData {
   a: unknown[];
   g?: unknown[];
+  d?: (string | null)[];
   ts: number;
 }
 
@@ -88,9 +89,11 @@ export function useAnnotationDraft({
     if (timerRef.current) clearTimeout(timerRef.current);
 
     timerRef.current = setTimeout(() => {
+      const hasDiffContext = annotations.some(a => a.diffContext);
       const payload: DraftData = {
         a: toShareable(annotations) as unknown[],
         g: toShareableImages(globalAttachments) as unknown[] | undefined,
+        d: hasDiffContext ? annotations.map(a => a.diffContext || null) : undefined,
         ts: Date.now(),
       };
 
@@ -115,7 +118,7 @@ export function useAnnotationDraft({
 
     if (!data?.a) return { annotations: [], globalAttachments: [] };
 
-    const restored = fromShareable(data.a as Parameters[0]);
+    const restored = fromShareable(data.a as Parameters[0], data.d);
     const restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters[0]) ?? []) : [];
 
     return { annotations: restored, globalAttachments: restoredGlobal };
diff --git a/packages/ui/hooks/useSharing.ts b/packages/ui/hooks/useSharing.ts
index 8792b079e..0c3040e97 100644
--- a/packages/ui/hooks/useSharing.ts
+++ b/packages/ui/hooks/useSharing.ts
@@ -122,7 +122,7 @@ export function useSharing(
         if (payload) {
           setMarkdown(payload.p);
 
-          const restoredAnnotations = fromShareable(payload.a);
+          const restoredAnnotations = fromShareable(payload.a, payload.d);
           setAnnotations(restoredAnnotations);
 
           if (payload.g?.length) {
@@ -156,7 +156,7 @@ export function useSharing(
         setMarkdown(payload.p);
 
         // Convert shareable annotations to full annotations
-        const restoredAnnotations = fromShareable(payload.a);
+        const restoredAnnotations = fromShareable(payload.a, payload.d);
         setAnnotations(restoredAnnotations);
 
         // Restore global attachments if present
@@ -308,7 +308,7 @@ export function useSharing(
       const planTitle = titleLine ? titleLine.replace(/^#+\s*/, '').trim() : 'Unknown Plan';
 
       // Convert to full annotations
-      const importedAnnotations = fromShareable(payload.a);
+      const importedAnnotations = fromShareable(payload.a, payload.d);
 
       if (importedAnnotations.length === 0) {
         return { success: true, count: 0, planTitle, error: 'No annotations found in share link' };
diff --git a/packages/ui/types.ts b/packages/ui/types.ts
index e3f7cd756..599202289 100644
--- a/packages/ui/types.ts
+++ b/packages/ui/types.ts
@@ -28,6 +28,7 @@ export interface Annotation {
   images?: ImageAttachment[]; // Attached images with human-readable names
   isQuickLabel?: boolean; // true if created via quick label chip
   quickLabelTip?: string; // optional instruction tip from the label definition
+  diffContext?: 'added' | 'removed' | 'modified'; // set when annotation created in plan diff view
   // web-highlighter metadata for cross-element selections
   startMeta?: {
     parentTagName: string;
diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts
index 2e04b8617..45b81ec4b 100644
--- a/packages/ui/utils/parser.ts
+++ b/packages/ui/utils/parser.ts
@@ -282,6 +282,16 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt
 
     output += `## ${index + 1}. `;
 
+    // Add diff context label if annotation was created in diff view
+    if (ann.diffContext) {
+      const diffLabels: Record = {
+        added: '[In newly added content]',
+        removed: '[In removed content]',
+        modified: '[In modified content]',
+      };
+      output += `${diffLabels[ann.diffContext]} `;
+    }
+
     switch (ann.type) {
       case 'DELETION':
         output += `Remove this\n`;
diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts
index bd7715f15..a70c708ac 100644
--- a/packages/ui/utils/sharing.ts
+++ b/packages/ui/utils/sharing.ts
@@ -27,6 +27,7 @@ export interface SharePayload {
   p: string;  // plan markdown
   a: ShareableAnnotation[];
   g?: ShareableImage[];  // global attachments (path strings or [path, name] tuples)
+  d?: (string | null)[];  // diffContext per annotation (parallel to `a`): 'added' | 'removed' | 'modified' | null
 }
 
 /**
@@ -87,7 +88,7 @@ export function toShareable(annotations: Annotation[]): ShareableAnnotation[] {
  * Note: blockId, offsets, and meta will need to be populated separately
  * by finding the text in the rendered document.
  */
-export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
+export function fromShareable(data: ShareableAnnotation[], diffContexts?: (string | null)[] | null): Annotation[] {
   const typeMap: Record = {
     'D': AnnotationType.DELETION,
     'R': AnnotationType.REPLACEMENT,
@@ -98,6 +99,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
 
   return data.map((item, index) => {
     const type = item[0];
+    const dc = diffContexts?.[index] as Annotation['diffContext'] | null | undefined;
 
     // Handle global comments specially: ['G', text, author, images?]
     if (type === 'G') {
@@ -116,6 +118,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
         createdA: Date.now() + index,
         author: author || undefined,
         images: parseShareableImages(rawImages),
+        ...(dc ? { diffContext: dc } : {}),
       };
     }
 
@@ -140,6 +143,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
       author: author || undefined,
       images: parseShareableImages(rawImages),
       ...(isQuickLabel ? { isQuickLabel } : {}),
+      ...(dc ? { diffContext: dc } : {}),
       // startMeta/endMeta will be set by web-highlighter
     };
   });
@@ -148,6 +152,16 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
 /**
  * Generate a full shareable URL from plan and annotations
  */
+/**
+ * Build the diffContext parallel array for the share payload.
+ * Returns undefined if no annotations have diffContext (keeps payload small).
+ */
+function buildDiffContextArray(annotations: Annotation[]): (string | null)[] | undefined {
+  const hasAny = annotations.some(a => a.diffContext);
+  if (!hasAny) return undefined;
+  return annotations.map(a => a.diffContext || null);
+}
+
 export async function generateShareUrl(
   markdown: string,
   annotations: Annotation[],
@@ -158,6 +172,7 @@ export async function generateShareUrl(
     p: markdown,
     a: toShareable(annotations),
     g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
+    d: buildDiffContextArray(annotations),
   };
 
   const hash = await compress(payload);
@@ -229,6 +244,7 @@ export async function createShortShareUrl(
       p: markdown,
       a: toShareable(annotations),
       g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined,
+      d: buildDiffContextArray(annotations),
     };
 
     const compressed = await compress(payload);

From e53e80101058f4a61d7d4a5f3b335ba0223aea06 Mon Sep 17 00:00:00 2001
From: Michael Ramos 
Date: Sun, 15 Mar 2026 14:36:56 -0700
Subject: [PATCH 2/6] refactor: extract useAnnotationHighlighter hook, fix diff
 annotation bugs

Extract ~250 lines of duplicated annotation plumbing from Viewer.tsx and
PlanCleanDiffView.tsx into a shared useAnnotationHighlighter hook. Fixes
two bugs from the original copy-paste: highlights vanishing on re-render
(unstable callback in useEffect deps) and persisted annotations not
restoring into DOM (missing findTextInDOM path). Diff annotations are
explicitly excluded from text-based restoration to avoid wrong-match
binding. Also fixes code-block annotation deletion not re-applying
syntax highlighting (data-highlighted attribute and execution order).

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 CLAUDE.md                                     |  10 +-
 packages/ui/components/Viewer.tsx             | 700 +++---------------
 .../plan-diff/PlanCleanDiffView.tsx           | 374 +---------
 packages/ui/hooks/useAnnotationHighlighter.ts | 658 ++++++++++++++++
 4 files changed, 804 insertions(+), 938 deletions(-)
 create mode 100644 packages/ui/hooks/useAnnotationHighlighter.ts

diff --git a/CLAUDE.md b/CLAUDE.md
index c3fe11a55..4ce857f2c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,7 +52,7 @@ plannotator/
 │   │   │   ├── plan-diff/        # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
 │   │   │   └── sidebar/          # SidebarContainer, SidebarTabs, VersionBrowser
 │   │   ├── utils/                # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts
-│   │   ├── hooks/                # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts
+│   │   ├── hooks/                # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts
 │   │   └── types.ts
 │   ├── shared/                   # Cross-package types (EditorAnnotation)
 │   ├── editor/                   # Plan review App.tsx
@@ -227,6 +227,10 @@ When a user denies a plan and Claude resubmits, the UI shows what changed betwee
 
 **State** (`packages/ui/hooks/usePlanDiff.ts`): Manages base version selection, diff computation, and version fetching. The server sends `previousPlan` with the initial `/api/plan` response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser.
 
+**Diff annotations:** The clean diff view supports full annotation (same as normal plan view). Annotations created in diff mode carry a `diffContext` field (`added`/`removed`/`modified`). Exported feedback includes context labels like `[In newly added content]`.
+
+**Annotation hook** (`packages/ui/hooks/useAnnotationHighlighter.ts`): Shared annotation infrastructure used by both `Viewer.tsx` and `PlanCleanDiffView.tsx`. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. Accepts an optional `resolveContext` callback for diff-specific context tagging.
+
 **Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with two tabs — Table of Contents and Version Browser. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only).
 
 ## Data Types
@@ -258,6 +262,7 @@ interface Annotation {
   createdA: number; // Timestamp
   author?: string; // Tater identity
   images?: ImageAttachment[]; // Attached images with names
+  diffContext?: 'added' | 'removed' | 'modified'; // Set when annotation created in plan diff view
   startMeta?: { parentTagName; parentIndex; textOffset };
   endMeta?: { parentTagName; parentIndex; textOffset };
 }
@@ -286,7 +291,7 @@ interface Block {
 - Horizontal rules (`---`)
 - Paragraphs (default)
 
-`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`.
+`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. Annotations with `diffContext` include labels like `[In newly added content]`.
 
 ## Annotation System
 
@@ -311,6 +316,7 @@ interface SharePayload {
   p: string; // Plan markdown
   a: ShareableAnnotation[]; // Compact annotations
   g?: ShareableImage[]; // Global attachments
+  d?: (string | null)[]; // diffContext per annotation, parallel to `a`
 }
 
 type ShareableAnnotation =
diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx
index 41c9dba58..f86acd3e8 100644
--- a/packages/ui/components/Viewer.tsx
+++ b/packages/ui/components/Viewer.tsx
@@ -1,6 +1,5 @@
 import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
 import { createPortal } from 'react-dom';
-import Highlighter from '@plannotator/web-highlighter';
 import hljs from 'highlight.js';
 import 'highlight.js/styles/github-dark.css';
 import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment } from '../types';
@@ -37,6 +36,7 @@ import { type QuickLabel } from '../utils/quickLabels';
 import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
 import { PinpointOverlay } from './PinpointOverlay';
 import { usePinpoint } from '../hooks/usePinpoint';
+import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
 
 interface ViewerProps {
   blocks: Block[];
@@ -147,37 +147,57 @@ export const Viewer = forwardRef(({
     }
   };
   const containerRef = useRef(null);
-  const highlighterRef = useRef(null);
-  const modeRef = useRef(mode);
-  const onAddAnnotationRef = useRef(onAddAnnotation);
-  const pendingSourceRef = useRef(null);
-  const justCreatedIdRef = useRef(null);
-  const [toolbarState, setToolbarState] = useState<{
-    element: HTMLElement;
-    source: any;
-    selectionText: string;
-  } | null>(null);
   const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
   const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
-  const [commentPopover, setCommentPopover] = useState<{
+  // Viewer-specific comment popover state (global comments + code blocks)
+  const [viewerCommentPopover, setViewerCommentPopover] = useState<{
     anchorEl: HTMLElement;
     contextText: string;
     initialText?: string;
     isGlobal: boolean;
-    source?: any;
     codeBlock?: { block: Block; element: HTMLElement };
   } | null>(null);
-  const [quickLabelPicker, setQuickLabelPicker] = useState<{
+  // Viewer-specific quick label state (code blocks)
+  const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{
     anchorEl: HTMLElement;
-    cursorHint?: { x: number; y: number };
-    source?: any;
-    codeBlock?: { block: Block; element: HTMLElement };
+    codeBlock: { block: Block; element: HTMLElement };
   } | null>(null);
-  const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
   const hoverTimeoutRef = useRef(null);
   const stickySentinelRef = useRef(null);
   const [isStuck, setIsStuck] = useState(false);
 
+  // Shared annotation infrastructure via hook
+  const {
+    highlighterRef,
+    toolbarState,
+    commentPopover: hookCommentPopover,
+    quickLabelPicker: hookQuickLabelPicker,
+    handleAnnotate,
+    handleQuickLabel,
+    handleToolbarClose,
+    handleRequestComment,
+    handleCommentSubmit: hookCommentSubmit,
+    handleCommentClose: hookCommentClose,
+    handleFloatingQuickLabel: hookFloatingQuickLabel,
+    handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss,
+    removeHighlight: hookRemoveHighlight,
+    clearAllHighlights,
+    applyAnnotations,
+  } = useAnnotationHighlighter({
+    containerRef,
+    annotations,
+    onAddAnnotation,
+    onSelectAnnotation,
+    selectedAnnotationId,
+    mode,
+  });
+
+  // Ref for onAddAnnotation (code block annotation needs it)
+  const onAddAnnotationRef = useRef(onAddAnnotation);
+  useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]);
+  const modeRef = useRef(mode);
+  useEffect(() => { modeRef.current = mode; }, [mode]);
+
   // Pinpoint mode: hover + click to select elements
   const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => {
     const codeEl = element.querySelector('code');
@@ -186,13 +206,13 @@ export const Viewer = forwardRef(({
     if (modeRef.current === 'redline') {
       applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
     } else if (modeRef.current === 'quickLabel') {
-      setQuickLabelPicker({
+      setCodeBlockQuickLabelPicker({
         anchorEl: element,
         codeBlock: { block: blocks.find(b => b.id === blockId)!, element },
       });
     } else {
       // Show comment popover anchored to the code block
-      setCommentPopover({
+      setViewerCommentPopover({
         anchorEl: element,
         contextText: (codeEl.textContent || '').slice(0, 80),
         isGlobal: false,
@@ -205,7 +225,7 @@ export const Viewer = forwardRef(({
     containerRef,
     highlighterRef,
     inputMethod,
-    enabled: !toolbarState && !commentPopover && !quickLabelPicker && !(isPlanDiffActive ?? false),
+    enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false),
     onCodeBlockClick: handlePinpointCodeBlockClick,
   });
 
@@ -236,15 +256,6 @@ export const Viewer = forwardRef(({
     return () => observer.disconnect();
   }, [stickyActions]);
 
-  // Keep refs in sync with props
-  useEffect(() => {
-    modeRef.current = mode;
-  }, [mode]);
-
-  useEffect(() => {
-    onAddAnnotationRef.current = onAddAnnotation;
-  }, [onAddAnnotation]);
-
   // Cmd+C / Ctrl+C keyboard shortcut for copying selected text
   useEffect(() => {
     const handleKeyDown = async (e: KeyboardEvent) => {
@@ -271,525 +282,33 @@ export const Viewer = forwardRef(({
     return () => document.removeEventListener('keydown', handleKeyDown);
   }, [toolbarState]);
 
-  // Helper to create annotation from highlighter source
-  const createAnnotationFromSource = (
-    highlighter: Highlighter,
-    source: any,
-    type: AnnotationType,
-    text?: string,
-    images?: ImageAttachment[],
-    isQuickLabel?: boolean,
-    quickLabelTip?: string,
-  ) => {
-    const doms = highlighter.getDoms(source.id);
-    let blockId = '';
-    let startOffset = 0;
-
-    if (doms?.length > 0) {
-      const el = doms[0] as HTMLElement;
-      let parent = el.parentElement;
-      while (parent && !parent.dataset.blockId) {
-        parent = parent.parentElement;
-      }
-      if (parent?.dataset.blockId) {
-        blockId = parent.dataset.blockId;
-        const blockText = parent.textContent || '';
-        const beforeText = blockText.split(source.text)[0];
-        startOffset = beforeText?.length || 0;
-      }
-    }
-
-    const newAnnotation: Annotation = {
-      id: source.id,
-      blockId,
-      startOffset,
-      endOffset: startOffset + source.text.length,
-      type,
-      text,
-      originalText: source.text,
-      createdA: Date.now(),
-      author: getIdentity(),
-      startMeta: source.startMeta,
-      endMeta: source.endMeta,
-      images,
-      ...(isQuickLabel ? { isQuickLabel: true } : {}),
-      ...(quickLabelTip ? { quickLabelTip } : {}),
-    };
-
-    if (type === AnnotationType.DELETION) {
-      highlighter.addClass('deletion', source.id);
-    } else if (type === AnnotationType.COMMENT) {
-      highlighter.addClass('comment', source.id);
-    }
-
-    justCreatedIdRef.current = newAnnotation.id;
-    onAddAnnotationRef.current(newAnnotation);
-  };
-
-  // Helper to find text in DOM and create a range
-  const findTextInDOM = useCallback((searchText: string): Range | null => {
-    if (!containerRef.current) return null;
-
-    const walker = document.createTreeWalker(
-      containerRef.current,
-      NodeFilter.SHOW_TEXT,
-      null
-    );
-
-    let node: Text | null;
-    while ((node = walker.nextNode() as Text | null)) {
-      const text = node.textContent || '';
-      const index = text.indexOf(searchText);
-      if (index !== -1) {
-        const range = document.createRange();
-        range.setStart(node, index);
-        range.setEnd(node, index + searchText.length);
-        return range;
-      }
-    }
-
-    // Try across multiple text nodes for multi-line content
-    const fullText = containerRef.current.textContent || '';
-    const searchIndex = fullText.indexOf(searchText);
-    if (searchIndex === -1) return null;
-
-    // Use Selection API to find and select the text
-    const selection = window.getSelection();
-    if (!selection) return null;
-
-    // Reset walker
-    const walker2 = document.createTreeWalker(
-      containerRef.current,
-      NodeFilter.SHOW_TEXT,
-      null
-    );
-
-    let charCount = 0;
-    let startNode: Text | null = null;
-    let startOffset = 0;
-    let endNode: Text | null = null;
-    let endOffset = 0;
-
-    while ((node = walker2.nextNode() as Text | null)) {
-      const nodeLength = node.textContent?.length || 0;
-
-      if (!startNode && charCount + nodeLength > searchIndex) {
-        startNode = node;
-        startOffset = searchIndex - charCount;
-      }
-
-      if (startNode && charCount + nodeLength >= searchIndex + searchText.length) {
-        endNode = node;
-        endOffset = searchIndex + searchText.length - charCount;
-        break;
-      }
-
-      charCount += nodeLength;
-    }
-
-    if (startNode && endNode) {
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      return range;
-    }
-
-    return null;
-  }, []);
-
+  // Imperative handle — delegates to hook, extends removeHighlight for code blocks
   useImperativeHandle(ref, () => ({
     removeHighlight: (id: string) => {
-      // Try highlighter first (for regular text selections)
-      highlighterRef.current?.remove(id);
-
-      // Handle manually created highlights (may be multiple marks with same ID)
+      // Code block annotations need syntax re-highlighting after removal.
+      // Must run BEFORE hookRemoveHighlight, which removes the  elements.
       const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
       manualHighlights?.forEach(el => {
         const parent = el.parentNode;
-        
-        // Check if this is a code block annotation (parent is  element)
         if (parent && parent.nodeName === 'CODE') {
-          // For code blocks, we need to restore the plain text and re-highlight
           const codeEl = parent as HTMLElement;
           const plainText = el.textContent || '';
+          el.remove();
           codeEl.textContent = plainText;
-          
-          // Re-apply syntax highlighting
           const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
-          if (block?.language) {
-            codeEl.className = `hljs font-mono language-${block.language}`;
-            hljs.highlightElement(codeEl);
-          }
-        } else {
-          // For regular text, unwrap the mark
-          while (el.firstChild) {
-            parent?.insertBefore(el.firstChild, el);
-          }
-        }
-        el.remove();
-      });
-    },
-
-    clearAllHighlights: () => {
-      // Clear all manual highlights (shared annotations and code blocks)
-      const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]');
-      manualHighlights?.forEach(el => {
-        const parent = el.parentNode;
-        while (el.firstChild) {
-          parent?.insertBefore(el.firstChild, el);
+          codeEl.removeAttribute('data-highlighted');
+          codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`;
+          hljs.highlightElement(codeEl);
         }
-        el.remove();
       });
 
-      // Clear web-highlighter highlights
-      const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight');
-      webHighlights?.forEach(el => {
-        const parent = el.parentNode;
-        while (el.firstChild) {
-          parent?.insertBefore(el.firstChild, el);
-        }
-        el.remove();
-      });
+      hookRemoveHighlight(id);
     },
+    clearAllHighlights,
+    applySharedAnnotations: applyAnnotations,
+  }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
 
-    applySharedAnnotations: (sharedAnnotations: Annotation[]) => {
-      const highlighter = highlighterRef.current;
-      if (!highlighter || !containerRef.current) return;
-
-      sharedAnnotations.forEach(ann => {
-        // Skip if already highlighted
-        const existingDoms = highlighter.getDoms(ann.id);
-        if (existingDoms && existingDoms.length > 0) return;
-
-        // Also skip if manually highlighted
-        const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
-        if (existingManual) return;
-
-        // Find the text in the DOM
-        const range = findTextInDOM(ann.originalText);
-        if (!range) {
-          console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
-          return;
-        }
-
-        try {
-          // Multi-mark approach: wrap each text node portion separately
-          // This avoids destructive extractContents() that breaks DOM structure
-          const textNodes: { node: Text; start: number; end: number }[] = [];
-
-          // Collect all text nodes within the range
-          const walker = document.createTreeWalker(
-            range.commonAncestorContainer.nodeType === Node.TEXT_NODE
-              ? range.commonAncestorContainer.parentNode!
-              : range.commonAncestorContainer,
-            NodeFilter.SHOW_TEXT,
-            null
-          );
-
-          let node: Text | null;
-          let inRange = false;
-
-          while ((node = walker.nextNode() as Text | null)) {
-            // Check if this node is the start container
-            if (node === range.startContainer) {
-              inRange = true;
-              const start = range.startOffset;
-              const end = node === range.endContainer ? range.endOffset : node.length;
-              if (end > start) {
-                textNodes.push({ node, start, end });
-              }
-              if (node === range.endContainer) break;
-              continue;
-            }
-
-            // Check if this node is the end container
-            if (node === range.endContainer) {
-              if (inRange) {
-                const end = range.endOffset;
-                if (end > 0) {
-                  textNodes.push({ node, start: 0, end });
-                }
-              }
-              break;
-            }
-
-            // Node is fully within range
-            if (inRange && node.length > 0) {
-              textNodes.push({ node, start: 0, end: node.length });
-            }
-          }
-
-          // If we only have one text node and it's fully contained, use simple approach
-          if (textNodes.length === 0) {
-            console.warn(`No text nodes found for annotation ${ann.id}`);
-            return;
-          }
-
-          // Wrap each text node portion with its own mark (process in reverse to avoid offset issues)
-          textNodes.reverse().forEach(({ node, start, end }) => {
-            try {
-              const nodeRange = document.createRange();
-              nodeRange.setStart(node, start);
-              nodeRange.setEnd(node, end);
-
-              const mark = document.createElement('mark');
-              mark.className = 'annotation-highlight';
-              mark.dataset.bindId = ann.id;
-
-              if (ann.type === AnnotationType.DELETION) {
-                mark.classList.add('deletion');
-              } else if (ann.type === AnnotationType.COMMENT) {
-                mark.classList.add('comment');
-              }
-
-              // surroundContents works reliably for single text node ranges
-              nodeRange.surroundContents(mark);
-
-              // Make it clickable
-              mark.addEventListener('click', () => {
-                onSelectAnnotation(ann.id);
-              });
-            } catch (e) {
-              console.warn(`Failed to wrap text node for annotation ${ann.id}:`, e);
-            }
-          });
-        } catch (e) {
-          console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
-        }
-      });
-    }
-  }), [findTextInDOM, onSelectAnnotation]);
-
-  // Track last mouse position for cursor-anchored quick label picker
-  useEffect(() => {
-    const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; };
-    document.addEventListener('mouseup', track, true);
-    return () => document.removeEventListener('mouseup', track, true);
-  }, []);
-
-  useEffect(() => {
-    if (!containerRef.current) return;
-
-    const highlighter = new Highlighter({
-      $root: containerRef.current,
-      exceptSelectors: ['.annotation-toolbar', 'button'],
-      wrapTag: 'mark',
-      style: { className: 'annotation-highlight' }
-    });
-
-    highlighterRef.current = highlighter;
-
-    highlighter.on(Highlighter.event.CREATE, ({ sources }: { sources: any[] }) => {
-      if (sources.length > 0) {
-        const source = sources[0];
-        const doms = highlighter.getDoms(source.id);
-        if (doms?.length > 0) {
-          // Clean up previous pending highlight and dismiss open popover
-          if (pendingSourceRef.current) {
-            highlighter.remove(pendingSourceRef.current.id);
-            pendingSourceRef.current = null;
-          }
-          setCommentPopover(null);
-          setQuickLabelPicker(null);
-
-          if (modeRef.current === 'redline') {
-            // Auto-delete in redline mode
-            createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
-            window.getSelection()?.removeAllRanges();
-          } else if (modeRef.current === 'comment') {
-            // Comment mode - open CommentPopover directly
-            pendingSourceRef.current = source;
-            setCommentPopover({
-              anchorEl: doms[0] as HTMLElement,
-              contextText: source.text.slice(0, 80),
-              isGlobal: false,
-              source,
-            });
-          } else if (modeRef.current === 'quickLabel') {
-            // Quick Label mode - show floating label picker directly
-            pendingSourceRef.current = source;
-            setQuickLabelPicker({
-              anchorEl: doms[0] as HTMLElement,
-              cursorHint: lastMousePosRef.current,
-              source,
-            });
-          } else {
-            // Selection mode - show toolbar menu
-            const selectionText = source.text;
-            pendingSourceRef.current = source;
-            setToolbarState({ element: doms[0] as HTMLElement, source, selectionText });
-          }
-        }
-      }
-    });
-
-    highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
-      onSelectAnnotation(id);
-    });
-
-    highlighter.run();
-
-    // Mobile: bridge native text selection (long-press) to the highlighter's CREATE flow.
-    // On mobile/touch, native selection handles don't reliably fire touchend on the content
-    // root, so the web-highlighter's built-in PointerEnd listener never triggers.
-    // This selectionchange listener detects valid selections and uses the highlighter's
-    // public fromRange() API to programmatically create the highlight and emit CREATE.
-    // Use (pointer: coarse) instead of 'ontouchstart' in window — the latter is true on
-    // desktop Chrome when the machine has a touchscreen or DevTools touch was toggled.
-    const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
-    let selectionTimer: ReturnType;
-    const handleSelectionChange = isTouchPrimary ? () => {
-      clearTimeout(selectionTimer);
-      selectionTimer = setTimeout(() => {
-        const sel = window.getSelection();
-        if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
-        if (!containerRef.current?.contains(sel.anchorNode)) return;
-
-        const range = sel.getRangeAt(0);
-        highlighter.fromRange(range);
-      }, 400);
-    } : null;
-
-    if (handleSelectionChange) {
-      document.addEventListener('selectionchange', handleSelectionChange);
-    }
-
-    return () => {
-      if (handleSelectionChange) {
-        clearTimeout(selectionTimer);
-        document.removeEventListener('selectionchange', handleSelectionChange);
-      }
-      highlighter.dispose();
-    };
-  }, [onSelectAnnotation]);
-
-  useEffect(() => {
-    const highlighter = highlighterRef.current;
-    if (!highlighter) return;
-
-    annotations.forEach(ann => {
-      try {
-        const doms = highlighter.getDoms(ann.id);
-        if (doms?.length > 0) {
-          if (ann.type === AnnotationType.DELETION) {
-            highlighter.addClass('deletion', ann.id);
-          } else if (ann.type === AnnotationType.COMMENT) {
-            highlighter.addClass('comment', ann.id);
-          }
-        }
-      } catch (e) {}
-    });
-  }, [annotations]);
-
-  // Scroll to and focus the selected annotation's highlight in the content
-  useEffect(() => {
-    if (!containerRef.current) return;
-
-    // Clear all previously focused highlights
-    containerRef.current.querySelectorAll('.annotation-highlight.focused').forEach(el => {
-      el.classList.remove('focused');
-    });
-
-    if (!selectedAnnotationId) return;
-
-    // Skip scroll+focus when annotation was just created (user is already looking at it)
-    if (justCreatedIdRef.current === selectedAnnotationId) {
-      justCreatedIdRef.current = null;
-      return;
-    }
-
-    // Find highlight elements: try web-highlighter first, then manual marks
-    const highlighter = highlighterRef.current;
-    let targetElements: Element[] = [];
-
-    if (highlighter) {
-      try {
-        const doms = highlighter.getDoms(selectedAnnotationId);
-        if (doms && doms.length > 0) {
-          targetElements = Array.from(doms);
-        }
-      } catch (e) {}
-    }
-
-    if (targetElements.length === 0) {
-      const manualMarks = containerRef.current.querySelectorAll(
-        `[data-bind-id="${selectedAnnotationId}"]`
-      );
-      if (manualMarks.length > 0) {
-        targetElements = Array.from(manualMarks);
-      }
-    }
-
-    if (targetElements.length === 0) return;
-
-    // Apply focused class to all elements and scroll the first one into view
-    targetElements.forEach(el => el.classList.add('focused'));
-    targetElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
-  }, [selectedAnnotationId]);
-
-  const handleAnnotate = (type: AnnotationType) => {
-    const highlighter = highlighterRef.current;
-    if (!toolbarState || !highlighter) return;
-
-    createAnnotationFromSource(highlighter, toolbarState.source, type);
-    pendingSourceRef.current = null;
-    setToolbarState(null);
-    window.getSelection()?.removeAllRanges();
-  };
-
-  const handleQuickLabel = (label: QuickLabel) => {
-    const highlighter = highlighterRef.current;
-    if (!toolbarState || !highlighter) return;
-
-    createAnnotationFromSource(
-      highlighter, toolbarState.source, AnnotationType.COMMENT,
-      `${label.emoji} ${label.text}`, undefined, true, label.tip
-    );
-    pendingSourceRef.current = null;
-    setToolbarState(null);
-    window.getSelection()?.removeAllRanges();
-  };
-
-  const handleFloatingQuickLabel = useCallback((label: QuickLabel) => {
-    if (!quickLabelPicker) return;
-
-    if (quickLabelPicker.source && highlighterRef.current) {
-      createAnnotationFromSource(
-        highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT,
-        `${label.emoji} ${label.text}`, undefined, true, label.tip
-      );
-      pendingSourceRef.current = null;
-    } else if (quickLabelPicker.codeBlock) {
-      const codeEl = quickLabelPicker.codeBlock.element.querySelector('code');
-      if (codeEl) {
-        applyCodeBlockAnnotation(
-          quickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT,
-          `${label.emoji} ${label.text}`, undefined, true, label.tip
-        );
-      }
-    }
-
-    setQuickLabelPicker(null);
-    window.getSelection()?.removeAllRanges();
-  }, [quickLabelPicker]);
-
-  const handleQuickLabelPickerDismiss = useCallback(() => {
-    if (quickLabelPicker?.source && highlighterRef.current) {
-      highlighterRef.current.remove(quickLabelPicker.source.id);
-      pendingSourceRef.current = null;
-    }
-    setQuickLabelPicker(null);
-    window.getSelection()?.removeAllRanges();
-  }, [quickLabelPicker]);
-
-  const handleToolbarClose = () => {
-    if (toolbarState && highlighterRef.current) {
-      highlighterRef.current.remove(toolbarState.source.id);
-    }
-    pendingSourceRef.current = null;
-    setToolbarState(null);
-    window.getSelection()?.removeAllRanges();
-  };
+  // --- Viewer-specific: code block annotation ---
 
   const applyCodeBlockAnnotation = (
     blockId: string,
@@ -826,7 +345,6 @@ export const Viewer = forwardRef(({
       ...(quickLabelTip ? { quickLabelTip } : {}),
     };
 
-    justCreatedIdRef.current = newAnnotation.id;
     onAddAnnotationRef.current(newAnnotation);
     window.getSelection()?.removeAllRanges();
   };
@@ -854,25 +372,12 @@ export const Viewer = forwardRef(({
     setHoveredCodeBlock(null);
   };
 
-  // CommentPopover handlers
-
-  const handleRequestComment = (initialChar?: string) => {
-    if (!toolbarState) return;
-    setCommentPopover({
-      anchorEl: toolbarState.element,
-      contextText: toolbarState.selectionText.slice(0, 80),
-      initialText: initialChar,
-      isGlobal: false,
-      source: toolbarState.source,
-    });
-    // Close toolbar but keep pendingSourceRef
-    setToolbarState(null);
-  };
+  // Viewer-specific comment popover handlers (code blocks + global comments)
 
   const handleCodeBlockRequestComment = (initialChar?: string) => {
     if (!hoveredCodeBlock) return;
     const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || '';
-    setCommentPopover({
+    setViewerCommentPopover({
       anchorEl: hoveredCodeBlock.element,
       contextText: codeText.slice(0, 80),
       initialText: initialChar,
@@ -882,10 +387,10 @@ export const Viewer = forwardRef(({
     setHoveredCodeBlock(null);
   };
 
-  const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
-    if (!commentPopover) return;
+  const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
+    if (!viewerCommentPopover) return;
 
-    if (commentPopover.isGlobal) {
+    if (viewerCommentPopover.isGlobal) {
       const newAnnotation: Annotation = {
         id: `global-${Date.now()}`,
         blockId: '',
@@ -899,35 +404,18 @@ export const Viewer = forwardRef(({
         images,
       };
       onAddAnnotation(newAnnotation);
-    } else if (commentPopover.source && highlighterRef.current) {
-      createAnnotationFromSource(
-        highlighterRef.current,
-        commentPopover.source,
-        AnnotationType.COMMENT,
-        text,
-        images
-      );
-      pendingSourceRef.current = null;
-      window.getSelection()?.removeAllRanges();
-    } else if (commentPopover.codeBlock) {
-      const codeEl = commentPopover.codeBlock.element.querySelector('code');
+    } else if (viewerCommentPopover.codeBlock) {
+      const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
       if (codeEl) {
-        applyCodeBlockAnnotation(commentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
+        applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
       }
     }
 
-    setCommentPopover(null);
+    setViewerCommentPopover(null);
   };
 
-  const handleCommentClose = useCallback(() => {
-    setCommentPopover((prev) => {
-      if (prev?.source && highlighterRef.current) {
-        highlighterRef.current.remove(prev.source.id);
-        pendingSourceRef.current = null;
-      }
-      return null;
-    });
-    window.getSelection()?.removeAllRanges();
+  const handleViewerCommentClose = useCallback(() => {
+    setViewerCommentPopover(null);
   }, []);
 
   return (
@@ -1015,7 +503,7 @@ export const Viewer = forwardRef(({
           
); -}; +}); diff --git a/packages/ui/hooks/useAnnotationHighlighter.ts b/packages/ui/hooks/useAnnotationHighlighter.ts index 42991c9bf..e700c95df 100644 --- a/packages/ui/hooks/useAnnotationHighlighter.ts +++ b/packages/ui/hooks/useAnnotationHighlighter.ts @@ -244,7 +244,6 @@ export function useAnnotationHighlighter({ anns.forEach(ann => { if (ann.type === AnnotationType.GLOBAL_COMMENT) return; - if (ann.diffContext) return; // Diff annotations don't restore via text search // Skip if already highlighted try { @@ -488,10 +487,6 @@ export function useAnnotationHighlighter({ const needsRestore = annotations.filter(ann => { if (ann.type === AnnotationType.GLOBAL_COMMENT) return false; - // Diff annotations should never be restored via text search — the same text - // can appear in multiple diff chunks/sides, causing wrong-match binding. - // They stay visible in the annotation panel with their diff context badge. - if (ann.diffContext) return false; try { const doms = highlighter.getDoms(ann.id); if (doms && doms.length > 0) return false; diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 599202289..98397437f 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -28,7 +28,7 @@ export interface Annotation { images?: ImageAttachment[]; // Attached images with human-readable names isQuickLabel?: boolean; // true if created via quick label chip quickLabelTip?: string; // optional instruction tip from the label definition - diffContext?: 'added' | 'removed' | 'modified'; // set when annotation created in plan diff view + diffContext?: 'added' | 'removed' | 'modified' | 'unchanged'; // set when annotation created in plan diff view // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 45b81ec4b..c1b684844 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -284,12 +284,7 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt // Add diff context label if annotation was created in diff view if (ann.diffContext) { - const diffLabels: Record = { - added: '[In newly added content]', - removed: '[In removed content]', - modified: '[In modified content]', - }; - output += `${diffLabels[ann.diffContext]} `; + output += `[In diff content] `; } switch (ann.type) { From 240e24db1c3d4f24912a6201f4276b4437e00d1c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 15 Mar 2026 16:51:44 -0700 Subject: [PATCH 4/6] refactor: block-level diff annotation replaces text-selection approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile text-selection annotation in diff mode with block-level hover annotation. Hovering added/removed/modified sections shows the annotation toolbar (same pattern as code block hover in Viewer). No web-highlighter, no DOM marks, no findTextInDOM restoration. Annotations live purely in React state — delete just removes from state, toggle preserves annotations in panel, no stale marks possible. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/editor/App.tsx | 3 - .../plan-diff/PlanCleanDiffView.tsx | 357 +++++++++++++----- .../components/plan-diff/PlanDiffViewer.tsx | 12 +- 3 files changed, 274 insertions(+), 98 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 33c468065..7bba2d482 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -97,7 +97,6 @@ const App: React.FC = () => { const [versionInfo, setVersionInfo] = useState(null); const viewerRef = useRef(null); - const diffViewerRef = useRef<{ removeHighlight: (id: string) => void }>(null); const containerRef = useRef(null); // Resizable panels @@ -647,7 +646,6 @@ const App: React.FC = () => { const handleDeleteAnnotation = (id: string) => { viewerRef.current?.removeHighlight(id); - diffViewerRef.current?.removeHighlight(id); setAnnotations(prev => prev.filter(a => a.id !== id)); if (selectedAnnotationId === id) setSelectedAnnotationId(null); }; @@ -1168,7 +1166,6 @@ const App: React.FC = () => { {/* Plan Diff View or Normal Plan View */} {isPlanDiffActive && planDiff.diffBlocks && planDiff.diffStats ? ( void; onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId?: string | null; mode?: EditorMode; } -export interface DiffViewHandle { - removeHighlight: (id: string) => void; -} - -export const PlanCleanDiffView = forwardRef(({ +export const PlanCleanDiffView: React.FC = ({ blocks, - annotations = [], onAddAnnotation, onSelectAnnotation, selectedAnnotationId = null, mode = "selection", -}, ref) => { - const containerRef = useRef(null); - - // Resolve diff context from DOM — walks up to find data-diff-type - const resolveDiffContext = (el: HTMLElement): Annotation["diffContext"] => { - let node: HTMLElement | null = el; - while (node && node !== containerRef.current) { - const dt = node.dataset.diffType; - if (dt === "added" || dt === "removed" || dt === "modified" || dt === "unchanged") return dt; - node = node.parentElement; +}) => { + const modeRef = useRef(mode); + const onAddAnnotationRef = useRef(onAddAnnotation); + const hoverTimeoutRef = useRef | null>(null); + + const [hoveredBlock, setHoveredBlock] = useState<{ + element: HTMLElement; + block: PlanDiffBlock; + index: number; + diffContext: Annotation['diffContext']; + } | null>(null); + const [isExiting, setIsExiting] = useState(false); + + const [commentPopover, setCommentPopover] = useState<{ + anchorEl: HTMLElement; + contextText: string; + initialText?: string; + block: PlanDiffBlock; + index: number; + diffContext: Annotation['diffContext']; + } | null>(null); + + const [quickLabelPicker, setQuickLabelPicker] = useState<{ + anchorEl: HTMLElement; + block: PlanDiffBlock; + index: number; + diffContext: Annotation['diffContext']; + } | null>(null); + + useEffect(() => { modeRef.current = mode; }, [mode]); + useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); + + // Scroll to selected annotation's diff block + useEffect(() => { + if (!selectedAnnotationId) return; + const el = document.querySelector(`[data-diff-block-index]`); + // Find the block by checking all diff blocks for matching annotation blockId + // For now, this is a no-op — diff annotations don't have scroll-to support yet + }, [selectedAnnotationId]); + + const createDiffAnnotation = useCallback(( + block: PlanDiffBlock, + index: number, + diffContext: Annotation['diffContext'], + type: AnnotationType, + text?: string, + images?: ImageAttachment[], + isQuickLabel?: boolean, + quickLabelTip?: string, + ) => { + const content = block.type === 'modified' && diffContext === 'removed' + ? block.oldContent || block.content + : block.content; + + const newAnnotation: Annotation = { + id: `diff-${Date.now()}-${index}`, + blockId: `diff-block-${index}`, + startOffset: 0, + endOffset: content.length, + type, + text, + originalText: content.slice(0, 500), // Cap for very large blocks + createdA: Date.now(), + author: getIdentity(), + images, + diffContext, + ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), + }; + + onAddAnnotationRef.current?.(newAnnotation); + }, []); + + // Hover handlers + const handleHover = useCallback((element: HTMLElement, block: PlanDiffBlock, index: number, diffContext: Annotation['diffContext']) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setIsExiting(false); + if (!commentPopover && !quickLabelPicker) { + setHoveredBlock({ element, block, index, diffContext }); } - return undefined; + }, [commentPopover, quickLabelPicker]); + + const handleLeave = useCallback(() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsExiting(true); + setTimeout(() => { + setHoveredBlock(null); + setIsExiting(false); + }, 150); + }, 100); + }, []); + + // Toolbar handlers + const handleAnnotate = (type: AnnotationType) => { + if (!hoveredBlock) return; + createDiffAnnotation(hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext, type); + setHoveredBlock(null); + }; + + const handleQuickLabel = (label: QuickLabel) => { + if (!hoveredBlock) return; + createDiffAnnotation( + hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext, + AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip + ); + setHoveredBlock(null); + }; + + const handleToolbarClose = () => { + setHoveredBlock(null); }; - const { - toolbarState, - commentPopover, - quickLabelPicker, - handleAnnotate, - handleQuickLabel, - handleToolbarClose, - handleRequestComment, - handleCommentSubmit, - handleCommentClose, - handleFloatingQuickLabel, - handleQuickLabelPickerDismiss, - removeHighlight, - } = useAnnotationHighlighter({ - containerRef, - annotations, - onAddAnnotation, - onSelectAnnotation, - selectedAnnotationId, - mode, - enabled: !!onAddAnnotation, - resolveContext: resolveDiffContext, - }); - - useImperativeHandle(ref, () => ({ removeHighlight }), [removeHighlight]); + const handleRequestComment = (initialChar?: string) => { + if (!hoveredBlock) return; + const content = hoveredBlock.block.type === 'modified' && hoveredBlock.diffContext === 'removed' + ? hoveredBlock.block.oldContent || hoveredBlock.block.content + : hoveredBlock.block.content; + setCommentPopover({ + anchorEl: hoveredBlock.element, + contextText: content.slice(0, 80), + initialText: initialChar, + block: hoveredBlock.block, + index: hoveredBlock.index, + diffContext: hoveredBlock.diffContext, + }); + setHoveredBlock(null); + }; + + const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => { + if (!commentPopover) return; + createDiffAnnotation( + commentPopover.block, commentPopover.index, commentPopover.diffContext, + AnnotationType.COMMENT, text, images + ); + setCommentPopover(null); + }; + + const handleCommentClose = useCallback(() => { + setCommentPopover(null); + }, []); + + const handleFloatingQuickLabel = useCallback((label: QuickLabel) => { + if (!quickLabelPicker) return; + createDiffAnnotation( + quickLabelPicker.block, quickLabelPicker.index, quickLabelPicker.diffContext, + AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip + ); + setQuickLabelPicker(null); + }, [quickLabelPicker, createDiffAnnotation]); + + const handleQuickLabelPickerDismiss = useCallback(() => { + setQuickLabelPicker(null); + }, []); + + // Mode-aware click on hovered block + const handleBlockClick = useCallback((block: PlanDiffBlock, index: number, element: HTMLElement, diffContext: Annotation['diffContext']) => { + if (modeRef.current === 'redline') { + createDiffAnnotation(block, index, diffContext, AnnotationType.DELETION); + } else if (modeRef.current === 'comment') { + const content = block.type === 'modified' && diffContext === 'removed' + ? block.oldContent || block.content + : block.content; + setCommentPopover({ + anchorEl: element, + contextText: content.slice(0, 80), + block, + index, + diffContext, + }); + } else if (modeRef.current === 'quickLabel') { + setQuickLabelPicker({ anchorEl: element, block, index, diffContext }); + } + // In selection mode, the toolbar handles it via its own buttons + }, [createDiffAnnotation]); return ( -
+
{blocks.map((block, index) => ( - + handleHover(el, block, index, diffContext) : undefined} + onLeave={onAddAnnotation ? handleLeave : undefined} + onClick={onAddAnnotation && modeRef.current !== 'selection' ? (el, diffContext) => handleBlockClick(block, index, el, diffContext) : undefined} + /> ))} - {/* Text selection toolbar */} - {toolbarState && ( + {/* Block hover toolbar (selection mode) */} + {hoveredBlock && !commentPopover && !quickLabelPicker && ( { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setIsExiting(false); + }} + onMouseLeave={() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsExiting(true); + setTimeout(() => { + setHoveredBlock(null); + setIsExiting(false); + }, 150); + }, 100); + }} /> )} @@ -119,45 +272,86 @@ export const PlanCleanDiffView = forwardRef )}
); -}); +}; + +// --- DiffBlockRenderer with hover support --- + +interface DiffBlockRendererProps { + block: PlanDiffBlock; + index: number; + isHovered: boolean; + hoveredDiffContext?: Annotation['diffContext']; + onHover?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void; + onLeave?: () => void; + onClick?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void; +} + +const DiffBlockRenderer: React.FC = ({ + block, index, isHovered, hoveredDiffContext, onHover, onLeave, onClick, +}) => { + const hoverProps = (diffContext: Annotation['diffContext']) => onHover ? { + onMouseEnter: (e: React.MouseEvent) => onHover(e.currentTarget, diffContext), + onMouseLeave: () => onLeave?.(), + onClick: onClick ? (e: React.MouseEvent) => onClick(e.currentTarget, diffContext) : undefined, + style: { cursor: onHover ? 'pointer' : undefined } as React.CSSProperties, + } : {}; + + const ringClass = (diffContext: Annotation['diffContext']) => + isHovered && hoveredDiffContext === diffContext ? 'ring-1 ring-primary/30 rounded' : ''; -const DiffBlockRenderer: React.FC<{ block: PlanDiffBlock }> = ({ block }) => { switch (block.type) { case "unchanged": return ( -
+
); case "added": return ( -
+
); case "removed": return ( -
+
); case "modified": return ( -
-
+
+
-
+
@@ -168,10 +362,8 @@ const DiffBlockRenderer: React.FC<{ block: PlanDiffBlock }> = ({ block }) => { } }; -/** - * Renders a markdown string chunk using parseMarkdownToBlocks + simplified block rendering. - * Reuses the same visual output as the Viewer component. - */ +// --- Rendering components (unchanged) --- + const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => { const blocks = React.useMemo( () => parseMarkdownToBlocks(content), @@ -187,11 +379,6 @@ const MarkdownChunk: React.FC<{ content: string }> = ({ content }) => { ); }; -/** - * Simplified block renderer — same visual output as Viewer's BlockRenderer - * but without code block hover, mermaid, or linked doc support. - * Adds data-block-id for annotation anchoring. - */ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { switch (block.type) { case "heading": { @@ -327,9 +514,6 @@ const SimpleBlockRenderer: React.FC<{ block: Block }> = ({ block }) => { } }; -/** - * Simplified code block with syntax highlighting (no hover/copy toolbar). - */ const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => { const codeRef = useRef(null); @@ -360,9 +544,6 @@ const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => { ); }; -/** - * Inline markdown renderer — handles bold, italic, inline code, links. - */ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => { const parts: React.ReactNode[] = []; let remaining = text; diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index 8d93d6bd6..a74b76de4 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -6,14 +6,14 @@ * diff content instead of the annotatable plan. */ -import React, { useState, forwardRef } from "react"; +import React, { useState } from "react"; import type { PlanDiffBlock, PlanDiffStats } from "../../utils/planDiffEngine"; import type { Annotation, EditorMode } from "../../types"; import { PlanDiffModeSwitcher, type PlanDiffMode, } from "./PlanDiffModeSwitcher"; -import { PlanCleanDiffView, type DiffViewHandle } from "./PlanCleanDiffView"; +import { PlanCleanDiffView } from "./PlanCleanDiffView"; import { PlanRawDiffView } from "./PlanRawDiffView"; import { PlanDiffBadge } from "./PlanDiffBadge"; import { VSCodeIcon } from "./VSCodeIcon"; @@ -36,7 +36,7 @@ interface PlanDiffViewerProps { mode?: EditorMode; } -export const PlanDiffViewer = forwardRef(({ +export const PlanDiffViewer: React.FC = ({ diffBlocks, diffStats, diffMode, @@ -51,7 +51,7 @@ export const PlanDiffViewer = forwardRef(({ onSelectAnnotation, selectedAnnotationId, mode, -}, ref) => { +}) => { const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false); const [vscodeDiffError, setVscodeDiffError] = useState(null); @@ -179,9 +179,7 @@ export const PlanDiffViewer = forwardRef(({ {/* Diff content */} {diffMode === "clean" ? ( (({
); -}); +}; From cdce497f0e1808669f963fa062ba4bfb84a2c6b6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 15 Mar 2026 17:32:23 -0700 Subject: [PATCH 5/6] chore: remove dead resolveContext plumbing from useAnnotationHighlighter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanCleanDiffView no longer uses this hook (switched to block-level hover). Remove the resolveContext option, ref, sync effect, and call site — no consumer passes it. Update header comment to reflect the hook is Viewer-only. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/hooks/useAnnotationHighlighter.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/ui/hooks/useAnnotationHighlighter.ts b/packages/ui/hooks/useAnnotationHighlighter.ts index e700c95df..e5fe74c65 100644 --- a/packages/ui/hooks/useAnnotationHighlighter.ts +++ b/packages/ui/hooks/useAnnotationHighlighter.ts @@ -1,11 +1,8 @@ /** - * useAnnotationHighlighter — shared annotation infrastructure for Viewer and PlanCleanDiffView. + * useAnnotationHighlighter — annotation infrastructure for Viewer. * * Manages: web-highlighter lifecycle, toolbar/popover/quicklabel state, * annotation creation, text-based restoration (drafts/shares), scroll-to-selected. - * - * Extension point: `resolveContext` callback lets diff view tag annotations - * with their diff section (added/removed/modified). */ import { useEffect, useRef, useState, useCallback, type RefObject } from 'react'; @@ -46,8 +43,6 @@ export interface UseAnnotationHighlighterOptions { selectedAnnotationId: string | null; mode: EditorMode; enabled?: boolean; - /** Extension point: resolve extra context from the highlight DOM element (e.g. diff section) */ - resolveContext?: (el: HTMLElement) => Annotation['diffContext']; } export interface UseAnnotationHighlighterReturn { @@ -79,13 +74,11 @@ export function useAnnotationHighlighter({ selectedAnnotationId, mode, enabled = true, - resolveContext, }: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn { const highlighterRef = useRef(null); const modeRef = useRef(mode); const onAddAnnotationRef = useRef(onAddAnnotation); const onSelectAnnotationRef = useRef(onSelectAnnotation); - const resolveContextRef = useRef(resolveContext); const pendingSourceRef = useRef(null); const justCreatedIdRef = useRef(null); const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); @@ -98,7 +91,6 @@ export function useAnnotationHighlighter({ useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); useEffect(() => { onSelectAnnotationRef.current = onSelectAnnotation; }, [onSelectAnnotation]); - useEffect(() => { resolveContextRef.current = resolveContext; }, [resolveContext]); // Track mouse position for quick label picker useEffect(() => { @@ -188,7 +180,6 @@ export function useAnnotationHighlighter({ const doms = highlighter.getDoms(source.id); let blockId = ''; let startOffset = 0; - let diffContext: Annotation['diffContext']; if (doms?.length > 0) { const el = doms[0] as HTMLElement; @@ -202,10 +193,6 @@ export function useAnnotationHighlighter({ const beforeText = blockText.split(source.text)[0]; startOffset = beforeText?.length || 0; } - // Extension point: resolve context (e.g. diff section) - if (resolveContextRef.current) { - diffContext = resolveContextRef.current(el); - } } const newAnnotation: Annotation = { @@ -223,7 +210,6 @@ export function useAnnotationHighlighter({ images, ...(isQuickLabel ? { isQuickLabel: true } : {}), ...(quickLabelTip ? { quickLabelTip } : {}), - ...(diffContext ? { diffContext } : {}), }; if (type === AnnotationType.DELETION) { From a79765170bc50b452ed559c7219ef064fa372f5f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 15 Mar 2026 20:12:25 -0700 Subject: [PATCH 6/6] chore: cleanup dead code, fix share/draft restore leaking diff annotations - Remove no-op scroll-to-selected useEffect - Add hoverTimeoutRef cleanup on unmount - Deduplicate toolbar onMouseLeave (call handleLeave) - Remove dead annotations prop from PlanDiffViewer - Remove as any cast + stale isGlobal field from hook - Remove unused 'unchanged' from diffContext type - Filter diff annotations from share/draft restore paths - Fix stale CLAUDE.md claims Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 6 +++--- packages/editor/App.tsx | 5 ++--- .../plan-diff/PlanCleanDiffView.tsx | 21 ++++++------------- .../components/plan-diff/PlanDiffViewer.tsx | 2 -- packages/ui/hooks/useAnnotationHighlighter.ts | 3 +-- packages/ui/types.ts | 2 +- 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ce857f2c..e5f536af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,9 +227,9 @@ When a user denies a plan and Claude resubmits, the UI shows what changed betwee **State** (`packages/ui/hooks/usePlanDiff.ts`): Manages base version selection, diff computation, and version fetching. The server sends `previousPlan` with the initial `/api/plan` response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser. -**Diff annotations:** The clean diff view supports full annotation (same as normal plan view). Annotations created in diff mode carry a `diffContext` field (`added`/`removed`/`modified`). Exported feedback includes context labels like `[In newly added content]`. +**Diff annotations:** The clean diff view supports block-level annotation — hover over added/removed/modified sections to annotate entire blocks. Annotations carry a `diffContext` field (`added`/`removed`/`modified`). Exported feedback includes `[In diff content]` labels. -**Annotation hook** (`packages/ui/hooks/useAnnotationHighlighter.ts`): Shared annotation infrastructure used by both `Viewer.tsx` and `PlanCleanDiffView.tsx`. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. Accepts an optional `resolveContext` callback for diff-specific context tagging. +**Annotation hook** (`packages/ui/hooks/useAnnotationHighlighter.ts`): Annotation infrastructure used by `Viewer.tsx`. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. The diff view uses its own block-level hover system instead. **Sidebar** (`packages/ui/hooks/useSidebar.ts`): Shared left sidebar with two tabs — Table of Contents and Version Browser. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). @@ -291,7 +291,7 @@ interface Block { - Horizontal rules (`---`) - Paragraphs (default) -`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. Annotations with `diffContext` include labels like `[In newly added content]`. +`exportAnnotations(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. Annotations with `diffContext` include `[In diff content]` labels. ## Annotation System diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 7bba2d482..b8861e386 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -264,7 +264,7 @@ const App: React.FC = () => { if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); // Apply highlights to DOM after a tick setTimeout(() => { - viewerRef.current?.applySharedAnnotations(restored); + viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); }, 100); } }, [restoreDraft]); @@ -279,7 +279,7 @@ const App: React.FC = () => { const timer = setTimeout(() => { // Clear existing highlights first (important when loading new share URL) viewerRef.current?.clearAllHighlights(); - viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations); + viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext)); clearPendingSharedAnnotations(); }, 100); return () => clearTimeout(timer); @@ -1175,7 +1175,6 @@ const App: React.FC = () => { baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined} baseVersion={planDiff.diffBaseVersion ?? undefined} maxWidth={planMaxWidth} - annotations={annotations.filter(a => !!a.diffContext)} onAddAnnotation={handleAddAnnotation} onSelectAnnotation={handleSelectAnnotation} selectedAnnotationId={selectedAnnotationId} diff --git a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx index 0f11aec24..91e742645 100644 --- a/packages/ui/components/plan-diff/PlanCleanDiffView.tsx +++ b/packages/ui/components/plan-diff/PlanCleanDiffView.tsx @@ -64,13 +64,12 @@ export const PlanCleanDiffView: React.FC = ({ useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); - // Scroll to selected annotation's diff block + // Clean up hover timeout on unmount useEffect(() => { - if (!selectedAnnotationId) return; - const el = document.querySelector(`[data-diff-block-index]`); - // Find the block by checking all diff blocks for matching annotation blockId - // For now, this is a no-op — diff annotations don't have scroll-to support yet - }, [selectedAnnotationId]); + return () => { + if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current); + }; + }, []); const createDiffAnnotation = useCallback(( block: PlanDiffBlock, @@ -244,15 +243,7 @@ export const PlanCleanDiffView: React.FC = ({ } setIsExiting(false); }} - onMouseLeave={() => { - hoverTimeoutRef.current = setTimeout(() => { - setIsExiting(true); - setTimeout(() => { - setHoveredBlock(null); - setIsExiting(false); - }, 150); - }, 100); - }} + onMouseLeave={handleLeave} /> )} diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index a74b76de4..dc1e5cf72 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -29,7 +29,6 @@ interface PlanDiffViewerProps { baseVersion?: number; maxWidth?: number; // Annotation props - annotations?: Annotation[]; onAddAnnotation?: (ann: Annotation) => void; onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId?: string | null; @@ -46,7 +45,6 @@ export const PlanDiffViewer: React.FC = ({ baseVersionLabel, baseVersion, maxWidth, - annotations, onAddAnnotation, onSelectAnnotation, selectedAnnotationId, diff --git a/packages/ui/hooks/useAnnotationHighlighter.ts b/packages/ui/hooks/useAnnotationHighlighter.ts index e5fe74c65..d8a55b0a7 100644 --- a/packages/ui/hooks/useAnnotationHighlighter.ts +++ b/packages/ui/hooks/useAnnotationHighlighter.ts @@ -390,9 +390,8 @@ export function useAnnotationHighlighter({ setCommentPopover({ anchorEl: doms[0] as HTMLElement, contextText: source.text.slice(0, 80), - isGlobal: false, source, - } as any); + }); } else if (modeRef.current === 'quickLabel') { pendingSourceRef.current = source; setQuickLabelPicker({ diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 98397437f..599202289 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -28,7 +28,7 @@ export interface Annotation { images?: ImageAttachment[]; // Attached images with human-readable names isQuickLabel?: boolean; // true if created via quick label chip quickLabelTip?: string; // optional instruction tip from the label definition - diffContext?: 'added' | 'removed' | 'modified' | 'unchanged'; // set when annotation created in plan diff view + diffContext?: 'added' | 'removed' | 'modified'; // set when annotation created in plan diff view // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string;