backnotprop/plannotator
tldr.page
SPIKE Guide Diff Annotation Reuse 20260702 194831

SPIKE: reusing Pierre diff + annotation machinery for Guide takeover sections

Date: 2026-07-02 Related: adr/decisions/006-guided-review-first-class-feature-20260702-192821.md

1. DiffViewer.tsx — props, coupling, standalone-mount viability

File: packages/review-editor/components/DiffViewer.tsx:136-238

Props contract (DiffViewerProps, lines 136-189): everything it needs comes in as props — patch: string, filePath, oldPath?, status?, reviewBase? (for /api/file-content), prUrl?/prDiffScope? (draft namespacing only), display flags (diffStyle, diffOverflow, diffIndicators, lineDiffType, disableLineNumbers, disableBackground, expandUnchanged, fontFamily, fontSize), the annotation set + callbacks (annotations, selectedAnnotationId, scrollTargetAnnotation, pendingSelection, onLineSelection, onAddAnnotation, onAddFileComment, onEditAnnotation, onSelectAnnotation, onDeleteAnnotation), viewed/staged UI state, search props, and AI props. Zero dock imports, zero dockview types, zero useReviewState() calls inside DiffViewer.tsx itself. It is already a pure, prop-driven component.

Dock coupling lives entirely in the wrapper, not the component: ReviewDiffPanel.tsx:1-123 is a ~110-line adapter that reads useReviewState() and a dockview filePath param, does the state.files.find(...) lookup, computes fileAnnotations/aiMessagesForFile/searchMatchesForFile, and passes everything into <DiffViewer> (lines 69-120). Confirms the ADR's claim in Consequences: "any coupling ... to the dock layout gets factored out rather than copied" — for DiffViewer there is nothing to factor out; a guide section can build the same prop bag directly from useReviewState() without touching ReviewDiffPanel at all.

File-content fetching (lines 309-352): on [filePath, oldPath, reviewBase] change, fetches /api/file-content?path=&oldPath=&base=, then re-parses the patch with processFile() against the full file contents so hunks can expand (augmentedDiff, useMemo at 331). Falls back to the raw-patch fileDiff (getSingularPatch(patch), line 307) if the fetch fails, is empty, or content no longer reconciles with the patch (isContentConsistentWithPatch, line 337) — silent fallback, no error UI. This is fully self-contained per mounted instance; N standalone DiffViewers each do their own independent fetch (no dedup across instances, but that's already true of the dock's single-instance panel too).

Standalone mountability: yes. DiffViewer only needs patch + filePath (+ optional oldPath/reviewBase for expansion) and the annotation prop bag. It has its own scroll container (OverlayScrollArea, its own containerRef/viewport via useOverlayViewport), its own ToolbarHost instance (line 722), its own FileHeader/FileCommentBanner. Nothing assumes it is inside dockview or that it is the only instance in the DOM — the isFocused prop already exists specifically to arbitrate cross-instance state (see §3/§6).

2. AllFilesCodeView.tsx as an alternative

File: packages/review-editor/components/AllFilesCodeView.tsx (2054 lines; read in two passes).

This is the "every file in one scroll container" surface built on Pierre's virtualized CodeView (single instance, many CodeViewItems). It is architecturally the wrong shape for guide sections:

  • One CodeView instance is meant to own the whole file set, uncontrolled/seeded-once (initialItems), remounted via a fileSetKey on file-set changes (lines 497-541). Using "one CodeView per guide section" multiplies this heavy machinery (identity maps, augmentRef, scroll-idle-deferred augmentation queue, worker-pool gating) N times for what's typically 1-5 small files per section — pure overhead with none of virtualization's payoff (virtualization matters at 50+ files, not 2-3).
  • Depends on worker-pool readiness (useIsWorkerPoolReadyOrDisabled, line 438) and a global highlight-worker theme sync — designed around "one big surface," not many small independent instances.
  • Its prop surface is dock/all-files-specific: registerCollapseAllToggle/onAllCollapsedChange (dock tab-strip collapse-all button), isActive (gates [ ] / z / v / a / c / x keyboard shortcuts to "this is the active panel"), fileOrder: 'tree' | 'list' (mirrors whichever left panel is active), canStagePath (git-status-panel-specific staging gate). None of this maps cleanly onto "a handful of diffs inside a prose section."
  • Per-file annotation actions are indirected through activeFilePath (whichever file the user last clicked in) rather than being addressed directly per file (onAddAnnotationForFile(filePath, ...), lines 165-174, routed via handleAddAnnotation at 629 which reads activeFilePath). That's fine for "one big list," awkward for "N independent small diffs the guide already knows the path of."

