Files
rfc-app/frontend/src/components/PRView.jsx
T
Ben Stull 0e1805b8ce Release 0.2.1: PRView Rules-of-Hooks fix (React #310 blank page)
PRView.jsx declared its threadsByKind useMemo after the early-return
guard for the loading state. First mount: pr is null, early return
fires, 14 hooks called. Second render: pr populated, execution
reaches the useMemo, 15 hooks — React #310, page goes blank.

Move the useMemo above the early returns and null-safe the
pr?.threads access so the hook count stays stable across renders.

Pre-existing bug in the post-Contribute-rewrite PR view; surfaced
in production by 0.2.0's graduation merge race fix enabling the
workflow that opens this code path. Patch per §20.2 — no operator
action required beyond rebuilding the frontend and restarting.
2026-05-26 08:50:48 -07:00

549 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PRView.jsx — the §10.3 PR review page.
//
// Three-column shape per §8.1: the catalog on the left is the App
// chrome (so it sits outside this component, same as RFCView). This
// component owns the center (diff toggleable between unified and
// split) and the right (compressed conversation + inline review
// surface). A header strip carries title, editable description,
// status, the merge button, and aggregate counts.
//
// The per-user seen-cursor (§10.3) accents new hunks and new
// conversation messages on the next visit. The cursor advances on
// view; reviewers do not mark anything as read.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
advancePRSeen,
editPRDescription,
getPR,
mergePR,
postPRReview,
startResolutionBranch,
withdrawPR,
} from '../api'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
const navigate = useNavigate()
const prNumber = Number(prNumberParam)
const [pr, setPR] = useState(null)
const [error, setError] = useState(null)
const [diffMode, setDiffMode] = useState('unified') // 'unified' | 'split'
const [editingHeader, setEditingHeader] = useState(false)
const [draftTitle, setDraftTitle] = useState('')
const [draftDescription, setDraftDescription] = useState('')
const [acting, setActing] = useState(null) // 'merge' | 'withdraw' | 'resolution'
// Review-comment composer state.
const [reviewDraft, setReviewDraft] = useState(null) // { quote, anchorPayload }
const [reviewText, setReviewText] = useState('')
const refresh = useCallback(() => {
return getPR(slug, prNumber)
.then(setPR)
.catch(err => setError(err.message))
}, [slug, prNumber])
useEffect(() => { refresh() }, [refresh])
// §10.3: advance the seen cursor on view. We snapshot the most
// recent commit sha and the highest message id we render, then
// POST. Fire-and-forget; the next read returns the advanced cursor.
useEffect(() => {
if (!pr) return
const headSha = pr.merge_commit_sha || pr.head_sha
const allMessages = Object.values(pr.messages_by_thread || {}).flat()
const lastMessageId = allMessages.reduce((m, x) => Math.max(m, x.id || 0), 0)
if (!headSha && !lastMessageId) return
if (viewer == null) return
advancePRSeen(slug, prNumber, {
lastSeenCommitSha: headSha,
lastSeenMessageId: lastMessageId || null,
}).catch(() => {})
}, [pr?.head_sha, pr?.merge_commit_sha, prNumber, slug, viewer])
// ── header actions ─────────────────────────────────────────────
const onMerge = useCallback(async () => {
if (!confirm(`Merge PR #${prNumber}? §10.5 produces a no-fast-forward merge commit on main.`)) return
setActing('merge')
setError(null)
try {
await mergePR(slug, prNumber)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, refresh])
const onWithdraw = useCallback(async () => {
if (!confirm(`Withdraw PR #${prNumber}? The branch is not deleted. §10.8.`)) return
setActing('withdraw')
setError(null)
try {
await withdrawPR(slug, prNumber)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, refresh])
const onResolutionBranch = useCallback(async () => {
if (!confirm('Start a resolution branch off main? §10.9 cuts a fresh branch, replays the diff, and opens a new PR.')) return
setActing('resolution')
setError(null)
try {
const { resolution_branch } = await startResolutionBranch(slug, prNumber)
navigate(`/rfc/${slug}?branch=${encodeURIComponent(resolution_branch)}`)
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, navigate])
const startHeaderEdit = useCallback(() => {
setDraftTitle(pr?.title || '')
setDraftDescription(pr?.description || '')
setEditingHeader(true)
}, [pr])
const saveHeader = useCallback(async () => {
try {
await editPRDescription(slug, prNumber, {
title: draftTitle.trim(),
description: draftDescription.trim(),
})
setEditingHeader(false)
await refresh()
} catch (e) {
setError(e.message)
}
}, [slug, prNumber, draftTitle, draftDescription, refresh])
// ── review composer ────────────────────────────────────────────
const onSubmitReview = useCallback(async () => {
if (!reviewText.trim()) return
try {
await postPRReview(slug, prNumber, {
text: reviewText.trim(),
anchorPayload: reviewDraft?.anchorPayload || {},
quote: reviewDraft?.quote || null,
})
setReviewText('')
setReviewDraft(null)
await refresh()
} catch (e) {
setError(e.message)
}
}, [slug, prNumber, reviewText, reviewDraft, refresh])
// §10.6: split messages by thread kind for the visual distinction
// §10.4 requires. Within each kind, sort by id for chronological
// order. Declared above the early returns below so the hook count
// stays stable between the loading render (pr == null) and the
// loaded render (Rules of Hooks — React #310 otherwise).
const threadsByKind = useMemo(() => {
const out = { chat: [], review: [], flag: [] }
for (const t of pr?.threads || []) {
out[t.thread_kind]?.push(t)
}
return out
}, [pr?.threads])
// ── render ─────────────────────────────────────────────────────
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
if (!pr) return <article className="entry-view">Loading PR</article>
const merged = pr.state === 'merged'
const withdrawn = pr.state === 'withdrawn'
const closed = pr.state === 'closed'
const open = pr.state === 'open'
const supersededBy = pr.superseded_by_pr_number
const supersedes = pr.supersedes_pr_number
const seenSha = pr.seen?.last_seen_commit_sha
const seenMsgId = pr.seen?.last_seen_message_id || 0
// Compute new-since-cursor accents. A commit is "new" when the PR
// moved past the seen sha; for v1 we accent the diff whole-cloth
// when seenSha differs from current head/merge sha (a future slice
// can render per-hunk accents — the cursor is in place).
const diffHasNewCommits = seenSha && seenSha !== (pr.merge_commit_sha || pr.head_sha)
return (
<div className="rfc-view pr-view">
{/* Header strip */}
<div className="pr-header">
<div className="pr-header-left">
<div className="pr-breadcrumb">
<a href={`/rfc/${slug}`}>{pr.rfc_id || pr.slug}</a>
<span className="breadcrumb-sep"></span>
<a href={`/rfc/${slug}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
<span className="breadcrumb-sep"></span>
<strong>PR #{pr.pr_number}</strong>
</div>
{editingHeader ? (
<div className="pr-header-edit">
<input
type="text"
className="modal-input"
value={draftTitle}
onChange={e => setDraftTitle(e.target.value)}
maxLength={240}
/>
<textarea
className="modal-textarea"
value={draftDescription}
onChange={e => setDraftDescription(e.target.value)}
rows={4}
maxLength={8000}
/>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => setEditingHeader(false)}>Cancel</button>
<button className="btn-primary" onClick={saveHeader}>Save</button>
</div>
</div>
) : (
<>
<h1 className="pr-title">{pr.title}</h1>
{pr.description && (
<p className="pr-description">{pr.description}</p>
)}
{pr.capabilities?.can_edit_text && (
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
)}
</>
)}
</div>
<div className="pr-header-right">
<StateBanner state={pr.state} mergedAt={pr.merged_at} closedAt={pr.closed_at} />
{(supersedes || supersededBy) && (
<div className="pr-supersedes">
{supersedes && <>Supersedes <a href={`/rfc/${slug}/pr/${supersedes}`}>#{supersedes}</a></>}
{supersededBy && <>Closed by <a href={`/rfc/${slug}/pr/${supersededBy}`}>#{supersededBy}</a></>}
</div>
)}
<div className="pr-counts">
<span className="pr-count-flags" title="Open flags on the source branch (§8.13)">
{pr.counts.open_flags} flag{pr.counts.open_flags === 1 ? '' : 's'}
</span>
<span className="pr-count-reviews">
{pr.counts.open_review_threads} review thread{pr.counts.open_review_threads === 1 ? '' : 's'}
</span>
<span className="pr-count-chats">
{pr.counts.open_chat_threads} open chat{pr.counts.open_chat_threads === 1 ? '' : 's'}
</span>
</div>
{open && pr.mergeable === false && (
<div className="pr-conflict-banner">
<p>
<strong>Merge conflict with main.</strong> Per §10.9, start a
resolution branch off main's tip to replay your work.
</p>
<button
className="btn-primary"
onClick={onResolutionBranch}
disabled={acting === 'resolution'}
>
{acting === 'resolution' ? 'Starting' : 'Start resolution branch'}
</button>
</div>
)}
<div className="pr-actions">
{pr.capabilities?.can_merge && pr.mergeable !== false && (
<button
className="btn-primary"
onClick={onMerge}
disabled={acting === 'merge'}
>
{acting === 'merge' ? 'Merging' : 'Merge'}
</button>
)}
{pr.capabilities?.can_withdraw && (
<button
className="btn-secondary"
onClick={onWithdraw}
disabled={acting === 'withdraw'}
>
{acting === 'withdraw' ? 'Withdrawing' : 'Withdraw'}
</button>
)}
</div>
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in to review</a>
)}
</div>
</div>
{/* Two columns under the header */}
<div className="rfc-body pr-body">
<div className="editor-area">
<div className="diff-mode-toolbar">
<button
className={`btn-link ${diffMode === 'unified' ? 'active' : ''}`}
onClick={() => setDiffMode('unified')}
>Unified</button>
<button
className={`btn-link ${diffMode === 'split' ? 'active' : ''}`}
onClick={() => setDiffMode('split')}
>Split</button>
{diffHasNewCommits && (
<span className="pr-diff-accent" title="New commits since your last visit">
New since your last visit
</span>
)}
</div>
<DiffPane
mainBody={pr.main_body || ''}
branchBody={pr.branch_body || ''}
mode={diffMode}
onReviewRange={(quote, anchorPayload) => {
if (!pr.capabilities?.can_post_review) return
setReviewDraft({ quote, anchorPayload })
setReviewText('')
}}
/>
</div>
<div className="right-panel">
<PRConversation
threads={pr.threads || []}
messagesByThread={pr.messages_by_thread || {}}
threadsByKind={threadsByKind}
seenMsgId={seenMsgId}
/>
{reviewDraft && pr.capabilities?.can_post_review && (
<div className="pr-review-composer">
<div className="pr-review-quote">
<strong>Reviewing:</strong>
<pre>{reviewDraft.quote || '(no selection)'}</pre>
</div>
<textarea
className="modal-textarea"
value={reviewText}
onChange={e => setReviewText(e.target.value)}
placeholder="Leave a review comment on this range — §10.4."
rows={4}
/>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => { setReviewDraft(null); setReviewText('') }}>Cancel</button>
<button className="btn-primary" onClick={onSubmitReview} disabled={!reviewText.trim()}>Post review</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
function StateBanner({ state, mergedAt, closedAt }) {
if (state === 'merged') return <div className="pr-state-banner merged">Merged {fmtDate(mergedAt)}</div>
if (state === 'withdrawn') return <div className="pr-state-banner withdrawn">Withdrawn {fmtDate(closedAt)}</div>
if (state === 'closed') return <div className="pr-state-banner closed">Closed {fmtDate(closedAt)}</div>
return <div className="pr-state-banner open">Open</div>
}
function fmtDate(iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
// ─────────────────────────────────────────────────────────────────
// Conversation surface — chat + review interleaved by chronology.
// §8.5: compressed by default. We expand all for v1; the toggle and
// the "Show full conversation" affordance are tracked as a §19.2
// follow-on if usage shows it matters.
// ─────────────────────────────────────────────────────────────────
function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId }) {
// Flatten every message with its thread context, sort by id.
const items = []
for (const t of threads) {
const msgs = messagesByThread[t.id] || []
for (const m of msgs) {
items.push({ ...m, _thread: t })
}
if (t.thread_kind === 'flag' && msgs.length === 0) {
items.push({
id: `flag-${t.id}`,
role: 'system',
text: t.label || '',
_thread: t,
created_at: t.created_at,
})
}
}
items.sort((a, b) => {
const ai = typeof a.id === 'number' ? a.id : 0
const bi = typeof b.id === 'number' ? b.id : 0
return ai - bi
})
return (
<div className="chat-panel pr-conversation">
<div className="pr-conv-disclosure">
{threadsByKind.review.length > 0 && (
<div className="thread-disclosure">
<strong>{threadsByKind.review.length}</strong> review thread{threadsByKind.review.length === 1 ? '' : 's'}
</div>
)}
{threadsByKind.flag.length > 0 && (
<div className="thread-disclosure">
<strong>{threadsByKind.flag.length}</strong> flag{threadsByKind.flag.length === 1 ? '' : 's'}
</div>
)}
</div>
<ul className="chat-feed">
{items.map(m => {
const t = m._thread
const isReview = t?.thread_kind === 'review'
const isFlag = t?.thread_kind === 'flag'
const isNew = typeof m.id === 'number' && m.id > seenMsgId
const cls = [
'chat-msg',
`chat-msg-${m.role}`,
isReview ? 'chat-msg-review' : '',
isFlag ? 'chat-msg-flag' : '',
isNew ? 'chat-msg-new' : '',
].filter(Boolean).join(' ')
return (
<li key={m.id} className={cls} data-message-id={m.id}>
<div className="chat-msg-header">
{isReview && <span className="chat-msg-badge review">Review</span>}
{isFlag && <span className="chat-msg-badge flag">Flag</span>}
<span className="chat-msg-author">
{m.role === 'assistant' ? `Claude (${m.model_id || 'ai'})` : (m.author_display || m.author_login || (m.role === 'system' ? 'system' : ''))}
</span>
{isNew && <span className="chat-msg-new-pip" title="New since your last visit">●</span>}
</div>
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
<div className="chat-msg-body">{m.text}</div>
</li>
)
})}
</ul>
</div>
)
}
// ─────────────────────────────────────────────────────────────────
// Diff pane — minimal line-by-line unified/split renderer over the
// branch's RFC.md vs main's RFC.md. The §19.2 candidate "DiffView
// reconstruction from changes history" is the long-form follow-on;
// for Slice 3 we serve the file-level diff readers expect.
// ─────────────────────────────────────────────────────────────────
function DiffPane({ mainBody, branchBody, mode, onReviewRange }) {
const lines = useMemo(() => computeLineDiff(mainBody, branchBody), [mainBody, branchBody])
if (mode === 'split') {
return (
<div className="diff-pane diff-split">
<div className="diff-col">
<div className="diff-col-header">main</div>
{lines.map((l, i) => (
<DiffLine key={`m${i}`} side="left" type={l.type} text={l.left} onReviewRange={onReviewRange} />
))}
</div>
<div className="diff-col">
<div className="diff-col-header">{`branch`}</div>
{lines.map((l, i) => (
<DiffLine key={`b${i}`} side="right" type={l.type} text={l.right} onReviewRange={onReviewRange} />
))}
</div>
</div>
)
}
return (
<div className="diff-pane diff-unified">
{lines.map((l, i) => (
<UnifiedRow key={i} line={l} onReviewRange={onReviewRange} />
))}
</div>
)
}
function UnifiedRow({ line, onReviewRange }) {
if (line.type === 'equal') {
return <div className="diff-row diff-equal" onMouseUp={makeReviewHandler(line.left || line.right, onReviewRange)}>
<span className="diff-marker"> </span><span className="diff-text">{line.left}</span>
</div>
}
return (
<>
{line.left != null && (
<div className="diff-row diff-del" onMouseUp={makeReviewHandler(line.left, onReviewRange)}>
<span className="diff-marker"></span><span className="diff-text">{line.left}</span>
</div>
)}
{line.right != null && (
<div className="diff-row diff-add" onMouseUp={makeReviewHandler(line.right, onReviewRange)}>
<span className="diff-marker">+</span><span className="diff-text">{line.right}</span>
</div>
)}
</>
)
}
function DiffLine({ type, text, onReviewRange }) {
const cls = `diff-row diff-${type === 'equal' ? 'equal' : (text == null ? 'empty' : type)}`
return (
<div className={cls} onMouseUp={makeReviewHandler(text || '', onReviewRange)}>
<span className="diff-text">{text || ''}</span>
</div>
)
}
function makeReviewHandler(rowText, onReviewRange) {
return () => {
const sel = window.getSelection?.()
if (!sel || sel.isCollapsed) return
const quote = sel.toString()
if (!quote || !quote.trim()) return
onReviewRange?.(quote, { row_text: rowText, quote })
}
}
// Crude longest-common-subsequence-ish line diff. Sufficient for the
// single-file diff we render; switching to a proper diff library is a
// §19.2 candidate ("markdown round-trip fidelity" earned attention
// first). The output is a list of {type, left, right} rows where
// type is one of 'equal' | 'del' | 'add'.
function computeLineDiff(a, b) {
const aLines = (a || '').split('\n')
const bLines = (b || '').split('\n')
const n = aLines.length, m = bLines.length
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (aLines[i] === bLines[j]) dp[i][j] = dp[i + 1][j + 1] + 1
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
const out = []
let i = 0, j = 0
while (i < n && j < m) {
if (aLines[i] === bLines[j]) {
out.push({ type: 'equal', left: aLines[i], right: bLines[j] })
i++; j++
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', left: aLines[i], right: null })
i++
} else {
out.push({ type: 'add', left: null, right: bLines[j] })
j++
}
}
while (i < n) { out.push({ type: 'del', left: aLines[i++], right: null }) }
while (j < m) { out.push({ type: 'add', left: null, right: bLines[j++] }) }
return out
}