Files
rfc-app/frontend/src/components/RFCDiscussionPanel.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

291 lines
10 KiB
React

// 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 (
<div className="discussion-panel">
<div className="discussion-header">
<span className="discussion-header-title">
Discussion <span className="beta-chip">Beta</span>
</span>
<span className="discussion-header-meta">
{openThreads.length} open thread{openThreads.length === 1 ? '' : 's'}
{' · '}contribution requires a PR
</span>
</div>
{threads.length > 1 && (
<div className="discussion-thread-tabs">
{threads.map(t => (
<button
key={t.id}
type="button"
className={`discussion-thread-tab ${t.id === activeThreadId ? 'active' : ''} ${t.state === 'resolved' ? 'resolved' : ''}`}
onClick={() => setActiveThreadId(t.id)}
title={t.label || (t.id === activeThreadId ? 'Current thread' : 'Open thread')}
>
{t.label || (t.anchor_kind === 'whole-doc' && !t.label ? 'General' : `Thread ${t.id}`)}
{t.state === 'resolved' && ' ✓'}
</button>
))}
</div>
)}
<div className="discussion-messages">
{error && <div className="discussion-error">{error}</div>}
{activeMessages.length === 0 && !error && (
<div className="discussion-empty">
<p>
{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.'}
</p>
</div>
)}
{activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
<div className="discussion-composer">
{viewer ? (
<>
<textarea
className="discussion-composer-textarea"
value={composer}
onChange={e => setComposer(e.target.value)}
onKeyDown={onKeyDown}
placeholder={
activeThread?.label
? `Reply in "${activeThread.label}" — Cmd/Ctrl+Enter to send`
: 'Discuss this RFC — Cmd/Ctrl+Enter to send'
}
disabled={sending}
rows={3}
/>
<div className="discussion-composer-actions">
<button
type="button"
className="btn-secondary"
onClick={handleNewThread}
disabled={sending}
title="Open a fresh discussion thread on this RFC"
>
New thread
</button>
{activeThread
&& activeThread.state === 'open'
&& (activeThread.created_by === viewer.user_id
|| viewer.role === 'owner'
|| viewer.role === 'admin') && (
<button
type="button"
className="btn-link"
onClick={() => handleResolve(activeThread.id)}
disabled={sending}
title="Mark this discussion thread resolved"
>
Resolve
</button>
)}
<button
type="button"
className="btn-primary"
onClick={handleSend}
disabled={sending || !composer.trim()}
>
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</>
) : (
<div className="discussion-readonly">
Read-only <a href="/auth/login">sign in</a> to join the discussion.
Discussion is in private <strong>Beta</strong>.
</div>
)}
</div>
</div>
)
}
function DiscussionMessage({ message }) {
const isSystem = message.role === 'system'
if (isSystem) {
return (
<div className="discussion-message system">
<div className="discussion-system-bubble">{message.text}</div>
</div>
)
}
return (
<div className={`discussion-message ${message.role}`}>
<div className="discussion-message-meta">
<span className="discussion-message-author">
@{message.author_login || '—'}
</span>
<span className="discussion-message-time">
{formatTimestamp(message.created_at)}
</span>
</div>
{message.quote && (
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">{message.text}</div>
</div>
)
}
function formatTimestamp(ts) {
if (!ts) return ''
try {
const d = new Date(ts + (ts.endsWith('Z') ? '' : 'Z'))
return d.toLocaleString()
} catch {
return ts
}
}