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
+769 -38
View File
@@ -1,55 +1,786 @@
// RFCView.jsx — §9.4 super-draft view (and a stub for active RFCs).
// RFCView.jsx — the §8 active-RFC view.
//
// Slice 1 ships read-only body rendering: the breadcrumb names the
// entry, the body renders via marked. The discuss-vs-contribute toggle,
// per-branch chat, change-card panel, and breadcrumb dropdown all land
// in Slice 2 per §8.
// Three-column shape per §8.1 (catalog left, this component's content
// in the middle and right). Opens on main in discuss mode per §8.2;
// supports the §8.3 discuss-vs-contribute flip on non-main branches.
// "Start Contributing" on main calls the §17 promote-to-branch
// endpoint; on a non-main branch it is a pure mode flip per §8.14.
//
// The render path inherits the §18 carryovers: Tiptap editor, the
// <change> parser (which the backend owns, not the frontend), the
// SelectionTooltip, the prompt bar, the change-card panel, the
// DiffView toggle.
//
// Super-draft entries are deferred to Slice 4 per docs/DEV.md; this
// component renders a polite "open in Slice 4" placeholder for them.
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { marked } from 'marked'
import { getRFC } from '../api'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
acceptChange as apiAccept,
createThread,
declineChange as apiDecline,
getBranch,
getRFC,
getRFCMain,
getThreadMessages,
listModels,
manualFlush,
promoteToBranch,
reaskChange,
resolveThread,
setBranchVisibility,
streamChatTurn,
} from '../api'
import Editor, { selectionHighlightKey } from './Editor.jsx'
import SelectionTooltip from './SelectionTooltip.jsx'
import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx'
export default function RFCView() {
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) }
}
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.
const editorRef = useRef(null)
const originalParagraphsRef = useRef([])
const [editorContent, setEditorContent] = useState('')
// Selection + tooltip + selection highlight per §8.12.
const [selection, setSelection] = useState(null)
const [highlightRange, setHighlightRange] = useState(null)
const [reviewMode, setReviewMode] = useState(false)
const [reviewHTML, setReviewHTML] = useState('')
// 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)
// Manual-edit buffer state per §8.11.
const [manualPending, setManualPending] = useState(null)
// {paragraphCount, deadline} — null when buffer empty
const [manualCountdown, setManualCountdown] = useState(null)
useEffect(() => {
setEntry(null); setError(null)
getRFC(slug).then(setEntry).catch(err => setError(err.message))
listModels()
.then(({ models, default: def }) => {
setModels(models || [])
setSelectedModel(def || models?.[0]?.id || '')
})
.catch(() => {})
}, [slug])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!entry) return <div className="entry-view">Loading</div>
// Slice 4 owns super-draft body editing; render a placeholder for now.
const isSuperDraft = entry?.state === 'super-draft'
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
if (!entry || entry.state !== 'active') return
setError(null)
setEditorContent('')
setMessages([])
setChanges([])
setPendingDiscussChanges([])
setManualPending(null)
setReviewMode(false)
setSelection(null)
setHighlightRange(null)
setMode('discuss')
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
getBranch(slug, branchParam)
.then(view => {
setBranchView(view)
setEditorContent(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 + highlight wiring (§8.12).
const handleSelectionChange = useCallback((sel) => {
setSelection(sel)
if (sel?.from != null) setHighlightRange({ from: sel.from, to: sel.to })
else setHighlightRange(null)
}, [])
useEffect(() => {
const editor = editorRef.current
if (!editor?.view) return
editor.view.dispatch(editor.state.tr.setMeta(selectionHighlightKey, highlightRange))
}, [highlightRange])
// Manual-edit debounced upsert per §8.11 — produces a pending manual
// card with a live countdown and an explicit Save now.
const flushManualBuffer = useCallback(async () => {
const editor = editorRef.current
if (!editor || !branchView || mode !== 'contribute') return
const text = editor.getText()
// Convert to a rough markdown by stripping HTML — for v1 we round-trip
// through the editor's getText; this matches the prototype's behavior.
// A faithful HTML→markdown round-trip is a §19.2 candidate.
const newContent = text.trim() + '\n'
if (!newContent || 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 || [])
setManualPending(null)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
}
} catch (err) {
setError(err.message)
}
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
const handleEditorUpdate = useMemo(() => debounce((plainText) => {
if (mode !== 'contribute' || !branchView) return
const editor = editorRef.current
if (!editor) return
const currentParagraphs = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
currentParagraphs.push(node.textContent.trim())
}
})
const baseline = originalParagraphsRef.current || []
let changed = 0
currentParagraphs.forEach((t, i) => {
const orig = (baseline[i] ?? '').trim()
if (t !== orig) changed++
})
if (changed > 0) {
setManualPending({ paragraphCount: changed })
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
} else {
setManualPending(null)
setManualCountdown(null)
}
}, MANUAL_DEBOUNCE_MS), [mode, branchView])
// 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 {
const { branch_name } = 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) {
// The §8.14 buffered proposals are already `pending` rows on the
// backend — surfacing the change panel exposes them.
setPendingDiscussChanges([])
}
setMode('contribute')
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams])
// ── 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, from: highlightRange?.from, to: highlightRange?.to },
label,
})
const fresh = await getBranch(slug, branchParam)
setBranchView(fresh)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} catch (err) {
setError(err.message)
}
}, [slug, branchParam, viewer, highlightRange])
// ── Accept / decline / reask ──────────────────────────────────────────
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
try {
const { commit_sha } = await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
// Inject tracked-change markup into the editor so it renders inline.
const editor = editorRef.current
if (editor && change.original) {
const html = editor.getHTML()
const tracked =
`<span class="tracked-delete" data-change-id="${change.id}">${change.original}</span>` +
`<span class="tracked-insert" data-change-id="${change.id}">${proposed}</span>`
const next = html.replace(change.original, tracked)
if (next !== html) editor.commands.setContent(next, false)
}
// Pull the authoritative branch state — body, sha, changes.
const fresh = await getBranch(slug, branchParam)
setBranchView(fresh)
setChanges(fresh.changes || [])
// We do not reset editorContent here — the editor is showing the
// tracked markup overlay; resetting would clear the visual diff
// until DiffView is toggled.
} 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])
// ── 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)
}
}, [])
const toggleReviewMode = useCallback(() => {
setReviewMode(prev => {
if (!prev) setReviewHTML(editorRef.current?.getHTML() || '')
return !prev
})
}, [])
// ── 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 (isSuperDraft) {
return (
<article className="entry-view">
<div className="entry-state-banner">Super-draft</div>
<h1 className="entry-title">{entry.title}</h1>
<p className="field-help">
Super-draft body editing on the meta repo lands in Slice 4 per
<code> docs/DEV.md</code>. The Slice 2 view is scoped to active
RFCs chat, branches, change panel, AI participation. The
super-draft body below is the pitch as merged.
</p>
<div className="entry-body" style={{ whiteSpace: 'pre-wrap' }}>{entry.body}</div>
</article>
)
}
if (entry.state !== 'active') {
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 && !reviewMode
const showPromptBar = !!viewer
const inDiscuss = mode === 'discuss'
const pendingCount = changes.filter(c => c.state === 'pending').length
const stateClass = entry.state === 'active' ? 'active' : ''
return (
<article className="entry-view">
<div className={`entry-state-banner ${stateClass}`}>
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
</div>
<h1 className="entry-title">{entry.title}</h1>
<div className="entry-meta">
<span>{entry.slug}</span>
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>}
{entry.proposed_at && <> · {entry.proposed_at}</>}
{entry.tags.length > 0 && (
<div style={{ marginTop: 6 }}>
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{entry.state === 'active' && (
<div className="entry-state-banner" style={{ background: '#fffbeb', borderColor: '#fde68a', color: '#92400e' }}>
The active-RFC view (editor, branches, chat) lands in Slice 2.
The body below is the canonical main-branch text.
<div className="rfc-view">
{/* Breadcrumb */}
<div className="rfc-breadcrumb">
<span className="breadcrumb-label">{entry.id || 'active'}</span>
<span className="breadcrumb-sep"></span>
<strong>{entry.title}</strong>
<span className="breadcrumb-sep"></span>
<BranchDropdown
current={branchParam}
mainView={mainView}
onPick={onPickBranch}
viewer={viewer}
/>
<span className="breadcrumb-sep">·</span>
<span className="breadcrumb-meta">
{mainView ? `${mainView.branches.length} 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' : 'Flip back to read-only discuss'}
>
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
</button>
)}
{(branchParam === 'main' || !canContribute) && viewer && (
<button
type="button"
className="btn-start-contribution-header"
onClick={handleStartContributing}
>
Start Contributing
</button>
)}
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a>
)}
{canChangeSettings && branchParam !== 'main' && (
<button
type="button"
className="btn-link"
onClick={() => setShowVisibility(true)}
>Branch settings</button>
)}
</div>
</div>
{/* Two columns: editor + chat */}
<div className="rfc-body">
<div className="editor-area" onClick={handleEditorClick}>
{branchParam === 'main' && (
<div className="discuss-mode-banner">
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>
)}
{mode === 'contribute' && canContribute && (
<div className="editor-toolbar">
<button
type="button"
className={`btn-review-toggle${reviewMode ? ' active' : ''}`}
onClick={toggleReviewMode}
title="Toggle DiffView: read-only render of accepted changes in context"
>
{reviewMode ? ' Back to editing' : 'Review changes'}
</button>
<span className="editor-toolbar-hint">
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
{pendingCount} pending
</span>
</div>
)}
{reviewMode ? (
<DiffView html={reviewHTML} changes={changes} messages={messages} />
) : (
<>
<Editor
content={editorContent}
editorRef={editorRef}
originalParagraphsRef={originalParagraphsRef}
onSelectionChange={handleSelectionChange}
onUpdate={editorEditable ? handleEditorUpdate : undefined}
editable={editorEditable}
/>
<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. <a href="/auth/login">Sign in</a> to participate.
</div>
)}
</>
)}
</div>
<div className="right-panel">
<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,
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>
{showVisibility && (
<BranchVisibilityModal
slug={slug}
branch={branchParam}
current={branchView.visibility}
onClose={() => setShowVisibility(false)}
onSaved={async () => {
const fresh = await getBranch(slug, branchParam)
setBranchView(fresh)
setShowVisibility(false)
}}
/>
)}
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
/>
</article>
</div>
)
}
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, onPick }) {
const [open, setOpen] = useState(false)
const items = [{ name: 'main' }, ...(mainView?.branches || [])]
return (
<div className="branch-dropdown">
<button
type="button"
className="branch-dropdown-trigger"
onClick={() => setOpen(o => !o)}
>
{current === 'main' ? 'main' : current}
</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.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>
))}
</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>
)
}