Slice 4: super-draft body editing per §9.5 + §9.6
The §17 routing-collapse rule lands in api_branches.py and api_prs.py — every branches/<branch>/... and prs/<n>/... route dispatches on the entry's state to pick the right Gitea repo, and the body extracted from the entry's frontmatter envelope is what the editor and the diff see. The bot grows open_metadata_pr; cache grows refresh_meta_branches. Two §17 routes added: start-edit-branch and metadata. The §9.4 super-draft view replaces RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to dodge the §19.2 path-routing candidate while preserving §9.5's structural shape. Covered by tests/test_super_draft_vertical.py (10 tests). The full Slices 1-4 suite is 35/35 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,30 @@ export async function resolveThread(slug, branch, threadId) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
||||
|
||||
export async function startEditBranch(slug, body = {}) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/start-edit-branch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function editMetadata(slug, { title, tags, prDescription }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title ?? null,
|
||||
tags: tags ?? null,
|
||||
pr_description: prDescription ?? null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
|
||||
|
||||
export async function draftPRText(slug, branch) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// RFCView.jsx — the §8 active-RFC view.
|
||||
// RFCView.jsx — the §8 active-RFC view and (per §17's routing-collapse
|
||||
// rule + §9.4) the §9.4 super-draft view.
|
||||
//
|
||||
// Three-column shape per §8.1 (catalog left, this component's content
|
||||
// in the middle and right). Opens on main in discuss mode per §8.2;
|
||||
// supports the §8.3 discuss-vs-contribute flip on non-main branches.
|
||||
// "Start Contributing" on main calls the §17 promote-to-branch
|
||||
// endpoint; on a non-main branch it is a pure mode flip per §8.14.
|
||||
// Three-column shape per §8.1. Opens on main in discuss mode per §8.2
|
||||
// for active RFCs, on the canonical body in discuss mode per §9.4 for
|
||||
// super-drafts. The discuss-vs-contribute flip on non-main / non-edit
|
||||
// branches per §8.3 carries over unchanged.
|
||||
//
|
||||
// The render path inherits the §18 carryovers: Tiptap editor, the
|
||||
// <change> parser (which the backend owns, not the frontend), the
|
||||
// SelectionTooltip, the prompt bar, the change-card panel, the
|
||||
// DiffView toggle.
|
||||
//
|
||||
// Super-draft entries are deferred to Slice 4 per docs/DEV.md; this
|
||||
// component renders a polite "open in Slice 4" placeholder for them.
|
||||
// The active-RFC and super-draft surfaces share their editor,
|
||||
// chat, change-card, DiffView, selection-tooltip, and PR-modal
|
||||
// machinery; the only branchings are "Start Contributing" (which
|
||||
// dispatches to promote-to-branch for active main and start-edit-branch
|
||||
// for a super-draft's canonical body per §9.5) and the metadata pane
|
||||
// (super-draft-only per §9.5).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
acceptChange as apiAccept,
|
||||
createThread,
|
||||
declineChange as apiDecline,
|
||||
editMetadata,
|
||||
getBranch,
|
||||
getRFC,
|
||||
getRFCMain,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
reaskChange,
|
||||
resolveThread,
|
||||
setBranchVisibility,
|
||||
startEditBranch,
|
||||
streamChatTurn,
|
||||
} from '../api'
|
||||
import Editor, { selectionHighlightKey } from './Editor.jsx'
|
||||
@@ -100,12 +101,17 @@ export default function RFCView({ viewer }) {
|
||||
.catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
// Slice 4 owns super-draft body editing; render a placeholder for now.
|
||||
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||
// the same surface as active RFCs — the bot, the chat, the change
|
||||
// panel, and the PR flow all dispatch on the entry's state internally.
|
||||
// The view-level differences are: the breadcrumb's state label, the
|
||||
// metadata pane, and the start-contributing dispatch target.
|
||||
const isSuperDraft = entry?.state === 'super-draft'
|
||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
if (!entry || entry.state !== 'active') return
|
||||
if (!entry || (entry.state !== 'active' && entry.state !== 'super-draft')) return
|
||||
setError(null)
|
||||
setEditorContent('')
|
||||
setMessages([])
|
||||
@@ -217,7 +223,11 @@ export default function RFCView({ viewer }) {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
if (branchParam === 'main') {
|
||||
try {
|
||||
const { branch_name } = await promoteToBranch(slug)
|
||||
// §9.5 dispatch: super-drafts cut a meta-repo edit branch via
|
||||
// start-edit-branch; active RFCs run §8.14's promote-to-branch.
|
||||
const { branch_name } = isSuperDraft
|
||||
? await startEditBranch(slug)
|
||||
: await promoteToBranch(slug)
|
||||
setSearchParams({ branch: branch_name })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
@@ -226,12 +236,10 @@ export default function RFCView({ viewer }) {
|
||||
}
|
||||
// Non-main: pure mode flip per §8.14.
|
||||
if (pendingDiscussChanges.length > 0) {
|
||||
// The §8.14 buffered proposals are already `pending` rows on the
|
||||
// backend — surfacing the change panel exposes them.
|
||||
setPendingDiscussChanges([])
|
||||
}
|
||||
setMode('contribute')
|
||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams])
|
||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])
|
||||
|
||||
// ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
|
||||
const submitChatTurn = useCallback(async (text, quote) => {
|
||||
@@ -425,22 +433,7 @@ export default function RFCView({ viewer }) {
|
||||
// Render early-out states.
|
||||
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
|
||||
if (!entry) return <article className="entry-view">Loading…</article>
|
||||
if (isSuperDraft) {
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className="entry-state-banner">Super-draft</div>
|
||||
<h1 className="entry-title">{entry.title}</h1>
|
||||
<p className="field-help">
|
||||
Super-draft body editing on the meta repo lands in Slice 4 per
|
||||
<code> docs/DEV.md</code>. The Slice 2 view is scoped to active
|
||||
RFCs — chat, branches, change panel, AI participation. The
|
||||
super-draft body below is the pitch as merged.
|
||||
</p>
|
||||
<div className="entry-body" style={{ whiteSpace: 'pre-wrap' }}>{entry.body}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (entry.state !== 'active') {
|
||||
if (entry.state !== 'active' && entry.state !== 'super-draft') {
|
||||
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
|
||||
}
|
||||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||||
@@ -468,19 +461,22 @@ export default function RFCView({ viewer }) {
|
||||
<div className="rfc-view">
|
||||
{/* Breadcrumb */}
|
||||
<div className="rfc-breadcrumb">
|
||||
<span className="breadcrumb-label">{entry.id || 'active'}</span>
|
||||
<span className="breadcrumb-label">
|
||||
{isSuperDraft ? 'super-draft' : (entry.id || 'active')}
|
||||
</span>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<BranchDropdown
|
||||
current={branchParam}
|
||||
mainView={mainView}
|
||||
isSuperDraft={isSuperDraft}
|
||||
onPick={onPickBranch}
|
||||
viewer={viewer}
|
||||
/>
|
||||
<span className="breadcrumb-sep">·</span>
|
||||
<span className="breadcrumb-meta">
|
||||
{mainView ? `${mainView.branches.length} branch${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
|
||||
{mainView ? `${mainView.branches.length} ${isSuperDraft ? 'edit branch' : 'branch'}${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
|
||||
{mainView && mainView.open_prs.length > 0 && ` · ${mainView.open_prs.length} PR${mainView.open_prs.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<div className="breadcrumb-actions">
|
||||
@@ -532,6 +528,16 @@ export default function RFCView({ viewer }) {
|
||||
onClick={() => setShowVisibility(true)}
|
||||
>Branch settings</button>
|
||||
)}
|
||||
{isSuperDraft && branchView?.capabilities?.can_edit_metadata && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowMetadataPane(true)}
|
||||
title="§9.5 — open a small meta-repo PR to edit title or tags"
|
||||
>
|
||||
Metadata
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,8 +546,9 @@ export default function RFCView({ viewer }) {
|
||||
<div className="editor-area" onClick={handleEditorClick}>
|
||||
{branchParam === 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
main is read-only — PRs are the only path to change it.
|
||||
Open a branch to propose edits.
|
||||
{isSuperDraft
|
||||
? 'Canonical body is read-only — PRs against the meta repo are the only path to change it. Use Start Contributing to cut an edit branch.'
|
||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
@@ -685,6 +692,22 @@ export default function RFCView({ viewer }) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMetadataPane && (
|
||||
<MetadataPaneModal
|
||||
slug={slug}
|
||||
currentTitle={entry.title}
|
||||
currentTags={entry.tags || []}
|
||||
onClose={() => setShowMetadataPane(false)}
|
||||
onOpened={(prNumber) => {
|
||||
setShowMetadataPane(false)
|
||||
// Refresh main view so the new open PR surfaces in the
|
||||
// breadcrumb meta count immediately.
|
||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||
navigate(`/rfc/${slug}/pr/${prNumber}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -746,9 +769,14 @@ async function loadAllMessages(slug, branch, threads) {
|
||||
return all
|
||||
}
|
||||
|
||||
function BranchDropdown({ current, mainView, onPick }) {
|
||||
function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = [{ name: 'main' }, ...(mainView?.branches || [])]
|
||||
// For super-drafts the first dropdown position is "canonical body"
|
||||
// per §9.4 — the entry as it appears on meta-repo main. For active
|
||||
// RFCs it is literally `main` per §8.1.
|
||||
const mainLabel = isSuperDraft ? 'canonical body' : 'main'
|
||||
const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
|
||||
const currentLabel = current === 'main' ? mainLabel : current
|
||||
return (
|
||||
<div className="branch-dropdown">
|
||||
<button
|
||||
@@ -756,7 +784,7 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
className="branch-dropdown-trigger"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{current === 'main' ? 'main' : current} ▾
|
||||
{currentLabel} ▾
|
||||
</button>
|
||||
{open && (
|
||||
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
|
||||
@@ -767,7 +795,7 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
|
||||
onClick={() => { setOpen(false); onPick(b.name) }}
|
||||
>
|
||||
<span className="branch-name">{b.name}</span>
|
||||
<span className="branch-name">{b.label || b.name}</span>
|
||||
{b.visibility && b.name !== 'main' && !b.visibility.read_public && (
|
||||
<span className="branch-private-icon" title="Private">🔒</span>
|
||||
)}
|
||||
@@ -782,6 +810,88 @@ function BranchDropdown({ current, mainView, onPick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataPaneModal({ slug, currentTitle, currentTags, onClose, onOpened }) {
|
||||
// §9.5: a small meta-repo PR that touches only frontmatter. Slug
|
||||
// renames are deferred per §9.5 and the §19.2 candidate — the slug
|
||||
// field is rendered read-only as a deliberate signal.
|
||||
const [title, setTitle] = useState(currentTitle || '')
|
||||
const [tagsRaw, setTagsRaw] = useState((currentTags || []).join(', '))
|
||||
const [prDescription, setPrDescription] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true); setErr(null)
|
||||
try {
|
||||
const tags = tagsRaw
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
const result = await editMetadata(slug, {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
prDescription: prDescription.trim() || null,
|
||||
})
|
||||
if (result?.noop) {
|
||||
setErr('No changes — title and tags match the current entry.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
onOpened(result.pr_number)
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Edit metadata</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label style={{ marginTop: 12 }}>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsRaw}
|
||||
onChange={e => setTagsRaw(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="identity, schema"
|
||||
/>
|
||||
<label style={{ marginTop: 12 }}>PR description (optional)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={prDescription}
|
||||
onChange={e => setPrDescription(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="What changed and why."
|
||||
/>
|
||||
<p className="field-help" style={{ marginTop: 12 }}>
|
||||
§9.5: title and tag edits open a small meta-repo PR via the bot.
|
||||
Slug renames are not supported in v1.
|
||||
</p>
|
||||
{err && <p className="field-error">{err}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
<button className="btn-primary" onClick={onSave} disabled={saving}>
|
||||
{saving ? 'Opening PR…' : 'Open metadata PR'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
|
||||
const [readPublic, setReadPublic] = useState(!!current?.read_public)
|
||||
const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
|
||||
|
||||
Reference in New Issue
Block a user