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,236 @@
|
||||
// ChangePanel.jsx — the §8.8 change-card panel.
|
||||
//
|
||||
// Sits below the chat in contribute mode. Pending cards stack on top
|
||||
// of resolved stubs. Each AI card carries accept / edit-before-accept /
|
||||
// decline per §8.9; each manual card carries the live status line per
|
||||
// §8.11. Stale cards surface the §8.11 warning + Re-ask path. Clicking
|
||||
// a card's "↑ from this message" affordance scrolls the chat back to
|
||||
// the originating message.
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const PREVIEW_LENGTH = 220
|
||||
|
||||
function diffWords(original, proposed) {
|
||||
const a = (original || '').split(/(\s+)/)
|
||||
const b = (proposed || '').split(/(\s+)/)
|
||||
const m = a.length, n = b.length
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
|
||||
for (let i = 1; i <= m; i++)
|
||||
for (let j = 1; j <= n; j++)
|
||||
dp[i][j] = a[i-1] === b[j-1]
|
||||
? dp[i-1][j-1] + 1
|
||||
: Math.max(dp[i-1][j], dp[i][j-1])
|
||||
const tokens = []
|
||||
let i = m, j = n
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && a[i-1] === b[j-1]) {
|
||||
tokens.unshift({ text: a[i-1], type: 'same' }); i--; j--
|
||||
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
|
||||
tokens.unshift({ text: b[j-1], type: 'add' }); j--
|
||||
} else {
|
||||
tokens.unshift({ text: a[i-1], type: 'remove' }); i--
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function InlineDiff({ original, proposed }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const tokens = diffWords(original, proposed)
|
||||
const fullText = tokens.map(t => t.text).join('')
|
||||
const needsTruncation = fullText.length > PREVIEW_LENGTH
|
||||
let shown = tokens
|
||||
if (needsTruncation && !expanded) {
|
||||
let count = 0
|
||||
const cutoff = tokens.findIndex(t => { count += t.text.length; return count > PREVIEW_LENGTH })
|
||||
if (cutoff !== -1) shown = tokens.slice(0, cutoff)
|
||||
}
|
||||
return (
|
||||
<div className="inline-diff">
|
||||
{shown.map((token, idx) =>
|
||||
token.type === 'same' ? <span key={idx}>{token.text}</span> :
|
||||
token.type === 'add' ? <span key={idx} className="diff-word-add">{token.text}</span> :
|
||||
<span key={idx} className="diff-word-remove">{token.text}</span>
|
||||
)}
|
||||
{needsTruncation && !expanded && <span className="text-fade">…</span>}
|
||||
{needsTruncation && (
|
||||
<button className="expand-toggle" onClick={() => setExpanded(e => !e)}>
|
||||
{expanded ? '↑ Show less' : '↓ Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChangePanel({
|
||||
changes,
|
||||
onAccept,
|
||||
onDecline,
|
||||
onReask,
|
||||
onScrollToMessage,
|
||||
focusedChangeId,
|
||||
manualPendingStatus, // {paragraphCount, savingIn, onSaveNow} or null
|
||||
}) {
|
||||
const pending = changes.filter(c => c.state === 'pending')
|
||||
const resolved = changes.filter(c => c.state !== 'pending')
|
||||
|
||||
return (
|
||||
<div className="change-panel">
|
||||
<div className="change-panel-header">
|
||||
Changes
|
||||
{pending.length > 0 && <span className="badge">{pending.length}</span>}
|
||||
</div>
|
||||
<div className="change-list">
|
||||
{manualPendingStatus && (
|
||||
<ManualPendingCard
|
||||
paragraphCount={manualPendingStatus.paragraphCount}
|
||||
savingIn={manualPendingStatus.savingIn}
|
||||
onSaveNow={manualPendingStatus.onSaveNow}
|
||||
/>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<div className="change-group-label">Pending</div>
|
||||
)}
|
||||
{pending.map(c => (
|
||||
<ChangeItem
|
||||
key={c.id}
|
||||
change={c}
|
||||
focused={focusedChangeId === c.id}
|
||||
onAccept={onAccept}
|
||||
onDecline={onDecline}
|
||||
onReask={onReask}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
))}
|
||||
{resolved.length > 0 && (
|
||||
<div className="change-group-label muted">Resolved</div>
|
||||
)}
|
||||
{resolved.map(c => (
|
||||
<ResolvedStub key={c.id} change={c} onScrollToMessage={onScrollToMessage} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) {
|
||||
return (
|
||||
<div className="change-item type-manual state-pending">
|
||||
<div className="change-meta">
|
||||
<span className="change-author">You · manual edit</span>
|
||||
<span className="change-state-badge pending">unsaved</span>
|
||||
</div>
|
||||
<div className="change-label">
|
||||
{paragraphCount} paragraph{paragraphCount === 1 ? '' : 's'} edited directly
|
||||
</div>
|
||||
<div className="change-manual-status">
|
||||
unsaved · auto-save in {savingIn}
|
||||
<button type="button" className="btn-save-now" onClick={onSaveNow}>Save now</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChangeItem({ change, focused, onAccept, onDecline, onReask, onScrollToMessage }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [edited, setEdited] = useState(change.proposed || '')
|
||||
const itemRef = useRef(null)
|
||||
const isStale = !!change.stale_since
|
||||
|
||||
useEffect(() => {
|
||||
if (focused && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}, [focused])
|
||||
|
||||
const handleStartEdit = () => { setEdited(change.proposed || ''); setEditing(true) }
|
||||
const handleAcceptEdited = () => {
|
||||
onAccept({ change, proposed: edited, wasEdited: true })
|
||||
setEditing(false)
|
||||
}
|
||||
const handleAcceptStraight = () => {
|
||||
onAccept({ change, proposed: change.proposed, wasEdited: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`change-item state-${change.state} type-${change.kind === 'ai' ? 'claude' : 'manual'}${focused ? ' focused' : ''}${isStale ? ' stale' : ''}`}
|
||||
>
|
||||
<div className="change-meta">
|
||||
<span className="change-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
|
||||
<span className={`change-state-badge ${change.state}`}>
|
||||
{isStale ? 'stale' : change.state}
|
||||
</span>
|
||||
</div>
|
||||
{change.reason && (
|
||||
<div className="change-label">{change.reason}</div>
|
||||
)}
|
||||
{isStale && (
|
||||
<div className="change-stale-banner">
|
||||
The original text has changed since this was proposed.
|
||||
</div>
|
||||
)}
|
||||
{change.kind === 'ai' && (
|
||||
<div className="change-diff">
|
||||
{editing ? (
|
||||
<textarea
|
||||
className="diff-edit-textarea"
|
||||
value={edited}
|
||||
onChange={e => setEdited(e.target.value)}
|
||||
rows={Math.min(12, Math.max(3, edited.split('\n').length + 1))}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<InlineDiff original={change.original} proposed={change.proposed} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{change.source_message_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="change-source-link"
|
||||
onClick={() => onScrollToMessage?.(change.source_message_id)}
|
||||
>↑ from this message</button>
|
||||
)}
|
||||
<div className="change-actions">
|
||||
{editing ? (
|
||||
<>
|
||||
<button className="btn-accept" onClick={handleAcceptEdited}>Accept edit</button>
|
||||
<button className="btn-edit" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-accept" onClick={handleAcceptStraight}>
|
||||
{isStale ? 'Apply anyway…' : 'Accept'}
|
||||
</button>
|
||||
<button className="btn-edit" onClick={handleStartEdit}>Edit</button>
|
||||
<button className="btn-decline" onClick={() => onDecline(change.id)}>Decline</button>
|
||||
{isStale && (
|
||||
<button className="btn-reask" onClick={() => onReask?.(change.id)}>Re-ask</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedStub({ change, onScrollToMessage }) {
|
||||
const short = (change.reason || '').slice(0, 80)
|
||||
return (
|
||||
<div className={`change-stub state-${change.state}`}>
|
||||
<span className="stub-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
|
||||
<span className={`stub-badge ${change.state}`}>{change.state}</span>
|
||||
<span className="stub-reason">{short || (change.kind === 'manual' ? 'manual edit' : 'change')}</span>
|
||||
{change.source_message_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="stub-source-link"
|
||||
onClick={() => onScrollToMessage?.(change.source_message_id)}
|
||||
>↑</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// ChatPanel.jsx — the §8 right-column chat surface.
|
||||
//
|
||||
// One feed of every message on the branch's threads (per §8.12),
|
||||
// rendered in chronological order. Sub-thread messages render with a
|
||||
// gutter accent and the quoted anchor preview; flag rows render with a
|
||||
// flag badge. Per §8.8 each assistant message that produced changes
|
||||
// carries a "↓ N changes added below" hint that flashes the matching
|
||||
// cards in the change panel.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MODEL_STYLES } from '../modelStyles'
|
||||
|
||||
function ModelLabel({ modelId, streaming }) {
|
||||
const style = MODEL_STYLES[modelId] || MODEL_STYLES.default
|
||||
return (
|
||||
<div className="chat-model-label" style={{ color: style.color }}>
|
||||
<span className="chat-model-dot" style={{ background: style.color }} />
|
||||
{style.label}{streaming ? '…' : ''}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function stripChangeTags(text) {
|
||||
return text
|
||||
.replace(/<change>[\s\S]*?<\/change>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export default function ChatPanel({
|
||||
messages,
|
||||
threads,
|
||||
changes,
|
||||
branchName,
|
||||
isStreaming,
|
||||
contributionMode,
|
||||
onScrollToChange,
|
||||
onStartContribution,
|
||||
threadFilter,
|
||||
onThreadFilterChange,
|
||||
onResolveThread,
|
||||
forkedFromMessage,
|
||||
}) {
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length])
|
||||
|
||||
const openThreads = threads.filter(t => t.state === 'open')
|
||||
const flagThreads = openThreads.filter(t => t.thread_kind === 'flag')
|
||||
const chatThreads = openThreads.filter(t => t.thread_kind === 'chat')
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-row">
|
||||
<span className="chat-header-title">
|
||||
Branch chat · <strong>{branchName}</strong>
|
||||
</span>
|
||||
{forkedFromMessage && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-fork-link"
|
||||
onClick={() => onScrollToChange?.(null, forkedFromMessage)}
|
||||
title="Forked from this conversation"
|
||||
>
|
||||
← Forked from this conversation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-thread-disclosure">
|
||||
{chatThreads.length > 0 && (
|
||||
<span>{chatThreads.length} open thread{chatThreads.length === 1 ? '' : 's'}</span>
|
||||
)}
|
||||
{flagThreads.length > 0 && (
|
||||
<span className="chat-thread-flag-count">
|
||||
· {flagThreads.length} open flag{flagThreads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{threadFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-filter-clear"
|
||||
onClick={() => onThreadFilterChange?.(null)}
|
||||
>Clear filter</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<p>
|
||||
{contributionMode
|
||||
? 'Ask the AI about this RFC. Concrete edit suggestions land in the panel below.'
|
||||
: 'Ask the AI about this RFC. When suggestions are ready you can start a contribution.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
changes={changes}
|
||||
contributionMode={contributionMode}
|
||||
onScrollToChange={onScrollToChange}
|
||||
onStartContribution={onStartContribution}
|
||||
onResolveThread={onResolveThread}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatMessage({ message, changes, contributionMode, onScrollToChange, onStartContribution, onResolveThread, isStreaming }) {
|
||||
const isUser = message.role === 'user'
|
||||
const isSystem = message.role === 'system'
|
||||
const isFlag = message.thread_kind === 'flag'
|
||||
const displayText = isUser || isSystem ? message.text : stripChangeTags(message.text)
|
||||
const messageChanges = changes.filter(c => c.source_message_id === message.id)
|
||||
const subThread = message.anchor_kind && message.anchor_kind !== 'whole-doc'
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="chat-message system">
|
||||
<div className="chat-system-bubble">{message.text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFlag) {
|
||||
return (
|
||||
<div className="chat-message flag">
|
||||
<div className="chat-flag-row">
|
||||
<span className="chat-flag-icon" title="Flag">⚑</span>
|
||||
<div className="chat-flag-content">
|
||||
<div className="chat-flag-author">@{message.author_login || '—'} flagged:</div>
|
||||
<div className="chat-flag-text">{message.flag_label || message.text}</div>
|
||||
{message.anchor_preview && (
|
||||
<div className="chat-anchor-preview">quoted: "{message.anchor_preview}"</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-flag-resolve"
|
||||
onClick={() => onResolveThread?.(message.thread_id)}
|
||||
>Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-message ${isUser ? 'user' : 'assistant'}${message.streaming ? ' streaming' : ''}${subThread ? ' in-thread' : ''}`}>
|
||||
{subThread && message.anchor_preview && (
|
||||
<div className="chat-anchor-preview">on: "{message.anchor_preview}"</div>
|
||||
)}
|
||||
{isUser && message.quote && (
|
||||
<div className="chat-quote">"{message.quote}"</div>
|
||||
)}
|
||||
{!isUser && message.model_id && (
|
||||
<ModelLabel modelId={message.model_id} streaming={message.streaming} />
|
||||
)}
|
||||
<div className="chat-bubble">
|
||||
{displayText
|
||||
? displayText
|
||||
: message.streaming
|
||||
? <span className="chat-thinking">Thinking…</span>
|
||||
: null}
|
||||
{message.streaming && displayText && <span className="chat-cursor" />}
|
||||
</div>
|
||||
{!isUser && !message.streaming && messageChanges.length > 0 && (
|
||||
contributionMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-change-hint"
|
||||
onClick={() => onScrollToChange?.(messageChanges[0].id)}
|
||||
>
|
||||
↓ {messageChanges.length} change{messageChanges.length === 1 ? '' : 's'} added below
|
||||
</button>
|
||||
) : (
|
||||
<div className="chat-change-hint discuss">
|
||||
{messageChanges.length} change{messageChanges.length === 1 ? '' : 's'} proposed —{' '}
|
||||
<button className="chat-change-hint-cta" onClick={onStartContribution}>
|
||||
start a contribution to apply {messageChanges.length === 1 ? 'it' : 'them'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// DiffView.jsx — the §8.10 read-only render surface for accepted changes.
|
||||
//
|
||||
// In contribute mode, a toolbar toggle replaces the editor with this
|
||||
// view. We reconstruct the markup for every accepted change in branch
|
||||
// history by reading the `changes` table (passed in as `changes`) plus
|
||||
// the current rendered HTML; hovering any tracked span surfaces a
|
||||
// tooltip with the change's type/model/prompt/reason context. Carryover
|
||||
// from the prototype.
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { MODEL_STYLES } from '../modelStyles'
|
||||
|
||||
function tooltipStyle(x, y) {
|
||||
const vw = window.innerWidth, vh = window.innerHeight
|
||||
const style = {}
|
||||
if (x > vw * 0.55) style.right = vw - x + 10
|
||||
else style.left = x + 14
|
||||
if (y > vh * 0.55) style.bottom = vh - y + 10
|
||||
else style.top = y + 14
|
||||
return style
|
||||
}
|
||||
|
||||
function changeContext(change, messages) {
|
||||
if (!change?.source_message_id) return {}
|
||||
const idx = messages.findIndex(m => m.id === change.source_message_id)
|
||||
if (idx < 0) return {}
|
||||
const assistant = messages[idx]
|
||||
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
|
||||
return { assistant, userMsg }
|
||||
}
|
||||
|
||||
function ChangeTooltip({ change, messages, position }) {
|
||||
const { assistant, userMsg } = changeContext(change, messages)
|
||||
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
|
||||
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
|
||||
|
||||
return (
|
||||
<div className="diff-tooltip" style={style}>
|
||||
<div className="diff-tooltip-header">
|
||||
{change.kind === 'ai' ? (
|
||||
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
|
||||
{modelStyle.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
|
||||
)}
|
||||
{change.was_edited_before_accept && (
|
||||
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
|
||||
)}
|
||||
</div>
|
||||
{userMsg && (
|
||||
<div className="diff-tooltip-prompt">
|
||||
{userMsg.quote && (
|
||||
<div className="diff-tooltip-quote">
|
||||
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
|
||||
</div>
|
||||
)}
|
||||
<div className="diff-tooltip-prompt-text">
|
||||
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{change.reason && (
|
||||
<div className="diff-tooltip-reason">
|
||||
<span className="diff-tooltip-reason-label">Reason</span>
|
||||
{change.reason}
|
||||
</div>
|
||||
)}
|
||||
{!userMsg && change.kind === 'ai' && (
|
||||
<div className="diff-tooltip-no-context">
|
||||
No linked conversation message in this session.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DiffView({ html, changes, messages }) {
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
const span = e.target.closest('[data-change-id]')
|
||||
if (span) {
|
||||
const id = span.getAttribute('data-change-id')
|
||||
const change = changes.find(c => String(c.id) === String(id))
|
||||
if (change) {
|
||||
setTooltip({ change, position: { x: e.clientX, y: e.clientY } })
|
||||
return
|
||||
}
|
||||
}
|
||||
setTooltip(null)
|
||||
}, [changes])
|
||||
|
||||
const acceptedCount = changes.filter(c => c.state === 'accepted').length
|
||||
|
||||
return (
|
||||
<div className="diff-view-wrapper">
|
||||
{acceptedCount === 0 && (
|
||||
<div className="diff-view-empty">
|
||||
No accepted changes yet on this branch. Accept proposals from the
|
||||
change panel to see them rendered here in place.
|
||||
</div>
|
||||
)}
|
||||
<div className="editor-content">
|
||||
<div
|
||||
className="tiptap diff-view-document"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
{tooltip && <ChangeTooltip change={tooltip.change} messages={messages} position={tooltip.position} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// ModelPicker.jsx — segmented control for LLM provider switching.
|
||||
// §18 carryover. Hidden when only one model is configured.
|
||||
|
||||
import { MODEL_STYLES } from '../modelStyles'
|
||||
|
||||
export default function ModelPicker({ models, selected, onChange }) {
|
||||
if (!models || models.length <= 1) return null
|
||||
return (
|
||||
<div className="model-picker">
|
||||
{models.map(m => {
|
||||
const style = MODEL_STYLES[m.id] || MODEL_STYLES.default
|
||||
const isActive = m.id === selected
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`model-pill ${isActive ? 'active' : ''}`}
|
||||
style={isActive ? { background: style.bg, color: style.color, borderColor: style.color } : {}}
|
||||
onClick={() => onChange(m.id)}
|
||||
title={`Use ${m.name}`}
|
||||
>
|
||||
<span className="model-dot" style={{ background: style.color }} />
|
||||
{m.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// PromptBar.jsx — the §8.1 prompt-bar at the bottom of the center column.
|
||||
//
|
||||
// Carryover from the prototype. In discuss mode the contributor types
|
||||
// to talk; in contribute mode the model is told to lean toward concrete
|
||||
// edits. The selection-quote machinery is preserved — a passage
|
||||
// highlighted in the editor surfaces here as a "scoped to selection"
|
||||
// badge and travels to the backend with the message.
|
||||
|
||||
import { useState } from 'react'
|
||||
import ModelPicker from './ModelPicker.jsx'
|
||||
|
||||
export default function PromptBar({
|
||||
selection,
|
||||
onSubmit,
|
||||
disabled,
|
||||
models,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
discussMode = false,
|
||||
placeholder,
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!prompt.trim() || disabled) return
|
||||
onSubmit(prompt.trim(), selection)
|
||||
setPrompt('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prompt-bar">
|
||||
{selection && (
|
||||
<div className="selection-badge">
|
||||
<span className="selection-icon">✦</span>
|
||||
Scoped to selection — "{selection.slice(0, 80)}{selection.length > 80 ? '…' : ''}"
|
||||
</div>
|
||||
)}
|
||||
<div className="prompt-row">
|
||||
{models?.length > 1 && (
|
||||
<ModelPicker models={models} selected={selectedModel} onChange={onModelChange} />
|
||||
)}
|
||||
<textarea
|
||||
className="prompt-input"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() }
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ?? (
|
||||
selection ? 'Ask anything about the selected text…'
|
||||
: discussMode ? 'Ask anything about this RFC…'
|
||||
: 'Ask anything or propose changes…'
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button className="prompt-submit" onClick={handleSubmit} disabled={!prompt.trim() || disabled}>
|
||||
Ask
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +1,786 @@
|
||||
// RFCView.jsx — §9.4 super-draft view (and a stub for active RFCs).
|
||||
// RFCView.jsx — the §8 active-RFC view.
|
||||
//
|
||||
// Slice 1 ships read-only body rendering: the breadcrumb names the
|
||||
// entry, the body renders via marked. The discuss-vs-contribute toggle,
|
||||
// per-branch chat, change-card panel, and breadcrumb dropdown all land
|
||||
// in Slice 2 per §8.
|
||||
// Three-column shape per §8.1 (catalog left, this component's content
|
||||
// in the middle and right). Opens on main in discuss mode per §8.2;
|
||||
// supports the §8.3 discuss-vs-contribute flip on non-main branches.
|
||||
// "Start Contributing" on main calls the §17 promote-to-branch
|
||||
// endpoint; on a non-main branch it is a pure mode flip per §8.14.
|
||||
//
|
||||
// The render path inherits the §18 carryovers: Tiptap editor, the
|
||||
// <change> parser (which the backend owns, not the frontend), the
|
||||
// SelectionTooltip, the prompt bar, the change-card panel, the
|
||||
// DiffView toggle.
|
||||
//
|
||||
// Super-draft entries are deferred to Slice 4 per docs/DEV.md; this
|
||||
// component renders a polite "open in Slice 4" placeholder for them.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { getRFC } from '../api'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
acceptChange as apiAccept,
|
||||
createThread,
|
||||
declineChange as apiDecline,
|
||||
getBranch,
|
||||
getRFC,
|
||||
getRFCMain,
|
||||
getThreadMessages,
|
||||
listModels,
|
||||
manualFlush,
|
||||
promoteToBranch,
|
||||
reaskChange,
|
||||
resolveThread,
|
||||
setBranchVisibility,
|
||||
streamChatTurn,
|
||||
} from '../api'
|
||||
import Editor, { selectionHighlightKey } from './Editor.jsx'
|
||||
import SelectionTooltip from './SelectionTooltip.jsx'
|
||||
import PromptBar from './PromptBar.jsx'
|
||||
import ChatPanel from './ChatPanel.jsx'
|
||||
import ChangePanel from './ChangePanel.jsx'
|
||||
import DiffView from './DiffView.jsx'
|
||||
|
||||
export default function RFCView() {
|
||||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||
const MANUAL_DEBOUNCE_MS = 800
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
|
||||
}
|
||||
|
||||
export default function RFCView({ viewer }) {
|
||||
const { slug } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const branchParam = searchParams.get('branch') || 'main'
|
||||
|
||||
const [entry, setEntry] = useState(null)
|
||||
const [mainView, setMainView] = useState(null)
|
||||
const [branchView, setBranchView] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [models, setModels] = useState([])
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
|
||||
// Editor state — owned here so accept/decline can mutate it.
|
||||
const editorRef = useRef(null)
|
||||
const originalParagraphsRef = useRef([])
|
||||
const [editorContent, setEditorContent] = useState('')
|
||||
|
||||
// Selection + tooltip + selection highlight per §8.12.
|
||||
const [selection, setSelection] = useState(null)
|
||||
const [highlightRange, setHighlightRange] = useState(null)
|
||||
const [reviewMode, setReviewMode] = useState(false)
|
||||
const [reviewHTML, setReviewHTML] = useState('')
|
||||
|
||||
// Mode: discuss vs contribute (§8.3). Always discuss on main.
|
||||
const [mode, setMode] = useState('discuss')
|
||||
|
||||
// Chat + changes (loaded with the branch).
|
||||
const [messages, setMessages] = useState([])
|
||||
const [changes, setChanges] = useState([])
|
||||
const [pendingDiscussChanges, setPendingDiscussChanges] = useState([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [focusedChangeId, setFocusedChangeId] = useState(null)
|
||||
const [showVisibility, setShowVisibility] = useState(false)
|
||||
|
||||
// Manual-edit buffer state per §8.11.
|
||||
const [manualPending, setManualPending] = useState(null)
|
||||
// {paragraphCount, deadline} — null when buffer empty
|
||||
const [manualCountdown, setManualCountdown] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(null); setError(null)
|
||||
getRFC(slug).then(setEntry).catch(err => setError(err.message))
|
||||
listModels()
|
||||
.then(({ models, default: def }) => {
|
||||
setModels(models || [])
|
||||
setSelectedModel(def || models?.[0]?.id || '')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [slug])
|
||||
|
||||
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
|
||||
if (!entry) return <div className="entry-view">Loading…</div>
|
||||
// Slice 4 owns super-draft body editing; render a placeholder for now.
|
||||
const isSuperDraft = entry?.state === 'super-draft'
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
if (!entry || entry.state !== 'active') return
|
||||
setError(null)
|
||||
setEditorContent('')
|
||||
setMessages([])
|
||||
setChanges([])
|
||||
setPendingDiscussChanges([])
|
||||
setManualPending(null)
|
||||
setReviewMode(false)
|
||||
setSelection(null)
|
||||
setHighlightRange(null)
|
||||
setMode('discuss')
|
||||
|
||||
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
|
||||
getBranch(slug, branchParam)
|
||||
.then(view => {
|
||||
setBranchView(view)
|
||||
setEditorContent(view.body || '')
|
||||
setChanges(view.changes || [])
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
}, [slug, branchParam, entry])
|
||||
|
||||
// Load chat messages whenever the branch's main thread id resolves.
|
||||
useEffect(() => {
|
||||
if (!branchView?.main_thread_id) return
|
||||
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
|
||||
}, [branchView?.main_thread_id, slug, branchParam])
|
||||
|
||||
// Selection + highlight wiring (§8.12).
|
||||
const handleSelectionChange = useCallback((sel) => {
|
||||
setSelection(sel)
|
||||
if (sel?.from != null) setHighlightRange({ from: sel.from, to: sel.to })
|
||||
else setHighlightRange(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor?.view) 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.
|
||||
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()) {
|
||||
setManualPending(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await manualFlush(slug, branchParam, {
|
||||
newContent,
|
||||
paragraphCount: manualPending?.paragraphCount || 1,
|
||||
})
|
||||
if (!res.noop) {
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
setChanges(fresh.changes || [])
|
||||
setManualPending(null)
|
||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
|
||||
|
||||
const handleEditorUpdate = useMemo(() => debounce((plainText) => {
|
||||
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 || []
|
||||
let changed = 0
|
||||
currentParagraphs.forEach((t, i) => {
|
||||
const orig = (baseline[i] ?? '').trim()
|
||||
if (t !== orig) changed++
|
||||
})
|
||||
if (changed > 0) {
|
||||
setManualPending({ paragraphCount: changed })
|
||||
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
|
||||
} else {
|
||||
setManualPending(null)
|
||||
setManualCountdown(null)
|
||||
}
|
||||
}, MANUAL_DEBOUNCE_MS), [mode, branchView])
|
||||
|
||||
// Idle flush — auto-save when countdown elapses.
|
||||
useEffect(() => {
|
||||
if (!manualCountdown) return
|
||||
const delay = Math.max(0, manualCountdown.deadline - Date.now())
|
||||
const t = setTimeout(() => {
|
||||
flushManualBuffer()
|
||||
}, delay)
|
||||
return () => clearTimeout(t)
|
||||
}, [manualCountdown, flushManualBuffer])
|
||||
|
||||
// ── Start contributing ─────────────────────────────────────────────────
|
||||
const handleStartContributing = useCallback(async () => {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
if (branchParam === 'main') {
|
||||
try {
|
||||
const { branch_name } = await promoteToBranch(slug)
|
||||
setSearchParams({ branch: branch_name })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-main: pure mode flip per §8.14.
|
||||
if (pendingDiscussChanges.length > 0) {
|
||||
// The §8.14 buffered proposals are already `pending` rows on the
|
||||
// backend — surfacing the change panel exposes them.
|
||||
setPendingDiscussChanges([])
|
||||
}
|
||||
setMode('contribute')
|
||||
}, [viewer, slug, branchParam, pendingDiscussChanges, setSearchParams])
|
||||
|
||||
// ── Submit a chat turn (prompt bar or selection tooltip) ───────────────
|
||||
const submitChatTurn = useCallback(async (text, quote) => {
|
||||
if (!branchView?.main_thread_id || isStreaming) return
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
setIsStreaming(true)
|
||||
// Optimistic insert of the user message and an assistant placeholder.
|
||||
const tempUserId = `tmp-user-${Date.now()}`
|
||||
const tempAssistId = `tmp-assist-${Date.now()}`
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ id: tempUserId, role: 'user', author_login: viewer.gitea_login, text, quote, created_at: new Date().toISOString() },
|
||||
{ id: tempAssistId, role: 'assistant', text: '', model_id: selectedModel, streaming: true, created_at: new Date().toISOString() },
|
||||
])
|
||||
try {
|
||||
const { assistantId, userMsgId } = await streamChatTurn(
|
||||
slug,
|
||||
branchParam,
|
||||
branchView.main_thread_id,
|
||||
{ text, quote, model: selectedModel },
|
||||
{
|
||||
onChunk: chunk => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === tempAssistId ? { ...m, text: (m.text || '') + chunk } : m
|
||||
))
|
||||
},
|
||||
onChanges: payload => {
|
||||
// payload: { message_id, change_ids, count }
|
||||
// The page-level state holds onto the assistant id so we
|
||||
// can correlate change.source_message_id when the branch
|
||||
// re-loads below.
|
||||
if (payload?.message_id) {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === tempAssistId ? { ...m, id: payload.message_id, streaming: false } : m
|
||||
))
|
||||
}
|
||||
},
|
||||
onDone: () => { /* terminal */ },
|
||||
},
|
||||
)
|
||||
// Rebind to the real ids returned via response headers in case
|
||||
// the X-Assistant-Message-Id header arrived before the changes event.
|
||||
if (assistantId) {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === tempAssistId ? { ...m, id: Number(assistantId), streaming: false } : m
|
||||
))
|
||||
}
|
||||
if (userMsgId) {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === tempUserId ? { ...m, id: Number(userMsgId) } : m
|
||||
))
|
||||
}
|
||||
// Re-pull authoritative state: changes have been materialized server-side.
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setChanges(fresh.changes || [])
|
||||
// If we're in discuss mode and the new turn produced pending AI changes,
|
||||
// surface them as discuss-mode buffered count.
|
||||
if (mode === 'discuss') {
|
||||
const newPending = (fresh.changes || []).filter(c => c.state === 'pending' && c.kind === 'ai')
|
||||
setPendingDiscussChanges(newPending)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === tempAssistId ? { ...m, text: `[Error: ${err.message}]`, streaming: false } : m
|
||||
))
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}, [slug, branchParam, branchView?.main_thread_id, isStreaming, viewer, selectedModel, mode])
|
||||
|
||||
const handlePrompt = useCallback((text, sel) => {
|
||||
const quote = sel?.text || null
|
||||
submitChatTurn(text, quote)
|
||||
}, [submitChatTurn])
|
||||
|
||||
const handleTooltipAsk = useCallback(async (textOrNull, quote) => {
|
||||
if (textOrNull === null) { setSelection(null); return }
|
||||
setSelection(null)
|
||||
await submitChatTurn(textOrNull, quote)
|
||||
}, [submitChatTurn])
|
||||
|
||||
const handleTooltipFlag = useCallback(async (label, quote) => {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
setSelection(null)
|
||||
try {
|
||||
await createThread(slug, branchParam, {
|
||||
thread_kind: 'flag',
|
||||
anchor_kind: 'range',
|
||||
anchor_payload: { quote, from: highlightRange?.from, to: highlightRange?.to },
|
||||
label,
|
||||
})
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam, viewer, highlightRange])
|
||||
|
||||
// ── 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.
|
||||
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.
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam])
|
||||
|
||||
const handleDecline = useCallback(async (changeId) => {
|
||||
try {
|
||||
await apiDecline(slug, branchParam, changeId)
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setChanges(fresh.changes || [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam])
|
||||
|
||||
const handleReask = useCallback(async (changeId) => {
|
||||
try {
|
||||
await reaskChange(slug, branchParam, changeId)
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
setChanges(fresh.changes || [])
|
||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam])
|
||||
|
||||
const handleResolveThread = useCallback(async (threadId) => {
|
||||
try {
|
||||
await resolveThread(slug, branchParam, threadId)
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [slug, branchParam])
|
||||
|
||||
const handleSaveNow = useCallback(() => {
|
||||
flushManualBuffer()
|
||||
}, [flushManualBuffer])
|
||||
|
||||
// ── Editor-click → focus matching change card ──────────────────────────
|
||||
const handleEditorClick = useCallback((e) => {
|
||||
const span = e.target.closest('[data-change-id]')
|
||||
if (span) {
|
||||
const id = span.getAttribute('data-change-id')
|
||||
setFocusedChangeId(Number(id))
|
||||
setTimeout(() => setFocusedChangeId(null), 1800)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleReviewMode = useCallback(() => {
|
||||
setReviewMode(prev => {
|
||||
if (!prev) setReviewHTML(editorRef.current?.getHTML() || '')
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Branch dropdown navigation ─────────────────────────────────────────
|
||||
const onPickBranch = useCallback((name) => {
|
||||
if (name === branchParam) return
|
||||
if (name === 'main') {
|
||||
setSearchParams({})
|
||||
} else {
|
||||
setSearchParams({ branch: name })
|
||||
}
|
||||
}, [branchParam, setSearchParams])
|
||||
|
||||
// Render early-out states.
|
||||
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
|
||||
if (!entry) return <article className="entry-view">Loading…</article>
|
||||
if (isSuperDraft) {
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className="entry-state-banner">Super-draft</div>
|
||||
<h1 className="entry-title">{entry.title}</h1>
|
||||
<p className="field-help">
|
||||
Super-draft body editing on the meta repo lands in Slice 4 per
|
||||
<code> docs/DEV.md</code>. The Slice 2 view is scoped to active
|
||||
RFCs — chat, branches, change panel, AI participation. The
|
||||
super-draft body below is the pitch as merged.
|
||||
</p>
|
||||
<div className="entry-body" style={{ whiteSpace: 'pre-wrap' }}>{entry.body}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (entry.state !== 'active') {
|
||||
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
|
||||
}
|
||||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||||
|
||||
const canContribute = branchView.capabilities?.can_contribute && branchParam !== 'main'
|
||||
const canChangeSettings = branchView.capabilities?.can_change_branch_settings
|
||||
const editorEditable = mode === 'contribute' && canContribute && !reviewMode
|
||||
const showPromptBar = !!viewer
|
||||
|
||||
const inDiscuss = mode === 'discuss'
|
||||
const pendingCount = changes.filter(c => c.state === 'pending').length
|
||||
|
||||
const stateClass = entry.state === 'active' ? 'active' : ''
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className={`entry-state-banner ${stateClass}`}>
|
||||
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
|
||||
</div>
|
||||
<h1 className="entry-title">{entry.title}</h1>
|
||||
<div className="entry-meta">
|
||||
<span>{entry.slug}</span>
|
||||
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>}
|
||||
{entry.proposed_at && <> · {entry.proposed_at}</>}
|
||||
{entry.tags.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.state === 'active' && (
|
||||
<div className="entry-state-banner" style={{ background: '#fffbeb', borderColor: '#fde68a', color: '#92400e' }}>
|
||||
The active-RFC view (editor, branches, chat) lands in Slice 2.
|
||||
The body below is the canonical main-branch text.
|
||||
<div className="rfc-view">
|
||||
{/* Breadcrumb */}
|
||||
<div className="rfc-breadcrumb">
|
||||
<span className="breadcrumb-label">{entry.id || 'active'}</span>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<BranchDropdown
|
||||
current={branchParam}
|
||||
mainView={mainView}
|
||||
onPick={onPickBranch}
|
||||
viewer={viewer}
|
||||
/>
|
||||
<span className="breadcrumb-sep">·</span>
|
||||
<span className="breadcrumb-meta">
|
||||
{mainView ? `${mainView.branches.length} branch${mainView.branches.length === 1 ? '' : 'es'}` : '…'}
|
||||
{mainView && mainView.open_prs.length > 0 && ` · ${mainView.open_prs.length} PR${mainView.open_prs.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<div className="breadcrumb-actions">
|
||||
{branchParam !== 'main' && canContribute && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-mode-toggle ${mode}`}
|
||||
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
|
||||
title={mode === 'discuss' ? 'Flip into edit mode' : 'Flip back to read-only discuss'}
|
||||
>
|
||||
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
|
||||
</button>
|
||||
)}
|
||||
{(branchParam === 'main' || !canContribute) && viewer && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-start-contribution-header"
|
||||
onClick={handleStartContributing}
|
||||
>
|
||||
Start Contributing
|
||||
</button>
|
||||
)}
|
||||
{!viewer && (
|
||||
<a className="btn-link" href="/auth/login">Sign in</a>
|
||||
)}
|
||||
{canChangeSettings && branchParam !== 'main' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowVisibility(true)}
|
||||
>Branch settings</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two columns: editor + chat */}
|
||||
<div className="rfc-body">
|
||||
<div className="editor-area" onClick={handleEditorClick}>
|
||||
{branchParam === 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
main is read-only — PRs are the only path to change it.
|
||||
Open a branch to propose edits.
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||
flip to Contribute to edit the document and apply AI changes.
|
||||
</div>
|
||||
)}
|
||||
{!canContribute && branchParam !== 'main' && viewer && (
|
||||
<div className="discuss-mode-banner muted">
|
||||
You don't have contribute access to this branch. The branch
|
||||
creator or an arbiter can grant access.
|
||||
</div>
|
||||
)}
|
||||
{mode === 'contribute' && canContribute && (
|
||||
<div className="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-review-toggle${reviewMode ? ' active' : ''}`}
|
||||
onClick={toggleReviewMode}
|
||||
title="Toggle DiffView: read-only render of accepted changes in context"
|
||||
>
|
||||
{reviewMode ? '← Back to editing' : 'Review changes'}
|
||||
</button>
|
||||
<span className="editor-toolbar-hint">
|
||||
{changes.filter(c => c.state === 'accepted').length} accepted ·{' '}
|
||||
{pendingCount} pending
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{reviewMode ? (
|
||||
<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}
|
||||
/>
|
||||
{showPromptBar ? (
|
||||
<PromptBar
|
||||
selection={selection?.text || null}
|
||||
onSubmit={handlePrompt}
|
||||
disabled={isStreaming}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
discussMode={inDiscuss}
|
||||
/>
|
||||
) : (
|
||||
<div className="readonly-bar">
|
||||
Read-only view. <a href="/auth/login">Sign in</a> to participate.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-panel">
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
threads={branchView.threads || []}
|
||||
changes={changes}
|
||||
branchName={branchParam}
|
||||
isStreaming={isStreaming}
|
||||
contributionMode={mode === 'contribute'}
|
||||
onStartContribution={handleStartContributing}
|
||||
onScrollToChange={setFocusedChangeId}
|
||||
onResolveThread={handleResolveThread}
|
||||
/>
|
||||
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
|
||||
<ChangePanel
|
||||
changes={changes}
|
||||
onAccept={handleAccept}
|
||||
onDecline={handleDecline}
|
||||
onReask={handleReask}
|
||||
onScrollToMessage={focusMessage}
|
||||
focusedChangeId={focusedChangeId}
|
||||
manualPendingStatus={manualPending ? {
|
||||
paragraphCount: manualPending.paragraphCount,
|
||||
savingIn: manualCountdownLabel(manualCountdown),
|
||||
onSaveNow: handleSaveNow,
|
||||
} : null}
|
||||
/>
|
||||
)}
|
||||
{inDiscuss && pendingDiscussChanges.length > 0 && (
|
||||
<div className="contribution-cta">
|
||||
<div className="contribution-cta-count">
|
||||
{pendingDiscussChanges.length} change{pendingDiscussChanges.length === 1 ? '' : 's'} proposed
|
||||
</div>
|
||||
<p className="contribution-cta-desc">
|
||||
Flip into Contribute to act on them.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-start-contribution"
|
||||
onClick={handleStartContributing}
|
||||
>
|
||||
{branchParam === 'main' ? 'Start Contributing →' : 'Contribute on this branch →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showVisibility && (
|
||||
<BranchVisibilityModal
|
||||
slug={slug}
|
||||
branch={branchParam}
|
||||
current={branchView.visibility}
|
||||
onClose={() => setShowVisibility(false)}
|
||||
onSaved={async () => {
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
setShowVisibility(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function focusMessage(messageId) {
|
||||
const el = document.querySelector(`[data-message-id="${messageId}"]`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
function manualCountdownLabel(c) {
|
||||
if (!c) return ''
|
||||
const remaining = Math.max(0, c.deadline - Date.now())
|
||||
const totalSec = Math.ceil(remaining / 1000)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadAllMessages(slug, branch, threads) {
|
||||
// For Slice 2 we pull each thread's messages and stitch them in
|
||||
// chronological order. The branch chat is the unified feed of
|
||||
// every message across every thread per §8.12.
|
||||
if (!threads || threads.length === 0) return []
|
||||
const all = []
|
||||
for (const t of threads) {
|
||||
if (t.state !== 'open' && t.thread_kind !== 'flag') continue
|
||||
try {
|
||||
const { messages } = await getThreadMessages(slug, branch, t.id)
|
||||
for (const m of messages) {
|
||||
all.push({
|
||||
...m,
|
||||
thread_id: t.id,
|
||||
thread_kind: t.thread_kind,
|
||||
anchor_kind: t.anchor_kind,
|
||||
anchor_preview: t.anchor_payload?.quote || null,
|
||||
flag_label: t.thread_kind === 'flag' ? t.label : null,
|
||||
})
|
||||
}
|
||||
if (t.thread_kind === 'flag' && (!messages || messages.length === 0)) {
|
||||
all.push({
|
||||
id: `flag-${t.id}`,
|
||||
role: 'system',
|
||||
text: t.label || '',
|
||||
thread_id: t.id,
|
||||
thread_kind: 'flag',
|
||||
anchor_kind: t.anchor_kind,
|
||||
anchor_preview: t.anchor_payload?.quote || null,
|
||||
flag_label: t.label,
|
||||
created_at: t.created_at,
|
||||
author_login: null,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Tolerate per-thread fetch failures; the surface for triage
|
||||
// belongs to a future error overlay.
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''))
|
||||
return all
|
||||
}
|
||||
|
||||
function BranchDropdown({ current, mainView, onPick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const items = [{ name: 'main' }, ...(mainView?.branches || [])]
|
||||
return (
|
||||
<div className="branch-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
className="branch-dropdown-trigger"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{current === 'main' ? 'main' : current} ▾
|
||||
</button>
|
||||
{open && (
|
||||
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
|
||||
{items.map(b => (
|
||||
<button
|
||||
key={b.name}
|
||||
type="button"
|
||||
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
|
||||
onClick={() => { setOpen(false); onPick(b.name) }}
|
||||
>
|
||||
<span className="branch-name">{b.name}</span>
|
||||
{b.visibility && b.name !== 'main' && !b.visibility.read_public && (
|
||||
<span className="branch-private-icon" title="Private">🔒</span>
|
||||
)}
|
||||
{b.creator && (
|
||||
<span className="branch-creator">@{b.creator}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
|
||||
const [readPublic, setReadPublic] = useState(!!current?.read_public)
|
||||
const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true); setErr(null)
|
||||
try {
|
||||
await setBranchVisibility(slug, branch, { readPublic, contributeMode })
|
||||
onSaved()
|
||||
} catch (e) { setErr(e.message); setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Branch settings — {branch}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>
|
||||
<input type="checkbox" checked={readPublic} onChange={e => setReadPublic(e.target.checked)} />
|
||||
{' '}Public read access
|
||||
</label>
|
||||
<p className="field-help">
|
||||
§11.1: a public branch can be read by anyone, including anonymous viewers.
|
||||
</p>
|
||||
<label style={{ marginTop: 12 }}>Contribute mode</label>
|
||||
<select value={contributeMode} onChange={e => setContributeMode(e.target.value)}>
|
||||
<option value="just-me">Just me</option>
|
||||
<option value="specific">Specific contributors</option>
|
||||
<option value="any-contributor">Any contributor</option>
|
||||
</select>
|
||||
{err && <p className="field-error">{err}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
<button className="btn-primary" onClick={onSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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