Files
rfc-app/frontend/src/components/RFCView.jsx
T
Ben Stull c92730a737 Release 0.5.0: PR-less per-RFC discussion (contribution still requires PR)
Roadmap item #3. An RFC's main view now carries a discussion surface
distinct from PR comments and from branch chat. The substrate is the
existing threads/thread_messages tables — rows with branch_name IS NULL
scope to the RFC's main view; the schema already permitted that shape,
v0.5.0 is the first build to write it. Five new endpoints under
/api/rfcs/<slug>/discussion/..., a new RFCDiscussionPanel right-column
component used when branchParam === main, SPEC §10.10 settling
discussion-vs-contribution, and §17 listing the new routes. Notification
routing reuses the existing chat_message_in_participated_thread /
chat_reply_to_my_message event kinds with branch_name=null on the
fan-out row; a distinct event_kind is a §19.2 candidate. Anonymous
viewers can read; writes require contributor — v0.6.0's item #4 will
harden adjacent gates. No schema migration; minor bump, no operator
action required beyond rebuild and restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:04:52 -07:00

1148 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { claimOwnership } from '../api'
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(setEntry).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)
// 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 `<span class="tracked-*">` 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 <article className="entry-view"><p>Error: {error}</p></article>
if (!entry) return <article className="entry-view">Loading</article>
if (entry.state !== 'active' && entry.state !== 'super-draft') {
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
}
if (!branchView) return <article className="entry-view">Loading branch</article>
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 (
<div className="rfc-view">
{/* Breadcrumb */}
<div className="rfc-breadcrumb">
<span className="breadcrumb-label">
{isSuperDraft ? 'super-draft' : (entry.id || 'active')}
</span>
<span className="breadcrumb-sep"></span>
<strong>{entry.title}</strong>
<span className="breadcrumb-sep"></span>
<BranchDropdown
current={branchParam}
mainView={mainView}
isSuperDraft={isSuperDraft}
onPick={onPickBranch}
viewer={viewer}
/>
<span className="breadcrumb-sep">·</span>
<span className="breadcrumb-meta">
{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'}`}
</span>
<div className="breadcrumb-actions">
{branchParam !== 'main' && canContribute && (
<button
type="button"
className={`btn-mode-toggle ${mode}`}
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
>
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
<span className="beta-chip">Beta</span>
</button>
)}
{(branchParam === 'main' || !canContribute) && viewer && (
<button
type="button"
className="btn-start-contribution-header"
onClick={handleStartContributing}
>
Start Contributing
<span className="beta-chip">Beta</span>
</button>
)}
{!viewer && (
<a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
{canOpenPR && (
<button
type="button"
className="btn-primary btn-open-pr"
onClick={() => setShowPRModal(true)}
title="§10.1 — open a PR from this branch against main"
>
Open PR
</button>
)}
{openPRForBranch && (
<a
className="btn-link"
href={`/rfc/${slug}/pr/${openPRForBranch.pr_number}`}
title="View the open PR for this branch"
>
PR #{openPRForBranch.pr_number}
</a>
)}
{canChangeSettings && branchParam !== 'main' && (
<button
type="button"
className="btn-link"
onClick={() => setShowVisibility(true)}
>Branch settings</button>
)}
{isSuperDraft && branchView?.capabilities?.can_edit_metadata && (
<button
type="button"
className="btn-link"
onClick={() => setShowMetadataPane(true)}
title="§9.5 — open a small meta-repo PR to edit title or tags"
>
Metadata
</button>
)}
{isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
<button
type="button"
className="btn-link"
onClick={async () => {
setClaimError(null)
try {
const result = await claimOwnership(slug)
if (result?.noop) return
if (result?.pr_number) {
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
}
} catch (err) {
setClaimError(err.message)
}
}}
title="§13.1 — open a claim PR adding you to the entry's owners"
>
Claim ownership
</button>
)}
{isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-primary btn-graduate"
onClick={() => setShowGraduateDialog(true)}
title="§13 — graduate this super-draft to a per-RFC repo"
>
Graduate to RFC repo
</button>
)}
</div>
</div>
{claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div>
)}
{/* Two columns: editor + chat */}
<div className={`rfc-body${drawerOpen ? ' drawer-open' : ''}`}>
<div className="editor-area" onClick={handleEditorClick}>
{branchParam === 'main' && (
<div className="discuss-mode-banner">
{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.'}
</div>
)}
{inDiscuss && branchParam !== 'main' && (
<div className="discuss-mode-banner">
Discuss mode on <strong>{branchParam}</strong> chat freely;
flip to Contribute to edit the document and apply AI changes.
</div>
)}
{!canContribute && branchParam !== 'main' && viewer && (
<div className="discuss-mode-banner muted">
You don't have contribute access to this branch. The branch
creator or an arbiter can grant access.
</div>
)}
{showContributeToolbar && (
<div className="editor-toolbar">
<span className="editor-toolbar-hint">
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
{pendingCount} pending
</span>
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{showDiscussToolbar && (
<div className="editor-toolbar">
<button
type="button"
className={`btn-review-toggle${discussShowChanges ? ' active' : ''}`}
onClick={() => setDiscussShowChanges(v => !v)}
title="§8.10 — surface accepted tracked changes inline in the preview"
>
{discussShowChanges ? 'Hide tracked changes' : 'Show tracked changes'}
</button>
<span className="editor-toolbar-hint">
{changes.filter(c => c.state === 'accepted').length} accepted on this branch
</span>
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{!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.
<div className="editor-toolbar editor-toolbar-narrow-only">
<ChatDrawerToggleButton
open={drawerOpen}
count={drawerBadgeCount}
onToggle={() => setDrawerOpen(v => !v)}
/>
</div>
)}
{editorEditable ? (
<div className="editor-split">
<div className="editor-split-pane editor-split-raw">
<MarkdownSourceEditor
initialDoc={editorContent}
editorRef={editorRef}
onUpdate={handleEditorDocUpdate}
/>
</div>
<div className="editor-split-pane editor-split-preview">
<MarkdownPreview
content={previewContent}
onSelectionChange={handleSelectionChange}
changes={changes}
messages={messages}
showTrackedChanges
/>
</div>
</div>
) : (
<MarkdownPreview
content={editorContent}
onSelectionChange={handleSelectionChange}
className="markdown-preview-solo"
changes={changes}
messages={messages}
showTrackedChanges={inDiscuss && discussShowChanges}
/>
)}
<SelectionTooltip
selection={selection}
onAsk={handleTooltipAsk}
onFlag={handleTooltipFlag}
disabled={isStreaming || !viewer}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
{showPromptBar ? (
<PromptBar
selection={selection?.text || null}
onSubmit={handlePrompt}
disabled={isStreaming}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
discussMode={inDiscuss}
/>
) : (
<div className="readonly-bar">
Read-only view. Discussion is in private <strong>Beta</strong> —{' '}
<a href="/auth/login">sign in</a> if your email has been invited.
</div>
)}
</div>
<div
className="right-panel-backdrop"
onClick={() => setDrawerOpen(false)}
aria-hidden="true"
data-open={drawerOpen ? 'true' : 'false'}
/>
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
{/* 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' ? (
<RFCDiscussionPanel slug={slug} viewer={viewer} />
) : (
<ChatPanel
messages={messages}
threads={branchView.threads || []}
changes={changes}
branchName={branchParam}
isStreaming={isStreaming}
contributionMode={mode === 'contribute'}
onStartContribution={handleStartContributing}
onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread}
/>
)}
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
<ChangePanel
changes={changes}
onAccept={handleAccept}
onDecline={handleDecline}
onReask={handleReask}
onScrollToMessage={focusMessage}
focusedChangeId={focusedChangeId}
manualPendingStatus={manualPending ? {
paragraphCount: manualPending.paragraphCount,
diffs: manualPending.diffs,
savingIn: manualCountdownLabel(manualCountdown),
onSaveNow: handleSaveNow,
} : null}
/>
)}
{inDiscuss && pendingDiscussChanges.length > 0 && (
<div className="contribution-cta">
<div className="contribution-cta-count">
{pendingDiscussChanges.length} change{pendingDiscussChanges.length === 1 ? '' : 's'} proposed
</div>
<p className="contribution-cta-desc">
Flip into Contribute to act on them.
</p>
<button
type="button"
className="btn-start-contribution"
onClick={handleStartContributing}
>
{branchParam === 'main' ? 'Start Contributing ' : 'Contribute on this branch '}
</button>
</div>
)}
</div>
</div>
{showPRModal && (
<PRModal
slug={slug}
branch={branchParam}
branchIsPrivate={!branchView.visibility?.read_public}
onClose={() => setShowPRModal(false)}
onOpened={(prNumber) => {
setShowPRModal(false)
navigate(`/rfc/${slug}/pr/${prNumber}`)
}}
/>
)}
{showVisibility && (
<BranchVisibilityModal
slug={slug}
branch={branchParam}
current={branchView.visibility}
onClose={() => setShowVisibility(false)}
onSaved={async () => {
const fresh = await getBranch(slug, branchParam)
setBranchView(fresh)
setShowVisibility(false)
}}
/>
)}
{showGraduateDialog && (
<GraduateDialog
slug={slug}
entry={entry}
onClose={() => 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(() => {})
}}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}
currentTitle={entry.title}
currentTags={entry.tags || []}
onClose={() => 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}`)
}}
/>
)}
</div>
)
}
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 (
<button
type="button"
className={`btn-chat-drawer-toggle${open ? ' active' : ''}`}
onClick={onToggle}
aria-pressed={open}
aria-label={`${open ? 'Close' : 'Open'} chat panel${count > 0 ? ` (${count} pending)` : ''}`}
title={open ? 'Close chat panel' : 'Open chat panel'}
>
Chat
{count > 0 && <span className="badge">{count}</span>}
</button>
)
}
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 (
<div className="branch-dropdown">
<button
type="button"
className="branch-dropdown-trigger"
onClick={() => setOpen(o => !o)}
>
{currentLabel}
</button>
{open && (
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
{items.map(b => (
<button
key={b.name}
type="button"
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.name) }}
>
<span className="branch-name">{b.label || b.name}</span>
{b.visibility && b.name !== 'main' && !b.visibility.read_public && (
<span className="branch-private-icon" title="Private">🔒</span>
)}
{b.creator && (
<span className="branch-creator">@{b.creator}</span>
)}
</button>
))}
{preGrad.length > 0 && (
<>
<div className="branch-dropdown-section">
Pre-graduation history ({preGrad.length})
</div>
{preGrad.map(b => (
<button
key={b.branch_name}
type="button"
className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.branch_name) }}
title="§9.8: meta-repo edit branch from before graduation — read-only"
>
<span className="branch-name">{b.branch_name}</span>
<span className="branch-meta">
{b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
{b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
</span>
</button>
))}
</>
)}
</div>
)}
</div>
)
}
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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Edit metadata</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<label>Title</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
disabled={saving}
/>
<label style={{ marginTop: 12 }}>Tags (comma-separated)</label>
<input
type="text"
value={tagsRaw}
onChange={e => setTagsRaw(e.target.value)}
disabled={saving}
placeholder="identity, schema"
/>
<label style={{ marginTop: 12 }}>PR description (optional)</label>
<textarea
rows={3}
value={prDescription}
onChange={e => setPrDescription(e.target.value)}
disabled={saving}
placeholder="What changed and why."
/>
<p className="field-help" style={{ marginTop: 12 }}>
§9.5: title and tag edits open a small meta-repo PR via the bot.
Slug renames are not supported in v1.
</p>
{err && <p className="field-error">{err}</p>}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={onSave} disabled={saving}>
{saving ? 'Opening PR…' : 'Open metadata PR'}
</button>
</div>
</div>
</div>
)
}
function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
const [readPublic, setReadPublic] = useState(!!current?.read_public)
const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
const [saving, setSaving] = useState(false)
const [err, setErr] = useState(null)
const onSave = async () => {
setSaving(true); setErr(null)
try {
await setBranchVisibility(slug, branch, { readPublic, contributeMode })
onSaved()
} catch (e) { setErr(e.message); setSaving(false) }
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Branch settings {branch}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<label>
<input type="checkbox" checked={readPublic} onChange={e => setReadPublic(e.target.checked)} />
{' '}Public read access
</label>
<p className="field-help">
§11.1: a public branch can be read by anyone, including anonymous viewers.
</p>
<label style={{ marginTop: 12 }}>Contribute mode</label>
<select value={contributeMode} onChange={e => setContributeMode(e.target.value)}>
<option value="just-me">Just me</option>
<option value="specific">Specific contributors</option>
<option value="any-contributor">Any contributor</option>
</select>
{err && <p className="field-error">{err}</p>}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
}