// 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 ? (
)}
{change.source_message_id && (
)}
{editing ? (
<>
>
) : (
<>
{isStale && (
)}
>
)}
)
}
function ResolvedStub({ change, onScrollToMessage }) {
const short = (change.reason || '').slice(0, 80)
return (
{change.kind === 'ai' ? 'AI' : 'You'}
{change.state}
{short || (change.kind === 'manual' ? 'manual edit' : 'change')}
{change.source_message_id && (
)}
)
}