// RFCView.jsx — the §8 active-RFC view and (per §17's routing-collapse // rule + §9.4) the §9.4 super-draft view. // // Three-column shape per §8.1. Opens on main in discuss mode per §8.2 // for active RFCs, on the canonical body in discuss mode per §9.4 for // super-drafts. The discuss-vs-contribute flip on non-main / non-edit // branches per §8.3 carries over unchanged. // // The active-RFC and super-draft surfaces share their editor, chat, // change-card, preview-pane tracked-change overlay (§8.10), selection- // tooltip, and PR-modal machinery; the only branchings are // "Start Contributing" (which // dispatches to promote-to-branch for active main and start-edit-branch // for a super-draft's canonical body per §9.5) and the metadata pane // (super-draft-only per §9.5). import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { acceptChange as apiAccept, createThread, declineChange as apiDecline, editMetadata, getBranch, getRFC, getRFCMain, getThreadMessages, listModels, manualFlush, promoteToBranch, reaskChange, resolveThread, setBranchVisibility, startEditBranch, streamChatTurn, } from '../api' import MarkdownSourceEditor from './MarkdownSourceEditor.jsx' import MarkdownPreview from './MarkdownPreview.jsx' import SelectionTooltip from './SelectionTooltip.jsx' import PromptBar from './PromptBar.jsx' import ChatPanel from './ChatPanel.jsx' import RFCDiscussionPanel from './RFCDiscussionPanel.jsx' import ChangePanel, { diffWords } from './ChangePanel.jsx' import PRModal from './PRModal.jsx' import GraduateDialog from './GraduateDialog.jsx' import InvitationsModal from './InvitationsModal.jsx' import { claimOwnership, retireRFC, unretireRFC } from '../api' import { EVENTS, track } from '../lib/analytics' const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_DEBOUNCE_MS = 800 function debounce(fn, ms) { let t return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) } } // Split markdown source into paragraph-ish blocks on blank lines for the // §8.11 manual-edit count. Headings and lists each count as one block. function splitSourceParagraphs(md) { return (md || '') .split(/\n\s*\n/) .map(p => p.trim()) .filter(p => p.length > 0) } export default function RFCView({ viewer }) { const { slug } = useParams() const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const branchParam = searchParams.get('branch') || 'main' const [entry, setEntry] = useState(null) const [mainView, setMainView] = useState(null) const [branchView, setBranchView] = useState(null) const [error, setError] = useState(null) const [models, setModels] = useState([]) const [selectedModel, setSelectedModel] = useState('') // Editor state — owned here so accept/decline can mutate it. // editorRef points at the MarkdownSourceEditor handle ({view, getDoc, // setDoc}) when Contribute mode is mounted; otherwise null. The // read-only render is now MarkdownPreview, which has no imperative // surface to expose. const editorRef = useRef(null) const originalSourceLinesRef = useRef([]) const [editorContent, setEditorContent] = useState('') // Mirror of the live CM6 doc for the Contribute-mode preview pane, // debounced so the preview doesn't re-render on every keystroke. const [previewContent, setPreviewContent] = useState('') // Selection + tooltip per §8.12. With Tiptap retired from this view, // selection is sourced from window.getSelection() inside the // MarkdownPreview surface — see MarkdownPreview's onSelectionChange. const [selection, setSelection] = useState(null) // Phase 3 — Discuss-mode opt-in for the §8.10 tracked-change overlay // in the single-pane preview. Contribute mode is default-on (the // pane exists for editorial review of your own work); Discuss mode // keeps clean prose by default and exposes a toggle. const [discussShowChanges, setDiscussShowChanges] = useState(false) // Mode: discuss vs contribute (§8.3). Always discuss on main. const [mode, setMode] = useState('discuss') // Chat + changes (loaded with the branch). const [messages, setMessages] = useState([]) const [changes, setChanges] = useState([]) const [pendingDiscussChanges, setPendingDiscussChanges] = useState([]) const [isStreaming, setIsStreaming] = useState(false) const [focusedChangeId, setFocusedChangeId] = useState(null) const [showVisibility, setShowVisibility] = useState(false) const [showPRModal, setShowPRModal] = useState(false) // Manual-edit buffer state per §8.11. const [manualPending, setManualPending] = useState(null) // {paragraphCount, deadline} — null when buffer empty const [manualCountdown, setManualCountdown] = useState(null) // Phase 6 — narrow-viewport (<1280px) right-column collapse. The drawer // chrome is rendered unconditionally; CSS hides it above 1280px where // the right panel is always inline. Per-session, default closed. const [drawerOpen, setDrawerOpen] = useState(false) useEffect(() => { getRFC(slug).then(entry => { setEntry(entry) // v0.15.0 — analytics: fire RFC Viewed once per slug load. // We key on the slug param rather than the loaded entry so a // re-render doesn't double-fire; the slug is the stable // identifier. id is included for join-friendliness in the // Amplitude dashboard. track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id }) }).catch(err => setError(err.message)) listModels(slug) .then(({ models, default: def }) => { setModels(models || []) setSelectedModel(def || models?.[0]?.id || '') }) .catch(() => {}) }, [slug]) // Per §9.4 / §17's routing-collapse rule, super-drafts render through // the same surface as active RFCs — the bot, the chat, the change // panel, and the PR flow all dispatch on the entry's state internally. // The view-level differences are: the breadcrumb's state label, the // metadata pane, and the start-contributing dispatch target. const isSuperDraft = entry?.state === 'super-draft' const [showMetadataPane, setShowMetadataPane] = useState(false) const [showGraduateDialog, setShowGraduateDialog] = useState(false) const [claimError, setClaimError] = useState(null) // v0.16.0 (item #12): the per-RFC invitations modal. Visible only to // RFC owners (frontmatter) and platform admin/owner — the backend // gates the underlying endpoints regardless, so a leaked toggle // can't actually leak anything. const [showInvitationsModal, setShowInvitationsModal] = useState(false) // §13.7 retire / un-retire. RFC owners + site owners may retire; only // site owners may un-retire. The backend gates both regardless. const [actionError, setActionError] = useState(null) const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && ( viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login) ) const handleRetire = useCallback(async () => { if (!window.confirm( `Retire “${entry?.title || slug}”? It will be soft-deleted — removed ` + `from the catalog and every view. A site owner can un-retire it later.` )) return setActionError(null) try { await retireRFC(slug) navigate('/') // the entry is now hidden; return to the catalog } catch (err) { setActionError(err.message) } }, [slug, entry, navigate]) const handleUnretire = useCallback(async () => { setActionError(null) try { const res = await unretireRFC(slug) getRFC(slug).then(setEntry).catch(() => {}) if (res?.state) navigate(`/rfc/${slug}`) } catch (err) { setActionError(err.message) } }, [slug, navigate]) // Load main view + branch view whenever slug/branch changes. useEffect(() => { if (!entry || (entry.state !== 'active' && entry.state !== 'super-draft')) return setError(null) setEditorContent('') setMessages([]) setChanges([]) setPendingDiscussChanges([]) setManualPending(null) setDiscussShowChanges(false) setSelection(null) setMode('discuss') getRFCMain(slug).then(setMainView).catch(err => setError(err.message)) getBranch(slug, branchParam) .then(view => { setBranchView(view) setEditorContent(view.body || '') setPreviewContent(view.body || '') originalSourceLinesRef.current = splitSourceParagraphs(view.body || '') setChanges(view.changes || []) }) .catch(err => setError(err.message)) }, [slug, branchParam, entry]) // Load chat messages whenever the branch's main thread id resolves. useEffect(() => { if (!branchView?.main_thread_id) return loadAllMessages(slug, branchParam, branchView.threads).then(setMessages) }, [branchView?.main_thread_id, slug, branchParam]) // Selection wiring (§8.12). MarkdownPreview reports {text, coords} // sourced from window.getSelection(); the SelectionTooltip // positions itself from coords. No PM/Tiptap surface is involved. const handleSelectionChange = useCallback((sel) => { setSelection(sel) }, []) // Manual-edit debounced upsert per §8.11 — produces a pending manual // card with a live countdown and an explicit Save now. With the CM6 // markdown source editor (Phase 1), the doc IS the markdown — no more // HTML→getText round-trip, so §19.2 lossiness is gone. const flushManualBuffer = useCallback(async () => { const editor = editorRef.current if (!editor || !branchView || mode !== 'contribute') return if (typeof editor.getDoc !== 'function') return const raw = editor.getDoc() const newContent = raw.endsWith('\n') ? raw : raw + '\n' if (newContent.trim() === (branchView.body || '').trim()) { setManualPending(null) return } try { const res = await manualFlush(slug, branchParam, { newContent, paragraphCount: manualPending?.paragraphCount || 1, }) if (!res.noop) { const fresh = await getBranch(slug, branchParam) setBranchView(fresh) setChanges(fresh.changes || []) setPreviewContent(fresh.body || '') originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '') setManualPending(null) loadAllMessages(slug, branchParam, fresh.threads).then(setMessages) } } catch (err) { setError(err.message) } }, [slug, branchParam, branchView, mode, manualPending?.paragraphCount]) const handleEditorUpdate = useMemo(() => debounce((doc) => { if (mode !== 'contribute' || !branchView) return const current = splitSourceParagraphs(doc) const baseline = originalSourceLinesRef.current || [] const len = Math.max(current.length, baseline.length) // §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++) { 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 (diffs.length > 0) { setManualPending({ paragraphCount: diffs.length, diffs }) setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS }) } else { setManualPending(null) setManualCountdown(null) } }, MANUAL_DEBOUNCE_MS), [mode, branchView]) // Faster debounce for the live preview pane — manual-edit tracking // can stay at MANUAL_DEBOUNCE_MS, but the rendered preview should feel // responsive while typing. ~150ms keeps marked + mermaid re-renders off // the keystroke hot path without feeling stale. const handleEditorUpdatePreview = useMemo(() => debounce((doc) => { setPreviewContent(doc) }, 150), []) const handleEditorDocUpdate = useCallback((doc) => { handleEditorUpdate(doc) handleEditorUpdatePreview(doc) }, [handleEditorUpdate, handleEditorUpdatePreview]) // Idle flush — auto-save when countdown elapses. useEffect(() => { if (!manualCountdown) return const delay = Math.max(0, manualCountdown.deadline - Date.now()) const t = setTimeout(() => { flushManualBuffer() }, delay) return () => clearTimeout(t) }, [manualCountdown, flushManualBuffer]) // ── Start contributing ───────────────────────────────────────────────── const handleStartContributing = useCallback(async () => { if (!viewer) { window.location.href = '/auth/login'; return } if (branchParam === 'main') { try { // §9.5 dispatch: super-drafts cut a meta-repo edit branch via // start-edit-branch; active RFCs run §8.14's promote-to-branch. const { branch_name } = isSuperDraft ? await startEditBranch(slug) : await promoteToBranch(slug) setSearchParams({ branch: branch_name }) } catch (err) { setError(err.message) } return } // Non-main: pure mode flip per §8.14. if (pendingDiscussChanges.length > 0) { setPendingDiscussChanges([]) } setMode('contribute') }, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft]) // ── Submit a chat turn (prompt bar or selection tooltip) ─────────────── const submitChatTurn = useCallback(async (text, quote) => { if (!branchView?.main_thread_id || isStreaming) return if (!viewer) { window.location.href = '/auth/login'; return } setIsStreaming(true) // Optimistic insert of the user message and an assistant placeholder. const tempUserId = `tmp-user-${Date.now()}` const tempAssistId = `tmp-assist-${Date.now()}` setMessages(prev => [ ...prev, { id: tempUserId, role: 'user', author_login: viewer.gitea_login, text, quote, created_at: new Date().toISOString() }, { id: tempAssistId, role: 'assistant', text: '', model_id: selectedModel, streaming: true, created_at: new Date().toISOString() }, ]) try { const { assistantId, userMsgId } = await streamChatTurn( slug, branchParam, branchView.main_thread_id, { text, quote, model: selectedModel }, { onChunk: chunk => { setMessages(prev => prev.map(m => m.id === tempAssistId ? { ...m, text: (m.text || '') + chunk } : m )) }, onChanges: payload => { // payload: { message_id, change_ids, count } // The page-level state holds onto the assistant id so we // can correlate change.source_message_id when the branch // re-loads below. if (payload?.message_id) { setMessages(prev => prev.map(m => m.id === tempAssistId ? { ...m, id: payload.message_id, streaming: false } : m )) } }, onDone: () => { /* terminal */ }, }, ) // Rebind to the real ids returned via response headers in case // the X-Assistant-Message-Id header arrived before the changes event. if (assistantId) { setMessages(prev => prev.map(m => m.id === tempAssistId ? { ...m, id: Number(assistantId), streaming: false } : m )) } if (userMsgId) { setMessages(prev => prev.map(m => m.id === tempUserId ? { ...m, id: Number(userMsgId) } : m )) } // Re-pull authoritative state: changes have been materialized server-side. const fresh = await getBranch(slug, branchParam) setChanges(fresh.changes || []) // If we're in discuss mode and the new turn produced pending AI changes, // surface them as discuss-mode buffered count. if (mode === 'discuss') { const newPending = (fresh.changes || []).filter(c => c.state === 'pending' && c.kind === 'ai') setPendingDiscussChanges(newPending) } } catch (err) { setError(err.message) setMessages(prev => prev.map(m => m.id === tempAssistId ? { ...m, text: `[Error: ${err.message}]`, streaming: false } : m )) } finally { setIsStreaming(false) } }, [slug, branchParam, branchView?.main_thread_id, isStreaming, viewer, selectedModel, mode]) const handlePrompt = useCallback((text, sel) => { const quote = sel?.text || null submitChatTurn(text, quote) }, [submitChatTurn]) const handleTooltipAsk = useCallback(async (textOrNull, quote) => { if (textOrNull === null) { setSelection(null); return } setSelection(null) await submitChatTurn(textOrNull, quote) }, [submitChatTurn]) const handleTooltipFlag = useCallback(async (label, quote) => { if (!viewer) { window.location.href = '/auth/login'; return } setSelection(null) try { await createThread(slug, branchParam, { thread_kind: 'flag', anchor_kind: 'range', anchor_payload: { quote }, label, }) const fresh = await getBranch(slug, branchParam) setBranchView(fresh) loadAllMessages(slug, branchParam, fresh.threads).then(setMessages) } catch (err) { setError(err.message) } }, [slug, branchParam, viewer]) // ── Accept / decline / reask ────────────────────────────────────────── const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => { try { await apiAccept(slug, branchParam, change.id, { proposed, wasEdited }) // Pull authoritative branch state and reset the editor to the new // body. The proper tracked-changes overlay lives in Phase 3's // preview pane; with CM6 as the source editor there is no HTML // surface to inject `` into. const fresh = await getBranch(slug, branchParam) setBranchView(fresh) setChanges(fresh.changes || []) setEditorContent(fresh.body || '') setPreviewContent(fresh.body || '') originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '') } catch (err) { setError(err.message) } }, [slug, branchParam]) const handleDecline = useCallback(async (changeId) => { try { await apiDecline(slug, branchParam, changeId) const fresh = await getBranch(slug, branchParam) setChanges(fresh.changes || []) } catch (err) { setError(err.message) } }, [slug, branchParam]) const handleReask = useCallback(async (changeId) => { try { await reaskChange(slug, branchParam, changeId) const fresh = await getBranch(slug, branchParam) setBranchView(fresh) setChanges(fresh.changes || []) loadAllMessages(slug, branchParam, fresh.threads).then(setMessages) } catch (err) { setError(err.message) } }, [slug, branchParam]) const handleResolveThread = useCallback(async (threadId) => { try { await resolveThread(slug, branchParam, threadId) const fresh = await getBranch(slug, branchParam) setBranchView(fresh) loadAllMessages(slug, branchParam, fresh.threads).then(setMessages) } catch (err) { setError(err.message) } }, [slug, branchParam]) const handleSaveNow = useCallback(() => { flushManualBuffer() }, [flushManualBuffer]) // Phase 6 — Escape closes the drawer when open. Cheap and gated on // drawerOpen so the listener attaches only while needed. The CSS hides // the drawer chrome above 1280px regardless, so this is a no-op there // because drawerOpen has no visible effect either way. useEffect(() => { if (!drawerOpen) return const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [drawerOpen]) // ── Editor-click → focus matching change card ────────────────────────── const handleEditorClick = useCallback((e) => { const span = e.target.closest('[data-change-id]') if (span) { const id = span.getAttribute('data-change-id') setFocusedChangeId(Number(id)) setTimeout(() => setFocusedChangeId(null), 1800) } }, []) // ── Branch dropdown navigation ───────────────────────────────────────── const onPickBranch = useCallback((name) => { if (name === branchParam) return if (name === 'main') { setSearchParams({}) } else { setSearchParams({ branch: name }) } }, [branchParam, setSearchParams]) // Render early-out states. if (error) return

