Contribute rewrite Phase 1: CM6 markdown source editor

Swap the Contribute-mode editing surface from Tiptap WYSIWYG to a
CodeMirror 6 markdown source editor. Discuss mode and any read-only
viewing continues to render through Tiptap.

The §8.11 manual-edit debounce now reads the raw markdown source via
CodeMirror's doc, eliminating the lossy `editor.getText()` round-trip
that RFCView.jsx flagged as a §19.2 candidate; what the contributor
typed is exactly what gets POSTed to manual flush.

The §8.10 paragraph-margin gutter accent on Tiptap is dropped — it was
dead in read-only Discuss anyway, and Phase 4 of the Contribute
rewrite adds a change-anchored gutter against the CM6 raw pane.

The Tiptap-side accept-time injection of <span class="tracked-*">
markup also drops out — CM6 can't render those spans, and Phase 3's
preview pane is the proper home for tracked changes. The reviewMode /
DiffView toggle still works against marked-rendered HTML in the
interim until Phase 7 retires it.

Notes:
- Bundle gzipped grows ~50KB for CM6 modules (state/view/commands/
  language/lang-markdown). Mermaid in Phase 2 will be the larger lift
  and should lazy-load.
- Verified the CM6 editor mounts cleanly via Vite dev preview: ref
  handle (`view/getDoc/setDoc`) is wired, `onUpdate` fires on doc
  changes, gutter / line-numbers / active-line all render, and the
  source round-trips verbatim. Could not drive the full RFCView
  Contribute flow end-to-end without a running backend; the manual
  countdown + save-now + accept/decline pathways are verified by
  inspection only.
