Skip to content

Commit 4139999

Browse files
authored
feat(plan-diff): word-level inline diff rendering (#565)
* feat(plan-diff): word-level inline diff rendering 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. * chore(demo): restructure default demo, add VITE_DIFF_DEMO stress test 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. * style(plan-diff): refine modified-block visual — amber gutter, no fill 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. * fix(plan-diff): sanitize link hrefs against javascript: / data: schemes 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.
1 parent 7245546 commit 4139999

File tree

13 files changed

+1567
-127
lines changed

13 files changed

+1567
-127
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ apps/pi-extension/review-core.ts
3838
.idea
3939
.DS_Store
4040
*.suo
41+
42+
# Claude Code session-local runtime state (lock files, scheduled-task state).
43+
# Machine-specific; never belongs in the repo.
44+
.claude/
4145
*.ntvs*
4246
*.njsproj
4347
*.sln

apps/hook/dev-mock-api.ts

Lines changed: 396 additions & 27 deletions
Large diffs are not rendered by default.

bun.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/editor/App.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,18 @@ import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarCont
6565
import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser';
6666
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
6767
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
68-
import { DEMO_PLAN_CONTENT } from './demoPlan';
68+
// Demo content toggle. Default: the original Real-time Collaboration plan.
69+
// Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps
70+
// in the 20-case Auth Service Refactor test plan. dev-mock-api.ts reads the
71+
// same env var on the server side so V2/V3 stay paired.
72+
import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan';
73+
import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo';
74+
const USE_DIFF_DEMO =
75+
import.meta.env.VITE_DIFF_DEMO === '1' ||
76+
import.meta.env.VITE_DIFF_DEMO === 'true';
77+
const DEMO_PLAN_CONTENT = USE_DIFF_DEMO
78+
? DIFF_DEMO_PLAN_CONTENT
79+
: DEFAULT_DEMO_PLAN_CONTENT;
6980
import { useCheckboxOverrides } from './hooks/useCheckboxOverrides';
7081

7182
type NoteAutoSaveResults = {

packages/editor/demoPlan.ts

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
22
3+
## Context
4+
5+
This proposal introduces real-time collaborative editing to the Plannotator editor, letting reviewers annotate the same plan simultaneously with sub-second visibility of each other's cursors and edits. We are targeting **production-grade concurrency** for up to 50 active collaborators per document, with end-to-end edit-to-visible latency under 150ms at the 95th percentile. The implementation uses operational transforms running on a dedicated Node.js gateway that speaks \`WebSocket\` to clients and \`gRPC\` to the storage tier. See [the technical design doc](https://docs.example.com/realtime-v2) for the full rationale and rollout plan.
6+
7+
Runtime parameters for phase one:
8+
9+
\`\`\`typescript
10+
export const COLLAB_CONFIG = {
11+
maxCollaborators: 50,
12+
heartbeatIntervalMs: 5_000,
13+
operationBatchSize: 32,
14+
gateway: "wss://collab.plannotator.ai",
15+
} as const;
16+
\`\`\`
17+
318
## Overview
419
Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*.
520
@@ -58,43 +73,6 @@ CREATE TABLE collaborators (
5873
CREATE INDEX idx_collaborators_document ON collaborators(document_id);
5974
\`\`\`
6075
61-
### Architecture
62-
63-
\`\`\`mermaid
64-
flowchart LR
65-
subgraph Client["Client Browser"]
66-
UI[React UI] --> OT[OT Engine]
67-
OT <--> WS[WebSocket Client]
68-
end
69-
70-
subgraph Server["Backend"]
71-
WSS[WebSocket Server] <--> OTS[OT Transform]
72-
OTS <--> DB[(PostgreSQL)]
73-
end
74-
75-
WS <--> WSS
76-
\`\`\`
77-
78-
### Service Dependencies (Graphviz)
79-
80-
\`\`\`graphviz
81-
digraph CollaborationStack {
82-
rankdir=LR;
83-
node [shape=box, style="rounded"];
84-
85-
Browser [label="Client Browser"];
86-
API [label="WebSocket API"];
87-
OT [label="OT Engine"];
88-
Redis [label="Presence Cache"];
89-
Postgres [label="PostgreSQL"];
90-
91-
Browser -> API;
92-
API -> OT;
93-
OT -> Redis;
94-
OT -> Postgres;
95-
}
96-
\`\`\`
97-
9876
## Phase 2: Operational Transforms
9977
10078
> The key insight is that we need to transform operations against concurrent operations to maintain consistency.
@@ -330,5 +308,46 @@ export const CursorOverlay: React.FC<CursorOverlayProps> = ({
330308
331309
---
332310
311+
## Appendix: Diagrams
312+
313+
### Architecture
314+
315+
\`\`\`mermaid
316+
flowchart LR
317+
subgraph Client["Client Browser"]
318+
UI[React UI] --> OT[OT Engine]
319+
OT <--> WS[WebSocket Client]
320+
end
321+
322+
subgraph Server["Backend"]
323+
WSS[WebSocket Server] <--> OTS[OT Transform]
324+
OTS <--> DB[(PostgreSQL)]
325+
end
326+
327+
WS <--> WSS
328+
\`\`\`
329+
330+
### Service Dependencies (Graphviz)
331+
332+
\`\`\`graphviz
333+
digraph CollaborationStack {
334+
rankdir=LR;
335+
node [shape=box, style="rounded"];
336+
337+
Browser [label="Client Browser"];
338+
API [label="WebSocket API"];
339+
OT [label="OT Engine"];
340+
Redis [label="Presence Cache"];
341+
Postgres [label="PostgreSQL"];
342+
343+
Browser -> API;
344+
API -> OT;
345+
OT -> Redis;
346+
OT -> Postgres;
347+
}
348+
\`\`\`
349+
350+
---
351+
333352
**Target:** Ship MVP in next sprint
334353
`;

0 commit comments

Comments
 (0)