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:
Ben Stull
2026-05-24 04:35:14 -07:00
parent 779ba6db59
commit 3bc8fe92af
24 changed files with 5433 additions and 151 deletions
+191
View File
@@ -0,0 +1,191 @@
// Editor.jsx — the §8 center-column editor.
//
// Tiptap on ProseMirror per §18. Two ProseMirror plugins live alongside
// StarterKit:
//
// • paragraphDiff — the §8.10 paragraph-margin gutter accent. Compares
// each paragraph against an open-session baseline (the
// `originalParagraphsRef` ref the parent owns and refreshes when the
// baseline shifts — e.g. on branch switch or a server-side flush).
//
// • selectionHighlight — keeps a selected passage highlighted while
// focus moves to the §8.12 selection tooltip. Driven by meta
// transactions dispatched from the parent.
//
// The inline tracked-delete / tracked-insert markup from §8.10 is
// session-local HTML the parent injects via `editor.commands.setContent`
// when a change is accepted; the editor itself doesn't own that state.
// On reload the markup clears and DiffView (toolbar toggle) is the
// durable read of accepted changes.
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useCallback } from 'react'
import { marked } from 'marked'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
// ── Paragraph diff plugin ────────────────────────────────────────────────
const diffKey = new PluginKey('paragraphDiff')
function makeDiffPlugin(originalParagraphsRef) {
return new Plugin({
key: diffKey,
props: {
decorations(state) {
const originals = originalParagraphsRef?.current
if (!originals || originals.length === 0) return DecorationSet.empty
const decorations = []
let idx = 0
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
const current = node.textContent.trim()
const original = (originals[idx] ?? '').trim()
if (current !== original) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'paragraph-changed' })
)
}
idx++
}
})
return DecorationSet.create(state.doc, decorations)
},
},
})
}
function DiffExtension(originalParagraphsRef) {
return Extension.create({
name: 'paragraphDiff',
addProseMirrorPlugins() {
return [makeDiffPlugin(originalParagraphsRef)]
},
})
}
// ── Selection highlight plugin ────────────────────────────────────────────
export const selectionHighlightKey = new PluginKey('selectionHighlight')
function makeSelectionHighlightPlugin() {
return new Plugin({
key: selectionHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(selectionHighlightKey)
return meta !== undefined ? meta : prev
},
},
props: {
decorations(state) {
const range = selectionHighlightKey.getState(state)
if (!range || range.from >= range.to) return DecorationSet.empty
try {
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: 'selection-highlight' }),
])
} catch {
return DecorationSet.empty
}
},
},
})
}
function SelectionHighlightExtension() {
return Extension.create({
name: 'selectionHighlight',
addProseMirrorPlugins() {
return [makeSelectionHighlightPlugin()]
},
})
}
// ── Editor component ──────────────────────────────────────────────────────
export default function Editor({
content,
editorRef,
originalParagraphsRef,
onSelectionChange,
onUpdate,
editable = true,
}) {
const isMouseDownRef = useRef(false)
const reportSelection = useCallback((editor) => {
if (isMouseDownRef.current) return
const { from, to } = editor.state.selection
if (from !== to) {
const text = editor.state.doc.textBetween(from, to, ' ')
const coords = editor.view.coordsAtPos(from)
onSelectionChange?.({ text, coords, from, to })
} else {
onSelectionChange?.(null)
}
}, [onSelectionChange])
const editor = useEditor({
extensions: [
StarterKit,
DiffExtension(originalParagraphsRef),
SelectionHighlightExtension(),
],
content: '<p></p>',
editable,
onUpdate: ({ editor }) => {
onUpdate?.(editor.getText(), editor.getHTML())
},
onSelectionUpdate: ({ editor }) => {
reportSelection(editor)
},
})
// Expose editor instance to the parent.
useEffect(() => {
if (editorRef) editorRef.current = editor
}, [editor, editorRef])
useEffect(() => {
if (editor) editor.setEditable(editable)
}, [editor, editable])
useEffect(() => {
if (!editor) return
const el = editor.view.dom
const onMouseDown = () => { isMouseDownRef.current = true; onSelectionChange?.(null) }
const onMouseUp = () => { isMouseDownRef.current = false; reportSelection(editor) }
el.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
}
}, [editor, onSelectionChange, reportSelection])
// Reload content + snapshot baseline paragraphs.
useEffect(() => {
if (!editor || content == null) return
const html = marked.parse(content)
editor.commands.setContent(html, false)
if (originalParagraphsRef) {
const paragraphs = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
paragraphs.push(node.textContent.trim())
}
})
originalParagraphsRef.current = paragraphs
}
}, [content, editor])
return (
<div className="editor-wrapper">
<EditorContent editor={editor} className="editor-content" />
</div>
)
}