4afb018bb0
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).
254 lines
9.0 KiB
React
254 lines
9.0 KiB
React
// ChangePanel.jsx — the §8.8 change-card panel.
|
|
//
|
|
// Sits below the chat in contribute mode. Pending cards stack on top
|
|
// of resolved stubs. Each AI card carries accept / edit-before-accept /
|
|
// decline per §8.9; each manual card carries the live status line per
|
|
// §8.11. Stale cards surface the §8.11 warning + Re-ask path. Clicking
|
|
// a card's "↑ from this message" affordance scrolls the chat back to
|
|
// the originating message.
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
const PREVIEW_LENGTH = 220
|
|
|
|
// 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
|
|
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
|
|
for (let i = 1; i <= m; i++)
|
|
for (let j = 1; j <= n; j++)
|
|
dp[i][j] = a[i-1] === b[j-1]
|
|
? dp[i-1][j-1] + 1
|
|
: Math.max(dp[i-1][j], dp[i][j-1])
|
|
const tokens = []
|
|
let i = m, j = n
|
|
while (i > 0 || j > 0) {
|
|
if (i > 0 && j > 0 && a[i-1] === b[j-1]) {
|
|
tokens.unshift({ text: a[i-1], type: 'same' }); i--; j--
|
|
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
|
tokens.unshift({ text: b[j-1], type: 'add' }); j--
|
|
} else {
|
|
tokens.unshift({ text: a[i-1], type: 'remove' }); i--
|
|
}
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
function InlineDiff({ original, proposed }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const tokens = diffWords(original, proposed)
|
|
const fullText = tokens.map(t => t.text).join('')
|
|
const needsTruncation = fullText.length > PREVIEW_LENGTH
|
|
let shown = tokens
|
|
if (needsTruncation && !expanded) {
|
|
let count = 0
|
|
const cutoff = tokens.findIndex(t => { count += t.text.length; return count > PREVIEW_LENGTH })
|
|
if (cutoff !== -1) shown = tokens.slice(0, cutoff)
|
|
}
|
|
return (
|
|
<div className="inline-diff">
|
|
{shown.map((token, idx) =>
|
|
token.type === 'same' ? <span key={idx}>{token.text}</span> :
|
|
token.type === 'add' ? <span key={idx} className="diff-word-add">{token.text}</span> :
|
|
<span key={idx} className="diff-word-remove">{token.text}</span>
|
|
)}
|
|
{needsTruncation && !expanded && <span className="text-fade">…</span>}
|
|
{needsTruncation && (
|
|
<button className="expand-toggle" onClick={() => setExpanded(e => !e)}>
|
|
{expanded ? '↑ Show less' : '↓ Show more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ChangePanel({
|
|
changes,
|
|
onAccept,
|
|
onDecline,
|
|
onReask,
|
|
onScrollToMessage,
|
|
focusedChangeId,
|
|
manualPendingStatus, // {paragraphCount, savingIn, onSaveNow} or null
|
|
}) {
|
|
const pending = changes.filter(c => c.state === 'pending')
|
|
const resolved = changes.filter(c => c.state !== 'pending')
|
|
|
|
return (
|
|
<div className="change-panel">
|
|
<div className="change-panel-header">
|
|
Changes
|
|
{pending.length > 0 && <span className="badge">{pending.length}</span>}
|
|
</div>
|
|
<div className="change-list">
|
|
{manualPendingStatus && (
|
|
<ManualPendingCard
|
|
paragraphCount={manualPendingStatus.paragraphCount}
|
|
diffs={manualPendingStatus.diffs}
|
|
savingIn={manualPendingStatus.savingIn}
|
|
onSaveNow={manualPendingStatus.onSaveNow}
|
|
/>
|
|
)}
|
|
{pending.length > 0 && (
|
|
<div className="change-group-label">Pending</div>
|
|
)}
|
|
{pending.map(c => (
|
|
<ChangeItem
|
|
key={c.id}
|
|
change={c}
|
|
focused={focusedChangeId === c.id}
|
|
onAccept={onAccept}
|
|
onDecline={onDecline}
|
|
onReask={onReask}
|
|
onScrollToMessage={onScrollToMessage}
|
|
/>
|
|
))}
|
|
{resolved.length > 0 && (
|
|
<div className="change-group-label muted">Resolved</div>
|
|
)}
|
|
{resolved.map(c => (
|
|
<ResolvedStub key={c.id} change={c} onScrollToMessage={onScrollToMessage} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ManualPendingCard({ paragraphCount, diffs, savingIn, onSaveNow }) {
|
|
return (
|
|
<div className="change-item type-manual state-pending">
|
|
<div className="change-meta">
|
|
<span className="change-author">You · manual edit</span>
|
|
<span className="change-state-badge pending">unsaved</span>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChangeItem({ change, focused, onAccept, onDecline, onReask, onScrollToMessage }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [edited, setEdited] = useState(change.proposed || '')
|
|
const itemRef = useRef(null)
|
|
const isStale = !!change.stale_since
|
|
|
|
useEffect(() => {
|
|
if (focused && itemRef.current) {
|
|
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
}
|
|
}, [focused])
|
|
|
|
const handleStartEdit = () => { setEdited(change.proposed || ''); setEditing(true) }
|
|
const handleAcceptEdited = () => {
|
|
onAccept({ change, proposed: edited, wasEdited: true })
|
|
setEditing(false)
|
|
}
|
|
const handleAcceptStraight = () => {
|
|
onAccept({ change, proposed: change.proposed, wasEdited: false })
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={itemRef}
|
|
className={`change-item state-${change.state} type-${change.kind === 'ai' ? 'claude' : 'manual'}${focused ? ' focused' : ''}${isStale ? ' stale' : ''}`}
|
|
>
|
|
<div className="change-meta">
|
|
<span className="change-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
|
|
<span className={`change-state-badge ${change.state}`}>
|
|
{isStale ? 'stale' : change.state}
|
|
</span>
|
|
</div>
|
|
{change.reason && (
|
|
<div className="change-label">{change.reason}</div>
|
|
)}
|
|
{isStale && (
|
|
<div className="change-stale-banner">
|
|
The original text has changed since this was proposed.
|
|
</div>
|
|
)}
|
|
{change.kind === 'ai' && (
|
|
<div className="change-diff">
|
|
{editing ? (
|
|
<textarea
|
|
className="diff-edit-textarea"
|
|
value={edited}
|
|
onChange={e => setEdited(e.target.value)}
|
|
rows={Math.min(12, Math.max(3, edited.split('\n').length + 1))}
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<InlineDiff original={change.original} proposed={change.proposed} />
|
|
)}
|
|
</div>
|
|
)}
|
|
{change.source_message_id && (
|
|
<button
|
|
type="button"
|
|
className="change-source-link"
|
|
onClick={() => onScrollToMessage?.(change.source_message_id)}
|
|
>↑ from this message</button>
|
|
)}
|
|
<div className="change-actions">
|
|
{editing ? (
|
|
<>
|
|
<button className="btn-accept" onClick={handleAcceptEdited}>Accept edit</button>
|
|
<button className="btn-edit" onClick={() => setEditing(false)}>Cancel</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button className="btn-accept" onClick={handleAcceptStraight}>
|
|
{isStale ? 'Apply anyway…' : 'Accept'}
|
|
</button>
|
|
<button className="btn-edit" onClick={handleStartEdit}>Edit</button>
|
|
<button className="btn-decline" onClick={() => onDecline(change.id)}>Decline</button>
|
|
{isStale && (
|
|
<button className="btn-reask" onClick={() => onReask?.(change.id)}>Re-ask</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResolvedStub({ change, onScrollToMessage }) {
|
|
const short = (change.reason || '').slice(0, 80)
|
|
return (
|
|
<div className={`change-stub state-${change.state}`}>
|
|
<span className="stub-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
|
|
<span className={`stub-badge ${change.state}`}>{change.state}</span>
|
|
<span className="stub-reason">{short || (change.kind === 'manual' ? 'manual edit' : 'change')}</span>
|
|
{change.source_message_id && (
|
|
<button
|
|
type="button"
|
|
className="stub-source-link"
|
|
onClick={() => onScrollToMessage?.(change.source_message_id)}
|
|
>↑</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|