// 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' import { EVENTS, track } from '../lib/analytics' import LinkedText from './LinkedText' 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, }) // v0.15.0 — analytics: fire on §10.4 review-comment success. // surface=pr distinguishes this from RFC discussion comments. // No body text or quote material in the event. track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' }) 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

Error: {error}

if (!pr) return
Loading PR…
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 (
{/* Header strip */}
{pr.rfc_id || pr.slug} {pr.head_branch} PR #{pr.pr_number}
{editingHeader ? (
setDraftTitle(e.target.value)} maxLength={240} />