Slice 3: the PR flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user