9f548a340d
A second project's corpus now renders under /p/<id>/, isolated by its own
slug namespace. Completes step (1) "RFC app supports multiple projects" for
the read path.
- api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by
(project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as
default-project compat.
- cache.py: refresh_meta_repo iterates every projects row, mirroring each
content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc
gains a project_id arg).
- frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView
pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every
project serves), 404/not-readable branch kept; NotServedPlaceholder deleted.
- tests: test_project_scoped_serving.py (catalog scoping, entry isolation,
gated 404); ProjectLayout.test updated (non-default renders). 445 backend +
11 Vitest green; clean build.
Known limitation (next slice): write path + default-id re-stamp not yet scoped
(non-default project read-only); no live impact (no 2nd project in prod yet).
Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1263 lines
49 KiB
React
1263 lines
49 KiB
React
// 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. 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 active-RFC and super-draft surfaces share their editor, chat,
|
||
// change-card, preview-pane tracked-change overlay (§8.10), 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'
|
||
import {
|
||
acceptChange as apiAccept,
|
||
createThread,
|
||
declineChange as apiDecline,
|
||
editMetadata,
|
||
getBranch,
|
||
getRFC,
|
||
getRFCMain,
|
||
getThreadMessages,
|
||
listModels,
|
||
manualFlush,
|
||
promoteToBranch,
|
||
reaskChange,
|
||
resolveThread,
|
||
setBranchVisibility,
|
||
startEditBranch,
|
||
streamChatTurn,
|
||
} from '../api'
|
||
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
|
||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||
import SelectionTooltip from './SelectionTooltip.jsx'
|
||
import PromptBar from './PromptBar.jsx'
|
||
import ChatPanel from './ChatPanel.jsx'
|
||
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||
import PRModal from './PRModal.jsx'
|
||
import GraduateDialog from './GraduateDialog.jsx'
|
||
import InvitationsModal from './InvitationsModal.jsx'
|
||
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
||
import { EVENTS, track } from '../lib/analytics'
|
||
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
|
||
|
||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||
const MANUAL_DEBOUNCE_MS = 800
|
||
|
||
function debounce(fn, ms) {
|
||
let t
|
||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
|
||
}
|
||
|
||
// Split markdown source into paragraph-ish blocks on blank lines for the
|
||
// §8.11 manual-edit count. Headings and lists each count as one block.
|
||
function splitSourceParagraphs(md) {
|
||
return (md || '')
|
||
.split(/\n\s*\n/)
|
||
.map(p => p.trim())
|
||
.filter(p => p.length > 0)
|
||
}
|
||
|
||
export default function RFCView({ viewer }) {
|
||
const { slug } = useParams()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const navigate = useNavigate()
|
||
const pid = useProjectId()
|
||
|
||
const branchParam = searchParams.get('branch') || 'main'
|
||
|
||
const [entry, setEntry] = useState(null)
|
||
const [mainView, setMainView] = useState(null)
|
||
const [branchView, setBranchView] = useState(null)
|
||
const [error, setError] = useState(null)
|
||
const [models, setModels] = useState([])
|
||
const [selectedModel, setSelectedModel] = useState('')
|
||
|
||
// Editor state — owned here so accept/decline can mutate it.
|
||
// editorRef points at the MarkdownSourceEditor handle ({view, getDoc,
|
||
// setDoc}) when Contribute mode is mounted; otherwise null. The
|
||
// read-only render is now MarkdownPreview, which has no imperative
|
||
// surface to expose.
|
||
const editorRef = useRef(null)
|
||
const originalSourceLinesRef = useRef([])
|
||
const [editorContent, setEditorContent] = useState('')
|
||
// Mirror of the live CM6 doc for the Contribute-mode preview pane,
|
||
// debounced so the preview doesn't re-render on every keystroke.
|
||
const [previewContent, setPreviewContent] = useState('')
|
||
|
||
// Selection + tooltip per §8.12. With Tiptap retired from this view,
|
||
// selection is sourced from window.getSelection() inside the
|
||
// MarkdownPreview surface — see MarkdownPreview's onSelectionChange.
|
||
const [selection, setSelection] = useState(null)
|
||
// Phase 3 — Discuss-mode opt-in for the §8.10 tracked-change overlay
|
||
// in the single-pane preview. Contribute mode is default-on (the
|
||
// pane exists for editorial review of your own work); Discuss mode
|
||
// keeps clean prose by default and exposes a toggle.
|
||
const [discussShowChanges, setDiscussShowChanges] = useState(false)
|
||
|
||
// Mode: discuss vs contribute (§8.3). Always discuss on main.
|
||
const [mode, setMode] = useState('discuss')
|
||
|
||
// Chat + changes (loaded with the branch).
|
||
const [messages, setMessages] = useState([])
|
||
const [changes, setChanges] = useState([])
|
||
const [pendingDiscussChanges, setPendingDiscussChanges] = useState([])
|
||
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)
|
||
// {paragraphCount, deadline} — null when buffer empty
|
||
const [manualCountdown, setManualCountdown] = useState(null)
|
||
|
||
// Phase 6 — narrow-viewport (<1280px) right-column collapse. The drawer
|
||
// chrome is rendered unconditionally; CSS hides it above 1280px where
|
||
// the right panel is always inline. Per-session, default closed.
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
|
||
useEffect(() => {
|
||
getRFC(pid, slug).then(entry => {
|
||
setEntry(entry)
|
||
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
||
// We key on the slug param rather than the loaded entry so a
|
||
// re-render doesn't double-fire; the slug is the stable
|
||
// identifier. id is included for join-friendliness in the
|
||
// Amplitude dashboard.
|
||
track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
|
||
}).catch(err => setError(err.message))
|
||
listModels(slug)
|
||
.then(({ models, default: def }) => {
|
||
setModels(models || [])
|
||
setSelectedModel(def || models?.[0]?.id || '')
|
||
})
|
||
.catch(() => {})
|
||
}, [slug, pid])
|
||
|
||
// 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)
|
||
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
||
const [claimError, setClaimError] = useState(null)
|
||
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
|
||
// RFC owners (frontmatter) and platform admin/owner — the backend
|
||
// gates the underlying endpoints regardless, so a leaked toggle
|
||
// can't actually leak anything.
|
||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||
// §13.7 retire / un-retire. RFC owners + site owners may retire; only
|
||
// site owners may un-retire. The backend gates both regardless.
|
||
const [actionError, setActionError] = useState(null)
|
||
const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && (
|
||
viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login)
|
||
)
|
||
const handleRetire = useCallback(async () => {
|
||
if (!window.confirm(
|
||
`Retire “${entry?.title || slug}”? It will be soft-deleted — removed `
|
||
+ `from the catalog and every view. A site owner can un-retire it later.`
|
||
)) return
|
||
setActionError(null)
|
||
try {
|
||
await retireRFC(slug)
|
||
navigate('/') // the entry is now hidden; return to the catalog
|
||
} catch (err) {
|
||
setActionError(err.message)
|
||
}
|
||
}, [slug, entry, navigate])
|
||
const handleUnretire = useCallback(async () => {
|
||
setActionError(null)
|
||
try {
|
||
const res = await unretireRFC(slug)
|
||
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||
if (res?.state) navigate(entryPath(pid, slug))
|
||
} catch (err) {
|
||
setActionError(err.message)
|
||
}
|
||
}, [slug, navigate, pid])
|
||
|
||
// Load main view + branch view whenever slug/branch changes.
|
||
useEffect(() => {
|
||
if (!entry || (entry.state !== 'active' && entry.state !== 'super-draft')) return
|
||
setError(null)
|
||
setEditorContent('')
|
||
setMessages([])
|
||
setChanges([])
|
||
setPendingDiscussChanges([])
|
||
setManualPending(null)
|
||
setDiscussShowChanges(false)
|
||
setSelection(null)
|
||
setMode('discuss')
|
||
|
||
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
|
||
getBranch(slug, branchParam)
|
||
.then(view => {
|
||
setBranchView(view)
|
||
setEditorContent(view.body || '')
|
||
setPreviewContent(view.body || '')
|
||
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
|
||
setChanges(view.changes || [])
|
||
})
|
||
.catch(err => setError(err.message))
|
||
}, [slug, branchParam, entry])
|
||
|
||
// Load chat messages whenever the branch's main thread id resolves.
|
||
useEffect(() => {
|
||
if (!branchView?.main_thread_id) return
|
||
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
|
||
}, [branchView?.main_thread_id, slug, branchParam])
|
||
|
||
// Selection wiring (§8.12). MarkdownPreview reports {text, coords}
|
||
// sourced from window.getSelection(); the SelectionTooltip
|
||
// positions itself from coords. No PM/Tiptap surface is involved.
|
||
const handleSelectionChange = useCallback((sel) => {
|
||
setSelection(sel)
|
||
}, [])
|
||
|
||
// Manual-edit debounced upsert per §8.11 — produces a pending manual
|
||
// card with a live countdown and an explicit Save now. With the CM6
|
||
// markdown source editor (Phase 1), the doc IS the markdown — no more
|
||
// HTML→getText round-trip, so §19.2 lossiness is gone.
|
||
const flushManualBuffer = useCallback(async () => {
|
||
const editor = editorRef.current
|
||
if (!editor || !branchView || mode !== 'contribute') return
|
||
if (typeof editor.getDoc !== 'function') return
|
||
const raw = editor.getDoc()
|
||
const newContent = raw.endsWith('\n') ? raw : raw + '\n'
|
||
if (newContent.trim() === (branchView.body || '').trim()) {
|
||
setManualPending(null)
|
||
return
|
||
}
|
||
try {
|
||
const res = await manualFlush(slug, branchParam, {
|
||
newContent,
|
||
paragraphCount: manualPending?.paragraphCount || 1,
|
||
})
|
||
if (!res.noop) {
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
setChanges(fresh.changes || [])
|
||
setPreviewContent(fresh.body || '')
|
||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||
setManualPending(null)
|
||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||
}
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
|
||
|
||
const handleEditorUpdate = useMemo(() => debounce((doc) => {
|
||
if (mode !== 'contribute' || !branchView) return
|
||
const current = splitSourceParagraphs(doc)
|
||
const baseline = originalSourceLinesRef.current || []
|
||
const len = Math.max(current.length, baseline.length)
|
||
// §8.11 Phase 5: per-paragraph word-diff for the pending manual card.
|
||
// Naive index-aligned compare against the same baseline Phase 4's
|
||
// gutter uses; tolerates the insert-in-middle cascade as the "you've
|
||
// touched stuff below this point" cue. An LCS-anchored pass could
|
||
// refine this later.
|
||
const diffs = []
|
||
for (let i = 0; i < len; i++) {
|
||
const a = baseline[i] ?? ''
|
||
const b = current[i] ?? ''
|
||
if (a === b) continue
|
||
diffs.push({
|
||
baselineIndex: i,
|
||
baselineText: a,
|
||
currentText: b,
|
||
tokens: diffWords(a, b),
|
||
})
|
||
}
|
||
if (diffs.length > 0) {
|
||
setManualPending({ paragraphCount: diffs.length, diffs })
|
||
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
|
||
} else {
|
||
setManualPending(null)
|
||
setManualCountdown(null)
|
||
}
|
||
}, MANUAL_DEBOUNCE_MS), [mode, branchView])
|
||
|
||
// Faster debounce for the live preview pane — manual-edit tracking
|
||
// can stay at MANUAL_DEBOUNCE_MS, but the rendered preview should feel
|
||
// responsive while typing. ~150ms keeps marked + mermaid re-renders off
|
||
// the keystroke hot path without feeling stale.
|
||
const handleEditorUpdatePreview = useMemo(() => debounce((doc) => {
|
||
setPreviewContent(doc)
|
||
}, 150), [])
|
||
|
||
const handleEditorDocUpdate = useCallback((doc) => {
|
||
handleEditorUpdate(doc)
|
||
handleEditorUpdatePreview(doc)
|
||
}, [handleEditorUpdate, handleEditorUpdatePreview])
|
||
|
||
// Idle flush — auto-save when countdown elapses.
|
||
useEffect(() => {
|
||
if (!manualCountdown) return
|
||
const delay = Math.max(0, manualCountdown.deadline - Date.now())
|
||
const t = setTimeout(() => {
|
||
flushManualBuffer()
|
||
}, delay)
|
||
return () => clearTimeout(t)
|
||
}, [manualCountdown, flushManualBuffer])
|
||
|
||
// ── Start contributing ─────────────────────────────────────────────────
|
||
const handleStartContributing = useCallback(async () => {
|
||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||
if (branchParam === 'main') {
|
||
try {
|
||
// §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)
|
||
}
|
||
return
|
||
}
|
||
// Non-main: pure mode flip per §8.14.
|
||
if (pendingDiscussChanges.length > 0) {
|
||
setPendingDiscussChanges([])
|
||
}
|
||
setMode('contribute')
|
||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams, isSuperDraft])
|
||
|
||
// ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
|
||
const submitChatTurn = useCallback(async (text, quote) => {
|
||
if (!branchView?.main_thread_id || isStreaming) return
|
||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||
setIsStreaming(true)
|
||
// Optimistic insert of the user message and an assistant placeholder.
|
||
const tempUserId = `tmp-user-${Date.now()}`
|
||
const tempAssistId = `tmp-assist-${Date.now()}`
|
||
setMessages(prev => [
|
||
...prev,
|
||
{ id: tempUserId, role: 'user', author_login: viewer.gitea_login, text, quote, created_at: new Date().toISOString() },
|
||
{ id: tempAssistId, role: 'assistant', text: '', model_id: selectedModel, streaming: true, created_at: new Date().toISOString() },
|
||
])
|
||
try {
|
||
const { assistantId, userMsgId } = await streamChatTurn(
|
||
slug,
|
||
branchParam,
|
||
branchView.main_thread_id,
|
||
{ text, quote, model: selectedModel },
|
||
{
|
||
onChunk: chunk => {
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === tempAssistId ? { ...m, text: (m.text || '') + chunk } : m
|
||
))
|
||
},
|
||
onChanges: payload => {
|
||
// payload: { message_id, change_ids, count }
|
||
// The page-level state holds onto the assistant id so we
|
||
// can correlate change.source_message_id when the branch
|
||
// re-loads below.
|
||
if (payload?.message_id) {
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === tempAssistId ? { ...m, id: payload.message_id, streaming: false } : m
|
||
))
|
||
}
|
||
},
|
||
onDone: () => { /* terminal */ },
|
||
},
|
||
)
|
||
// Rebind to the real ids returned via response headers in case
|
||
// the X-Assistant-Message-Id header arrived before the changes event.
|
||
if (assistantId) {
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === tempAssistId ? { ...m, id: Number(assistantId), streaming: false } : m
|
||
))
|
||
}
|
||
if (userMsgId) {
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === tempUserId ? { ...m, id: Number(userMsgId) } : m
|
||
))
|
||
}
|
||
// Re-pull authoritative state: changes have been materialized server-side.
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setChanges(fresh.changes || [])
|
||
// If we're in discuss mode and the new turn produced pending AI changes,
|
||
// surface them as discuss-mode buffered count.
|
||
if (mode === 'discuss') {
|
||
const newPending = (fresh.changes || []).filter(c => c.state === 'pending' && c.kind === 'ai')
|
||
setPendingDiscussChanges(newPending)
|
||
}
|
||
} catch (err) {
|
||
setError(err.message)
|
||
setMessages(prev => prev.map(m =>
|
||
m.id === tempAssistId ? { ...m, text: `[Error: ${err.message}]`, streaming: false } : m
|
||
))
|
||
} finally {
|
||
setIsStreaming(false)
|
||
}
|
||
}, [slug, branchParam, branchView?.main_thread_id, isStreaming, viewer, selectedModel, mode])
|
||
|
||
const handlePrompt = useCallback((text, sel) => {
|
||
const quote = sel?.text || null
|
||
submitChatTurn(text, quote)
|
||
}, [submitChatTurn])
|
||
|
||
const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
|
||
if (textOrNull === null) { setSelection(null); return }
|
||
setSelection(null)
|
||
await submitChatTurn(textOrNull, quote)
|
||
}, [submitChatTurn])
|
||
|
||
const handleTooltipFlag = useCallback(async (label, quote) => {
|
||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||
setSelection(null)
|
||
try {
|
||
await createThread(slug, branchParam, {
|
||
thread_kind: 'flag',
|
||
anchor_kind: 'range',
|
||
anchor_payload: { quote },
|
||
label,
|
||
})
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam, viewer])
|
||
|
||
// ── Accept / decline / reask ──────────────────────────────────────────
|
||
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
|
||
try {
|
||
await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
|
||
// Pull authoritative branch state and reset the editor to the new
|
||
// body. The proper tracked-changes overlay lives in Phase 3's
|
||
// preview pane; with CM6 as the source editor there is no HTML
|
||
// surface to inject `<span class="tracked-*">` into.
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
setChanges(fresh.changes || [])
|
||
setEditorContent(fresh.body || '')
|
||
setPreviewContent(fresh.body || '')
|
||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam])
|
||
|
||
const handleDecline = useCallback(async (changeId) => {
|
||
try {
|
||
await apiDecline(slug, branchParam, changeId)
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setChanges(fresh.changes || [])
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam])
|
||
|
||
const handleReask = useCallback(async (changeId) => {
|
||
try {
|
||
await reaskChange(slug, branchParam, changeId)
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
setChanges(fresh.changes || [])
|
||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam])
|
||
|
||
const handleResolveThread = useCallback(async (threadId) => {
|
||
try {
|
||
await resolveThread(slug, branchParam, threadId)
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
}
|
||
}, [slug, branchParam])
|
||
|
||
const handleSaveNow = useCallback(() => {
|
||
flushManualBuffer()
|
||
}, [flushManualBuffer])
|
||
|
||
// Phase 6 — Escape closes the drawer when open. Cheap and gated on
|
||
// drawerOpen so the listener attaches only while needed. The CSS hides
|
||
// the drawer chrome above 1280px regardless, so this is a no-op there
|
||
// because drawerOpen has no visible effect either way.
|
||
useEffect(() => {
|
||
if (!drawerOpen) return
|
||
const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [drawerOpen])
|
||
|
||
// ── Editor-click → focus matching change card ──────────────────────────
|
||
const handleEditorClick = useCallback((e) => {
|
||
const span = e.target.closest('[data-change-id]')
|
||
if (span) {
|
||
const id = span.getAttribute('data-change-id')
|
||
setFocusedChangeId(Number(id))
|
||
setTimeout(() => setFocusedChangeId(null), 1800)
|
||
}
|
||
}, [])
|
||
|
||
// ── Branch dropdown navigation ─────────────────────────────────────────
|
||
const onPickBranch = useCallback((name) => {
|
||
if (name === branchParam) return
|
||
if (name === 'main') {
|
||
setSearchParams({})
|
||
} else {
|
||
setSearchParams({ branch: name })
|
||
}
|
||
}, [branchParam, setSearchParams])
|
||
|
||
// 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 (entry.state !== 'active' && entry.state !== 'super-draft') {
|
||
// §13.7: a retired entry is only reachable here by a site owner (the
|
||
// backend 404s everyone else), so this doubles as the direct-nav
|
||
// un-retire surface alongside the admin "Retired" list.
|
||
const canUnretire = entry.state === 'retired' && viewer?.role === 'owner'
|
||
return (
|
||
<article className="entry-view">
|
||
<p>This RFC is {entry.state}.</p>
|
||
{canUnretire && (
|
||
<>
|
||
<p className="muted">
|
||
Retired entries are hidden from the catalog and every view.
|
||
Un-retiring restores it to its prior state.
|
||
</p>
|
||
<button type="button" className="btn-primary" onClick={handleUnretire}>
|
||
Un-retire
|
||
</button>
|
||
</>
|
||
)}
|
||
{actionError && <p className="rfc-error-banner">{actionError}</p>}
|
||
</article>
|
||
)
|
||
}
|
||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||
|
||
const canContribute = branchView.capabilities?.can_contribute && branchParam !== 'main'
|
||
const canChangeSettings = branchView.capabilities?.can_change_branch_settings
|
||
const editorEditable = mode === 'contribute' && canContribute
|
||
const showPromptBar = !!viewer
|
||
|
||
const inDiscuss = mode === 'discuss'
|
||
const pendingCount = changes.filter(c => c.state === 'pending').length
|
||
// Phase 6 — count surfaced on the narrow-viewport drawer toggle. Mirrors
|
||
// the change-panel-header badge: pending AI changes + the manual card
|
||
// when one's buffered.
|
||
const drawerBadgeCount = pendingCount + (manualPending ? 1 : 0)
|
||
const showContributeToolbar = mode === 'contribute' && canContribute
|
||
const showDiscussToolbar = inDiscuss && branchParam !== 'main'
|
||
&& changes.some(c => c.state === 'accepted')
|
||
|
||
// §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 */}
|
||
<div className="rfc-breadcrumb">
|
||
<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} ${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">
|
||
{branchParam !== 'main' && canContribute && (
|
||
<button
|
||
type="button"
|
||
className={`btn-mode-toggle ${mode}`}
|
||
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
|
||
title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
|
||
>
|
||
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
|
||
<span className="beta-chip">Beta</span>
|
||
</button>
|
||
)}
|
||
{(branchParam === 'main' || !canContribute) && viewer && (
|
||
<button
|
||
type="button"
|
||
className="btn-start-contribution-header"
|
||
onClick={handleStartContributing}
|
||
>
|
||
Start Contributing
|
||
<span className="beta-chip">Beta</span>
|
||
</button>
|
||
)}
|
||
{!viewer && (
|
||
<a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
|
||
Sign in <span className="beta-chip">Beta</span>
|
||
</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={entryPrPath(pid, slug, openPRForBranch.pr_number)}
|
||
title="View the open PR for this branch"
|
||
>
|
||
PR #{openPRForBranch.pr_number} →
|
||
</a>
|
||
)}
|
||
{canChangeSettings && branchParam !== 'main' && (
|
||
<button
|
||
type="button"
|
||
className="btn-link"
|
||
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>
|
||
)}
|
||
{isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
|
||
<button
|
||
type="button"
|
||
className="btn-link"
|
||
onClick={async () => {
|
||
setClaimError(null)
|
||
try {
|
||
const result = await claimOwnership(slug)
|
||
if (result?.noop) return
|
||
if (result?.pr_number) {
|
||
navigate(entryPrPath(pid, slug, result.pr_number))
|
||
}
|
||
} catch (err) {
|
||
setClaimError(err.message)
|
||
}
|
||
}}
|
||
title="§13.1 — open a claim PR adding you to the entry's owners"
|
||
>
|
||
Claim ownership
|
||
</button>
|
||
)}
|
||
{isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||
<button
|
||
type="button"
|
||
className="btn-primary btn-graduate"
|
||
onClick={() => setShowGraduateDialog(true)}
|
||
title="§13 — graduate this super-draft to a per-RFC repo"
|
||
>
|
||
Graduate to RFC repo
|
||
</button>
|
||
)}
|
||
{/* v0.16.0 (item #12): owner-only invitations affordance.
|
||
Shown when the viewer is named in the RFC's frontmatter
|
||
`owners` list or holds a platform admin/owner role.
|
||
Available on both super-drafts and active RFCs. */}
|
||
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||
<button
|
||
type="button"
|
||
className="btn-link"
|
||
onClick={() => setShowInvitationsModal(true)}
|
||
title="Invite collaborators to this RFC"
|
||
>
|
||
Invitations
|
||
</button>
|
||
)}
|
||
{/* §13.7: Retire (soft delete). RFC owners + site owners only. */}
|
||
{canRetire && (
|
||
<button
|
||
type="button"
|
||
className="btn-link btn-retire"
|
||
onClick={handleRetire}
|
||
title="§13.7 — soft-delete this RFC (removes it from every view; a site owner can un-retire it)"
|
||
>
|
||
Retire
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{actionError && (
|
||
<div className="rfc-error-banner">Retire failed: {actionError}</div>
|
||
)}
|
||
{claimError && (
|
||
<div className="rfc-error-banner">Claim failed: {claimError}</div>
|
||
)}
|
||
|
||
{/* Two columns: editor + chat */}
|
||
<div className={`rfc-body${drawerOpen ? ' drawer-open' : ''}`}>
|
||
<div className="editor-area" onClick={handleEditorClick}>
|
||
{branchParam === 'main' && (
|
||
<div className="discuss-mode-banner">
|
||
{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>
|
||
)}
|
||
{/* #26: the optional ground-truth use case captured at propose
|
||
time. Shown on the canonical (main) view; muted "left blank"
|
||
treatment when the proposer didn't supply one. */}
|
||
{branchParam === 'main' && (
|
||
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
|
||
Intended use case
|
||
</div>
|
||
{entry.proposed_use_case
|
||
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
|
||
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||
</div>
|
||
)}
|
||
{inDiscuss && branchParam !== 'main' && (
|
||
<div className="discuss-mode-banner">
|
||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||
flip to Contribute to edit the document and apply AI changes.
|
||
</div>
|
||
)}
|
||
{!canContribute && branchParam !== 'main' && viewer && (
|
||
<div className="discuss-mode-banner muted">
|
||
You don't have contribute access to this branch. The branch
|
||
creator or an arbiter can grant access.
|
||
</div>
|
||
)}
|
||
{showContributeToolbar && (
|
||
<div className="editor-toolbar">
|
||
<span className="editor-toolbar-hint">
|
||
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
|
||
{pendingCount} pending
|
||
</span>
|
||
<ChatDrawerToggleButton
|
||
open={drawerOpen}
|
||
count={drawerBadgeCount}
|
||
onToggle={() => setDrawerOpen(v => !v)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{showDiscussToolbar && (
|
||
<div className="editor-toolbar">
|
||
<button
|
||
type="button"
|
||
className={`btn-review-toggle${discussShowChanges ? ' active' : ''}`}
|
||
onClick={() => setDiscussShowChanges(v => !v)}
|
||
title="§8.10 — surface accepted tracked changes inline in the preview"
|
||
>
|
||
{discussShowChanges ? 'Hide tracked changes' : 'Show tracked changes'}
|
||
</button>
|
||
<span className="editor-toolbar-hint">
|
||
{changes.filter(c => c.state === 'accepted').length} accepted on this branch
|
||
</span>
|
||
<ChatDrawerToggleButton
|
||
open={drawerOpen}
|
||
count={drawerBadgeCount}
|
||
onToggle={() => setDrawerOpen(v => !v)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!showContributeToolbar && !showDiscussToolbar && (
|
||
// Phase 6 — when neither editing toolbar would render, surface
|
||
// a narrow-only toolbar carrying just the drawer toggle. CSS
|
||
// hides it above 1280px where the right panel is always inline.
|
||
<div className="editor-toolbar editor-toolbar-narrow-only">
|
||
<ChatDrawerToggleButton
|
||
open={drawerOpen}
|
||
count={drawerBadgeCount}
|
||
onToggle={() => setDrawerOpen(v => !v)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{editorEditable ? (
|
||
<div className="editor-split">
|
||
<div className="editor-split-pane editor-split-raw">
|
||
<MarkdownSourceEditor
|
||
initialDoc={editorContent}
|
||
editorRef={editorRef}
|
||
onUpdate={handleEditorDocUpdate}
|
||
/>
|
||
</div>
|
||
<div className="editor-split-pane editor-split-preview">
|
||
<MarkdownPreview
|
||
content={previewContent}
|
||
onSelectionChange={handleSelectionChange}
|
||
changes={changes}
|
||
messages={messages}
|
||
showTrackedChanges
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<MarkdownPreview
|
||
content={editorContent}
|
||
onSelectionChange={handleSelectionChange}
|
||
className="markdown-preview-solo"
|
||
changes={changes}
|
||
messages={messages}
|
||
showTrackedChanges={inDiscuss && discussShowChanges}
|
||
/>
|
||
)}
|
||
<SelectionTooltip
|
||
selection={selection}
|
||
onAsk={handleTooltipAsk}
|
||
onFlag={handleTooltipFlag}
|
||
disabled={isStreaming || !viewer}
|
||
models={models}
|
||
selectedModel={selectedModel}
|
||
onModelChange={setSelectedModel}
|
||
/>
|
||
{showPromptBar ? (
|
||
<PromptBar
|
||
selection={selection?.text || null}
|
||
onSubmit={handlePrompt}
|
||
disabled={isStreaming}
|
||
models={models}
|
||
selectedModel={selectedModel}
|
||
onModelChange={setSelectedModel}
|
||
discussMode={inDiscuss}
|
||
/>
|
||
) : (
|
||
<div className="readonly-bar">
|
||
Read-only view. Discussion is in private <strong>Beta</strong> —{' '}
|
||
<a href="/auth/login">sign in</a> if your email has been invited.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className="right-panel-backdrop"
|
||
onClick={() => setDrawerOpen(false)}
|
||
aria-hidden="true"
|
||
data-open={drawerOpen ? 'true' : 'false'}
|
||
/>
|
||
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
|
||
{/* v0.5.0 — on main, the right panel is the PR-less discussion
|
||
* surface (threads.branch_name IS NULL). Branches keep their
|
||
* existing branch-chat panel; contribution still requires
|
||
* opening a PR from a branch via the Open PR affordance above. */}
|
||
{branchParam === 'main' ? (
|
||
<RFCDiscussionPanel slug={slug} viewer={viewer} />
|
||
) : (
|
||
<ChatPanel
|
||
messages={messages}
|
||
threads={branchView.threads || []}
|
||
changes={changes}
|
||
branchName={branchParam}
|
||
isStreaming={isStreaming}
|
||
contributionMode={mode === 'contribute'}
|
||
onStartContribution={handleStartContributing}
|
||
onScrollToChange={setFocusedChangeId}
|
||
onResolveThread={handleResolveThread}
|
||
/>
|
||
)}
|
||
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
|
||
<ChangePanel
|
||
changes={changes}
|
||
onAccept={handleAccept}
|
||
onDecline={handleDecline}
|
||
onReask={handleReask}
|
||
onScrollToMessage={focusMessage}
|
||
focusedChangeId={focusedChangeId}
|
||
manualPendingStatus={manualPending ? {
|
||
paragraphCount: manualPending.paragraphCount,
|
||
diffs: manualPending.diffs,
|
||
savingIn: manualCountdownLabel(manualCountdown),
|
||
onSaveNow: handleSaveNow,
|
||
} : null}
|
||
/>
|
||
)}
|
||
{inDiscuss && pendingDiscussChanges.length > 0 && (
|
||
<div className="contribution-cta">
|
||
<div className="contribution-cta-count">
|
||
{pendingDiscussChanges.length} change{pendingDiscussChanges.length === 1 ? '' : 's'} proposed
|
||
</div>
|
||
<p className="contribution-cta-desc">
|
||
Flip into Contribute to act on them.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className="btn-start-contribution"
|
||
onClick={handleStartContributing}
|
||
>
|
||
{branchParam === 'main' ? 'Start Contributing →' : 'Contribute on this branch →'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showPRModal && (
|
||
<PRModal
|
||
slug={slug}
|
||
branch={branchParam}
|
||
branchIsPrivate={!branchView.visibility?.read_public}
|
||
onClose={() => setShowPRModal(false)}
|
||
onOpened={(prNumber) => {
|
||
setShowPRModal(false)
|
||
navigate(entryPrPath(pid, slug, prNumber))
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{showVisibility && (
|
||
<BranchVisibilityModal
|
||
slug={slug}
|
||
branch={branchParam}
|
||
current={branchView.visibility}
|
||
onClose={() => setShowVisibility(false)}
|
||
onSaved={async () => {
|
||
const fresh = await getBranch(slug, branchParam)
|
||
setBranchView(fresh)
|
||
setShowVisibility(false)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{showGraduateDialog && (
|
||
<GraduateDialog
|
||
slug={slug}
|
||
entry={entry}
|
||
onClose={() => setShowGraduateDialog(false)}
|
||
onCompleted={() => {
|
||
setShowGraduateDialog(false)
|
||
// The catalog row and the RFC view now reflect `active`.
|
||
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||
getRFCMain(slug).then(setMainView).catch(() => {})
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{showInvitationsModal && (
|
||
<InvitationsModal
|
||
slug={slug}
|
||
rfcTitle={entry?.title}
|
||
onClose={() => setShowInvitationsModal(false)}
|
||
/>
|
||
)}
|
||
|
||
{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(entryPrPath(pid, slug, prNumber))
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChatDrawerToggleButton({ open, count, onToggle }) {
|
||
// Phase 6 — toolbar button that opens/closes the right-panel drawer
|
||
// at narrow viewports. The badge mirrors the change-panel-header's
|
||
// .badge styling; an aria-label gives screen readers the count. CSS
|
||
// hides the button above 1280px where the right panel is inline.
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`btn-chat-drawer-toggle${open ? ' active' : ''}`}
|
||
onClick={onToggle}
|
||
aria-pressed={open}
|
||
aria-label={`${open ? 'Close' : 'Open'} chat panel${count > 0 ? ` (${count} pending)` : ''}`}
|
||
title={open ? 'Close chat panel' : 'Open chat panel'}
|
||
>
|
||
Chat
|
||
{count > 0 && <span className="badge">{count}</span>}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function focusMessage(messageId) {
|
||
const el = document.querySelector(`[data-message-id="${messageId}"]`)
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
}
|
||
|
||
function manualCountdownLabel(c) {
|
||
if (!c) return ''
|
||
const remaining = Math.max(0, c.deadline - Date.now())
|
||
const totalSec = Math.ceil(remaining / 1000)
|
||
const m = Math.floor(totalSec / 60)
|
||
const s = totalSec % 60
|
||
return `${m}:${String(s).padStart(2, '0')}`
|
||
}
|
||
|
||
async function loadAllMessages(slug, branch, threads) {
|
||
// For Slice 2 we pull each thread's messages and stitch them in
|
||
// chronological order. The branch chat is the unified feed of
|
||
// every message across every thread per §8.12.
|
||
if (!threads || threads.length === 0) return []
|
||
const all = []
|
||
for (const t of threads) {
|
||
if (t.state !== 'open' && t.thread_kind !== 'flag') continue
|
||
try {
|
||
const { messages } = await getThreadMessages(slug, branch, t.id)
|
||
for (const m of messages) {
|
||
all.push({
|
||
...m,
|
||
thread_id: t.id,
|
||
thread_kind: t.thread_kind,
|
||
anchor_kind: t.anchor_kind,
|
||
anchor_preview: t.anchor_payload?.quote || null,
|
||
flag_label: t.thread_kind === 'flag' ? t.label : null,
|
||
})
|
||
}
|
||
if (t.thread_kind === 'flag' && (!messages || messages.length === 0)) {
|
||
all.push({
|
||
id: `flag-${t.id}`,
|
||
role: 'system',
|
||
text: t.label || '',
|
||
thread_id: t.id,
|
||
thread_kind: 'flag',
|
||
anchor_kind: t.anchor_kind,
|
||
anchor_preview: t.anchor_payload?.quote || null,
|
||
flag_label: t.label,
|
||
created_at: t.created_at,
|
||
author_login: null,
|
||
})
|
||
}
|
||
} catch {
|
||
// Tolerate per-thread fetch failures; the surface for triage
|
||
// belongs to a future error overlay.
|
||
}
|
||
}
|
||
all.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''))
|
||
return all
|
||
}
|
||
|
||
function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
|
||
const [open, setOpen] = useState(false)
|
||
// 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 preGrad = mainView?.pre_graduation_history || []
|
||
const currentLabel = current === 'main' ? mainLabel : current
|
||
return (
|
||
<div className="branch-dropdown">
|
||
<button
|
||
type="button"
|
||
className="branch-dropdown-trigger"
|
||
onClick={() => setOpen(o => !o)}
|
||
>
|
||
{currentLabel} ▾
|
||
</button>
|
||
{open && (
|
||
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
|
||
{items.map(b => (
|
||
<button
|
||
key={b.name}
|
||
type="button"
|
||
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
|
||
onClick={() => { setOpen(false); onPick(b.name) }}
|
||
>
|
||
<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>
|
||
)}
|
||
{b.creator && (
|
||
<span className="branch-creator">@{b.creator}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
{preGrad.length > 0 && (
|
||
<>
|
||
<div className="branch-dropdown-section">
|
||
Pre-graduation history ({preGrad.length})
|
||
</div>
|
||
{preGrad.map(b => (
|
||
<button
|
||
key={b.branch_name}
|
||
type="button"
|
||
className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
|
||
onClick={() => { setOpen(false); onPick(b.branch_name) }}
|
||
title="§9.8: meta-repo edit branch from before graduation — read-only"
|
||
>
|
||
<span className="branch-name">{b.branch_name}</span>
|
||
<span className="branch-meta">
|
||
{b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
|
||
{b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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')
|
||
const [saving, setSaving] = useState(false)
|
||
const [err, setErr] = useState(null)
|
||
|
||
const onSave = async () => {
|
||
setSaving(true); setErr(null)
|
||
try {
|
||
await setBranchVisibility(slug, branch, { readPublic, contributeMode })
|
||
onSaved()
|
||
} 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>Branch settings — {branch}</h2>
|
||
<button className="modal-close" onClick={onClose}>×</button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<label>
|
||
<input type="checkbox" checked={readPublic} onChange={e => setReadPublic(e.target.checked)} />
|
||
{' '}Public read access
|
||
</label>
|
||
<p className="field-help">
|
||
§11.1: a public branch can be read by anyone, including anonymous viewers.
|
||
</p>
|
||
<label style={{ marginTop: 12 }}>Contribute mode</label>
|
||
<select value={contributeMode} onChange={e => setContributeMode(e.target.value)}>
|
||
<option value="just-me">Just me</option>
|
||
<option value="specific">Specific contributors</option>
|
||
<option value="any-contributor">Any contributor</option>
|
||
</select>
|
||
{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 ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|