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