Verdict: DiffViewer is the better fit. A guide section with one-or-more diffs should render one DiffViewer per file, exactly like the dock's single-file panel does — just composed inline in the guide page's layout instead of inside a dockview panel. No virtualization needed at guide-page scale; simplicity and prop-locality win.

3. Annotation wiring outside the dock: ToolbarHost + useAnnotationToolbar

Files: packages/review-editor/components/ToolbarHost.tsx, packages/review-editor/hooks/useAnnotationToolbar.ts.

ToolbarHost (lines 24-52 for props) takes patch, filePath, isFocused, onLineSelection, onAddAnnotation, onEditAnnotation, plus optional AI props — the exact same shape DiffViewer already threads through (DiffViewer.tsx:722-735). It owns useAnnotationToolbar internally (line 74) so per-keystroke toolbar state doesn't re-render the diff; parents talk to it via ToolbarHostHandle (handleLineSelectionEnd, handleTokenClick, startEdit).

Not a singleton — it's designed for multiple simultaneous instances. useAnnotationToolbar.ts:47-48 keeps draftStore and restoreDraftKeyByFilePath as module-level Maps keyed by filePath, not component/instance state. This is deliberate: dockview keeps both the single-file panel and the all-files panel mounted simultaneously (they're different dockview tabs, and dockview-react panels stay alive in the DOM rather than being torn down on tab switch — see DockviewReact at App.tsx:2810-2817, no disableTabsOverflowList/keepalive opt-outs, and the existing isFocused-driven save/restore logic at lines 271-309 only makes sense if two instances for the same file can coexist). The isFocused prop is exactly the mechanism that arbitrates this: on isFocused true→false, the in-progress draft for the current range is stashed under restoreDraftKeyByFilePath.set(filePath, key) (line 278); on false→true it's replayed (lines 283-308). This means a guide DiffViewer instance for a file that's also open in a (now-hidden) dock panel is not a new class of problem — it's the exact multi-instance scenario the draft system already exists to handle. The guide integration just needs to participate in the same isFocused contract correctly (see §6 for the gap).

