Slice 3: the PR flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 12:37:54 -07:00
parent 33d9d7a482
commit a2bf89e90b
15 changed files with 2928 additions and 141 deletions
+135
View File
@@ -886,3 +886,138 @@
.diff-tooltip-no-context {
font-size: 11px; color: #aaa; font-style: italic;
}
/* ── Slice 3: the §10 PR flow surfaces ─────────────────────────────── */
.pr-modal { max-width: 640px; }
.pr-modal-warning {
background: #fef3c7; border: 1px solid #fbbf24;
padding: 10px 12px; border-radius: 6px; margin-bottom: 14px;
font-size: 12px; line-height: 1.5;
}
.pr-modal-warning p { margin: 0 0 6px 0; }
.modal-label {
display: block; font-size: 11px; font-weight: 600;
color: #555; margin: 10px 0 4px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.modal-input, .modal-textarea {
width: 100%; padding: 7px 10px; font-size: 13px;
border: 1px solid #d1d5db; border-radius: 4px; box-sizing: border-box;
font-family: inherit;
}
.modal-textarea { resize: vertical; min-height: 100px; }
.btn-open-pr { background: #1a1a1a; color: #fff; border: none;
padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; }
.btn-open-pr:hover { background: #333; }
/* PR view header strip + two-column body */
.pr-view { display: flex; flex-direction: column; height: 100%; }
.pr-header {
display: grid; grid-template-columns: 1fr auto; gap: 16px;
padding: 14px 18px; border-bottom: 1px solid #e5e7eb;
background: #fafafa;
}
.pr-header-left { min-width: 0; }
.pr-breadcrumb { font-size: 11px; color: #666; margin-bottom: 6px; }
.pr-breadcrumb a { color: #5b5bd6; text-decoration: none; }
.pr-breadcrumb a:hover { text-decoration: underline; }
.pr-title { font-size: 18px; margin: 0 0 6px 0; }
.pr-description { font-size: 13px; color: #444; margin: 0 0 6px 0; line-height: 1.55; }
.pr-header-edit { display: flex; flex-direction: column; gap: 8px; }
.pr-header-right {
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
font-size: 12px;
}
.pr-state-banner {
padding: 3px 10px; border-radius: 12px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em;
}
.pr-state-banner.open { background: #dcfce7; color: #14532d; }
.pr-state-banner.merged { background: #ddd6fe; color: #4c1d95; }
.pr-state-banner.withdrawn { background: #fef3c7; color: #78350f; }
.pr-state-banner.closed { background: #e5e7eb; color: #374151; }
.pr-counts { display: flex; gap: 12px; font-size: 11px; color: #555; }
.pr-count-flags { color: #b45309; font-weight: 600; font-size: 13px; }
.pr-supersedes { font-size: 11px; color: #6b7280; }
.pr-supersedes a { color: #5b5bd6; text-decoration: none; }
.pr-conflict-banner {
background: #fee2e2; border: 1px solid #fca5a5;
padding: 10px 12px; border-radius: 6px;
font-size: 12px; line-height: 1.5; max-width: 320px;
}
.pr-conflict-banner p { margin: 0 0 8px 0; }
.pr-actions { display: flex; gap: 8px; }
.pr-body { display: grid; grid-template-columns: 1fr 360px; flex: 1; overflow: hidden; }
.diff-mode-toolbar {
display: flex; gap: 12px; align-items: center;
padding: 6px 12px; border-bottom: 1px solid #f0f0ee;
font-size: 12px;
}
.diff-mode-toolbar .btn-link.active {
font-weight: 600; color: #1a1a1a;
}
.pr-diff-accent {
margin-left: auto;
font-size: 11px; color: #5b5bd6; font-weight: 600;
}
.diff-pane { flex: 1; overflow: auto; font-family: 'SF Mono', Monaco, monospace; font-size: 12px; }
.diff-pane.diff-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #e5e7eb; }
.diff-col { background: #fff; overflow: auto; }
.diff-col-header {
padding: 4px 10px; background: #f3f4f6; font-weight: 600;
font-size: 11px; color: #555; border-bottom: 1px solid #e5e7eb;
}
.diff-row {
display: flex; padding: 1px 8px;
white-space: pre-wrap; word-break: break-word; line-height: 1.5;
}
.diff-row.diff-equal { color: #374151; }
.diff-row.diff-del { background: #fee2e2; color: #7f1d1d; }
.diff-row.diff-add { background: #dcfce7; color: #14532d; }
.diff-row.diff-empty { background: #f9fafb; color: #d1d5db; }
.diff-marker { width: 16px; opacity: 0.5; user-select: none; }
.diff-text { flex: 1; }
/* PR conversation — chat + review interleaved */
.pr-conversation { padding: 10px 12px; overflow-y: auto; }
.pr-conv-disclosure {
display: flex; gap: 12px; font-size: 11px; color: #6b7280;
padding-bottom: 6px; border-bottom: 1px solid #f0f0ee; margin-bottom: 10px;
}
.thread-disclosure strong { color: #1a1a1a; }
.chat-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; }
.chat-msg {
display: flex; flex-direction: column; gap: 4px;
padding: 8px 10px; border-radius: 6px; background: #f9fafb;
font-size: 12px; line-height: 1.55;
}
.chat-msg-review { background: #ede9fe; border-left: 3px solid #7c3aed; }
.chat-msg-flag { background: #fef3c7; border-left: 3px solid #d97706; }
.chat-msg-new { box-shadow: 0 0 0 2px #c7d2fe; }
.chat-msg-header { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #6b7280; }
.chat-msg-badge {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.chat-msg-badge.review { background: #7c3aed; color: #fff; }
.chat-msg-badge.flag { background: #d97706; color: #fff; }
.chat-msg-author { font-weight: 600; color: #1a1a1a; }
.chat-msg-new-pip { color: #5b5bd6; }
.chat-msg-quote {
background: #fff; border-left: 2px solid #d1d5db;
padding: 4px 8px; font-size: 11px; color: #6b7280;
margin: 0; white-space: pre-wrap;
}
.chat-msg-body { white-space: pre-wrap; }
.pr-review-composer {
border-top: 1px solid #e5e7eb; padding: 10px 12px;
background: #fafafa;
}
.pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
.pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; }
+2
View File
@@ -3,6 +3,7 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { getMe } from './api'
import Catalog from './components/Catalog.jsx'
import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
@@ -51,6 +52,7 @@ export default function App() {
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
+73
View File
@@ -197,6 +197,79 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res)
}
// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
export async function draftPRText(slug, branch) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/pr-draft`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function openPR(slug, branch, { title, description }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
},
)
return jsonOrThrow(res)
}
export async function getPR(slug, prNumber) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/prs/${prNumber}`))
}
export async function advancePRSeen(slug, prNumber, { lastSeenCommitSha, lastSeenMessageId }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/seen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
last_seen_commit_sha: lastSeenCommitSha,
last_seen_message_id: lastSeenMessageId,
}),
})
return jsonOrThrow(res)
}
export async function postPRReview(slug, prNumber, { text, anchorPayload, quote }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, anchor_payload: anchorPayload || {}, quote: quote || null }),
})
return jsonOrThrow(res)
}
export async function mergePR(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function withdrawPR(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function editPRDescription(slug, prNumber, { title, description }) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/description`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
})
return jsonOrThrow(res)
}
export async function startResolutionBranch(slug, prNumber) {
const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/resolution-branch`, {
method: 'POST',
})
return jsonOrThrow(res)
}
// Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers
+114
View File
@@ -0,0 +1,114 @@
// PRModal.jsx the §10.2 PR creation modal.
//
// Opens from the §10.1 "Open PR" affordance on a branch view. The
// modal fetches an AI-drafted title and description on open via the
// /pr-draft endpoint, prefills both fields, lets the contributor edit
// before submit. When the source branch is private, surfaces the
// §11.3 universal-public confirmation inline above the form rather
// than as a separate dialog the confirmation is part of this
// surface per §10.1.
import { useEffect, useState } from 'react'
import { draftPRText, openPR } from '../api'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [drafting, setDrafting] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
draftPRText(slug, branch)
.then(d => {
if (cancelled) return
setTitle(d.title || '')
setDescription(d.description || '')
})
.catch(err => { if (!cancelled) setError(err.message) })
.finally(() => { if (!cancelled) setDrafting(false) })
return () => { cancelled = true }
}, [slug, branch])
const canSubmit = !!title.trim() && !drafting && !submitting && confirmed
const submit = async () => {
setSubmitting(true)
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
onOpened?.(pr_number)
} catch (e) {
setError(e.message)
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal pr-modal">
<div className="modal-header">
<h2>Open PR {branch}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{branchIsPrivate && (
<div className="pr-modal-warning">
<p>
<strong>This branch is currently private.</strong> Per §11.3,
opening a PR makes the branch and its history publicly readable.
There is no notion of a private PR.
</p>
<label>
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
/>
{' '}I understand the branch will become public.
</label>
</div>
)}
<label className="modal-label">Title</label>
<input
type="text"
className="modal-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={drafting ? 'Drafting from the diff and chat…' : 'A one-line structural description'}
disabled={drafting || submitting}
maxLength={240}
/>
<p className="field-help">
§10.2: a one-line structural description in spec voice. What was
edited, in what way. AI-drafted; edit before submit.
</p>
<label className="modal-label">Description</label>
<textarea
className="modal-textarea"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={drafting ? 'Drafting…' : 'Two to four sentences pulling from the chat'}
disabled={drafting || submitting}
rows={7}
maxLength={8000}
/>
<p className="field-help">
§10.2: two to four sentences pulling from the branch chat
what was argued, what shifted, what the arbiters are asked
to consider.
</p>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={onClose} disabled={submitting}>Cancel</button>
<button className="btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? 'Opening…' : 'Open PR'}
</button>
</div>
</div>
</div>
)
}
+546
View File
@@ -0,0 +1,546 @@
// 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])
// 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
// §10.6: split messages by thread kind for the visual distinction
// §10.4 requires. Within each kind, sort by id for chronological
// order.
const threadsByKind = useMemo(() => {
const out = { chat: [], review: [], flag: [] }
for (const t of pr.threads || []) {
out[t.thread_kind]?.push(t)
}
return out
}, [pr.threads])
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
}
+45
View File
@@ -38,6 +38,7 @@ import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx'
import PRModal from './PRModal.jsx'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -82,6 +83,7 @@ export default function RFCView({ viewer }) {
const [isStreaming, setIsStreaming] = useState(false)
const [focusedChangeId, setFocusedChangeId] = useState(null)
const [showVisibility, setShowVisibility] = useState(false)
const [showPRModal, setShowPRModal] = useState(false)
// Manual-edit buffer state per §8.11.
const [manualPending, setManualPending] = useState(null)
@@ -451,6 +453,17 @@ export default function RFCView({ viewer }) {
const inDiscuss = mode === 'discuss'
const pendingCount = changes.filter(c => c.state === 'pending').length
// §10.1: the Open PR affordance only surfaces on a non-main branch
// that has commits ahead of main and no open PR already.
const openPRForBranch = mainView?.open_prs?.find(p => p.head_branch === branchParam) || null
const branchHasCommitsAhead = branchView && mainView && branchView.body !== (mainView.body || '')
const canOpenPR = (
branchParam !== 'main'
&& canContribute
&& !openPRForBranch
&& branchHasCommitsAhead
)
return (
<div className="rfc-view">
{/* Breadcrumb */}
@@ -493,6 +506,25 @@ export default function RFCView({ viewer }) {
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a>
)}
{canOpenPR && (
<button
type="button"
className="btn-primary btn-open-pr"
onClick={() => setShowPRModal(true)}
title="§10.1 — open a PR from this branch against main"
>
Open PR
</button>
)}
{openPRForBranch && (
<a
className="btn-link"
href={`/rfc/${slug}/pr/${openPRForBranch.pr_number}`}
title="View the open PR for this branch"
>
PR #{openPRForBranch.pr_number}
</a>
)}
{canChangeSettings && branchParam !== 'main' && (
<button
type="button"
@@ -627,6 +659,19 @@ export default function RFCView({ viewer }) {
</div>
</div>
{showPRModal && (
<PRModal
slug={slug}
branch={branchParam}
branchIsPrivate={!branchView.visibility?.read_public}
onClose={() => setShowPRModal(false)}
onOpened={(prNumber) => {
setShowPRModal(false)
navigate(`/rfc/${slug}/pr/${prNumber}`)
}}
/>
)}
{showVisibility && (
<BranchVisibilityModal
slug={slug}