Files
rfc-app/frontend/src/components/PRView.jsx
T
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

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

570 lines
23 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'
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 <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">
<LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</p>
)}
{/* #26: the optional ground-truth use case for this change,
captured when the PR was opened. Muted "left blank"
treatment when none was supplied. */}
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
Intended use case:
</span>{' '}
{pr.proposed_use_case
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
</div>
{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">
<LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</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
}