renderAnnotation / inline rendering: DiffViewer builds lineAnnotations from annotations (filtered to scope ?? 'line' === 'line', mapped to Pierre's { side, lineNumber: ann.lineEnd, metadata } shape via lineAnnotationMetadata(), lines 510-518), merges in AI markers (521-538), and passes renderAnnotation (552-577) + mergedAnnotations into <FileDiff lineAnnotations=... renderAnnotation=...> (via PierreDiffContent, lines 107-109). renderAnnotation dispatches to InlineAnnotation (real annotations) or InlineAIMarker (AI markers) purely from the annotation's metadata.kind. This whole path is annotation-array-in, JSX-out — it has no dock dependency and needs nothing beyond what a guide section already has via ReviewStateContext (allAnnotations, filtered by filePath).

4. ReviewStateContext — what the diff+annotation path actually needs, and "for free" claim

File: packages/review-editor/dock/ReviewStateContext.tsx.

Confirmed: ReviewStateProvider wraps the entire app body in App.tsx:2253...3150 — header, file tree, dockview center panel, sidebar, TourDialog (mounted at line 3125, still inside the provider), and any future guide takeover — all of it is a descendant of <ReviewStateProvider value={reviewStateValue}>. So yes, a guide takeover screen rendered as a sibling/child anywhere in that tree gets the full ReviewState context for free via useReviewState() — no new context, no prop drilling needed.

Fields the diff+annotation path (ReviewDiffPanel + DiffViewer + ToolbarHost) actually consumes, all already on ReviewState: files, reviewBase, activeDiffBase, diffStyle/diffOverflow/diffIndicators/lineDiffType/disableLineNumbers/disableBackground/expandUnchanged/fontFamily/fontSize, allAnnotations, selectedAnnotationId, scrollTargetAnnotation, pendingSelection, onLineSelection, onAddAnnotation/onAddAnnotationForFile, onAddFileComment/onAddFileCommentForFile, onEditAnnotation, onSelectAnnotation, onDeleteAnnotation, viewedFiles/onToggleViewed, stagedFiles/stagingFile/onStage/canStageFiles/stageError, search fields, AI fields (aiAvailable, onAskAI/onAskAIForFile, isAILoading, onViewAIResponse, aiMessages/getAIHistoryForFile, onClickAIMarker), prMetadata/prDiffScope, onCodeNavRequest. A guide section component can read exactly the same fields ReviewDiffPanel.tsx reads (lines 24-121) — it's a smaller, purpose-built version of that same adapter, not a new subsystem.

One field is dock-shaped and would need a guide-local equivalent: focusedFilePath / isFocused derivation — see §6.

5. Per-file patch slicing

DiffFile (packages/review-editor/types.ts:8-15): { path, oldPath?, patch, additions, deletions, status }. parseDiffToFiles() (packages/review-editor/utils/diffParser.ts:31-61) splits the raw multi-file patch on ^diff --git boundaries (splitDiffChunks, lines 4-13) and stores each complete chunk verbatim as DiffFile.patch — i.e. patch already includes the file's own diff --git/---/+++/hunk headers, self-contained. This is exactly what getSingularPatch() expects (used identically in DiffViewer.tsx:307 and AllFilesCodeView.tsx:332) — confirmed by the fact the dock's per-file panel already calls getSingularPatch(file.patch) today with no extra wrapping.

Guide file references: a guide section can carry a list of file paths and resolve each via the exact lookup ReviewDiffPanel.tsx:19-21 already does: state.files.find(f => f.path === path). No new indexing structure needed — state.files (a DiffFile[]) is the join key, keyed by path (post-rename/new path).

Hunk-level slices — no existing annotatable precedent, but a read-only precedent exists and points at the right approach. TourStopCard.tsx's AnchorBlock (lines 109-160) renders a TourDiffAnchor ({ file, line, end_line, label, hunk }) via DiffHunkPreview (packages/review-editor/components/DiffHunkPreview.tsx), not DiffViewer. DiffHunkPreview is deliberately read-only/compact: no lineAnnotations, no ToolbarHost, no renderAnnotation, disableLineNumbers: true, overflow: 'wrap'. It handles three possible hunk-string shapes from the tour agent (lines 79-87): a full diff --git patch, a --- /+++ file-level diff (prepends a synthetic diff --git a/file b/file line), or a bare @@ ...@@ hunk (prepends synthetic diff --git/---/+++ headers) — then calls getSingularPatch() on the result. This proves getSingularPatch tolerates hunk-only input as long as it's wrapped with some header, but the synthetic a/file b/file path in the bare-hunk case is a placeholder, not the real path — fine for a preview, not fine for annotation export (which needs the real filePath/side/line numbers to round-trip through /api/feedback).

Better approach for guide hunk-level slices (if the ADR's deferred hunk-granularity question is resolved toward hunk-level): since guide sections are generated from the same diff already parsed into DiffFile[], a hunk slice should be built by extracting the real header block (diff --git through +++) plus the selected @@-hunk(s) verbatim from file.patch — not synthesized. That preserves the real path and real line numbers, so the resulting string is a legitimate single-file (partial-hunk) patch that DiffViewer can render with full annotation fidelity (real filePath prop, real line numbers flowing into onAddAnnotation//api/feedback) — DiffViewer does not care whether the patch contains one hunk or all of them, it just calls getSingularPatch(patch) and renders whatever's there. This is a straightforward new small utility (sliceHunksFromPatch(patch, hunkIndices) or similar), not a Pierre-level change.

Tour anchor "jump to file" today: AnchorBlock's "Open ↗" button (TourStopCard.tsx:138-143) calls onAnchorClick(anchor.file)TourDialog's handler → ultimately openDiffFile(filePath) in App.tsx:367-411. openDiffFile is dock-native: it finds/creates the REVIEW_DIFF_PANEL_ID dockview panel, updateParameters({ filePath }), setActive(), and sets activeFileIndex. It does not render inline in the Tour dialog — it closes/backgrounds the tour experience conceptually and switches the dock to the target file. This is the one existing "guide-like surface → jump into a real annotatable diff" interaction, and it works by leaving the guide-like surface and re-entering dock. That pattern would be a regression for Guided Review, where diffs render inline in the page (per the ADR) — so guide sections should not reuse openDiffFile's "hop to dock" jump; they should render the diffs directly via DiffViewer.

6. Scroll/selection interplay — dock-visibility assumptions that would break

  • focusedFilePath is not dock-visibility-aware. App.tsx:1778: focusedFilePath: files[activeFileIndex]?.path ?? null — purely derived from activeFileIndex, with no notion of "is the dock actually on screen." ReviewDiffPanel.tsx:22: isFocusedFile = !!file && state.focusedFilePath === file.path, passed to DiffViewer as isFocused (line 77), which ToolbarHost/useAnnotationToolbar uses for the cross-instance draft handoff described in §3. If the guide takeover hides the dock but leaves activeFileIndex/focusedFilePath untouched, the hidden dock's ReviewDiffPanel will still report isFocused=true for that file. A guide DiffViewer for the same file would need its own isFocused signal — if both report true simultaneously, two useAnnotationToolbar instances race on the same draftStore/restoreDraftKeyByFilePath key. Not catastrophic (last-write-wins on a Map), but a real edge case to close, not ignore. Simplest fix: gate the dock panel's isFocused on "guide takeover is not active" (e.g. state.focusedFilePath === file.path && !state.isGuideActive), and have the guide's own DiffViewer instances own isFocused per "which section/file is currently in view or being edited" within the guide page.
  • scrollTargetAnnotation (AnnotationScrollTarget { id, token }) is a single global signal on ReviewState (not per-surface) that both ReviewDiffPanel's DiffViewer and ReviewAllFilesDiffPanel's AllFilesCodeView react to today, each checking whether the target lives in their rendered content. A guide section's DiffViewer instances can subscribe to the same state.scrollTargetAnnotation with no changes — this is a shared broadcast, not a dock-exclusive channel, so it's already multi-surface-safe by construction (mirrors how the dock's two panel types already both listen to it).
  • pendingSelection is likewise a single global "current in-progress selection" (also ReviewState, consumed identically by both existing surfaces) — same shared-broadcast pattern, same "already handles multiple listeners" property. No changes needed to consume it from a guide section, but if the guide wants an independent pending-selection per open section (rather than one global one shared with a hidden dock), that's a product decision, not a technical blocker — onLineSelection would just need a guide-local piece of state instead of state.onLineSelection if isolation is wanted.
  • activeFileSearchMatches/activeSearchMatchId/activeSearchMatch are filtered/derived for "the single focused dock file" (ReviewDiffPanel.tsx:47-53, 108-111) — irrelevant to guide sections unless/until guide pages get their own search UI; safe to simply not wire these props into a guide DiffViewer (they're all optional with defaults).
  • AllFilesCodeView's Safari scroll guardian was explicitly NOT ported from DiffViewer (comment at AllFilesCodeView.tsx:133-140) because CodeView owns its own scroll model; this is a reminder that DiffViewer's own Safari guardian (DiffViewer.tsx:375-413) is tied to OverlayScrollbars + shadow-DOM assumptions specific to its own scroll container — each standalone DiffViewer instance in a guide page gets its own independent guardian for free (it's scoped to viewport/containerRef, not a singleton), so no cross-instance interference there.

Implications for guide diff sections

Component choice: render each guide section's diff(s) as one DiffViewer per file, directly composed in the guide page markup — not AllFilesCodeView, not a new component. DiffViewer is already dock-agnostic (its only "coupling" lives in the separately-discardable ReviewDiffPanel wrapper); the guide page just needs its own thin adapter analogous to ReviewDiffPanel.tsx (read useReviewState(), look up DiffFile by path, build the same prop bag) rather than reusing ReviewDiffPanel itself (which is dockview-IDockviewPanelProps-shaped and pulls filePath from dockview panel params).

Per-section composition: a guide section referencing files ["a.ts", "b.ts"] maps each path through state.files.find(f => f.path === path) and renders a stack of DiffViewers, each fed from useReviewState() the same way ReviewDiffPanel does — filtering state.allAnnotations by filePath (+ PR-scope match via annotationMatchesPrScope, same helper ReviewDiffPanel.tsx:29-32 and AllFilesCodeView.tsx both already use).

Annotation wiring plan: wire onAddAnnotation/onEditAnnotation/onSelectAnnotation/onDeleteAnnotation/onLineSelection straight through to state.onAddAnnotationForFile(filePath, ...) / state.onEditAnnotation / etc. — the same CodeAnnotation list in App.tsx, so a guide annotation is byte-for-byte the same object the dock produces (satisfies the ADR's hard constraint directly, no adapter logic needed beyond what ReviewDiffPanel already demonstrates).

Refactors actually needed (small, not "extract DiffViewer from the dock" — it's already extracted):

  1. A guide-local isFocused / "which file is the user currently annotating in the guide" signal, and a corresponding update to ReviewDiffPanel's isFocusedFile computation (or ReviewState.focusedFilePath itself) so dock panels stop claiming focus while the guide takeover owns the screen — closes the dual-isFocused draft race in §6.
  2. If hunk-level (not file-level) slicing is chosen per the ADR's deferred question: a small new utility to slice real hunks out of DiffFile.patch (headers + selected @@ blocks, verbatim — not DiffHunkPreview's synthetic-header approach, which discards path/line fidelity) so DiffViewer renders a genuinely annotatable partial-file patch.
  3. A guide-local adapter component (~GuideDiffSection.tsx, modeled 1:1 on ReviewDiffPanel.tsx's 110 lines) that reads useReviewState() and renders one or more DiffViewers for a section's file list — new code, but thin and precedented, not a new subsystem.

No changes are needed to DiffViewer.tsx, ToolbarHost.tsx, useAnnotationToolbar.ts, AnnotationToolbar, InlineAnnotation, or CodeAnnotation/exportAnnotations — all reused as-is, confirming the ADR's "reuse, not copies" constraint is achievable with modest new glue code.