Skip to content

feat(plan-diff): word-level inline diff rendering#565

Merged
backnotprop merged 4 commits intomainfrom
feat/fix-mkdown-render
Apr 15, 2026
Merged

feat(plan-diff): word-level inline diff rendering#565
backnotprop merged 4 commits intomainfrom
feat/fix-mkdown-render

Conversation

@backnotprop
Copy link
Copy Markdown
Owner

Summary

Closes #560.

Replaces the current "whole old block struck-through above the whole new block" rendering with in-context word-level highlights for small edits, while preserving the existing block-level behavior for structural changes. Users can toggle between three modes: Rendered (new default, word-level), Classic (legacy block-level stacked), and Raw (git-style).

Before: a one-word reword showed as the full paragraph twice.
After: just the changed words are struck/highlighted inline; surrounding prose is untouched.

What's in this PR

Engine (packages/ui/utils/planDiffEngine.ts)

  • Second-pass word diff on modified blocks that pass a whitelist gate (paragraph / heading / list-item with matching structural fields).
  • Sentinel substitution atomizes three patterns before the word diff runs so diff markers can't fragment them:
    • Inline code spans (`foo`)
    • Markdown links ([text](url))
    • Fenced code blocks (variable-length, via backreference)
  • Render wrapper picks up the NEW block's orderedStart so renumbered list items display the current plan's numeral.
  • Annotation-context helper returns both old and new content for inline-diffed modified blocks so comments on struck-through words preserve that text in the exported feedback.

Renderer (packages/ui/components/plan-diff/PlanCleanDiffView.tsx)

  • New InlineModifiedBlock component renders a modified block as a single structural wrapper with <ins>/<del> tags inside. The unified string is parsed through InlineMarkdown once, so markdown delimiter pairs (bold, italic, links) survive across token boundaries.
  • InlineMarkdown extended to recognize <ins>/<del> passthrough, recursively parse link anchor text, and include < in the plain-text stop-char scanner.
  • Click-to-annotate works in every editor mode, not just comment.

Mode switcher (packages/ui/components/plan-diff/PlanDiffModeSwitcher.tsx, PlanDiffViewer.tsx)

  • Third "Classic" tab between Rendered and Raw. Default is Rendered (word-level, marked "exp"); Classic forces block-level stacked fallback for every modified block; Raw is unchanged.

Styling (packages/ui/theme.css, packages/editor/index.css)

  • plan-diff-word-added / plan-diff-word-removed utility classes for inline highlights; box-decoration-break: clone so wrapped lines don't smear.
  • Inline <code> inside the diff wrappers picks up a tinted background so code-pill changes read unambiguously green/red.
  • New plan-diff-modified class (amber border) for inline-diff modified blocks — follows the GitHub / VSCode convention of green=add, red=remove, yellow=both.

Tests (packages/ui/utils/planDiffEngine.test.ts)

  • 18 tests covering the qualification gate, structural-field matching, sentinel round-trip (inline code / links / fences), and token content for common edit patterns.

Demo infrastructure

  • Default demo in demoPlan.ts gets a "Context" section at the top that showcases the engine (bold phrase swap, inline-code pill swaps, link URL change, single-line code edit). Mermaid + graphviz diagrams moved to an Appendix so they don't clutter the top of the plan.
  • dev-mock-api.ts splits its V1/V2 constants into *_DEFAULT (original Real-time Collaboration content, byte-identical to pre-branch state) and *_DIFF_TEST (opt-in 20-case engine stress test). Selected at runtime by the VITE_DIFF_DEMO env var.
  • App.tsx reads the same env var so V2/V3 always stay paired.
  • demoPlanDiffDemo.ts (new) holds the 20-case stress test as a kept-in-tree dev fixture, documented as opt-in.

Known limitations (documented in demo cases ⑯–⑳)

  • Word swap inside a multi-word bold phrase splits the ** delimiter pair — upstream tokenizer issue, requires AST-level diff injection to fix.
  • Literal <ins> / <del> HTML tag text in plan prose can't be disambiguated from injected diff markers — rare; documented.
  • Renumbered ordered list items (same text, different number): visually rendered as a modification with no word highlights — technically a modification because the number changed, but the text is unchanged.

Test plan

  • bun test packages/ui/utils/planDiffEngine.test.ts — all 18 tests pass
  • bun run dev:hook — default demo (Real-time Collaboration) loads; toggle diff view; verify word-level highlights on the Context paragraph and code block
  • VITE_DIFF_DEMO=1 bun run dev:hook — 20-case stress test loads; walk through each case and confirm behavior matches its "What to watch for" label
  • Click "Classic" in the mode switcher — modified blocks render as the legacy stacked fallback
  • Click "Raw" — git-style view unchanged
  • Click a modified block → comment popover opens directly in selection mode (no hover-then-toolbar needed)
  • Verify annotation on a modified block captures both old and new content in the exported feedback
  • Build the hook bundle (bun run --cwd apps/review build && bun run build:hook) and smoke-test the single-file HTML

Two-pass hierarchical diff (diffLines outer + diffWordsWithSpace inner)
so modified plan blocks render with inline insertions/deletions in
context instead of showing the whole old block struck-through above the
whole new block. Resolves #560.

Engine (packages/ui/utils/planDiffEngine.ts):
- computeInlineDiff runs a second-pass word diff on modified blocks
  that pass a whitelist gate (paragraph/heading/list-item with matching
  structural fields).
