Slice 2: the §8 active-RFC view in full
Per the §19.1 brief: the three-column shape (§8.1) opens on main
in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute
flip on non-main branches, hosts §8.4's per-branch chat with AI
participation (§18's <change> protocol → §8.14 changes rows), the
§8.8 change-card panel with §8.9 accept/decline/edit-before-accept,
the §8.10 tracked-change markup + DiffView toggle, the §8.11
manual-edit flushes with the stale-change mechanic, the §8.12
range and paragraph sub-threads, the §8.13 flag affordance, and
the §8.14 discuss-mode buffer.
Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main,
commit_accepted_change with the structured original/proposed/reason
body and Change-Id + Source-Message-Id + On-behalf-of trailers,
commit_manual_flush, ensure_rfc_repo_seed). cache.py grew
refresh_rfc_repo and the webhook dispatches on repository.full_name.
providers.py and chat.py port the §18 carryovers — multi-provider
LLM abstraction and SSE-streaming chat against the §5 threads /
thread_messages / changes schema. api_branches.py mounts the §17
branches/<branch>/* and threads/<thread_id>/* routes with the §6
/ §11 permission checks inline.
Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx,
ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx,
DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the
prototype and adapted to the canonical schema.
Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new
integration tests against an extended FakeGitea (PUT contents,
POST orgs/{org}/repos, seed_rfc_repo): main-view read,
promote-to-branch, accept (with and without edit-before-accept),
decline, manual flush + system message, flag creation, visibility
flip, anonymous read-but-no-contribute, stale-change refusal, and
the chat-streaming path with a fake provider injected. The 5
Slice 1 tests continue to pass alongside.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,3 +68,189 @@ export async function withdrawProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────
|
||||
|
||||
export async function listModels() {
|
||||
return jsonOrThrow(await fetch('/api/models'))
|
||||
}
|
||||
|
||||
export async function getRFCMain(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
|
||||
}
|
||||
|
||||
export async function getBranch(slug, branch) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
|
||||
))
|
||||
}
|
||||
|
||||
export async function promoteToBranch(slug, body = {}) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
proposed,
|
||||
was_edited_before_accept: !!wasEdited,
|
||||
force_apply_stale: !!forceApplyStale,
|
||||
}),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function declineChange(slug, branch, changeId) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function reaskChange(slug, branch, changeId) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function manualFlush(slug, branch, { newContent, paragraphCount }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
read_public: readPublic,
|
||||
contribute_mode: contributeMode,
|
||||
}),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function createThread(slug, branch, body) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listThreads(slug, branch) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function getThreadMessages(slug, branch, threadId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function postThreadMessage(slug, branch, threadId, { text, quote }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, quote }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resolveThread(slug, branch, threadId) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`,
|
||||
{ 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
|
||||
// (so the caller can pull X-Assistant-Message-Id without re-streaming).
|
||||
export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, quote, model }),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const detail = await res.text()
|
||||
throw new Error(`Chat failed: ${detail || res.status}`)
|
||||
}
|
||||
const assistantId = res.headers.get('X-Assistant-Message-Id')
|
||||
const userMsgId = res.headers.get('X-User-Message-Id')
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let currentEvent = null
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop()
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n')
|
||||
let dataLine = null
|
||||
let event = null
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7).trim()
|
||||
if (line.startsWith('data: ')) dataLine = line.slice(6).trim()
|
||||
}
|
||||
if (dataLine === null) continue
|
||||
if (event === 'changes') {
|
||||
try { onChanges?.(JSON.parse(dataLine)) } catch {}
|
||||
continue
|
||||
}
|
||||
if (dataLine === 'DONE') { onDone?.(); break }
|
||||
try {
|
||||
const text = new TextDecoder().decode(
|
||||
Uint8Array.from(atob(dataLine), c => c.charCodeAt(0))
|
||||
)
|
||||
onChunk?.(text)
|
||||
} catch {
|
||||
// partial chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
onDone?.()
|
||||
return { assistantId, userMsgId }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user