c92730a737
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>
291 lines
10 KiB
React
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
|
|
}
|
|
}
|