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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user