// RFCDiscussionPanel.jsx — v0.5.0's PR-less per-RFC discussion surface. // // Roadmap item #3: an RFC's main view now has a discussion surface // distinct from PR comments and from branch chat. The substrate is the // existing threads/thread_messages tables — rows with // `threads.branch_name IS NULL` scope to "the RFC, no branch yet." // // Reused as the right-column panel on `branchParam === 'main'`. Branch // chat (ChatPanel.jsx) keeps its existing role for branch-scoped work, // including PRs. Contribution remains gated behind opening a PR — // nothing here writes to the document. import { useCallback, useEffect, useRef, useState } from 'react' import { createDiscussionThread, getDiscussionThreadMessages, listDiscussionThreads, postDiscussionMessage, resolveDiscussionThread, } from '../api' export default function RFCDiscussionPanel({ slug, viewer }) { const [threads, setThreads] = useState([]) const [messagesByThread, setMessagesByThread] = useState({}) const [composer, setComposer] = useState('') const [activeThreadId, setActiveThreadId] = useState(null) const [error, setError] = useState(null) const [sending, setSending] = useState(false) const bottomRef = useRef(null) // Pull threads + messages on mount / slug change. useEffect(() => { if (!slug) return let cancelled = false setError(null) setThreads([]) setMessagesByThread({}) setActiveThreadId(null) listDiscussionThreads(slug) .then(async ({ items }) => { if (cancelled) return setThreads(items || []) // Pre-load messages for each thread. The list is small (per-RFC, // not per-branch) so a fan-out fetch is fine; §19.2 candidate // for paging if a hot RFC accumulates lots of threads. const collected = {} for (const t of items || []) { try { const { messages } = await getDiscussionThreadMessages(slug, t.id) collected[t.id] = messages } catch { collected[t.id] = [] } } if (!cancelled) { setMessagesByThread(collected) // Default the active thread to the system's lazy whole-doc // default (the first row with anchor_kind='whole-doc' and // no label) so the composer wires to a real id immediately. const dflt = (items || []).find( t => t.anchor_kind === 'whole-doc' && !t.label, ) setActiveThreadId(dflt?.id || items?.[0]?.id || null) } }) .catch(err => { if (!cancelled) setError(err.message) }) return () => { cancelled = true } }, [slug]) // Scroll to bottom when messages land in the active thread. useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [activeThreadId, messagesByThread[activeThreadId]?.length]) const handleSend = useCallback(async () => { if (!viewer) { window.location.href = '/auth/login'; return } const text = composer.trim() if (!text || sending) return setSending(true) setError(null) try { // If no thread yet, mint one with the message as its first turn. if (!activeThreadId) { const { thread_id, message_id } = await createDiscussionThread(slug, { message: text }) // Re-pull authoritative state — the default whole-doc thread // existed pre-this call (the GET creates it lazily), so we // either get the existing default's id back from the new // thread's row or the prior default; either way the list call // is the source of truth. const { items } = await listDiscussionThreads(slug) setThreads(items || []) const { messages } = await getDiscussionThreadMessages(slug, thread_id) setMessagesByThread(prev => ({ ...prev, [thread_id]: messages })) setActiveThreadId(thread_id) void message_id } else { const { message_id } = await postDiscussionMessage(slug, activeThreadId, { text }) const { messages } = await getDiscussionThreadMessages(slug, activeThreadId) setMessagesByThread(prev => ({ ...prev, [activeThreadId]: messages })) void message_id } setComposer('') } catch (err) { setError(err.message) } finally { setSending(false) } }, [composer, sending, viewer, slug, activeThreadId]) const handleNewThread = useCallback(async () => { if (!viewer) { window.location.href = '/auth/login'; return } setError(null) try { const { thread_id } = await createDiscussionThread(slug, { label: null, message: null }) const { items } = await listDiscussionThreads(slug) setThreads(items || []) setActiveThreadId(thread_id) setMessagesByThread(prev => ({ ...prev, [thread_id]: [] })) } catch (err) { setError(err.message) } }, [viewer, slug]) const handleResolve = useCallback(async (threadId) => { if (!viewer) return setError(null) try { await resolveDiscussionThread(slug, threadId) const { items } = await listDiscussionThreads(slug) setThreads(items || []) } catch (err) { setError(err.message) } }, [viewer, slug]) const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault() handleSend() } }, [handleSend]) const activeThread = threads.find(t => t.id === activeThreadId) || null const activeMessages = messagesByThread[activeThreadId] || [] const openThreads = threads.filter(t => t.state === 'open') return (
{viewer ? 'No discussion yet. Be the first to comment — discussion lives here without opening a PR. To propose an edit, use Start Contributing above.' : 'No discussion yet. Sign in to comment. Discussion lives here without opening a PR; proposed edits still flow through PRs.'}