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
@@ -0,0 +1,121 @@
// SelectionTooltip.jsx — the §8.12 selection-anchored entry point.
//
// Carryover from the prototype. The contributor selects a passage in
// the editor; this floating panel appears anchored to that selection.
// Submitting creates a new range-anchored chat thread (or invokes the
// AI on the current branch chat with the selection as `quote`).
//
// Two affordances per §8.13: an "Ask" button that opens (or continues)
// a chat thread, and a "Flag" button that drops a flag thread anchored
// to the selection.
import { useState, useEffect, useRef } from 'react'
import ModelPicker from './ModelPicker.jsx'
export default function SelectionTooltip({
selection,
onAsk,
onFlag,
disabled,
models,
selectedModel,
onModelChange,
}) {
const [mode, setMode] = useState('ask') // 'ask' | 'flag'
const [prompt, setPrompt] = useState('')
const [flagText, setFlagText] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (selection) {
setPrompt('')
setFlagText('')
setMode('ask')
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [selection?.text])
if (!selection) return null
const { coords } = selection
const TOOLTIP_HEIGHT = 110
const GAP = 10
const top = Math.max(8, coords.top - TOOLTIP_HEIGHT - GAP)
const left = Math.min(window.innerWidth - 360, Math.max(12, coords.left))
const handleSubmit = () => {
if (disabled) return
if (mode === 'ask') {
const text = prompt.trim()
if (!text) return
onAsk(text, selection.text)
setPrompt('')
} else {
const label = flagText.trim()
if (!label) return
onFlag(label, selection.text)
setFlagText('')
}
}
const onKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() }
if (e.key === 'Escape') onAsk(null)
}
return (
<div
className="selection-tooltip"
style={{ top, left }}
onMouseDown={e => e.preventDefault()}
>
<div className="selection-tooltip-quote">
"{selection.text.length > 80 ? selection.text.slice(0, 80) + '…' : selection.text}"
</div>
<div className="selection-tooltip-tabs">
<button
className={`selection-tooltip-tab ${mode === 'ask' ? 'active' : ''}`}
onClick={() => setMode('ask')}
>Ask</button>
<button
className={`selection-tooltip-tab ${mode === 'flag' ? 'active' : ''}`}
onClick={() => setMode('flag')}
>Flag</button>
</div>
{mode === 'ask' && models?.length > 1 && (
<ModelPicker models={models} selected={selectedModel} onChange={onModelChange} />
)}
<div className="selection-tooltip-input-row">
{mode === 'ask' ? (
<input
ref={inputRef}
className="selection-tooltip-input"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask anything about this passage…"
disabled={disabled}
/>
) : (
<input
ref={inputRef}
className="selection-tooltip-input"
value={flagText}
onChange={e => setFlagText(e.target.value.slice(0, 200))}
onKeyDown={onKeyDown}
placeholder="What's wrong with this passage?"
disabled={disabled}
maxLength={200}
/>
)}
<button
className="selection-tooltip-btn"
onClick={handleSubmit}
disabled={disabled || (mode === 'ask' ? !prompt.trim() : !flagText.trim())}
>
{mode === 'ask' ? 'Ask' : 'Flag'}
</button>
</div>
</div>
)
}