Files
backnotprop__plannotator/packages/ui/hooks/useExternalAnnotationHighlights.ts
Michael Ramos 91f57ad7a2 feat(ui): highlight external SSE annotations inline on plan text (#511)
* feat(ui): highlight external SSE annotations inline on plan text

External annotations posted via /api/external-annotations previously
appeared in the sidebar only. They now highlight the matching `originalText`
span in the rendered plan, giving tools (linters, agents) an optional way
to attach feedback to specific phrases — while `GLOBAL_COMMENT` (or any
annotation without `originalText`) still degrades to sidebar-only.

Implementation is a new focused hook that drives the Viewer's existing
imperative `applySharedAnnotations` / `removeHighlight` API, reusing the
same DOM text-search path that share-URL restoration uses. No protocol
change, no schema change to `transformPlanInput`, and App.tsx gains only
a single hook call.

The hook tracks applied ids with a type+originalText fingerprint so SSE
updates correctly remove+reapply, clears its bookkeeping only on plan
markdown change (where blocks re-render), and early-returns (preserving
state) while diff view or a linked doc overlay is active so SSE removals
arriving under those conditions still reconcile when the hook re-enables.

For provenance purposes, this commit was AI assisted.

* fix(ui): repaint external SSE highlights after share import, clean dead code

Addresses three review findings on the external annotation highlight hook:

- Share-import wipe: `importFromShareUrl` merges annotations without
  changing `markdown`, so our `planKey` stayed stable. The share-apply
  effect in App.tsx calls `clearAllHighlights()` to reset the DOM before
  applying imported annotations — which also wiped live external SSE
  highlights. The hook believed they were still painted and never re-drove
  them, leaving sidebar entries with no visible highlight until the next
  SSE event. Fix: hook now exposes a `reset()` that clears its applied-set
  and re-runs the main effect via a counter; App.tsx calls it right after
  `clearAllHighlights()` in the share-apply path.

- Removed a dead `nextIds.has(a.id)` guard inside the apply timer callback.
  `toAdd` is computed as a subset of `eligible`, and `nextIds` was built
  from `eligible.map(.id)`, so the guard was vacuously always true.

- Removed the stable `viewerRef` from the main effect's dep array; React
  ref objects have stable identity for the component's lifetime so it was
  noise. Added a comment noting the intentional omission.

For provenance purposes, this commit was AI assisted.

* docs(ui): note reset() in useExternalAnnotationHighlights header comment

For provenance purposes, this commit was AI assisted.
2026-04-07 11:10:23 -07:00

106 lines
4.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { Annotation } from '../types';
import { AnnotationType } from '../types';
import type { ViewerHandle } from '../components/Viewer';
/**
* Bridges SSE-delivered external annotations into the Viewer's imperative
* highlight API so tools can POST annotations with `originalText` and have
* them highlight real spans of the rendered plan.
*
* The Viewer's `applySharedAnnotations` already searches the DOM for
* `originalText` and dedupes against already-applied marks, so this hook
* just needs to drive it when the external list changes.
*
* - Annotations without `originalText` (or `GLOBAL_COMMENT`) stay sidebar-only.
* - Annotations with `diffContext` are skipped (diff view owns those).
* - On plan markdown change the applied set is cleared so re-rendered blocks
* get re-highlighted.
* - Callers can invoke the returned `reset()` to force a full re-apply — used
* by the share-import path in App.tsx after it calls `clearAllHighlights()`,
* which would otherwise leave our bookkeeping stale against a wiped DOM.
* - Disabled state no-ops WITHOUT clearing the applied set. This preserves the
* bookkeeping while the Viewer DOM is hidden (diff view / linked doc) so that
* any SSE removals that arrive while hidden are correctly reconciled when the
* hook re-enables.
*/
export function useExternalAnnotationHighlights(params: {
viewerRef: React.RefObject<ViewerHandle | null>;
externalAnnotations: Annotation[];
enabled: boolean;
/** Bump to force a full re-apply (e.g. plan markdown changed and blocks re-rendered). */
planKey: string;
}): { reset: () => void } {
const { viewerRef, externalAnnotations, enabled, planKey } = params;
// Tracks annotation IDs currently materialized as DOM highlights, along
// with a fingerprint so updates trigger remove+reapply.
const appliedRef = useRef<Map<string, string>>(new Map());
// Bumped to force the main effect to treat every current external as a
// fresh application target — used by `reset()` below.
const [resetCount, setResetCount] = useState(0);
// Clear tracking when plan content changes — the Viewer re-parses blocks
// and wipes marks, so our bookkeeping is stale.
useEffect(() => {
appliedRef.current.clear();
}, [planKey]);
useEffect(() => {
if (!enabled) return;
const viewer = viewerRef.current;
if (!viewer) return;
const eligible = externalAnnotations.filter(
a => a.type !== AnnotationType.GLOBAL_COMMENT && !a.diffContext && a.originalText,
);
const applied = appliedRef.current;
// Removals: previously applied but no longer present, or fingerprint changed.
const toRemove: string[] = [];
for (const [id, fp] of applied) {
const match = eligible.find(a => a.id === id);
if (!match || fingerprint(match) !== fp) {
toRemove.push(id);
}
}
toRemove.forEach(id => {
viewer.removeHighlight(id);
applied.delete(id);
});
// Additions: eligible but not yet applied (includes re-adds from updates).
const toAdd = eligible.filter(a => !applied.has(a.id));
if (toAdd.length === 0) return;
// Paint delay matches the existing draft/share restore pattern —
// ensures blocks are mounted before we walk the DOM.
const timer = setTimeout(() => {
const v = viewerRef.current;
if (!v) return;
v.applySharedAnnotations(toAdd);
toAdd.forEach(a => applied.set(a.id, fingerprint(a)));
}, 100);
return () => clearTimeout(timer);
// viewerRef is a stable ref object and intentionally omitted from deps.
}, [externalAnnotations, enabled, planKey, resetCount]);
// Forget everything we've tracked and force a full re-apply on the next
// effect run. Callers invoke this after an external action has wiped the
// Viewer DOM out from under us (e.g. `clearAllHighlights()` during share
// import) so live externals get repainted.
const reset = useCallback(() => {
appliedRef.current.clear();
setResetCount(c => c + 1);
}, []);
return { reset };
}
function fingerprint(a: Annotation): string {
return `${a.type}\u0000${a.originalText}`;
}