Error: {error}

if (!entry) return
Loading…
if (entry.state !== 'active' && entry.state !== 'super-draft') { // §13.7: a retired entry is only reachable here by a site owner (the // backend 404s everyone else), so this doubles as the direct-nav // un-retire surface alongside the admin "Retired" list. const canUnretire = entry.state === 'retired' && viewer?.role === 'owner' return (

This RFC is {entry.state}.

{canUnretire && ( <>

Retired entries are hidden from the catalog and every view. Un-retiring restores it to its prior state.

)} {actionError &&

{actionError}

}
) } if (!branchView) return
Loading branch…
const canContribute = branchView.capabilities?.can_contribute && branchParam !== 'main' const canChangeSettings = branchView.capabilities?.can_change_branch_settings const editorEditable = mode === 'contribute' && canContribute const showPromptBar = !!viewer const inDiscuss = mode === 'discuss' const pendingCount = changes.filter(c => c.state === 'pending').length // Phase 6 — count surfaced on the narrow-viewport drawer toggle. Mirrors // the change-panel-header badge: pending AI changes + the manual card // when one's buffered. const drawerBadgeCount = pendingCount + (manualPending ? 1 : 0) const showContributeToolbar = mode === 'contribute' && canContribute const showDiscussToolbar = inDiscuss && branchParam !== 'main' && changes.some(c => c.state === 'accepted') // §10.1: the Open PR affordance only surfaces on a non-main branch // that has commits ahead of main and no open PR already. const openPRForBranch = mainView?.open_prs?.find(p => p.head_branch === branchParam) || null const branchHasCommitsAhead = branchView && mainView && branchView.body !== (mainView.body || '') const canOpenPR = ( branchParam !== 'main' && canContribute && !openPRForBranch && branchHasCommitsAhead ) return (
{/* Breadcrumb */}
{isSuperDraft ? 'super-draft' : (entry.id || 'active')} {entry.title} · {mainView ? `${mainView.branches.length} ${isSuperDraft ? 'edit branch' : 'branch'}${mainView.branches.length === 1 ? '' : 'es'}` : '…'} {mainView && mainView.open_prs.length > 0 && ` · ${mainView.open_prs.length} PR${mainView.open_prs.length === 1 ? '' : 's'}`}
{branchParam !== 'main' && canContribute && ( )} {(branchParam === 'main' || !canContribute) && viewer && ( )} {!viewer && ( Sign in Beta )} {canOpenPR && ( )} {openPRForBranch && ( PR #{openPRForBranch.pr_number} → )} {canChangeSettings && branchParam !== 'main' && ( )} {isSuperDraft && branchView?.capabilities?.can_edit_metadata && ( )} {isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && ( )} {isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && ( )} {/* v0.16.0 (item #12): owner-only invitations affordance. Shown when the viewer is named in the RFC's frontmatter `owners` list or holds a platform admin/owner role. Available on both super-drafts and active RFCs. */} {viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && ( )} {/* §13.7: Retire (soft delete). RFC owners + site owners only. */} {canRetire && ( )}
{actionError && (
Retire failed: {actionError}
)} {claimError && (
Claim failed: {claimError}
)} {/* Two columns: editor + chat */}
{branchParam === 'main' && (
{isSuperDraft ? 'Canonical body is read-only — PRs against the meta repo are the only path to change it. Use Start Contributing to cut an edit branch.' : 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
)} {/* #26: the optional ground-truth use case captured at propose time. Shown on the canonical (main) view; muted "left blank" treatment when the proposer didn't supply one. */} {branchParam === 'main' && (
Intended use case
{entry.proposed_use_case ?
{entry.proposed_use_case}
: Left blank by the proposer.}
)} {inDiscuss && branchParam !== 'main' && (
Discuss mode on {branchParam} — chat freely; flip to Contribute to edit the document and apply AI changes.
)} {!canContribute && branchParam !== 'main' && viewer && (
You don't have contribute access to this branch. The branch creator or an arbiter can grant access.
)} {showContributeToolbar && (
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '} {pendingCount} pending setDrawerOpen(v => !v)} />
)} {showDiscussToolbar && (
{changes.filter(c => c.state === 'accepted').length} accepted on this branch setDrawerOpen(v => !v)} />
)} {!showContributeToolbar && !showDiscussToolbar && ( // Phase 6 — when neither editing toolbar would render, surface // a narrow-only toolbar carrying just the drawer toggle. CSS // hides it above 1280px where the right panel is always inline.
setDrawerOpen(v => !v)} />
)} {editorEditable ? (
) : ( )} {showPromptBar ? ( ) : (
Read-only view. Discussion is in private Beta —{' '} sign in if your email has been invited.
)}
setDrawerOpen(false)} aria-hidden="true" data-open={drawerOpen ? 'true' : 'false'} />
{/* v0.5.0 — on main, the right panel is the PR-less discussion * surface (threads.branch_name IS NULL). Branches keep their * existing branch-chat panel; contribution still requires * opening a PR from a branch via the Open PR affordance above. */} {branchParam === 'main' ? ( ) : ( )} {mode === 'contribute' && (changes.length > 0 || manualPending) && ( )} {inDiscuss && pendingDiscussChanges.length > 0 && (
{pendingDiscussChanges.length} change{pendingDiscussChanges.length === 1 ? '' : 's'} proposed

Flip into Contribute to act on them.

)}
{showPRModal && ( setShowPRModal(false)} onOpened={(prNumber) => { setShowPRModal(false) navigate(`/rfc/${slug}/pr/${prNumber}`) }} /> )} {showVisibility && ( setShowVisibility(false)} onSaved={async () => { const fresh = await getBranch(slug, branchParam) setBranchView(fresh) setShowVisibility(false) }} /> )} {showGraduateDialog && ( setShowGraduateDialog(false)} onCompleted={() => { setShowGraduateDialog(false) // The catalog row and the RFC view now reflect `active`. getRFC(slug).then(setEntry).catch(() => {}) getRFCMain(slug).then(setMainView).catch(() => {}) }} /> )} {showInvitationsModal && ( setShowInvitationsModal(false)} /> )} {showMetadataPane && ( setShowMetadataPane(false)} onOpened={(prNumber) => { setShowMetadataPane(false) // Refresh main view so the new open PR surfaces in the // breadcrumb meta count immediately. getRFCMain(slug).then(setMainView).catch(() => {}) navigate(`/rfc/${slug}/pr/${prNumber}`) }} /> )}
) } function ChatDrawerToggleButton({ open, count, onToggle }) { // Phase 6 — toolbar button that opens/closes the right-panel drawer // at narrow viewports. The badge mirrors the change-panel-header's // .badge styling; an aria-label gives screen readers the count. CSS // hides the button above 1280px where the right panel is inline. return ( ) } function focusMessage(messageId) { const el = document.querySelector(`[data-message-id="${messageId}"]`) if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } function manualCountdownLabel(c) { if (!c) return '' const remaining = Math.max(0, c.deadline - Date.now()) const totalSec = Math.ceil(remaining / 1000) const m = Math.floor(totalSec / 60) const s = totalSec % 60 return `${m}:${String(s).padStart(2, '0')}` } async function loadAllMessages(slug, branch, threads) { // For Slice 2 we pull each thread's messages and stitch them in // chronological order. The branch chat is the unified feed of // every message across every thread per §8.12. if (!threads || threads.length === 0) return [] const all = [] for (const t of threads) { if (t.state !== 'open' && t.thread_kind !== 'flag') continue try { const { messages } = await getThreadMessages(slug, branch, t.id) for (const m of messages) { all.push({ ...m, thread_id: t.id, thread_kind: t.thread_kind, anchor_kind: t.anchor_kind, anchor_preview: t.anchor_payload?.quote || null, flag_label: t.thread_kind === 'flag' ? t.label : null, }) } if (t.thread_kind === 'flag' && (!messages || messages.length === 0)) { all.push({ id: `flag-${t.id}`, role: 'system', text: t.label || '', thread_id: t.id, thread_kind: 'flag', anchor_kind: t.anchor_kind, anchor_preview: t.anchor_payload?.quote || null, flag_label: t.label, created_at: t.created_at, author_login: null, }) } } catch { // Tolerate per-thread fetch failures; the surface for triage // belongs to a future error overlay. } } all.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '')) return all } function BranchDropdown({ current, mainView, isSuperDraft, onPick }) { const [open, setOpen] = useState(false) // For super-drafts the first dropdown position is "canonical body" // per §9.4 — the entry as it appears on meta-repo main. For active // RFCs it is literally `main` per §8.1. const mainLabel = isSuperDraft ? 'canonical body' : 'main' const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])] const preGrad = mainView?.pre_graduation_history || [] const currentLabel = current === 'main' ? mainLabel : current return (
{open && (
setOpen(false)}> {items.map(b => ( ))} {preGrad.length > 0 && ( <>
Pre-graduation history ({preGrad.length})
{preGrad.map(b => ( ))} )}
)}
) } function MetadataPaneModal({ slug, currentTitle, currentTags, onClose, onOpened }) { // §9.5: a small meta-repo PR that touches only frontmatter. Slug // renames are deferred per §9.5 and the §19.2 candidate — the slug // field is rendered read-only as a deliberate signal. const [title, setTitle] = useState(currentTitle || '') const [tagsRaw, setTagsRaw] = useState((currentTags || []).join(', ')) const [prDescription, setPrDescription] = useState('') const [saving, setSaving] = useState(false) const [err, setErr] = useState(null) const onSave = async () => { setSaving(true); setErr(null) try { const tags = tagsRaw .split(',') .map(t => t.trim()) .filter(Boolean) const result = await editMetadata(slug, { title: title.trim(), tags, prDescription: prDescription.trim() || null, }) if (result?.noop) { setErr('No changes — title and tags match the current entry.') setSaving(false) return } onOpened(result.pr_number) } catch (e) { setErr(e.message) setSaving(false) } } return (
{ if (e.target === e.currentTarget) onClose() }}>

Edit metadata

setTitle(e.target.value)} disabled={saving} /> setTagsRaw(e.target.value)} disabled={saving} placeholder="identity, schema" />