- 125 backend integration tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 09:49:17 -07:00
parent 55a8be051a
commit 13d59b5d26
6 changed files with 427 additions and 135 deletions
+79 -57
View File
@@ -34,7 +34,9 @@ import {
streamChatTurn,
} from '../api'
import Editor, { selectionHighlightKey } from './Editor.jsx'
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
import SelectionTooltip from './SelectionTooltip.jsx'
import { marked } from 'marked'
import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx'
@@ -51,6 +53,15 @@ function debounce(fn, ms) {
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()
@@ -66,8 +77,11 @@ export default function RFCView({ viewer }) {
const [selectedModel, setSelectedModel] = useState('')
// Editor state — owned here so accept/decline can mutate it.
// editorRef holds either a Tiptap instance (Discuss / read-only) or a
// MarkdownSourceEditor handle ({view, getDoc, setDoc}) (Contribute);
// call sites branch on shape.
const editorRef = useRef(null)
const originalParagraphsRef = useRef([])
const originalSourceLinesRef = useRef([])
const [editorContent, setEditorContent] = useState('')
// Selection + tooltip + selection highlight per §8.12.
@@ -132,6 +146,7 @@ export default function RFCView({ viewer }) {
.then(view => {
setBranchView(view)
setEditorContent(view.body || '')
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
setChanges(view.changes || [])
})
.catch(err => setError(err.message))
@@ -152,21 +167,23 @@ export default function RFCView({ viewer }) {
useEffect(() => {
const editor = editorRef.current
if (!editor?.view) return
// Only Tiptap exposes `state.tr` — when CM6 is mounted, the selection
// highlight has no surface to render against, so skip the dispatch.
if (!editor?.view || !editor?.state?.tr) return
editor.view.dispatch(editor.state.tr.setMeta(selectionHighlightKey, highlightRange))
}, [highlightRange])
// Manual-edit debounced upsert per §8.11 — produces a pending manual
// card with a live countdown and an explicit Save now.
// 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
const text = editor.getText()
// Convert to a rough markdown by stripping HTML — for v1 we round-trip
// through the editor's getText; this matches the prototype's behavior.
// A faithful HTML→markdown round-trip is a §19.2 candidate.
const newContent = text.trim() + '\n'
if (!newContent || newContent.trim() === (branchView.body || '').trim()) {
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
}
@@ -179,6 +196,7 @@ export default function RFCView({ viewer }) {
const fresh = await getBranch(slug, branchParam)
setBranchView(fresh)
setChanges(fresh.changes || [])
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
setManualPending(null)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
}
@@ -187,22 +205,15 @@ export default function RFCView({ viewer }) {
}
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
const handleEditorUpdate = useMemo(() => debounce((plainText) => {
const handleEditorUpdate = useMemo(() => debounce((doc) => {
if (mode !== 'contribute' || !branchView) return
const editor = editorRef.current
if (!editor) return
const currentParagraphs = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
currentParagraphs.push(node.textContent.trim())
}
})
const baseline = originalParagraphsRef.current || []
const current = splitSourceParagraphs(doc)
const baseline = originalSourceLinesRef.current || []
const len = Math.max(current.length, baseline.length)
let changed = 0
currentParagraphs.forEach((t, i) => {
const orig = (baseline[i] ?? '').trim()
if (t !== orig) changed++
})
for (let i = 0; i < len; i++) {
if ((current[i] ?? '') !== (baseline[i] ?? '')) changed++
}
if (changed > 0) {
setManualPending({ paragraphCount: changed })
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
@@ -347,24 +358,16 @@ export default function RFCView({ viewer }) {
// ── Accept / decline / reask ──────────────────────────────────────────
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
try {
const { commit_sha } = await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
// Inject tracked-change markup into the editor so it renders inline.
const editor = editorRef.current
if (editor && change.original) {
const html = editor.getHTML()
const tracked =
`<span class="tracked-delete" data-change-id="${change.id}">${change.original}</span>` +
`<span class="tracked-insert" data-change-id="${change.id}">${proposed}</span>`
const next = html.replace(change.original, tracked)
if (next !== html) editor.commands.setContent(next, false)
}
// Pull the authoritative branch state — body, sha, changes.
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 || [])
// We do not reset editorContent here — the editor is showing the
// tracked markup overlay; resetting would clear the visual diff
// until DiffView is toggled.
setEditorContent(fresh.body || '')
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
} catch (err) {
setError(err.message)
}
@@ -419,10 +422,21 @@ export default function RFCView({ viewer }) {
const toggleReviewMode = useCallback(() => {
setReviewMode(prev => {
if (!prev) setReviewHTML(editorRef.current?.getHTML() || '')
if (!prev) {
// CM6 is the contribute-mode editor in Phase 1, so source HTML
// comes from rendering the current markdown via marked. The
// per-session inline tracked-change spans that Tiptap accumulated
// are gone for now; Phase 3's preview pane is the proper home
// for tracked changes anyway.
const editor = editorRef.current
const md = typeof editor?.getDoc === 'function'
? editor.getDoc()
: (branchView?.body || '')
setReviewHTML(marked.parse(md))
}
return !prev
})
}, [])
}, [branchView])
// ── Branch dropdown navigation ─────────────────────────────────────────
const onPickBranch = useCallback((name) => {
@@ -621,23 +635,31 @@ export default function RFCView({ viewer }) {
<DiffView html={reviewHTML} changes={changes} messages={messages} />
) : (
<>
<Editor
content={editorContent}
editorRef={editorRef}
originalParagraphsRef={originalParagraphsRef}
onSelectionChange={handleSelectionChange}
onUpdate={editorEditable ? handleEditorUpdate : undefined}
editable={editorEditable}
/>
<SelectionTooltip
selection={selection}
onAsk={handleTooltipAsk}
onFlag={handleTooltipFlag}
disabled={isStreaming || !viewer}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
{editorEditable ? (
<MarkdownSourceEditor
initialDoc={editorContent}
editorRef={editorRef}
onUpdate={handleEditorUpdate}
/>
) : (
<Editor
content={editorContent}
editorRef={editorRef}
onSelectionChange={handleSelectionChange}
editable={false}
/>
)}
{inDiscuss && (
<SelectionTooltip
selection={selection}
onAsk={handleTooltipAsk}
onFlag={handleTooltipFlag}
disabled={isStreaming || !viewer}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
)}
{showPromptBar ? (
<PromptBar
selection={selection?.text || null}