feat(plan-diff): word-level inline diff rendering#565
Merged
backnotprop merged 4 commits intomainfrom Apr 15, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)`foo`)[text](url))orderedStartso renumbered list items display the current plan's numeral.Renderer (
packages/ui/components/plan-diff/PlanCleanDiffView.tsx)InlineModifiedBlockcomponent renders a modified block as a single structural wrapper with<ins>/<del>tags inside. The unified string is parsed throughInlineMarkdownonce, so markdown delimiter pairs (bold, italic, links) survive across token boundaries.InlineMarkdownextended to recognize<ins>/<del>passthrough, recursively parse link anchor text, and include<in the plain-text stop-char scanner.comment.Mode switcher (
packages/ui/components/plan-diff/PlanDiffModeSwitcher.tsx,PlanDiffViewer.tsx)Styling (
packages/ui/theme.css,packages/editor/index.css)plan-diff-word-added/plan-diff-word-removedutility classes for inline highlights;box-decoration-break: cloneso wrapped lines don't smear.<code>inside the diff wrappers picks up a tinted background so code-pill changes read unambiguously green/red.plan-diff-modifiedclass (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)Demo infrastructure
demoPlan.tsgets 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.tssplits 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 theVITE_DIFF_DEMOenv var.App.tsxreads 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 ⑯–⑳)
**delimiter pair — upstream tokenizer issue, requires AST-level diff injection to fix.<ins>/<del>HTML tag text in plan prose can't be disambiguated from injected diff markers — rare; documented.Test plan
bun test packages/ui/utils/planDiffEngine.test.ts— all 18 tests passbun run dev:hook— default demo (Real-time Collaboration) loads; toggle diff view; verify word-level highlights on the Context paragraph and code blockVITE_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" labelbun run --cwd apps/review build && bun run build:hook) and smoke-test the single-file HTML