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
+569
View File
@@ -317,3 +317,572 @@
color: #666; text-decoration: none;
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
/* Override the §9.x padded read-view; the §8 surface manages its own
internal layout and needs to fill the pane edge-to-edge. */
padding: 0;
overflow: hidden;
display: flex;
}
.rfc-view {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.rfc-breadcrumb {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid #e5e5e5;
background: #fafafa;
font-size: 13px; color: #555;
flex-shrink: 0;
}
.breadcrumb-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: #888;
}
.breadcrumb-sep { color: #ccc; }
.breadcrumb-meta { color: #999; font-size: 12px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.btn-mode-toggle {
font-size: 12px; font-weight: 600;
padding: 4px 12px; border-radius: 999px;
border: 1px solid #d4d4d4; background: #fff; color: #444;
cursor: pointer;
}
.btn-mode-toggle.discuss { background: #fff; color: #444; }
.btn-mode-toggle.contribute { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.btn-start-contribution-header {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 5px 12px; font-size: 12px; font-weight: 600;
cursor: pointer;
}
.branch-dropdown { position: relative; }
.branch-dropdown-trigger {
background: none; border: 1px solid transparent; border-radius: 5px;
padding: 3px 8px; font-weight: 600; color: #1a1a1a; font-size: 13px;
cursor: pointer;
}
.branch-dropdown-trigger:hover { border-color: #e5e5e5; }
.branch-dropdown-menu {
position: absolute; top: 100%; left: 0; margin-top: 4px;
background: #fff; border: 1px solid #e5e5e5; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
z-index: 50; min-width: 240px; padding: 4px;
}
.branch-dropdown-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 6px 10px;
background: none; border: none; cursor: pointer; text-align: left;
font-size: 13px; border-radius: 5px;
}
.branch-dropdown-item:hover { background: #f5f5f5; }
.branch-dropdown-item.active { background: #f0f0ee; font-weight: 600; }
.branch-name { flex: 1; }
.branch-creator { font-size: 11px; color: #999; }
.branch-private-icon { font-size: 10px; }
.rfc-body { flex: 1; display: flex; overflow: hidden; }
/* ── Editor area ─────────────────────────────────────────────────────── */
.editor-area {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden; position: relative;
background: #fff;
}
.discuss-mode-banner {
padding: 8px 16px;
background: #fffbeb; border-bottom: 1px solid #fde68a;
color: #92400e; font-size: 12px;
}
.discuss-mode-banner.muted { background: #f0f0ee; color: #666; border-color: #e5e5e5; }
.editor-toolbar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
font-size: 12px; color: #777;
}
.btn-review-toggle {
background: #fff; color: #1a1a1a;
border: 1px solid #d4d4d4; border-radius: 5px;
padding: 4px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-review-toggle.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.editor-toolbar-hint { color: #999; font-size: 11px; }
.editor-wrapper {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.editor-content {
max-width: 720px; margin: 0 auto; outline: none;
}
.editor-content .tiptap {
outline: none; font-size: 15px; line-height: 1.75; color: #1a1a1a;
}
.editor-content .tiptap h1 { font-size: 22px; font-weight: 700; margin: 24px 0 12px; }
.editor-content .tiptap h2 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
.editor-content .tiptap h3 { font-size: 15px; font-weight: 600; margin: 16px 0 6px; }
.editor-content .tiptap p { margin: 0 0 12px; }
.editor-content .tiptap ul, .editor-content .tiptap ol { padding-left: 24px; }
.editor-content .tiptap code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.editor-content .tiptap .paragraph-changed {
border-left: 3px solid #f59e0b;
padding-left: 10px;
margin-left: -13px;
background: linear-gradient(to right, #fffbeb 0%, transparent 60%);
border-radius: 0 4px 4px 0;
}
.editor-content .tiptap .selection-highlight {
background: rgba(99, 102, 241, 0.15);
border-radius: 2px;
outline: 1px solid rgba(99, 102, 241, 0.3);
outline-offset: 1px;
}
.editor-content .tiptap .tracked-delete {
background: #fee2e2; color: #991b1b; text-decoration: line-through;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
.editor-content .tiptap .tracked-insert {
background: #dcfce7; color: #166534;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
.readonly-bar {
border-top: 1px solid #e5e5e5;
padding: 10px 16px; text-align: center;
font-size: 13px; color: #888; background: #fafafa;
}
.readonly-bar a { color: #1a1a1a; font-weight: 600; }
/* ── Prompt bar ──────────────────────────────────────────────────────── */
.prompt-bar {
border-top: 1px solid #e5e5e5;
background: #fff;
padding: 12px 48px;
flex-shrink: 0;
}
.selection-badge {
font-size: 12px; color: #5b5bd6;
margin-bottom: 8px; display: flex; align-items: center; gap: 6px;
}
.selection-icon { font-size: 10px; }
.prompt-row {
display: flex; gap: 10px; align-items: flex-end;
max-width: 720px; margin: 0 auto;
}
.prompt-input {
flex: 1; border: 1px solid #e5e5e5; border-radius: 8px;
padding: 9px 13px; font-size: 14px; font-family: inherit;
resize: none; line-height: 1.5; outline: none;
min-height: 40px; max-height: 120px;
}
.prompt-input:focus { border-color: #1a1a1a; }
.prompt-submit {
background: #1a1a1a; color: #fff; border: none;
border-radius: 8px; padding: 9px 16px;
font-size: 13px; font-weight: 600; cursor: pointer;
height: 40px;
}
.prompt-submit:disabled { background: #ccc; cursor: default; }
/* ── Model picker ──────────────────────────────────────────────────── */
.model-picker { display: flex; gap: 4px; padding: 0 4px; flex-shrink: 0; }
.model-pill {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 20px;
font-size: 12px; font-weight: 600;
border: 1px solid #e5e5e5; background: none; color: #666;
cursor: pointer;
}
.model-pill:hover { border-color: #aaa; color: #333; }
.model-pill.active { font-weight: 700; }
.model-dot { width: 7px; height: 7px; border-radius: 50%; }
/* ── Selection tooltip ───────────────────────────────────────────────── */
.selection-tooltip {
position: fixed; z-index: 100;
background: #fff; border: 1px solid #e5e5e5;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
padding: 8px 10px;
width: max-content; min-width: 320px; max-width: 480px;
display: flex; flex-direction: column; gap: 6px;
}
.selection-tooltip-quote {
font-size: 11px; color: #888; font-style: italic;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.selection-tooltip-tabs { display: flex; gap: 4px; }
.selection-tooltip-tab {
background: none; border: 1px solid transparent;
font-size: 12px; padding: 3px 10px; border-radius: 6px;
cursor: pointer; color: #666;
}
.selection-tooltip-tab.active { background: #1a1a1a; color: #fff; }
.selection-tooltip-input-row { display: flex; gap: 6px; align-items: center; }
.selection-tooltip-input {
flex: 1; border: 1px solid #e5e5e5; border-radius: 6px;
padding: 6px 10px; font-size: 13px; font-family: inherit; outline: none;
background: #f9f9f9;
}
.selection-tooltip-input:focus { border-color: #1a1a1a; background: #fff; }
.selection-tooltip-btn {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 6px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
}
.selection-tooltip-btn:disabled { background: #ccc; cursor: default; }
/* ── Right panel (chat + change panel) ───────────────────────────────── */
.right-panel {
width: 360px; flex-shrink: 0;
border-left: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden; background: #fff;
}
.chat-panel {
flex: 1; display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.chat-header {
padding: 10px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
display: flex; flex-direction: column; gap: 4px;
}
.chat-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.chat-header-title { font-size: 12px; color: #555; }
.chat-fork-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
}
.chat-thread-disclosure {
font-size: 11px; color: #888;
display: flex; gap: 4px; align-items: center;
}
.chat-thread-flag-count { color: #b45309; }
.chat-filter-clear {
margin-left: auto;
background: none; border: none; cursor: pointer;
font-size: 11px; color: #5b5bd6;
}
.chat-messages {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 10px;
}
.chat-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 24px;
}
.chat-empty p { font-size: 13px; color: #999; line-height: 1.6; max-width: 240px; }
.chat-message { display: flex; flex-direction: column; gap: 3px; }
.chat-message.user { align-items: flex-end; }
.chat-message.assistant { align-items: flex-start; }
.chat-message.system { align-items: stretch; }
.chat-message.flag { align-items: stretch; }
.chat-message.in-thread { padding-left: 12px; border-left: 2px solid #c4b5fd; }
.chat-bubble {
max-width: 92%;
padding: 8px 11px; border-radius: 14px;
font-size: 13px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.chat-message.user .chat-bubble {
background: #1a1a1a; color: #fff; border-bottom-right-radius: 4px;
}
.chat-message.assistant .chat-bubble {
background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px;
}
.chat-message.streaming .chat-bubble { opacity: 0.85; }
.chat-thinking { color: #999; font-style: italic; }
.chat-cursor {
display: inline-block; width: 2px; height: 12px;
background: #7c3aed; margin-left: 2px; vertical-align: middle;
animation: blink 0.75s step-end infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.chat-quote {
font-size: 11px; color: #888; font-style: italic;
border-left: 2px solid #d1d5db; padding-left: 7px; margin-bottom: 3px;
max-width: 92%; align-self: flex-end;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chat-anchor-preview {
font-size: 10px; color: #888; font-style: italic;
}
.chat-model-label {
font-size: 11px; font-weight: 700;
display: flex; align-items: center; gap: 4px; padding-left: 2px;
}
.chat-model-dot { width: 6px; height: 6px; border-radius: 50%; }
.chat-change-hint {
align-self: flex-start;
background: none; border: none; padding: 0;
font-size: 11px; color: #7c3aed; font-weight: 600;
cursor: pointer;
}
.chat-change-hint.discuss { color: #b45309; }
.chat-change-hint-cta {
background: none; border: none; padding: 0;
color: #5b5bd6; font-weight: 600; cursor: pointer; text-decoration: underline;
}
.chat-system-bubble {
font-size: 11px; color: #888; font-style: italic;
border-top: 1px dashed #e5e5e5; padding-top: 8px;
}
.chat-flag-row {
display: flex; gap: 8px;
padding: 8px 10px;
background: #fff7ed; border: 1px solid #fdba74; border-radius: 8px;
}
.chat-flag-icon { color: #c2410c; font-size: 14px; }
.chat-flag-content { flex: 1; }
.chat-flag-author { font-size: 11px; font-weight: 700; color: #9a3412; }
.chat-flag-text { font-size: 13px; color: #1a1a1a; margin-top: 2px; }
.chat-flag-resolve {
margin-top: 6px;
background: none; border: 1px solid #fdba74;
border-radius: 5px; padding: 2px 8px;
font-size: 11px; color: #c2410c; cursor: pointer;
}
/* ── Change panel ──────────────────────────────────────────────── */
.change-panel {
border-top: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden; flex-shrink: 0;
max-height: 50%;
}
.change-panel-header {
padding: 12px 14px;
font-size: 13px; font-weight: 600;
border-bottom: 1px solid #f0f0ee;
display: flex; align-items: center; gap: 8px;
}
.badge {
background: #b45309; color: #fff;
font-size: 11px; padding: 2px 7px; border-radius: 10px;
}
.change-list { flex: 1; overflow-y: auto; padding: 8px; }
.change-group-label {
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: #aaa; padding: 8px 6px 4px;
}
.change-group-label.muted { color: #ccc; }
.change-item {
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
border: 1px solid #e5e5e5;
font-size: 13px;
}
.change-item.type-claude { border-left: 3px solid #5b5bd6; }
.change-item.type-manual { border-left: 3px solid #888; }
.change-item.state-accepted { opacity: 0.5; }
.change-item.state-declined { opacity: 0.4; }
.change-item.stale { border-color: #fbbf24; background: #fffbeb; }
.change-item.focused {
animation: change-focus-flash 1.8s ease forwards;
}
@keyframes change-focus-flash {
0% { box-shadow: 0 0 0 3px #3b82f6; }
70% { box-shadow: 0 0 0 3px #3b82f6; }
100% { box-shadow: none; }
}
.change-meta {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.change-author { font-weight: 600; font-size: 12px; }
.change-state-badge {
font-size: 10px; padding: 2px 7px; border-radius: 10px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
}
.change-state-badge.pending { background: #fef3c7; color: #92400e; }
.change-state-badge.accepted { background: #dcfce7; color: #166534; }
.change-state-badge.declined { background: #f1f5f9; color: #94a3b8; }
.change-state-badge.stale { background: #fef3c7; color: #92400e; }
.change-label { color: #444; margin-bottom: 6px; line-height: 1.4; }
.change-stale-banner {
font-size: 11px; color: #92400e;
background: #fef3c7; border-radius: 4px;
padding: 4px 8px; margin-bottom: 6px;
}
.change-manual-status {
font-size: 11px; color: #888;
display: flex; align-items: center; justify-content: space-between;
margin-top: 6px;
}
.btn-save-now {
background: none; border: 1px solid #d4d4d4;
border-radius: 4px; padding: 2px 8px;
font-size: 11px; font-weight: 600; cursor: pointer;
}
.change-source-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
display: block; margin-top: 4px;
}
.change-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.btn-accept {
background: #166534; color: #fff;
border: none; border-radius: 5px;
padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-edit {
background: none; border: 1px solid #c4b5fd; color: #7c3aed;
border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-decline {
background: none; border: 1px solid #e5e5e5;
border-radius: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer;
}
.btn-reask {
background: none; border: 1px solid #fbbf24; color: #92400e;
border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.diff-edit-textarea {
width: 100%; font-size: 12px; font-family: inherit;
line-height: 1.5; border: 1px solid #c4b5fd;
border-radius: 4px; padding: 6px; resize: vertical;
}
.inline-diff {
font-size: 12px; line-height: 1.7;
background: #f9fafb; border-radius: 4px;
padding: 8px 10px;
white-space: pre-wrap; word-break: break-word;
border: 1px solid #e5e5e5;
}
.diff-word-add {
background: #dcfce7; color: #166534;
border-radius: 2px; padding: 1px 1px;
}
.diff-word-remove {
background: #fee2e2; color: #991b1b;
text-decoration: line-through;
border-radius: 2px; padding: 1px 1px;
}
.text-fade { color: #aaa; }
.expand-toggle {
display: block; margin-top: 5px;
background: none; border: none; padding: 0;
font-size: 11px; font-weight: 600; color: #7c3aed;
cursor: pointer; text-decoration: underline;
}
.change-stub {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: #888;
padding: 4px 6px; border-bottom: 1px solid #f5f5f5;
}
.change-stub .stub-author { font-weight: 600; color: #555; }
.change-stub .stub-badge {
font-size: 9px; padding: 1px 5px; border-radius: 8px;
}
.change-stub .stub-badge.accepted { background: #dcfce7; color: #166534; }
.change-stub .stub-badge.declined { background: #f1f5f9; color: #94a3b8; }
.change-stub .stub-reason { flex: 1; color: #777; }
.change-stub .stub-source-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
}
.contribution-cta {
border-top: 1px solid #e5e5e5;
background: #fafafa;
padding: 16px;
text-align: center;
}
.contribution-cta-count {
font-size: 13px; font-weight: 600; color: #1a1a1a;
}
.contribution-cta-desc {
font-size: 12px; color: #666; margin: 6px 0 12px;
}
.btn-start-contribution {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 8px 14px;
font-size: 13px; font-weight: 600; cursor: pointer;
}
/* ── DiffView ──────────────────────────────────────────────────── */
.diff-view-wrapper {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.diff-view-empty {
font-size: 13px; color: #999;
text-align: center; padding: 24px;
}
.diff-tooltip {
background: #fff; border: 1px solid #e5e5e5;
border-radius: 8px; padding: 10px 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
max-width: 320px; font-size: 12px;
z-index: 200;
}
.diff-tooltip-header {
display: flex; gap: 4px; flex-wrap: wrap;
margin-bottom: 6px;
}
.diff-tooltip-badge {
font-size: 10px; padding: 2px 7px; border-radius: 8px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
}
.diff-tooltip-badge--manual { background: #f1f5f9; color: #475569; }
.diff-tooltip-badge--edited { background: #faf5ff; color: #7c3aed; }
.diff-tooltip-prompt {
border-top: 1px solid #f0f0ee;
padding-top: 6px;
font-size: 11px; color: #444;
}
.diff-tooltip-quote {
font-style: italic; color: #888; margin-bottom: 4px;
}
.diff-tooltip-reason {
border-top: 1px solid #f0f0ee;
padding-top: 6px; margin-top: 6px;
color: #555;
}
.diff-tooltip-reason-label {
display: inline-block; font-size: 9px; font-weight: 700;
color: #888; text-transform: uppercase; letter-spacing: 0.04em;
margin-right: 6px;
}
.diff-tooltip-no-context {
font-size: 11px; color: #aaa; font-style: italic;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default function App() {
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
+186
View File
@@ -68,3 +68,189 @@ export async function withdrawProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
// ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────
export async function listModels() {
return jsonOrThrow(await fetch('/api/models'))
}
export async function getRFCMain(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}
export async function getBranch(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
}
export async function promoteToBranch(slug, body = {}) {
const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proposed,
was_edited_before_accept: !!wasEdited,
force_apply_stale: !!forceApplyStale,
}),
},
)
return jsonOrThrow(res)
}
export async function declineChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function reaskChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function manualFlush(slug, branch, { newContent, paragraphCount }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }),
},
)
return jsonOrThrow(res)
}
export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
read_public: readPublic,
contribute_mode: contributeMode,
}),
},
)
return jsonOrThrow(res)
}
export async function createThread(slug, branch, body) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
return jsonOrThrow(res)
}
export async function listThreads(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
))
}
export async function getThreadMessages(slug, branch, threadId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
))
}
export async function postThreadMessage(slug, branch, threadId, { text, quote }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote }),
},
)
return jsonOrThrow(res)
}
export async function resolveThread(slug, branch, threadId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
// Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers
// (so the caller can pull X-Assistant-Message-Id without re-streaming).
export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote, model }),
},
)
if (!res.ok) {
const detail = await res.text()
throw new Error(`Chat failed: ${detail || res.status}`)
}
const assistantId = res.headers.get('X-Assistant-Message-Id')
const userMsgId = res.headers.get('X-User-Message-Id')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop()
for (const part of parts) {
const lines = part.split('\n')
let dataLine = null
let event = null
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim()
if (line.startsWith('data: ')) dataLine = line.slice(6).trim()
}
if (dataLine === null) continue
if (event === 'changes') {
try { onChanges?.(JSON.parse(dataLine)) } catch {}
continue
}
if (dataLine === 'DONE') { onDone?.(); break }
try {
const text = new TextDecoder().decode(
Uint8Array.from(atob(dataLine), c => c.charCodeAt(0))
)
onChunk?.(text)
} catch {
// partial chunk
}
}
}
onDone?.()
return { assistantId, userMsgId }
}
+236
View File
@@ -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>
)
}
+196
View File
@@ -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>
)
}
+114
View File
@@ -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>
)
}
+191
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+769 -38
View File
@@ -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>
)
}
+23
View File
@@ -0,0 +1,23 @@
// modelStyles.js — colors and display labels for each LLM provider.
// §18 carryover from the prototype. Per §19.2's per-RFC-model topic,
// future per-RFC overrides land on top of this map without replacing it.
export const MODEL_STYLES = {
// Claude variants — shades of purple
'claude': { color: '#7c3aed', bg: '#faf5ff', label: 'Claude' },
'claude-sonnet': { color: '#7c3aed', bg: '#faf5ff', label: 'Sonnet' },
'claude-opus': { color: '#4c1d95', bg: '#f5f3ff', label: 'Opus' },
'claude-haiku': { color: '#a78bfa', bg: '#faf5ff', label: 'Haiku' },
// Gemini variants — shades of blue
'gemini': { color: '#1d4ed8', bg: '#eff6ff', label: 'Gemini' },
'gemini-pro': { color: '#1d4ed8', bg: '#eff6ff', label: 'Gemini Pro' },
'gemini-flash': { color: '#0284c7', bg: '#f0f9ff', label: 'Gemini Flash' },
'gemini-2-flash': { color: '#0ea5e9', bg: '#f0f9ff', label: 'Gemini 2 Flash' },
// OpenAI / Copilot
'openai': { color: '#059669', bg: '#ecfdf5', label: 'Copilot' },
// Fallback
'default': { color: '#6b7280', bg: '#f9fafb', label: 'AI' },
}