Files
rfc-app/frontend/src/components/PRView.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

554 lines
22 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'
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">{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
}