- Sentinel substitution atomizes inline-code spans, markdown links, and
  fenced code blocks before diffWordsWithSpace runs, so diff markers
  never land inside backticks, link hrefs, or across fence boundaries.
  Fence regex uses a backreference so variable-length (e.g., 4-backtick
  wrapping 3-backtick) fences are matched atomically.
- Annotation context for an inline-diffed modified block now captures
  both old and new content so comments on struck-through words preserve
  that text in the exported feedback.

Renderer (packages/ui/components/plan-diff/PlanCleanDiffView.tsx):
- New InlineModifiedBlock component renders a modified block as one
  structural wrapper with <ins>/<del> wrappers inside, parsed through
  the local InlineMarkdown in a single pass so markdown delimiter pairs
  survive across token boundaries.
- InlineMarkdown extended to recognize <ins>/<del> tag passthrough
  (with recursive parsing of the wrapped content) and to recursively
  parse link anchor text so diff markers inside links render correctly.
- Plain-text stop-char scanner includes '<' so <ins>/<del> dispatch
  re-enters the loop instead of swallowing tag text.
- Click-to-annotate works in every editor mode (not just comment), with
  the block-level onClick opening the popover directly.

Mode switcher (packages/ui/components/plan-diff/PlanDiffModeSwitcher.tsx):
- Adds a third "Classic" tab between Rendered and Raw. Rendered is the
  new word-level default (labeled "exp"); Classic forces the legacy
  block-level stacked fallback for every modified block.

Styling (packages/ui/theme.css, packages/editor/index.css):
- plan-diff-word-added / plan-diff-word-removed utility classes for
  inline highlights with box-decoration-break: clone across line wraps.
- Inline <code> inside the diff wrappers picks up a tinted background
  so code-pill changes read unambiguously green/red.
- New plan-diff-modified class (amber border) for inline-diff modified
  blocks, matching the GitHub/VSCode convention of green=add,
  red=remove, yellow=both.

Tests (packages/ui/utils/planDiffEngine.test.ts):
- 18 tests covering the engine's qualification gate, structural-field
  matching, sentinel round-trip (inline code / links / fences), token
  content for common edit patterns.

For provenance purposes, this commit was AI assisted.
Demo content changes that support the word-level diff work but do not
alter shipped app behavior — only what other devs see running dev:hook.

packages/editor/demoPlan.ts (default V3 editor content):
- Added a "Context" section at the top of the plan with prose that
  showcases the word-level engine in V2→V3 diff: bold phrase swap,
  inline-code pill swaps, a link URL change, and a single-line code
  edit inside a config block.
- Moved the mermaid architecture diagram and graphviz service map to
  an "Appendix: Diagrams" section at the end of the plan; they were
  rendering ugly mid-document.

apps/hook/dev-mock-api.ts (Vite mock for the diff API):
- PLAN_V1 / PLAN_V2 split into *_DEFAULT (original Real-time
  Collaboration plan — preserved identically from pre-branch state) and
  *_DIFF_TEST (the 20-case Auth Service Refactor diff-engine stress
  test, kept as an opt-in tool).
- Resolves which pair to serve based on VITE_DIFF_DEMO env var. Matches
  the V2 Context section to the new V3 Context, with differences that
  produce rich word-level inline diffs on first load.
- Diagrams moved to Appendix in V2_DEFAULT to match V3.

packages/editor/App.tsx:
- Both demo imports are active. VITE_DIFF_DEMO=1 swaps
  DIFF_DEMO_PLAN_CONTENT into the editor's default; unset renders the
  original Real-time Collaboration plan as before.

packages/editor/demoPlanDiffDemo.ts (new):
- 20-case stress test (paragraphs, headings, lists, tables, fences,
  blockquotes, known limitations). Each case has an identical
  "What to watch for" blockquote label in both V2 and V3 so the diff
  view cleanly isolates each case. Opt-in only.

.gitignore:
- Ignore .claude/ runtime lock/state files. Machine-specific content
  that should not be tracked.

For provenance purposes, this commit was AI assisted.
Drop the yellow background fill from .plan-diff-modified and keep only a
softened amber left border. Added/removed blocks remain loud (full fill +
strong border) because add/remove are block-scope events — the whole
block matters. Modify is a word-scope event — the individual changed
words carry loud inline red/green highlights, and a block-level fill
would compete with that inline work. The amber gutter at 75% opacity now
reads as a quiet "look inside, the change is in the text" marker that
sits coherently with the rest of the palette.

For provenance purposes, this commit was AI assisted.
PlanCleanDiffView has its own local copy of InlineMarkdown (separate
from the one in Viewer.tsx). The link-rendering branch was passing the
captured URL directly to href with no validation, so a plan containing
  [click me](javascript:alert(document.cookie))
would render as a live clickable anchor in the diff view. Plan content
is attacker-influenced — Claude pulls from source comments, READMEs,
fetched URLs — so this is a real exploit path in the diff flow.

Port the same guard Viewer.tsx already has: sanitizeLinkUrl() rejects
javascript:, data:, vbscript:, and file: schemes (case-insensitive, with
optional leading whitespace). Rejected links render their anchor text as
plain text instead of a clickable <a>, so the content is still visible
to the reader but no longer dangerous.

For provenance purposes, this commit was AI assisted.
@backnotprop backnotprop merged commit 4139999 into main Apr 15, 2026
7 checks passed
@backnotprop backnotprop deleted the feat/fix-mkdown-render branch April 15, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Word level diff / Diff display options for Plan version comparison

1 participant