From 4afb018bb0d143c8f27523eac5b8e52ee130d0fb Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 25 May 2026 11:29:03 -0700 Subject: [PATCH] Contribute rewrite Phase 5: inline word-diff in manual pending card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §8.11 manual-edit card was a stub — "{N} paragraphs edited directly" plus a countdown and a Save-now button. The spec says the card should grow one inline word-diff per touched paragraph as the contributor types, in the same green/red register the Phase 3 accepted overlay uses. Phase 5 lands that. In RFCView.jsx, the 800ms-debounced handleEditorUpdate now computes a per-paragraph token diff alongside the existing paragraph count and stores both on manualPending. Baseline is the same Phase 4 gutter source — originalSourceLinesRef.current, the last server-confirmed body split on blank lines — so the card resets to empty the same moment the gutter clears (accept/decline/manualFlush/branch-switch). In ChangePanel.jsx, diffWords is exported (it was already the AI card's inline-diff engine — token-level LCS over a whitespace- preserving /(\\s+)/ split, ~30 lines, no runtime dep). The manual card consumes the same tokens. Wholly-inserted paragraphs render as all-insert blocks; wholly-deleted paragraphs as all-delete blocks. Visual register is intentionally shared with Phase 3's preview overlay: same #dcfce7/#166534 inserts, same #fee2e2/#991b1b strike-through deletes. Selectors are scoped under .change-manual-diff rather than reusing .markdown-preview .tracked-* since the card lives outside the preview surface. Pre-fancy stance, matching Phase 4's gutter: the diff is index-aligned against the baseline, so adding a paragraph in the middle lights up the rest of the doc. Tolerated as the "you've touched stuff below this point" cue. An LCS-anchored future pass can fix it. Verification gap matching Phases 2/3/4: backend was not running this session, so the live RFCView → real-branch flow wasn't exercised. Drove the Vite preview sandbox instead — mounted ChangePanel with a hand-built diffs payload, confirmed three blocks render (mixed edit, wholly-inserted, wholly-deleted), inserts/deletes carry the expected computed colors, no console errors. Backend integration suite still green (125 passed). --- frontend/src/App.css | 27 +++++++++++++++++++++++++ frontend/src/components/ChangePanel.jsx | 21 +++++++++++++++++-- frontend/src/components/RFCView.jsx | 24 +++++++++++++++++----- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 4227b82..7f8cb69 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -879,6 +879,33 @@ display: flex; align-items: center; justify-content: space-between; margin-top: 6px; } + +/* §8.11 Phase 5 — per-paragraph word-diff inside the pending manual + card. Shares Phase 3's green/red register with .markdown-preview + .tracked-insert / .tracked-delete so the contributor sees one + "change layer" vocabulary across the preview pane (accepted) and the + change card (their own pending typing). Scoped under its own class + rather than reusing the preview selectors since the card is not + inside .markdown-preview. */ +.change-manual-diff { + margin: 6px 0; + display: flex; flex-direction: column; gap: 6px; +} +.change-manual-diff-block { + font-size: 12px; line-height: 1.6; + background: #f9fafb; border-radius: 4px; + padding: 6px 8px; border: 1px solid #e5e5e5; + white-space: pre-wrap; word-break: break-word; +} +.change-manual-diff .manual-diff-insert { + background: #dcfce7; color: #166534; + border-radius: 2px; padding: 1px 2px; +} +.change-manual-diff .manual-diff-delete { + background: #fee2e2; color: #991b1b; + text-decoration: line-through; + border-radius: 2px; padding: 1px 2px; +} .btn-save-now { background: none; border: 1px solid #d4d4d4; border-radius: 4px; padding: 2px 8px; diff --git a/frontend/src/components/ChangePanel.jsx b/frontend/src/components/ChangePanel.jsx index 0feb5c6..4f7be67 100644 --- a/frontend/src/components/ChangePanel.jsx +++ b/frontend/src/components/ChangePanel.jsx @@ -11,7 +11,10 @@ import { useState, useEffect, useRef } from 'react' const PREVIEW_LENGTH = 220 -function diffWords(original, proposed) { +// Word-level LCS over whitespace-preserving tokens. Used by both the +// AI change-card InlineDiff (original→proposed) and Phase 5's +// manual-pending-card per-paragraph diff (baseline→current). +export function diffWords(original, proposed) { const a = (original || '').split(/(\s+)/) const b = (proposed || '').split(/(\s+)/) const m = a.length, n = b.length @@ -85,6 +88,7 @@ export default function ChangePanel({ {manualPendingStatus && ( @@ -114,7 +118,7 @@ export default function ChangePanel({ ) } -function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) { +function ManualPendingCard({ paragraphCount, diffs, savingIn, onSaveNow }) { return (
@@ -124,6 +128,19 @@ function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) {
{paragraphCount} paragraph{paragraphCount === 1 ? '' : 's'} edited directly
+ {diffs && diffs.length > 0 && ( +
+ {diffs.map(d => ( +
+ {d.tokens.map((t, idx) => + t.type === 'same' ? {t.text} : + t.type === 'add' ? {t.text} : + {t.text} + )} +
+ ))} +
+ )}
unsaved · auto-save in {savingIn} diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 52ab255..ec5b979 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -39,7 +39,7 @@ import SelectionTooltip from './SelectionTooltip.jsx' import { marked } from 'marked' import PromptBar from './PromptBar.jsx' import ChatPanel from './ChatPanel.jsx' -import ChangePanel from './ChangePanel.jsx' +import ChangePanel, { diffWords } from './ChangePanel.jsx' import DiffView from './DiffView.jsx' import PRModal from './PRModal.jsx' import GraduateDialog from './GraduateDialog.jsx' @@ -214,12 +214,25 @@ export default function RFCView({ viewer }) { const current = splitSourceParagraphs(doc) const baseline = originalSourceLinesRef.current || [] const len = Math.max(current.length, baseline.length) - let changed = 0 + // §8.11 Phase 5: per-paragraph word-diff for the pending manual card. + // Naive index-aligned compare against the same baseline Phase 4's + // gutter uses; tolerates the insert-in-middle cascade as the "you've + // touched stuff below this point" cue. An LCS-anchored pass could + // refine this later. + const diffs = [] for (let i = 0; i < len; i++) { - if ((current[i] ?? '') !== (baseline[i] ?? '')) changed++ + const a = baseline[i] ?? '' + const b = current[i] ?? '' + if (a === b) continue + diffs.push({ + baselineIndex: i, + baselineText: a, + currentText: b, + tokens: diffWords(a, b), + }) } - if (changed > 0) { - setManualPending({ paragraphCount: changed }) + if (diffs.length > 0) { + setManualPending({ paragraphCount: diffs.length, diffs }) setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS }) } else { setManualPending(null) @@ -754,6 +767,7 @@ export default function RFCView({ viewer }) { focusedChangeId={focusedChangeId} manualPendingStatus={manualPending ? { paragraphCount: manualPending.paragraphCount, + diffs: manualPending.diffs, savingIn: manualCountdownLabel(manualCountdown), onSaveNow: handleSaveNow, } : null}