// 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 (
{shown.map((token, idx) => token.type === 'same' ? {token.text} : token.type === 'add' ? {token.text} : {token.text} )} {needsTruncation && !expanded && } {needsTruncation && ( )}
) } 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 (
Changes {pending.length > 0 && {pending.length}}
{manualPendingStatus && ( )} {pending.length > 0 && (
Pending
)} {pending.map(c => ( ))} {resolved.length > 0 && (
Resolved
)} {resolved.map(c => ( ))}
) } function ManualPendingCard({ paragraphCount, diffs, savingIn, onSaveNow }) { return (
You · manual edit unsaved
{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}
) } 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 (
{change.kind === 'ai' ? 'AI' : 'You'} {isStale ? 'stale' : change.state}
{change.reason && (
{change.reason}
)} {isStale && (
The original text has changed since this was proposed.
)} {change.kind === 'ai' && (
{editing ? (