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