Slice 2: the §8 active-RFC view in full

Per the §19.1 brief: the three-column shape (§8.1) opens on main
in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute
flip on non-main branches, hosts §8.4's per-branch chat with AI
participation (§18's <change> protocol → §8.14 changes rows), the
§8.8 change-card panel with §8.9 accept/decline/edit-before-accept,
the §8.10 tracked-change markup + DiffView toggle, the §8.11
manual-edit flushes with the stale-change mechanic, the §8.12
range and paragraph sub-threads, the §8.13 flag affordance, and
the §8.14 discuss-mode buffer.

Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main,
commit_accepted_change with the structured original/proposed/reason
body and Change-Id + Source-Message-Id + On-behalf-of trailers,
commit_manual_flush, ensure_rfc_repo_seed). cache.py grew
refresh_rfc_repo and the webhook dispatches on repository.full_name.
providers.py and chat.py port the §18 carryovers — multi-provider
LLM abstraction and SSE-streaming chat against the §5 threads /
thread_messages / changes schema. api_branches.py mounts the §17
branches/<branch>/* and threads/<thread_id>/* routes with the §6
/ §11 permission checks inline.

Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx,
ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx,
DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the
prototype and adapted to the canonical schema.

Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new
integration tests against an extended FakeGitea (PUT contents,
POST orgs/{org}/repos, seed_rfc_repo): main-view read,
promote-to-branch, accept (with and without edit-before-accept),
decline, manual flush + system message, flag creation, visibility
flip, anonymous read-but-no-contribute, stale-change refusal, and
the chat-streaming path with a fake provider injected. The 5
Slice 1 tests continue to pass alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:35:14 -07:00
parent 779ba6db59
commit 3bc8fe92af
24 changed files with 5433 additions and 151 deletions
+236
View File
@@ -0,0 +1,236 @@
// 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
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}
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, 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>
<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>
)
}