Contribute rewrite Phase 5: inline word-diff in manual pending card

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).
This commit is contained in:
Ben Stull
2026-05-25 11:29:03 -07:00
parent 886bbf5512
commit 4afb018bb0
3 changed files with 65 additions and 7 deletions
+27
View File
@@ -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;
+19 -2
View File
@@ -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 && (
<ManualPendingCard
paragraphCount={manualPendingStatus.paragraphCount}
diffs={manualPendingStatus.diffs}
savingIn={manualPendingStatus.savingIn}
onSaveNow={manualPendingStatus.onSaveNow}
/>
@@ -114,7 +118,7 @@ export default function ChangePanel({
)
}
function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) {
function ManualPendingCard({ paragraphCount, diffs, savingIn, onSaveNow }) {
return (
<div className="change-item type-manual state-pending">
<div className="change-meta">
@@ -124,6 +128,19 @@ function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) {
<div className="change-label">
{paragraphCount} paragraph{paragraphCount === 1 ? '' : 's'} edited directly
</div>
{diffs && diffs.length > 0 && (
<div className="change-manual-diff">
{diffs.map(d => (
<div key={d.baselineIndex} className="change-manual-diff-block">
{d.tokens.map((t, idx) =>
t.type === 'same' ? <span key={idx}>{t.text}</span> :
t.type === 'add' ? <span key={idx} className="manual-diff-insert">{t.text}</span> :
<span key={idx} className="manual-diff-delete">{t.text}</span>
)}
</div>
))}
</div>
)}
<div className="change-manual-status">
unsaved · auto-save in {savingIn}
<button type="button" className="btn-save-now" onClick={onSaveNow}>Save now</button>
+19 -5
View File
@@ -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}