Files
rfc-app/frontend/src/components/RFCDiscussionPanel.jsx
T
Ben Stull 72f8457933 Release 0.15.0: Amplitude Analytics + Session Replay (with #21 Part C identity lifecycle)
Wave 5 / Track A. Roadmap item #13. Folds in #21 Part C user-identity-
lifecycle work inline rather than as a follow-up release (operator
ask: "implement Amplitude best practices from the very get-go").

Wraps @amplitude/unified with:
  - consent-gated lazy init (v0.13.0 cookie banner; SDK never loads
    without explicit analytics opt-in)
  - amplitude.initAll(KEY, { analytics: { autocapture: true },
    sessionReplay: { sampleRate: 1 } }) — vendor-recommended shape
  - identify({ user_id, properties? }) with set vs setOnce semantics
  - setUserProperties(properties) for mid-session state changes
  - anonymize() clears both user_id binding AND property cache

Wired into App.jsx (identify on me.user with role / permission_state /
passcode_set / device_trusted as set; first_sign_in_at / account_created_at
as setOnce; Page Viewed on route change; Sign-out fires User Signed
Out + anonymize), Login.jsx (User Signed In + Beta Access Requested),
and the per-feature surfaces (ProposeModal, PRModal, RFCView,
RFCDiscussionPanel, PRView, Admin).

Binding: VITE_AMPLITUDE_API_KEY via `flotilla overlay set`
(bundle-embedded public key like VITE_TURNSTILE_SITE_KEY — not
secret). Mid-Session-L correction: original dispatch brief specified
secret-set; vendor guidance + bundle visibility settled it as overlay.

PII discipline: no email, no display_name, no gitea_login, no free-text
fields ever sent. Properties are opaque ids + enums + timestamps +
booleans only.

Subagent ξ shipped the wrapper structure + nine-event taxonomy on
feature/v0.15.0-amplitude (0fd8c52 + 6cfbf69 post-correction).
Driver-side integration extended the wrapper with setUserProperties
+ identify properties for the #21 Part C identity-lifecycle work.
Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:04:10 -07:00

296 lines
11 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'
import { EVENTS, track } from '../lib/analytics'
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('')
// v0.15.0 — analytics: fire on a successful discussion post.
// surface=discussion distinguishes this from PR review comments
// which fire from PRView with surface=pr. No body text.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' })
} 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
}
}