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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user