Slice 2: the §8 active-RFC view in full

Per the §19.1 brief: the three-column shape (§8.1) opens on main
in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute
flip on non-main branches, hosts §8.4's per-branch chat with AI
participation (§18's <change> protocol → §8.14 changes rows), the
§8.8 change-card panel with §8.9 accept/decline/edit-before-accept,
the §8.10 tracked-change markup + DiffView toggle, the §8.11
manual-edit flushes with the stale-change mechanic, the §8.12
range and paragraph sub-threads, the §8.13 flag affordance, and
the §8.14 discuss-mode buffer.

Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main,
commit_accepted_change with the structured original/proposed/reason
body and Change-Id + Source-Message-Id + On-behalf-of trailers,
commit_manual_flush, ensure_rfc_repo_seed). cache.py grew
refresh_rfc_repo and the webhook dispatches on repository.full_name.
providers.py and chat.py port the §18 carryovers — multi-provider
LLM abstraction and SSE-streaming chat against the §5 threads /
thread_messages / changes schema. api_branches.py mounts the §17
branches/<branch>/* and threads/<thread_id>/* routes with the §6
/ §11 permission checks inline.

Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx,
ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx,
DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the
prototype and adapted to the canonical schema.

Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new
integration tests against an extended FakeGitea (PUT contents,
POST orgs/{org}/repos, seed_rfc_repo): main-view read,
promote-to-branch, accept (with and without edit-before-accept),
decline, manual flush + system message, flag creation, visibility
flip, anonymous read-but-no-contribute, stale-change refusal, and
the chat-streaming path with a fake provider injected. The 5
Slice 1 tests continue to pass alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:35:14 -07:00
parent 779ba6db59
commit 3bc8fe92af
24 changed files with 5433 additions and 151 deletions
+132 -51
View File
@@ -2405,63 +2405,109 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build." v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: the active-RFC view in full ### 19.1 Next slice: the PR flow
Slice 1 of the build has landed. The repository scaffolding Slice 2 of the build has landed. The §8 active-RFC view is wired
(`backend/`, `frontend/`, `scripts/`, `docs/`) is in place; the §5 end-to-end against the local Gitea: the three-column shape (§8.1)
canonical app tables exist as numbered SQLite migrations with the inherits the §7 catalog on the left, hosts a Tiptap editor in the
§4 cache mirror beside them; the §1 bot wrapper is the single center with a breadcrumb dropdown listing main + open branches +
chokepoint every Git write flows through, with the §6.5 open PRs, and surfaces per-branch chat plus the change-card panel
`On-behalf-of:` trailer applied uniformly and an `actions` row on the right. Opening an active RFC lands on `main` in discuss mode
recorded; Gitea OAuth provisions a `users` row on first sign-in per §8.2; `Start Contributing` on main calls the §17
with role resolved from `OWNER_GITEA_LOGIN`; the §4.1 webhook promote-to-branch endpoint to cut a new branch via the bot and
receiver and the periodic reconciler both write to the cache and re-anchors any pending `main`-scoped `changes` rows to it (§8.14).
neither user actions nor the API do; the §7 left pane (catalog On a non-main branch the §8.3 discuss-vs-contribute toggle flips
with search, sort, state-filter chips, pending-ideas disclosure, the editor between read-only and edit-enabled. The §18 carryovers
"+ Propose New RFC" button) renders against `GET /api/rfcs` and landed in `backend/app/providers.py` and `backend/app/chat.py` and
`GET /api/proposals`; and the end-to-end propose-to-super-draft on the frontend as `Editor.jsx`, `ChatPanel.jsx`, `ChangePanel.jsx`,
vertical works: propose modal opens the idea PR, owner merges from `PromptBar.jsx`, `SelectionTooltip.jsx`, `DiffView.jsx`, and
the pending-idea view, webhook (or reconciler sweep) updates the `ModelPicker.jsx`. AI chat parses `<change>` blocks per §18 into
cache, the catalog crossfades the super-draft in, and the `changes` rows with `state='pending'` per §8.14; accept runs the
super-draft view renders the body. The vertical is covered by bot's per-accepted-change commit (§8.6) with the structured body
integration tests against an in-process Gitea simulator. and `Change-Id`, `Source-Message-Id`, and `On-behalf-of:` trailers;
decline persists the row as evidence per §8.9; edit-before-accept
preserves the AI's original text under an `AI proposed:` section
of the commit body per §8.9. Manual edits flush as one commit per
window with a system-author chat message landing per §10.6. The
§8.10 tracked-change markup is session-local in the editor; DiffView
is the read-only render of the same accepted changes. The §8.11
stale-change machinery sets `changes.stale_since` when a manual
edit changes the document such that a pending AI proposal's
`original` no longer locates; the re-ask and force-apply paths are
wired. The §8.12 range threads (via the selection tooltip) and the
§8.13 flag threads (via the selection tooltip's flag tab) materialize
as `threads` rows scoped to the branch; the chat feed renders them
inline with the whole-doc default thread. The §11.1 visibility and
§6.4 contribute grants are wired with the branch-creator /
arbiter / admin set per §11.1, §11.2, §6.3. The §4 cache grew a
`refresh_rfc_repo` path that the webhook dispatches per
`repository.full_name` and the reconciler sweeps for every active
entry. The vertical is covered by `backend/tests/test_rfc_view_vertical.py`
— eleven integration tests against an extended FakeGitea that
supports per-RFC repos.
Several §9 affordances that depend on infrastructure that has not Several §8 / §10 affordances were deferred from Slice 2 to later
yet been built were deferred from Slice 1 to Slice 2 — they are slices — they're not new candidate topics, only delivery sequencing:
not new candidate topics, only delivery sequencing:
- The §9.1 propose modal's AI-suggested tags and the §9.2 - **Super-draft body editing on meta-repo edit branches (§9.5).**
AI-drafted PR description — the AI surface lands with chat. The `branches/<branch>` machinery is structurally general enough
- The §9.3 two-step composer-then-preview decline dialog — that meta-repo edit branches fall out of it once Slice 4 wires
shipped as a single-step required-comment input in Slice 1, with the super-draft view's "Start Contributing" to cut against the
the preview-and-confirm ceremony pulled into the existing §19.2 meta repo. The Slice 2 RFCView renders a placeholder for
"pending-idea view's interaction design (remainder)" topic super-draft entries pointing at Slice 4.
alongside the merge-confirmation ceremony. - **PR-anchored review threads (§10.4).** `thread_kind='review'` is
- The §9.3 pre-merge chat thread on a pending-idea view and its in the §5 schema and the threads endpoints honor it generically,
migration to the super-draft on merge — depends on the per-RFC but the PR-page surface that anchors review threads to diff
/ per-branch chat infrastructure Slice 2 builds. hunks lands with Slice 3.
- **DiffView's full reconstruction from `changes` history.** Slice 2
's DiffView renders the editor's current HTML, which carries the
session-local tracked-change markup from accepts done in the
current session. Rebuilding the markup for accepted changes
earlier in branch history is the §19.2 "persistent
accepted-change markup" topic; the §8.10 framing already commits
the markup to session-local scope and points returning
contributors at DiffView, which is the durable artifact.
- **The §10.6 PR-side seen-cursor reconciliation.** Manual-edit
flushes drop a system-author message per §10.6 in Slice 2, but
the per-PR seen-cursor that uses the marker ships with Slice 3.
**Slice 2 is the active-RFC view per §8 in full.** The view **Slice 3 is the PR flow per §10 in full.** Open a PR via the
inherits the three-column shape (§8.1), opens on `main` in §10.1 affordance on a branch (with the §11.3 universal-public
discuss mode by default (§8.2), supports the §8.3 confirmation when the branch is private); the §10.2 AI-drafted
discuss-vs-contribute mode flip on non-main branches, hosts §8.4's creation modal pulls title and description from the diff plus the
per-branch chat with AI participation (the §18 `<change>` branch chat. The §10.3 PR review page inherits the §8.1
protocol parsing into `changes` rows per §8.6), the §8.8 three-column shape — catalog left, diff (unified or split) in the
change-card panel with §8.9's accept / decline / center, the compressed conversation plus the inline review-comment
edit-before-accept resolution, the §8.10 tracked-change markup surface (§10.4) on the right. The §10.3 per-user seen-cursor
and DiffView toggle, the §8.11 manual-edit flushes, the §8.12 mechanism accents new hunks and new conversation messages on the
range and paragraph sub-threads, the §8.13 flag affordance, and next visit. The §10.4 review comments materialize as
the §8.14 discuss-mode buffer. The carryover assets — the Tiptap `thread_kind='review'`, `anchor_kind='range'` threads anchored to
configuration, the SelectionTooltip, the `<change>` parser, the the post-PR document state, surfaced inline with the AI chat
prompt-bar selection-quote machinery, the multi-provider LLM visually distinguished. §10.5 merge writes a no-fast-forward merge
abstraction, the SSE streaming — are present in working form in commit preserving the per-accepted-change commit nodes from §8.6
the prototype at as individual reachable commits in main's history. §10.6 update-
`/Users/benstull/projects/wiggleverse/rfc-app-prototype/` and after-open re-renders the diff as new commits arrive (which they
should be lifted directly per §18. already do — Slice 2's manual-flush and accept-change paths both
push immediately). §10.7 post-merge renders the PR read-only with
a `Merged` banner and starts §12's 90-day deletion timer. §10.8
withdraw closes the PR with the same read-only treatment. §10.9
conflict-replay cuts a fresh resolution branch off main's tip,
replays the source branch's diff (running the AI participant
against unambiguous conflicts and surfacing the rest to the
contributor), and opens a new PR with the original auto-closing on
merge.
The carryover assets Slice 3 inherits: none new from the prototype
beyond what Slice 2 already lifted. The prototype's `PRModal.jsx`
was a one-shot submission flow; §10's PR creation modal is its
descendant but the spec broadened the surface considerably. The
seen-cursor advances are pure schema work — `pr_seen` and
`branch_chat_seen` are in the §5 schema; Slice 3 wires the
advance-on-view reconciler from §15.7.
The next build session should read `SPEC.md`, `README.md`, and The next build session should read `SPEC.md`, `README.md`, and
`docs/DEV.md` and pick up Slice 2 cleanly without re-briefing. `docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The
The working agreement in §19.3 carries forward: implement the working agreement in §19.3 continues to apply: implement the
slice, correct the spec only where running code reveals it was slice, correct the spec only where running code reveals it was
wrong at a structural level, accumulate new candidate topics in wrong at a structural level, accumulate new candidate topics in
§19.2, do not extend the spec beyond what the slice requires. §19.2, do not extend the spec beyond what the slice requires.
@@ -2570,6 +2616,41 @@ binding.
topic once the cost of "the cache thinks the bot proposed topic once the cost of "the cache thinks the bot proposed
everything pre-app" becomes concrete. Touches §4.1 (the everything pre-app" becomes concrete. Touches §4.1 (the
reconciler's job description) and §15.9 (the attribution rule). reconciler's job description) and §15.9 (the attribution rule).
- **Branch-name path routing.** Slice 2's `branches/<branch>`
endpoints use FastAPI's default `{branch}` path-segment matcher,
which refuses slashes. The Slice 2 auto-generated branch name
steered around this with `<login>-draft-<hex>`, but a user who
renames to a slashed name will 404 on read. The fix is to convert
every `branches/<branch>` route to `{branch:path}` with the
understanding that ordering matters (more-specific routes like
`branches/main/promote-to-branch` must register first). Surfaced
by Slice 2's testing; defer-able until a user actually wants a
slashed branch name.
- **Markdown round-trip fidelity in the editor.** Slice 2's manual-
flush converts the Tiptap document to text via `editor.getText()`,
which discards markdown structure on round-trip (lists become
flat lines, headings lose their `#`, links collapse to their text
content). A faithful HTML-to-markdown serializer — or switching
the on-disk format to a structured one the editor owns natively
— earns its own session once usage shows where the loss bites.
Touches §8.6 (commit unit) and §8.11 (the manual-edit card's
diff fidelity).
- **The chat feed's per-thread filter affordances.** §8.12 commits a
top-of-chat disclosure that lists open threads with anchor previews
and per-thread filter affordances. Slice 2 wired the disclosure
counts; the filter that collapses the feed down to a single
thread, and the per-thread "Reply" affordance that posts back into
a specific thread from the unified feed, are the natural follow-on.
Small scope, defer-able until the feed grows busy enough to
warrant.
- **Cross-branch source-message labelling.** §8.14's data-model rule
permits a `changes` row whose `source_message_id` points at a
message in main's chat — the row's `branch_name` was mutated from
`main` to the new branch on promote-to-branch, but the message
reference stays. Slice 2's frontend doesn't yet label these as
"from a conversation on main" in the change panel; a small visual
treatment is the natural follow-on. Surfaced by §8.14's data path
going through Slice 2 for the first time.
- **Body full-text search.** When the time comes. - **Body full-text search.** When the time comes.
Topic 13 (notifications) is settled and folded into §5 (the Topic 13 (notifications) is settled and folded into §5 (the
+17 -2
View File
@@ -17,10 +17,11 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from . import auth, db, entry as entry_mod, cache from . import api_branches, auth, db, entry as entry_mod, cache
from .bot import Bot from .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError from .gitea import Gitea, GiteaError
from .providers import BaseProvider
class ProposeBody(BaseModel): class ProposeBody(BaseModel):
@@ -34,8 +35,22 @@ class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000) comment: str = Field(min_length=1, max_length=4000)
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: def make_router(
config: Config,
gitea: Gitea,
bot: Bot,
providers: dict[str, BaseProvider] | None = None,
) -> APIRouter:
# Use `is None` rather than `providers or {}` — an empty dict is
# falsy, and the test harness mutates the dict the closure holds to
# inject a fake provider; substituting a fresh `{}` here would
# silently drop those mutations.
if providers is None:
providers = {}
router = APIRouter() router = APIRouter()
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
# Mounting them on the same router keeps the §17 layout flat.
router.include_router(api_branches.make_router(config, gitea, bot, providers))
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role # Auth surface — extends the prototype's pattern but reads role
File diff suppressed because it is too large Load Diff
+213
View File
@@ -220,3 +220,216 @@ class Bot:
rfc_slug=slug, rfc_slug=slug,
pr_number=pr_number, pr_number=pr_number,
) )
# ----- Per-RFC repo: branches (§8.3, §8.14) -----
async def cut_branch_from_main(
self,
actor: Actor,
*,
owner: str,
repo: str,
new_branch: str,
slug: str,
from_branch: str = "main",
) -> dict:
"""Per §8.14: 'Start Contributing' on main cuts a new branch.
Also covers the §8.3 case of a contributor wanting a fresh branch
for a piece of work. Returns the Gitea branch payload.
"""
created = await self._gitea.create_branch(owner, repo, new_branch, from_branch=from_branch)
_log(
actor,
"create_branch",
rfc_slug=slug,
branch_name=new_branch,
details={"from": from_branch, "repo": f"{owner}/{repo}"},
)
return created
# ----- Per-RFC repo: per-accepted-change commits (§8.6, §8.9) -----
async def commit_accepted_change(
self,
actor: Actor,
*,
owner: str,
repo: str,
branch: str,
file_path: str,
new_content: str,
prior_sha: str,
change_id: int,
original: str,
proposed: str,
ai_proposed: str | None,
reason: str,
source_message_id: int | None,
slug: str,
) -> str:
"""Per §8.6: one commit per accepted change.
The commit message subject is a short structural description; the
body carries `original`, `proposed`, and `reason` in named
sections. When the contributor edited the AI's proposal before
accepting (§8.9's `was_edited_before_accept`), the AI's original
wording is preserved under an `AI proposed:` section so the
timeline records both what was offered and what landed.
Trailers: `Change-Id`, `Source-Message-Id` (where applicable),
and the standard `On-behalf-of:` per §6.5.
Returns the commit SHA.
"""
subject = _subject_from_reason(reason, fallback="Accept change")
body_lines = [
"**Original:**",
original.strip(),
"",
"**Proposed:**",
proposed.strip(),
]
if ai_proposed is not None and ai_proposed.strip() != proposed.strip():
body_lines += ["", "**AI proposed (edited before accept):**", ai_proposed.strip()]
if reason and reason.strip():
body_lines += ["", "**Reason:**", reason.strip()]
body_lines += ["", f"Change-Id: {change_id}"]
if source_message_id is not None:
body_lines += [f"Source-Message-Id: {source_message_id}"]
body_lines += [_trailer(actor)]
message = subject + "\n\n" + "\n".join(body_lines).strip()
result = await self._gitea.update_file(
owner,
repo,
file_path,
content=new_content,
sha=prior_sha,
message=message,
branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
_log(
actor,
"accept_change",
rfc_slug=slug,
branch_name=branch,
bot_commit_sha=sha,
details={"change_id": change_id, "file_path": file_path},
)
return sha
# ----- Per-RFC repo: manual-edit flushes (§8.6, §8.11) -----
async def commit_manual_flush(
self,
actor: Actor,
*,
owner: str,
repo: str,
branch: str,
file_path: str,
new_content: str,
prior_sha: str,
change_id: int,
paragraph_count: int,
slug: str,
) -> str:
"""Per §8.6 / §8.11: one commit per manual-edit flush window.
Subject names the structural extent so a reviewer scanning the
log can size the change at a glance; the body carries the
change-id trailer that binds the commit to the resolved card in
the panel.
"""
plural = "" if paragraph_count == 1 else "s"
subject = f"manual edit: {paragraph_count} paragraph{plural}"
body_lines = [
f"Change-Id: {change_id}",
_trailer(actor),
]
message = subject + "\n\n" + "\n".join(body_lines)
result = await self._gitea.update_file(
owner,
repo,
file_path,
content=new_content,
sha=prior_sha,
message=message,
branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
_log(
actor,
"manual_flush",
rfc_slug=slug,
branch_name=branch,
bot_commit_sha=sha,
details={"change_id": change_id, "paragraph_count": paragraph_count},
)
return sha
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
async def ensure_rfc_repo_seed(
self,
actor: Actor,
*,
owner: str,
repo: str,
slug: str,
title: str,
body: str,
) -> None:
"""Create the per-RFC repo and seed `RFC.md` on `main` if missing.
Slice 2 surfaces against per-RFC repos that Slice 5's graduation
flow will eventually create. Until graduation exists, this is the
seam test fixtures and ad-hoc dev workflows use to bring an RFC
repo into existence the bot stays the only Git writer and the
seed itself enters the audit log.
"""
existing = await self._gitea.get_repo(owner, repo)
if existing is None:
await self._gitea.create_org_repo(owner, repo, description=f"RFC: {title}")
# If main has a tip already, leave it alone — the seed is idempotent.
main = await self._gitea.get_branch(owner, repo, "main")
if main is not None:
return
message = "Seed RFC.md\n\n" + _trailer(actor)
await self._gitea.create_file(
owner,
repo,
"RFC.md",
content=body,
message=message,
branch="main",
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
_log(
actor,
"seed_rfc_repo",
rfc_slug=slug,
branch_name="main",
details={"repo": f"{owner}/{repo}", "title": title},
)
def _subject_from_reason(reason: str, fallback: str) -> str:
"""One-line commit subject derived from the change's reason.
Truncated to 72 chars so the Git log scans cleanly. Exact length is
an implementation detail per §8.6.
"""
text = (reason or "").strip().split("\n")[0]
if not text:
return fallback
if len(text) > 72:
return text[:69].rstrip() + ""
return text
+144
View File
@@ -119,6 +119,139 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
) )
async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
"""Mirror an active RFC's per-RFC repo into the cache.
Reads `RFC.md` on main into `cached_rfcs.body` (per §4 #3), lists
branches into `cached_branches`, and lists open PRs into
`cached_prs` with `pr_kind='rfc_branch'`. Per §4.1 this runs in two
places: a webhook arrival for events on the per-RFC repo, and the
reconciler sweep.
"""
row = db.conn().execute(
"SELECT repo, state FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not row or not row["repo"] or row["state"] != "active":
return
if "/" not in row["repo"]:
log.warning("refresh_rfc_repo: %s has malformed repo %r", slug, row["repo"])
return
owner, repo = row["repo"].split("/", 1)
# Body on main — populates the discuss-mode default surface per §8.2.
try:
result = await gitea.read_file(owner, repo, "RFC.md", ref="main")
except GiteaError as e:
log.warning("refresh_rfc_repo(%s): read_file failed: %s", slug, e)
result = None
if result is not None:
text, sha = result
db.conn().execute(
"""
UPDATE cached_rfcs
SET body = ?, body_sha = ?, last_main_commit_at = datetime('now'),
updated_at = datetime('now')
WHERE slug = ?
""",
(text, sha, slug),
)
# Branches — every branch the bot knows about per §11.5 / §12.
try:
branches = await gitea.list_branches(owner, repo)
except GiteaError as e:
log.warning("refresh_rfc_repo(%s): list_branches failed: %s", slug, e)
branches = []
seen_branches: set[str] = set()
for b in branches:
name = b.get("name") or ""
if not name:
continue
seen_branches.add(name)
head_sha = (b.get("commit") or {}).get("id") or ""
last_commit_at = (b.get("commit") or {}).get("timestamp")
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
""",
(slug, name, head_sha, last_commit_at),
)
# Mark previously-known branches that disappeared as deleted, keeping
# the row per §11.5 ("branch removed from Gitea, row remains").
existing = {
r["branch_name"]
for r in db.conn().execute(
"SELECT branch_name FROM cached_branches WHERE rfc_slug = ? AND state != 'deleted'",
(slug,),
)
}
for missing in existing - seen_branches:
db.conn().execute(
"UPDATE cached_branches SET state = 'deleted' WHERE rfc_slug = ? AND branch_name = ?",
(slug, missing),
)
# PRs on the per-RFC repo (pr_kind = 'rfc_branch'). Slice 3 owns the
# full PR surface; we mirror metadata here so the §8.1 breadcrumb
# dropdown's "1 PR" count is honest from Slice 2 onward.
repo_full = f"{owner}/{repo}"
bot_login = config.gitea_bot_user
try:
open_pulls = await gitea.list_pulls(owner, repo, state="open")
closed_pulls = await gitea.list_pulls(owner, repo, state="closed")
except GiteaError as e:
log.warning("refresh_rfc_repo(%s): list_pulls failed: %s", slug, e)
open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
gitea_opener,
bot_login,
slug,
pull["number"],
pull.get("body") or "",
)
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha)
VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
state = excluded.state,
opened_by = excluded.opened_by,
merged_at = excluded.merged_at,
closed_at = excluded.closed_at,
head_sha = excluded.head_sha
""",
(
slug,
repo_full,
pull["number"],
pull.get("title") or "",
pull.get("body") or "",
state,
opened_by,
pull.get("created_at"),
pull.get("merged_at"),
pull.get("closed_at"),
head_branch,
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
),
)
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
"""Reconcile open meta-repo PRs into cached_prs. """Reconcile open meta-repo PRs into cached_prs.
@@ -296,6 +429,17 @@ class Reconciler:
try: try:
await refresh_meta_repo(self._config, self._gitea) await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea) await refresh_meta_pulls(self._config, self._gitea)
# Per-RFC repos: refresh each active entry. Meta-repo refresh
# must come first so newly-graduated entries land in
# cached_rfcs before we try to reach their per-RFC repos.
active = [
r["slug"]
for r in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'active' AND repo IS NOT NULL"
)
]
for slug in active:
await refresh_rfc_repo(self._config, self._gitea, slug)
except Exception: except Exception:
log.exception("reconciler: sweep failed") log.exception("reconciler: sweep failed")
else: else:
+321
View File
@@ -0,0 +1,321 @@
"""SSE-streaming chat layer — §18 carryover, adapted to the §5 schema.
The prototype kept conversation state in an in-memory `RFCChat`
keyed by a session_id. Here, history is the durable list of
`thread_messages` rows on a `threads` row, scoped to one branch (or
to a sub-thread anchored to a range or paragraph within it). The
streaming response is parsed for `<change>` blocks per §18; each
`<change>` becomes a `changes` row with `state='pending'` per §8.14
the moment it is parsed, regardless of mode.
This module exposes two seams:
- `build_history` and `build_system_prompt` pure functions a caller
can use to assemble the LLM request without owning a provider.
- `stream_assistant_turn` the orchestration that creates the
assistant `thread_messages` row, runs the provider's streaming
interface, parses `<change>` blocks as they accumulate, materializes
`changes` rows on completion, and yields SSE-shaped text chunks.
Per the §1 invariant, no Git writes happen here chat is app data;
turning an accepted `<change>` into a commit is a separate gesture
that goes through `bot.py`.
"""
from __future__ import annotations
import base64
import json
import logging
import re
from dataclasses import dataclass
from typing import AsyncIterator, Iterator
from . import db
from .providers import BaseProvider
log = logging.getLogger(__name__)
# The §18 system prompt, adapted from the prototype. The prototype's
# version assumed one RFC document loaded as context; here the document
# is the branch's RFC.md at its current tip. The selection-quote shape
# (§8.12) is wired by the caller into the user message text — not the
# system prompt — so the model sees it as part of the turn.
SYSTEM_PROMPT = """You are a participant in the Wiggleverse RFC framework — a standardization process for natural-language vocabulary that humans and machines need to share. You are collaborating with a human contributor on the RFC titled "{title}".
The contributor's gestures may be questions, objections, sketches of new framings, or direct edit requests. Your role is to translate them into concrete proposed edits where possible. The transcript of this conversation is the durable evidence the definition was earned, so be specific and stay close to the text.
Format each proposed change as one <change> block:
<change>
<original>exact text to replace, copied verbatim from the document</original>
<proposed>replacement text</proposed>
<reason>why this change improves the document one or two short sentences</reason>
</change>
Rules:
- The <original> text must match the document character-for-character. Do not paraphrase, do not abbreviate.
- One <change> block per distinct edit. Multiple blocks are encouraged when the contributor's input touches several passages.
- If the contributor is asking a general question or exploring an idea not yet ready to become an edit, respond in plain prose. When in doubt, lean toward proposing an edit.
- After your <change> blocks you may add a brief conversational note. Keep it short.
---
The current document:
{body}
"""
# ---------------------------------------------------------------------------
# History / prompt assembly
# ---------------------------------------------------------------------------
def build_history(thread_id: int) -> list[dict]:
"""Pull the thread's messages in chronological order, in the
{role, content} shape every provider's `send` interface expects.
System-author rows are excluded the prompt template carries the
standing instructions; system-author messages are inline narrative
that doesn't change the model's behavior.
"""
rows = db.conn().execute(
"""
SELECT role, text FROM thread_messages
WHERE thread_id = ? AND role IN ('user', 'assistant')
ORDER BY id
""",
(thread_id,),
).fetchall()
return [{"role": r["role"], "content": r["text"]} for r in rows]
def build_system_prompt(*, title: str, body: str) -> str:
return SYSTEM_PROMPT.format(title=title, body=body)
# ---------------------------------------------------------------------------
# <change> parsing
# ---------------------------------------------------------------------------
_CHANGE_RE = re.compile(
r"<change>\s*<original>([\s\S]*?)</original>\s*<proposed>([\s\S]*?)</proposed>\s*<reason>([\s\S]*?)</reason>\s*</change>",
re.MULTILINE,
)
@dataclass(frozen=True)
class ParsedChange:
original: str
proposed: str
reason: str
def parse_changes(text: str) -> list[ParsedChange]:
"""Per §18: pull every well-formed <change> block out of an assistant
message. Mid-stream partials are simply not matched yet; the parser
runs once on completion."""
return [
ParsedChange(m.group(1).strip(), m.group(2).strip(), m.group(3).strip())
for m in _CHANGE_RE.finditer(text)
]
# ---------------------------------------------------------------------------
# Persistence — turn boundaries
# ---------------------------------------------------------------------------
def append_user_message(
*,
thread_id: int,
author_user_id: int,
text: str,
quote: str | None,
) -> int:
cur = db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text, quote)
VALUES (?, 'user', ?, ?, ?)
""",
(thread_id, author_user_id, text, quote),
)
return cur.lastrowid
def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int:
cur = db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, model_id, text)
VALUES (?, 'assistant', ?, '')
""",
(thread_id, model_id),
)
return cur.lastrowid
def finalize_assistant_message(*, message_id: int, text: str) -> None:
db.conn().execute(
"UPDATE thread_messages SET text = ? WHERE id = ?",
(text, message_id),
)
def append_system_message(*, thread_id: int, text: str) -> int:
"""Used by §10.6 (manual-edit-flush markers), §9.3 (decline-comment
record), and any other system-narrated event that needs to live
inline in chat. role='system', author_user_id=NULL."""
cur = db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, text)
VALUES (?, 'system', ?)
""",
(thread_id, text),
)
return cur.lastrowid
def materialize_changes(
*,
rfc_slug: str,
branch_name: str,
thread_id: int,
source_message_id: int,
parsed: list[ParsedChange],
) -> list[int]:
"""Per §8.14: every <change> block becomes a `changes` row with
state='pending' immediately, regardless of mode. Returns the new
row ids in source order."""
ids: list[int] = []
for ch in parsed:
cur = db.conn().execute(
"""
INSERT INTO changes
(rfc_slug, branch_name, thread_id, source_message_id,
kind, state, original, proposed, reason)
VALUES (?, ?, ?, ?, 'ai', 'pending', ?, ?, ?)
""",
(rfc_slug, branch_name, thread_id, source_message_id, ch.original, ch.proposed, ch.reason),
)
ids.append(cur.lastrowid)
return ids
def mark_stale_overlapping(
*,
rfc_slug: str,
branch_name: str,
new_body: str,
) -> int:
"""Per §8.11: when a manual edit changes the document such that a
pending AI proposal's `original` no longer locates, set its
`stale_since`. The contributor's action stays gated on the stale
card; state stays `pending`.
Returns the number of rows marked stale on this call (idempotent
on re-entry already-stale rows aren't touched twice).
"""
rows = db.conn().execute(
"""
SELECT id, original FROM changes
WHERE rfc_slug = ? AND branch_name = ?
AND kind = 'ai' AND state = 'pending' AND stale_since IS NULL
""",
(rfc_slug, branch_name),
).fetchall()
marked = 0
for r in rows:
original = (r["original"] or "").strip()
if original and original not in new_body:
db.conn().execute(
"UPDATE changes SET stale_since = datetime('now') WHERE id = ?",
(r["id"],),
)
marked += 1
return marked
# ---------------------------------------------------------------------------
# SSE shape — base64 chunks for binary-safe transport
# ---------------------------------------------------------------------------
def sse_chunk(text: str) -> str:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
return f"data: {encoded}\n\n"
def sse_event(name: str, payload: dict) -> str:
return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
async def stream_assistant_turn(
*,
provider: BaseProvider,
system_prompt: str,
history: list[dict],
user_message: str,
thread_id: int,
rfc_slug: str,
branch_name: str,
assistant_message_id: int,
) -> AsyncIterator[str]:
"""Run the provider's streaming interface, yielding SSE-encoded
chunks. On completion, materializes `changes` rows from any
`<change>` blocks in the assembled text and emits a trailing
`changes` event listing the new change ids.
The user's message must already have been persisted by the caller
before this is invoked; the placeholder assistant row whose id is
`assistant_message_id` must exist too. This module's job is to
populate the assistant row's text and materialize the changes; the
caller wires it into a FastAPI StreamingResponse.
Provider streaming is synchronous (an `Iterator[str]`) per §18; we
drain it eagerly into chunks and yield them as async strings. This
is sufficient at single-process scale (§4.2) and the streaming
impl is what the prototype shipped re-wrapping it in a worker
thread for a future deployment shape is a one-liner if it
matters.
"""
full_text_chunks: list[str] = []
# Hand the provider the user turn appended to history.
history_for_call = list(history) + [{"role": "user", "content": user_message}]
def _drain() -> Iterator[str]:
try:
yield from provider.send_streaming(system_prompt, history_for_call)
except Exception as e:
log.exception("provider stream failed")
yield f"\n\n[Provider error: {e}]"
for chunk in _drain():
if not chunk:
continue
full_text_chunks.append(chunk)
yield sse_chunk(chunk)
full_text = "".join(full_text_chunks)
finalize_assistant_message(message_id=assistant_message_id, text=full_text)
parsed = parse_changes(full_text)
new_ids = materialize_changes(
rfc_slug=rfc_slug,
branch_name=branch_name,
thread_id=thread_id,
source_message_id=assistant_message_id,
parsed=parsed,
)
yield sse_event(
"changes",
{
"message_id": assistant_message_id,
"change_ids": new_ids,
"count": len(new_ids),
},
)
yield "data: DONE\n\n"
+13 -2
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, webhooks from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks
from .bot import Bot from .bot import Bot
from .config import load_config from .config import load_config
from .gitea import Gitea from .gitea import Gitea
@@ -32,13 +32,24 @@ async def lifespan(app: FastAPI):
bot = Bot(gitea) bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea) reconciler = cache.Reconciler(config, gitea)
# §18 carryover: the multi-provider LLM abstraction. Provider
# construction can fail (missing key, wrong env value) — if it does,
# the rest of the app still serves; chat endpoints surface a clear
# 503 instead of crashing the process.
try:
providers = providers_mod.load_from_config(config)
except Exception:
log.exception("provider construction failed; chat will be disabled")
providers = {}
app.state.config = config app.state.config = config
app.state.gitea = gitea app.state.gitea = gitea
app.state.bot = bot app.state.bot = bot
app.state.reconciler = reconciler app.state.reconciler = reconciler
app.state.providers = providers
app.include_router(_oauth_router(config)) app.include_router(_oauth_router(config))
app.include_router(api_routes.make_router(config, gitea, bot)) app.include_router(api_routes.make_router(config, gitea, bot, providers))
app.include_router(webhooks.make_router(config, gitea)) app.include_router(webhooks.make_router(config, gitea))
reconciler.start() reconciler.start()
+195
View File
@@ -0,0 +1,195 @@
"""Multi-provider LLM abstraction — §18 carryover from the prototype.
Each provider speaks a common interface `send` and `send_streaming`
so the chat layer in `chat.py` is provider-agnostic. Enabled providers
and their API keys are configured via env per the prototype's
`ENABLED_MODELS` contract; per §16 / §19.2, per-RFC model availability
and credential delegation are deferred until the topic is settled.
"""
from __future__ import annotations
from typing import Iterator
class BaseProvider:
name: str = "base"
display_name: str = "Base"
def send(self, system: str, history: list[dict]) -> str:
raise NotImplementedError
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
raise NotImplementedError
# ---------------------------------------------------------------------------
# Anthropic — Claude
# ---------------------------------------------------------------------------
class AnthropicProvider(BaseProvider):
name = "claude"
def __init__(self, api_key: str, model: str = "claude-sonnet-4-6", display_name: str = "Claude"):
import anthropic
self.client = anthropic.Anthropic(api_key=api_key)
self.model = model
self.display_name = display_name
def send(self, system: str, history: list[dict]) -> str:
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=system,
messages=history,
)
return response.content[0].text
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
with self.client.messages.stream(
model=self.model,
max_tokens=4096,
system=system,
messages=history,
) as stream:
for text in stream.text_stream:
yield text
# ---------------------------------------------------------------------------
# Google — Gemini
# ---------------------------------------------------------------------------
class GeminiProvider(BaseProvider):
name = "gemini"
def __init__(self, api_key: str, model: str = "gemini-1.5-pro", display_name: str = "Gemini"):
import google.generativeai as genai
genai.configure(api_key=api_key)
self._genai = genai
self.model_name = model
self.display_name = display_name
def _build_model(self, system: str):
return self._genai.GenerativeModel(model_name=self.model_name, system_instruction=system)
def _convert_history(self, history: list[dict]) -> list[dict]:
return [
{"role": "user" if msg["role"] == "user" else "model", "parts": [msg["content"]]}
for msg in history
]
def send(self, system: str, history: list[dict]) -> str:
model = self._build_model(system)
prior = self._convert_history(history[:-1])
chat = model.start_chat(history=prior)
response = chat.send_message(history[-1]["content"])
return response.text
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
model = self._build_model(system)
prior = self._convert_history(history[:-1])
chat = model.start_chat(history=prior)
response = chat.send_message(history[-1]["content"], stream=True)
for chunk in response:
if chunk.text:
yield chunk.text
# ---------------------------------------------------------------------------
# OpenAI-compatible — OpenAI, Copilot, or any compatible endpoint
# ---------------------------------------------------------------------------
class OpenAIProvider(BaseProvider):
name = "openai"
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: str | None = None, display_name: str = "Copilot"):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url or "https://api.openai.com/v1")
self.model = model
self.display_name = display_name
def _messages(self, system: str, history: list[dict]) -> list[dict]:
return [{"role": "system", "content": system}] + [
{"role": msg["role"], "content": msg["content"]} for msg in history
]
def send(self, system: str, history: list[dict]) -> str:
response = self.client.chat.completions.create(
model=self.model, max_tokens=4096, messages=self._messages(system, history)
)
return response.choices[0].message.content
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
stream = self.client.chat.completions.create(
model=self.model, max_tokens=4096, messages=self._messages(system, history), stream=True
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield delta
# ---------------------------------------------------------------------------
# Variants and factory — preserved from the prototype to keep the contract.
# ---------------------------------------------------------------------------
_CLAUDE_VARIANTS: dict[str, tuple[str, str]] = {
"claude": ("claude-sonnet-4-6", "Claude"),
"claude-sonnet": ("claude-sonnet-4-6", "Claude Sonnet"),
"claude-opus": ("claude-opus-4-6", "Claude Opus"),
"claude-haiku": ("claude-haiku-4-5-20251001", "Claude Haiku"),
}
_GEMINI_VARIANTS: dict[str, tuple[str, str]] = {
"gemini": ("gemini-1.5-pro", "Gemini"),
"gemini-pro": ("gemini-1.5-pro", "Gemini Pro"),
"gemini-flash": ("gemini-1.5-flash", "Gemini Flash"),
"gemini-2-flash": ("gemini-2.0-flash", "Gemini 2 Flash"),
}
def load_providers(env: dict) -> dict[str, BaseProvider]:
"""Instantiate enabled providers from env — same contract as the prototype."""
enabled = [m.strip() for m in env.get("ENABLED_MODELS", "claude").split(",") if m.strip()]
providers: dict[str, BaseProvider] = {}
anthropic_key = env.get("ANTHROPIC_API_KEY") or ""
google_key = env.get("GOOGLE_API_KEY") or ""
openai_key = env.get("OPENAI_API_KEY") or ""
for key in enabled:
prefix = key.upper().replace("-", "_")
if key in _CLAUDE_VARIANTS and anthropic_key:
default_model, default_name = _CLAUDE_VARIANTS[key]
providers[key] = AnthropicProvider(
api_key=anthropic_key,
model=env.get(f"{prefix}_MODEL", default_model),
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
)
elif key in _GEMINI_VARIANTS and google_key:
default_model, default_name = _GEMINI_VARIANTS[key]
providers[key] = GeminiProvider(
api_key=google_key,
model=env.get(f"{prefix}_MODEL", default_model),
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
)
elif key == "openai" and openai_key:
providers["openai"] = OpenAIProvider(
api_key=openai_key,
model=env.get("OPENAI_MODEL", "gpt-4o"),
base_url=env.get("OPENAI_BASE_URL"),
display_name=env.get("OPENAI_DISPLAY_NAME", "Copilot"),
)
return providers
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
"ENABLED_MODELS": ",".join(config.enabled_models),
"ANTHROPIC_API_KEY": config.anthropic_api_key,
"GOOGLE_API_KEY": config.google_api_key,
"OPENAI_API_KEY": config.openai_api_key,
}
return load_providers(env)
+25 -6
View File
@@ -10,11 +10,12 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import json
import logging import logging
from fastapi import APIRouter, Header, HTTPException, Request from fastapi import APIRouter, Header, HTTPException, Request
from . import cache from . import cache, db
from .config import Config from .config import Config
from .gitea import Gitea from .gitea import Gitea
@@ -47,14 +48,25 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
if event not in EVENTS_OF_INTEREST: if event not in EVENTS_OF_INTEREST:
return {"ok": True, "ignored": event} return {"ok": True, "ignored": event}
# Slice 1 only acts on meta-repo events; per-RFC-repo events # Identify the originating repo. For the meta repo we refresh
# land in their respective slices. The handler is generous in # the entry cache + meta-PR cache; for a per-RFC repo we refresh
# what it accepts — any meta-repo change is a cue to refresh # just that repo's branches/PRs/main body. The handler stays
# the whole meta-repo cache, since the cache is small and the # generous in what it accepts — refreshes are idempotent and
# refresh is idempotent. # small enough that overlapping events do not pile up.
try: try:
payload = json.loads(body) if body else {}
except Exception:
payload = {}
repo_full = (payload.get("repository") or {}).get("full_name") or ""
meta_full = f"{config.gitea_org}/{config.meta_repo}"
try:
if repo_full == meta_full or not repo_full:
await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_pulls(config, gitea) await cache.refresh_meta_pulls(config, gitea)
else:
slug = _slug_for_repo(repo_full)
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
except Exception: except Exception:
log.exception("webhook refresh failed") log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed") raise HTTPException(status_code=500, detail="Refresh failed")
@@ -69,3 +81,10 @@ def _verify_signature(body: bytes, header: str, secret: str) -> bool:
return False return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header) return hmac.compare_digest(expected, header)
def _slug_for_repo(repo_full: str) -> str | None:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,)
).fetchone()
return row["slug"] if row else None
+54 -6
View File
@@ -34,22 +34,38 @@ import pytest
class FakeGitea: class FakeGitea:
"""A narrow in-memory simulation of the Gitea API the slice uses.""" """A narrow in-memory simulation of the Gitea API the slices exercise.
Slice 2 extends the seam to cover per-RFC repos: PUT contents
(update file), POST orgs/{org}/repos (create repo), and branch
listing with commit timestamps. The simulator is intentionally
minimal only the routes the production paths actually call.
"""
def __init__(self): def __init__(self):
# files: (owner, repo, branch, path) -> {"content": str, "sha": str} # files: (owner, repo, branch, path) -> {"content": str, "sha": str}
self.files: dict[tuple[str, str, str, str], dict] = {} self.files: dict[tuple[str, str, str, str], dict] = {}
# branches: (owner, repo) -> {branch_name -> {"sha": str}} # branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str}}
self.branches: dict[tuple[str, str], dict[str, dict]] = {} self.branches: dict[tuple[str, str], dict[str, dict]] = {}
# pulls: (owner, repo) -> list[pull-dict] # pulls: (owner, repo) -> list[pull-dict]
self.pulls: dict[tuple[str, str], list[dict]] = {} self.pulls: dict[tuple[str, str], list[dict]] = {}
# repos: set of (owner, repo)
self.repos: set[tuple[str, str]] = set()
self._pr_counter = 0 self._pr_counter = 0
self._commit_counter = 0 self._commit_counter = 0
self._seed_repo("wiggleverse", "meta") self._seed_repo("wiggleverse", "meta")
def _seed_repo(self, owner, repo): def _seed_repo(self, owner, repo):
self.branches[(owner, repo)] = {"main": {"sha": "initial"}} self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
self.pulls[(owner, repo)] = [] self.pulls[(owner, repo)] = []
self.repos.add((owner, repo))
def seed_rfc_repo(self, owner, repo, *, rfc_md_body):
"""Convenience: seed a per-RFC repo with an RFC.md on main."""
self._seed_repo(owner, repo)
sha = self._next_sha()
self.files[(owner, repo, "main", "RFC.md")] = {"content": rfc_md_body, "sha": sha}
self.branches[(owner, repo)]["main"] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
def _next_sha(self): def _next_sha(self):
self._commit_counter += 1 self._commit_counter += 1
@@ -62,8 +78,29 @@ class FakeGitea:
payload = json.loads(body) if body else {} payload = json.loads(body) if body else {}
# GET /repos/{owner}/{repo} # GET /repos/{owner}/{repo}
if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path): m_repo = re.fullmatch(r"/repos/([^/]+)/([^/]+)", path)
return httpx.Response(200, json={"name": path.split("/")[-1]}) if method == "GET" and m_repo:
owner, repo = m_repo.groups()
if (owner, repo) in self.repos:
return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"})
return httpx.Response(404, json={"message": "not found"})
# POST /orgs/{org}/repos
m = re.fullmatch(r"/orgs/([^/]+)/repos", path)
if method == "POST" and m:
org = m.group(1)
name = payload["name"]
self._seed_repo(org, name)
return httpx.Response(201, json={"name": name, "full_name": f"{org}/{name}"})
# GET /repos/{owner}/{repo}/branches (list)
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path)
if method == "GET" and m:
owner, repo = m.groups()
items = []
for name, b in self.branches.get((owner, repo), {}).items():
items.append({"name": name, "commit": {"id": b["sha"], "timestamp": b.get("ts")}})
return httpx.Response(200, json=items)
# GET /repos/{owner}/{repo}/branches/{branch} # GET /repos/{owner}/{repo}/branches/{branch}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path) m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path)
@@ -126,9 +163,20 @@ class FakeGitea:
content = base64.b64decode(payload["content"]).decode() content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha() sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha} self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch]["sha"] = sha self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
return httpx.Response(201, json={"commit": {"sha": sha}}) return httpx.Response(201, json={"commit": {"sha": sha}})
# PUT /repos/{owner}/{repo}/contents/{path} — update_file
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "PUT" and m:
owner, repo, fpath = m.groups()
branch = payload["branch"]
content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})
# GET /repos/{owner}/{repo}/pulls?state=... # GET /repos/{owner}/{repo}/pulls?state=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path) m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path)
if method == "GET" and m: if method == "GET" and m:
+564
View File
@@ -0,0 +1,564 @@
"""End-to-end integration tests for the Slice 2 vertical (§8 in full).
Reuses FakeGitea + the session-cookie forging helpers from
`test_propose_vertical.py`, extends FakeGitea with the per-RFC repo
routes Slice 2 needs (PUT contents, POST orgs/{org}/repos, seeded
RFC.md), and walks the §8 vertical end-to-end against an in-process
fake Gitea:
* Seed an active RFC with a per-RFC repo holding RFC.md.
* GET /api/rfcs/<slug>/main and /branches/<branch> three-column
feed against the cache + live branch read.
* POST promote-to-branch cut a new branch from main.
* Materialize an AI-style change directly in the database (the LLM
is mocked out where possible; one separate test exercises the
chat streaming path with a fake provider injected).
* POST accept runs the bot's commit and updates `changes` row.
* POST decline non-commit path; row persists as evidence.
* POST manual-flush bot commit, system message lands in branch chat.
* POST threads create a flag, surface it on subsequent reads.
* POST visibility flip read_public and contribute_mode.
* POST chat fake provider returns a known <change> block; the
response materializes a `changes` row.
"""
from __future__ import annotations
import json
import pytest
# Reuse the harness already proven by Slice 1. We import via the
# top-level module name (no leading dot) because pytest discovers
# `tests/` as a flat directory of test modules without an __init__.py.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def seed_active_rfc(fake: FakeGitea, *, slug: str, title: str, body: str) -> str:
"""Seed an active RFC end-to-end: create the meta-repo entry, the
per-RFC repo with RFC.md on main, and the cached_rfcs row. The
real graduation flow lands in Slice 5; until it exists, this is
the test seam for "the RFC view's preconditions are met."
"""
from app import db
import yaml
repo_full = f"wiggleverse/rfc-0001-{slug}"
owner, repo = repo_full.split("/", 1)
fake.seed_rfc_repo(owner, repo, rfc_md_body=body)
# Meta-repo entry — what the cache would mirror after graduation.
fm = {
"slug": slug,
"title": title,
"state": "active",
"id": "RFC-0001",
"repo": repo_full,
"proposed_by": "alice",
"proposed_at": "2026-05-01",
"graduated_at": "2026-05-22",
"graduated_by": "ben",
"owners": ["alice"],
"arbiters": ["ben"],
"tags": ["identity"],
}
entry_text = "---\n" + yaml.safe_dump(fm, sort_keys=False).rstrip() + "\n---\n"
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": entry_text, "sha": sha}
# Write cached_rfcs row directly — the reconciler would also write
# this on its next sweep, but the test seam avoids the extra hop.
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at)
VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""",
(
slug,
title,
"RFC-0001",
repo_full,
"alice",
"2026-05-01",
"2026-05-22",
"ben",
json.dumps(["alice"]),
json.dumps(["ben"]),
json.dumps(["identity"]),
body,
sha,
),
)
# Seed cached_branches for main, since the reconciler hasn't necessarily
# run yet inside the test client's lifespan. The webhook+reconciler
# path is what writes this in production; we shortcut it here.
db.conn().execute(
"""
INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', datetime('now'))
""",
(slug, sha),
)
return repo_full
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
SEED_BODY = """# Open Human Model
Open Human Model is a framework for representing humans.
It defines consent, trait, and agency in compatible terms.
"""
def test_rfc_main_view_renders_against_per_rfc_repo(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.get("/api/rfcs/open-human-model/main")
assert r.status_code == 200, r.text
d = r.json()
assert d["slug"] == "open-human-model"
assert "Open Human Model" in d["body"]
# main is in the branches list (cached).
assert any(b["name"] == "main" for b in d["branches"])
def test_promote_to_branch_creates_branch_and_navigates(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
assert r.status_code == 200, r.text
branch_name = r.json()["branch_name"]
assert branch_name.startswith("alice-draft-")
# The branch is reachable as its own view.
r = client.get(f"/api/rfcs/ohm/branches/{branch_name}")
assert r.status_code == 200, r.text
view = r.json()
assert view["branch_name"] == branch_name
# The branch starts from main's body — the editor opens on it.
assert "Open Human Model" in view["body"]
# The whole-doc chat thread exists by default.
assert view["main_thread_id"]
# The bot's create_branch action is in the audit log per §6.5.
actions = db.conn().execute(
"SELECT action_kind, on_behalf_of FROM actions WHERE action_kind = 'create_branch'"
).fetchall()
assert any((a["action_kind"], a["on_behalf_of"]) == ("create_branch", "alice") for a in actions)
def test_accept_ai_change_commits_and_updates_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Cut a branch the contributor owns.
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
# The whole-doc chat thread is created lazily on first branch
# view (§8.12) — GET the branch so it materializes.
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
""",
(
branch,
thread_id,
"Open Human Model is a framework for representing humans.",
"Open Human Model is a framework for representing humans in software systems.",
"tightens scope",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={
"proposed": "Open Human Model is a framework for representing humans in software systems.",
"was_edited_before_accept": False,
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["commit_sha"]
# The change row is now accepted with the commit sha bound.
row = db.conn().execute(
"SELECT state, commit_sha, acted_by, was_edited_before_accept FROM changes WHERE id = ?",
(change_id,),
).fetchone()
assert row["state"] == "accepted"
assert row["commit_sha"] == body["commit_sha"]
assert row["acted_by"] == 2
assert not row["was_edited_before_accept"]
# The branch's RFC.md on Gitea now reflects the change.
owner, repo = "wiggleverse", "rfc-0001-ohm"
new_body = fake.files[(owner, repo, branch, "RFC.md")]["content"]
assert "in software systems" in new_body
def test_accept_with_edit_before_accept_records_flag_and_ai_original(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
""",
(branch, thread_id,
"It defines consent, trait, and agency in compatible terms.",
"It defines consent, trait, harm, and agency in compatible terms.",
"adds harm"),
)
change_id = cur.lastrowid
edited = "It defines consent, trait, harm, and agency together."
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={"proposed": edited, "was_edited_before_accept": True},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT proposed, was_edited_before_accept FROM changes WHERE id = ?",
(change_id,),
).fetchone()
assert row["was_edited_before_accept"] == 1
assert row["proposed"] == edited
# The commit body carries both the AI's original proposed
# text and the contributor's revision per §8.9.
body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
assert "harm" in body
# The contributor's edited text won, not the AI's.
assert "together." in body
def test_decline_change_persists_as_evidence_no_commit(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', 'x', 'y', 'why')
""",
(branch, thread_id),
)
change_id = cur.lastrowid
prior_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
r = client.post(f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/decline")
assert r.status_code == 200, r.text
# No commit, no body change.
post_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"]
assert prior_sha == post_sha
# The card stays as evidence.
row = db.conn().execute(
"SELECT state FROM changes WHERE id = ?", (change_id,)
).fetchone()
assert row["state"] == "declined"
def test_manual_flush_commits_and_drops_system_message(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
new_body = SEED_BODY + "\n\nA new paragraph.\n"
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/manual-flush",
json={"new_content": new_body, "paragraph_count": 1},
)
assert r.status_code == 200, r.text
assert r.json()["commit_sha"]
# The branch RFC.md was updated.
body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"]
assert "A new paragraph" in body
# Per §10.6: a system-author message landed in the branch chat.
rows = db.conn().execute(
"""
SELECT m.role, m.text FROM thread_messages m
JOIN threads t ON t.id = m.thread_id
WHERE t.rfc_slug = 'ohm' AND t.branch_name = ?
""",
(branch,),
).fetchall()
assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows)
def test_create_flag_thread_surfaces_on_branch_view(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/threads",
json={
"thread_kind": "flag",
"anchor_kind": "range",
"anchor_payload": {"quote": "consent"},
"label": "needs an example",
},
)
assert r.status_code == 200, r.text
thread_id = r.json()["thread_id"]
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
threads = r.json()["threads"]
assert any(t["id"] == thread_id and t["thread_kind"] == "flag" for t in threads)
def test_visibility_flip_locks_out_non_grantees(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
# Flip the branch private.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/visibility",
json={"read_public": False},
)
assert r.status_code == 200, r.text
# Bob (a different contributor) is now blocked from reading it.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
assert r.status_code == 403
# Alice (the creator) still can.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get(f"/api/rfcs/ohm/branches/{branch}")
assert r.status_code == 200
def test_anonymous_can_read_main_but_not_contribute(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# No sign-in.
r = client.get("/api/rfcs/ohm/main")
assert r.status_code == 200
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
assert r.status_code == 401
def test_stale_change_refuses_silent_apply(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
# Stale by construction: original text not in the document.
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?)
""",
(branch, thread_id, "Text that does not appear", "Replacement.", "test"),
)
change_id = cur.lastrowid
# Refused without force.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={"proposed": "Replacement.", "was_edited_before_accept": False},
)
assert r.status_code == 409
# The row is marked stale per §8.11.
row = db.conn().execute(
"SELECT state, stale_since FROM changes WHERE id = ?", (change_id,)
).fetchone()
assert row["state"] == "pending"
assert row["stale_since"]
# Force-apply succeeds and appends.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
json={"proposed": "Replacement.", "was_edited_before_accept": False, "force_apply_stale": True},
)
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# Chat streaming with a fake provider
# ---------------------------------------------------------------------------
class FakeProvider:
name = "claude"
display_name = "Claude"
def __init__(self, fixed_response: str):
self._response = fixed_response
def send(self, system, history):
return self._response
def send_streaming(self, system, history):
# Single-chunk stream — sufficient for the orchestration test.
yield self._response
def test_chat_turn_materializes_change_from_change_block(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
fake_response = (
"Here is a tightening:\n\n"
"<change>\n"
"<original>Open Human Model is a framework for representing humans.</original>\n"
"<proposed>Open Human Model is a framework for representing humans across software systems.</proposed>\n"
"<reason>scopes the framework</reason>\n"
"</change>\n\n"
"Let me know if that fits."
)
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
# Inject the fake provider — the app's `providers` dict is built
# at startup; we replace it for the test so the chat endpoint
# resolves a deterministic response.
app.state.providers["claude"] = FakeProvider(fake_response)
# The router resolved `providers` at construction time; rebuild
# the slice 2 router with the fake provider in place.
from app import api as api_routes
# Find and replace the existing branches router. Simpler: monkey
# patch the providers dict referenced by the router closure.
# The closure receives the dict by reference, so mutating it
# propagates.
# (Above mutation already does that — nothing more to do.)
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
json={"text": "Can you tighten the opening?", "model": "claude"},
)
assert r.status_code == 200, r.text
# Drain the stream so the orchestrator finishes its work.
body = r.content.decode()
assert "DONE" in body
# A change row materialized from the <change> block.
rows = db.conn().execute(
"SELECT kind, state, original, proposed, reason FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ?",
(branch,),
).fetchall()
ai_rows = [r for r in rows if r["kind"] == "ai"]
assert len(ai_rows) == 1
assert ai_rows[0]["state"] == "pending"
assert "humans across software systems" in ai_rows[0]["proposed"]
assert "scopes the framework" in ai_rows[0]["reason"]
# The assistant message persisted with the full text.
msgs = db.conn().execute(
"SELECT role, text FROM thread_messages WHERE thread_id = ? ORDER BY id",
(thread_id,),
).fetchall()
assert msgs[-1]["role"] == "assistant"
assert "<change>" in msgs[-1]["text"]
+123 -43
View File
@@ -49,6 +49,34 @@ chips, pending-ideas disclosure), and one end-to-end vertical: propose
→ idea PR opens → owner merges → super-draft appears in the catalog → → idea PR opens → owner merges → super-draft appears in the catalog →
super-draft view renders the body. super-draft view renders the body.
### Slice 2 — shipped
The §8 active-RFC view in full. The bot wrapper grew per-RFC-repo
write operations — branch cut from main, accept-change commit with
the structured `original`/`proposed`/`reason` body and trailers,
manual-edit flush, and a `ensure_rfc_repo_seed` seam Slice 5's
graduation will eventually replace. The §4 cache now mirrors per-RFC
repos via a new `refresh_rfc_repo` path; the webhook receiver
dispatches on `repository.full_name` so per-RFC events refresh just
that repo, and the reconciler sweeps every active entry. The §18
carryovers landed as `backend/app/providers.py` (the multi-provider
abstraction, unchanged from the prototype) and `backend/app/chat.py`
(an adapter that runs the provider's streaming interface against
`thread_messages` rows, parses `<change>` blocks, and materializes
`changes` rows per §8.14). The §17 endpoints owned by Slice 2 — the
`branches/<branch>/*` and `threads/<thread_id>/*` families — live in
`backend/app/api_branches.py`, mounted alongside Slice 1's routes via
`api.make_router`. On the frontend, `RFCView.jsx` was rebuilt as the
§8 three-column surface; `Editor.jsx`, `ChatPanel.jsx`,
`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`,
`DiffView.jsx`, `ModelPicker.jsx`, and `modelStyles.js` were lifted
from the prototype and adapted to the canonical `threads` /
`thread_messages` / `changes` shape rather than the prototype's
global session_id. The §18 carryovers explicitly preserved: SSE
streaming with base64-encoded chunks, Tiptap + ProseMirror plugin for
the paragraph-margin gutter accent, the prompt-bar selection-quote
machinery, the model picker.
The §17 endpoints exercised so far: The §17 endpoints exercised so far:
| Method | Path | § | | Method | Path | § |
@@ -64,29 +92,70 @@ The §17 endpoints exercised so far:
| POST | `/api/proposals/{pr_number}/withdraw` | §9.3 | | POST | `/api/proposals/{pr_number}/withdraw` | §9.3 |
| POST | `/api/webhooks/gitea` | §4.1 | | POST | `/api/webhooks/gitea` | §4.1 |
| GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 | | GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 |
| GET | `/api/models` | §18 |
| GET | `/api/rfcs/{slug}/main` | §8.1, §8.2, §17 |
| GET | `/api/rfcs/{slug}/branches/{branch}` | §8.4, §17 |
| POST | `/api/rfcs/{slug}/branches/main/promote-to-branch` | §8.14, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/accept` | §8.9, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/decline` | §8.9, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/reask` | §8.11, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/manual-flush` | §8.11, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/visibility` | §11.1, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/grants` | §6.4, §17 |
| DELETE | `/api/rfcs/{slug}/branches/{branch}/grants/{login}` | §6.4 |
| GET | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §8.13 |
| GET | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/resolve` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/chat` | §18 |
### What's deferred from slice 1 Slice 2 ships covered by `backend/tests/test_rfc_view_vertical.py`
the FakeGitea simulator from Slice 1 grew per-RFC-repo support (PUT
contents, POST `orgs/{org}/repos`, `seed_rfc_repo`), and a new test
file walks the §8 vertical end-to-end: 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.
These were on the §9.1 spec but pushed to Slice 2 because they belong ### What's deferred from Slice 2
with surfaces that haven't been built yet:
- The propose modal's **AI-suggested tags** (§9.1) — the AI surface These were in the §8 spec but lean on infrastructure later slices
lands with Slice 2's chat wiring. The tag chip input works manually build, so they were scoped out of this slice without altering the
in the meantime. spec:
- The propose modal's **AI-drafted PR description** (§9.2) — same
reason. The PR description is the pitch text for now.
- The decline ceremony's **two-step composer-then-preview dialog**
(§9.3) — the single-step required-comment input is in place; the
preview-and-confirm beat is the kind of UX polish that the §19.2
topic "pending-idea view's interaction design (remainder)" should
pick up alongside the merge-confirmation ceremony.
- The §9.3 **pre-merge chat thread on a pending-idea view** and the
migration of those threads to the super-draft on merge — depends
on Slice 2's chat infrastructure.
These are deferred in the build's working sense — surfaces exist in - **Super-draft body editing on the meta repo (§9.5).** The
the spec, but they share infrastructure that's wired in a later slice `branches/<branch>` machinery is structurally general enough that
and would otherwise have to be wired twice. meta-repo edit branches fall out of it once Slice 4 wires the
super-draft view's "Start Contributing" gesture to cut against the
meta repo. The Slice 2 RFCView renders a placeholder for
super-draft entries pointing at Slice 4.
- **The §10.4 review threads on PRs.** `thread_kind='review'` is in
the schema and the threads endpoints honor it generically, but the
PR-page surface where review threads anchor to diff hunks lands
with Slice 3.
- **DiffView's full reconstruction from `changes` history.** Slice 2
renders the editor's current HTML (which carries the
session-local tracked-change markup from the accepts that happened
in this session) into DiffView; rebuilding the full accepted-change
markup from `changes` for a returning contributor needs a render
pipeline DiffView doesn't yet own. The current behavior matches
§8.10's "session-local" framing exactly; the §19.2 "persistent
accepted-change markup" topic is the durable extension when
evidence demands it.
- **The §10.6 PR-side commit / chat reconciliation.** Manual-edit
flushes drop a system-author message into branch chat per §10.6
in Slice 2, but the PR-side seen-cursor that uses the marker
ships with Slice 3.
- **Branch-name path conversion for slashes.** The auto-generated
branch name in Slice 2 is `<login>-draft-<hex>` (no slash) so the
FastAPI `{branch}` path segment matches without `{branch:path}`.
Users can still rename to a slashed name, but the routes will
404 on read; the proper fix is `{branch:path}` everywhere, which
lands cleanly when Slice 3 makes the same change to the PR routes
(PR numbers don't have this problem, but resolving the routing
shape once across both surfaces is the right hop).
## Environment notes ## Environment notes
@@ -123,31 +192,42 @@ and would otherwise have to be wired twice.
## Next slice ## Next slice
**Slice 2: the active-RFC view per §8.** **Slice 3: the PR flow per §10.**
The active-RFC view inherits the three-column shape (§8.1), opens §8 settled the within-branch surface; §10 settles the bridge between
on `main` in discuss mode by default (§8.2), supports the §8.3 a branch and main. The work covers the `Open PR` affordance from
discuss-vs-contribute mode flip on non-main branches, hosts §8.4's §10.1 (with the §11.3 universal-public confirmation when the branch
per-branch chat with AI participation (§18's `<change>` protocol is private), the §10.2 AI-drafted creation modal (title +
parsing into `changes` rows per §8.6), the §8.8 change-card panel description from the diff plus the branch chat), the §10.3 review
with §8.9's accept / decline / edit-before-accept resolution, the page (three-column, diff in the center, compressed conversation
§8.10 tracked-change markup and DiffView toggle, the §8.11 manual- right, per-user seen-cursor accenting new hunks and new messages),
edit flushes, the §8.12 range and paragraph sub-threads, the §8.13 the §10.4 `thread_kind='review'` threads anchored to diff hunks
flag affordance, and the §8.14 discuss-mode buffer. inline in branch chat, §10.5 merge (no-fast-forward, preserving the
per-acceptance commits), §10.6 update-after-open (commits and chat
arriving on the open PR, the manual-flush system message that
already lands per Slice 2), §10.7 post-merge (`Merged` banner,
chat read-only, 90-day deletion timer starts), §10.8 withdraw, and
§10.9 conflict-replay with the resolution-branch path. The shared
seen-cursor mechanism in §15.7 (the `pr_seen` and
`branch_chat_seen` cursors are in the schema already; Slice 3 wires
the advance-on-view reconciler).
The carryover assets that belong to Slice 2 are in the prototype The carryovers Slice 3 inherits — none new from the prototype; the
under `/Users/benstull/projects/wiggleverse/rfc-app-prototype/`: prototype's `PRModal.jsx` had a one-shot PR-creation flow that the
spec's §10 expanded considerably. The `backend/app/bot.py` operations
Slice 3 needs are: `open_pr`, `merge_pr` (style='merge' to preserve
the per-accepted-change commit nodes per §10.5), `close_pr` (for
withdraw), and the resolution-branch replay sequence from §10.9 —
which is structurally a `cut_branch_from_main` plus a series of
`commit_accepted_change` calls plus an `open_pr`.
- `frontend/src/components/Editor.jsx`, `ChatPanel.jsx`, The frontend needs a `PRView.jsx` sibling to `RFCView.jsx` that
`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`, inherits the §8.1 three-column shape but renders the diff instead
`DiffView.jsx`, `ModelPicker.jsx` — Tiptap config, the of the editor. The route is `/rfc/<slug>/prs/<n>`.
`<change>` parser, the selection-quote machinery, the
model-picker UX.
- `backend/providers.py`, `backend/chat.py` — the multi-provider
abstraction and the SSE-streaming chat layer.
These are §18 carryovers; reuse the working code rather than The next build session should read `SPEC.md`, `README.md`, and
rewriting. The prototype's *data model* and *permission shape* do `docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The
not carry; this codebase's `threads`, `thread_messages`, `changes`, working agreement in §19.3 continues to apply: implement the slice,
`changes.thread_id`, the §6 four-role model, and the per-branch correct the spec only where running code reveals it was wrong at a
chat thread are the canonical shape for Slice 2 to wire against. structural level, accumulate new candidate topics in §19.2, do not
extend the spec beyond what the slice requires.
+569
View File
@@ -317,3 +317,572 @@
color: #666; text-decoration: none; color: #666; text-decoration: none;
} }
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } .landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
/* Override the §9.x padded read-view; the §8 surface manages its own
internal layout and needs to fill the pane edge-to-edge. */
padding: 0;
overflow: hidden;
display: flex;
}
.rfc-view {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.rfc-breadcrumb {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid #e5e5e5;
background: #fafafa;
font-size: 13px; color: #555;
flex-shrink: 0;
}
.breadcrumb-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: #888;
}
.breadcrumb-sep { color: #ccc; }
.breadcrumb-meta { color: #999; font-size: 12px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.btn-mode-toggle {
font-size: 12px; font-weight: 600;
padding: 4px 12px; border-radius: 999px;
border: 1px solid #d4d4d4; background: #fff; color: #444;
cursor: pointer;
}
.btn-mode-toggle.discuss { background: #fff; color: #444; }
.btn-mode-toggle.contribute { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.btn-start-contribution-header {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 5px 12px; font-size: 12px; font-weight: 600;
cursor: pointer;
}
.branch-dropdown { position: relative; }
.branch-dropdown-trigger {
background: none; border: 1px solid transparent; border-radius: 5px;
padding: 3px 8px; font-weight: 600; color: #1a1a1a; font-size: 13px;
cursor: pointer;
}
.branch-dropdown-trigger:hover { border-color: #e5e5e5; }
.branch-dropdown-menu {
position: absolute; top: 100%; left: 0; margin-top: 4px;
background: #fff; border: 1px solid #e5e5e5; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
z-index: 50; min-width: 240px; padding: 4px;
}
.branch-dropdown-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 6px 10px;
background: none; border: none; cursor: pointer; text-align: left;
font-size: 13px; border-radius: 5px;
}
.branch-dropdown-item:hover { background: #f5f5f5; }
.branch-dropdown-item.active { background: #f0f0ee; font-weight: 600; }
.branch-name { flex: 1; }
.branch-creator { font-size: 11px; color: #999; }
.branch-private-icon { font-size: 10px; }
.rfc-body { flex: 1; display: flex; overflow: hidden; }
/* ── Editor area ─────────────────────────────────────────────────────── */
.editor-area {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden; position: relative;
background: #fff;
}
.discuss-mode-banner {
padding: 8px 16px;
background: #fffbeb; border-bottom: 1px solid #fde68a;
color: #92400e; font-size: 12px;
}
.discuss-mode-banner.muted { background: #f0f0ee; color: #666; border-color: #e5e5e5; }
.editor-toolbar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
font-size: 12px; color: #777;
}
.btn-review-toggle {
background: #fff; color: #1a1a1a;
border: 1px solid #d4d4d4; border-radius: 5px;
padding: 4px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-review-toggle.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.editor-toolbar-hint { color: #999; font-size: 11px; }
.editor-wrapper {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.editor-content {
max-width: 720px; margin: 0 auto; outline: none;
}
.editor-content .tiptap {
outline: none; font-size: 15px; line-height: 1.75; color: #1a1a1a;
}
.editor-content .tiptap h1 { font-size: 22px; font-weight: 700; margin: 24px 0 12px; }
.editor-content .tiptap h2 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
.editor-content .tiptap h3 { font-size: 15px; font-weight: 600; margin: 16px 0 6px; }
.editor-content .tiptap p { margin: 0 0 12px; }
.editor-content .tiptap ul, .editor-content .tiptap ol { padding-left: 24px; }
.editor-content .tiptap code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.editor-content .tiptap .paragraph-changed {
border-left: 3px solid #f59e0b;
padding-left: 10px;
margin-left: -13px;
background: linear-gradient(to right, #fffbeb 0%, transparent 60%);
border-radius: 0 4px 4px 0;
}
.editor-content .tiptap .selection-highlight {
background: rgba(99, 102, 241, 0.15);
border-radius: 2px;
outline: 1px solid rgba(99, 102, 241, 0.3);
outline-offset: 1px;
}
.editor-content .tiptap .tracked-delete {
background: #fee2e2; color: #991b1b; text-decoration: line-through;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
.editor-content .tiptap .tracked-insert {
background: #dcfce7; color: #166534;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
.readonly-bar {
border-top: 1px solid #e5e5e5;
padding: 10px 16px; text-align: center;
font-size: 13px; color: #888; background: #fafafa;
}
.readonly-bar a { color: #1a1a1a; font-weight: 600; }
/* ── Prompt bar ──────────────────────────────────────────────────────── */
.prompt-bar {
border-top: 1px solid #e5e5e5;
background: #fff;
padding: 12px 48px;
flex-shrink: 0;
}
.selection-badge {
font-size: 12px; color: #5b5bd6;
margin-bottom: 8px; display: flex; align-items: center; gap: 6px;
}
.selection-icon { font-size: 10px; }
.prompt-row {
display: flex; gap: 10px; align-items: flex-end;
max-width: 720px; margin: 0 auto;
}
.prompt-input {
flex: 1; border: 1px solid #e5e5e5; border-radius: 8px;
padding: 9px 13px; font-size: 14px; font-family: inherit;
resize: none; line-height: 1.5; outline: none;
min-height: 40px; max-height: 120px;
}
.prompt-input:focus { border-color: #1a1a1a; }
.prompt-submit {
background: #1a1a1a; color: #fff; border: none;
border-radius: 8px; padding: 9px 16px;
font-size: 13px; font-weight: 600; cursor: pointer;
height: 40px;
}
.prompt-submit:disabled { background: #ccc; cursor: default; }
/* ── Model picker ──────────────────────────────────────────────────── */
.model-picker { display: flex; gap: 4px; padding: 0 4px; flex-shrink: 0; }
.model-pill {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 20px;
font-size: 12px; font-weight: 600;
border: 1px solid #e5e5e5; background: none; color: #666;
cursor: pointer;
}
.model-pill:hover { border-color: #aaa; color: #333; }
.model-pill.active { font-weight: 700; }
.model-dot { width: 7px; height: 7px; border-radius: 50%; }
/* ── Selection tooltip ───────────────────────────────────────────────── */
.selection-tooltip {
position: fixed; z-index: 100;
background: #fff; border: 1px solid #e5e5e5;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
padding: 8px 10px;
width: max-content; min-width: 320px; max-width: 480px;
display: flex; flex-direction: column; gap: 6px;
}
.selection-tooltip-quote {
font-size: 11px; color: #888; font-style: italic;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.selection-tooltip-tabs { display: flex; gap: 4px; }
.selection-tooltip-tab {
background: none; border: 1px solid transparent;
font-size: 12px; padding: 3px 10px; border-radius: 6px;
cursor: pointer; color: #666;
}
.selection-tooltip-tab.active { background: #1a1a1a; color: #fff; }
.selection-tooltip-input-row { display: flex; gap: 6px; align-items: center; }
.selection-tooltip-input {
flex: 1; border: 1px solid #e5e5e5; border-radius: 6px;
padding: 6px 10px; font-size: 13px; font-family: inherit; outline: none;
background: #f9f9f9;
}
.selection-tooltip-input:focus { border-color: #1a1a1a; background: #fff; }
.selection-tooltip-btn {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 6px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
}
.selection-tooltip-btn:disabled { background: #ccc; cursor: default; }
/* ── Right panel (chat + change panel) ───────────────────────────────── */
.right-panel {
width: 360px; flex-shrink: 0;
border-left: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden; background: #fff;
}
.chat-panel {
flex: 1; display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.chat-header {
padding: 10px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
display: flex; flex-direction: column; gap: 4px;
}
.chat-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.chat-header-title { font-size: 12px; color: #555; }
.chat-fork-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
}
.chat-thread-disclosure {
font-size: 11px; color: #888;
display: flex; gap: 4px; align-items: center;
}
.chat-thread-flag-count { color: #b45309; }
.chat-filter-clear {
margin-left: auto;
background: none; border: none; cursor: pointer;
font-size: 11px; color: #5b5bd6;
}
.chat-messages {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 10px;
}
.chat-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 24px;
}
.chat-empty p { font-size: 13px; color: #999; line-height: 1.6; max-width: 240px; }
.chat-message { display: flex; flex-direction: column; gap: 3px; }
.chat-message.user { align-items: flex-end; }
.chat-message.assistant { align-items: flex-start; }
.chat-message.system { align-items: stretch; }
.chat-message.flag { align-items: stretch; }
.chat-message.in-thread { padding-left: 12px; border-left: 2px solid #c4b5fd; }
.chat-bubble {
max-width: 92%;
padding: 8px 11px; border-radius: 14px;
font-size: 13px; line-height: 1.55;
white-space: pre-wrap; word-break: break-word;
}
.chat-message.user .chat-bubble {
background: #1a1a1a; color: #fff; border-bottom-right-radius: 4px;
}
.chat-message.assistant .chat-bubble {
background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px;
}
.chat-message.streaming .chat-bubble { opacity: 0.85; }
.chat-thinking { color: #999; font-style: italic; }
.chat-cursor {
display: inline-block; width: 2px; height: 12px;
background: #7c3aed; margin-left: 2px; vertical-align: middle;
animation: blink 0.75s step-end infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.chat-quote {
font-size: 11px; color: #888; font-style: italic;
border-left: 2px solid #d1d5db; padding-left: 7px; margin-bottom: 3px;
max-width: 92%; align-self: flex-end;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chat-anchor-preview {
font-size: 10px; color: #888; font-style: italic;
}
.chat-model-label {
font-size: 11px; font-weight: 700;
display: flex; align-items: center; gap: 4px; padding-left: 2px;
}
.chat-model-dot { width: 6px; height: 6px; border-radius: 50%; }
.chat-change-hint {
align-self: flex-start;
background: none; border: none; padding: 0;
font-size: 11px; color: #7c3aed; font-weight: 600;
cursor: pointer;
}
.chat-change-hint.discuss { color: #b45309; }
.chat-change-hint-cta {
background: none; border: none; padding: 0;
color: #5b5bd6; font-weight: 600; cursor: pointer; text-decoration: underline;
}
.chat-system-bubble {
font-size: 11px; color: #888; font-style: italic;
border-top: 1px dashed #e5e5e5; padding-top: 8px;
}
.chat-flag-row {
display: flex; gap: 8px;
padding: 8px 10px;
background: #fff7ed; border: 1px solid #fdba74; border-radius: 8px;
}
.chat-flag-icon { color: #c2410c; font-size: 14px; }
.chat-flag-content { flex: 1; }
.chat-flag-author { font-size: 11px; font-weight: 700; color: #9a3412; }
.chat-flag-text { font-size: 13px; color: #1a1a1a; margin-top: 2px; }
.chat-flag-resolve {
margin-top: 6px;
background: none; border: 1px solid #fdba74;
border-radius: 5px; padding: 2px 8px;
font-size: 11px; color: #c2410c; cursor: pointer;
}
/* ── Change panel ──────────────────────────────────────────────── */
.change-panel {
border-top: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden; flex-shrink: 0;
max-height: 50%;
}
.change-panel-header {
padding: 12px 14px;
font-size: 13px; font-weight: 600;
border-bottom: 1px solid #f0f0ee;
display: flex; align-items: center; gap: 8px;
}
.badge {
background: #b45309; color: #fff;
font-size: 11px; padding: 2px 7px; border-radius: 10px;
}
.change-list { flex: 1; overflow-y: auto; padding: 8px; }
.change-group-label {
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: #aaa; padding: 8px 6px 4px;
}
.change-group-label.muted { color: #ccc; }
.change-item {
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
border: 1px solid #e5e5e5;
font-size: 13px;
}
.change-item.type-claude { border-left: 3px solid #5b5bd6; }
.change-item.type-manual { border-left: 3px solid #888; }
.change-item.state-accepted { opacity: 0.5; }
.change-item.state-declined { opacity: 0.4; }
.change-item.stale { border-color: #fbbf24; background: #fffbeb; }
.change-item.focused {
animation: change-focus-flash 1.8s ease forwards;
}
@keyframes change-focus-flash {
0% { box-shadow: 0 0 0 3px #3b82f6; }
70% { box-shadow: 0 0 0 3px #3b82f6; }
100% { box-shadow: none; }
}
.change-meta {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.change-author { font-weight: 600; font-size: 12px; }
.change-state-badge {
font-size: 10px; padding: 2px 7px; border-radius: 10px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
}
.change-state-badge.pending { background: #fef3c7; color: #92400e; }
.change-state-badge.accepted { background: #dcfce7; color: #166534; }
.change-state-badge.declined { background: #f1f5f9; color: #94a3b8; }
.change-state-badge.stale { background: #fef3c7; color: #92400e; }
.change-label { color: #444; margin-bottom: 6px; line-height: 1.4; }
.change-stale-banner {
font-size: 11px; color: #92400e;
background: #fef3c7; border-radius: 4px;
padding: 4px 8px; margin-bottom: 6px;
}
.change-manual-status {
font-size: 11px; color: #888;
display: flex; align-items: center; justify-content: space-between;
margin-top: 6px;
}
.btn-save-now {
background: none; border: 1px solid #d4d4d4;
border-radius: 4px; padding: 2px 8px;
font-size: 11px; font-weight: 600; cursor: pointer;
}
.change-source-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
display: block; margin-top: 4px;
}
.change-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.btn-accept {
background: #166534; color: #fff;
border: none; border-radius: 5px;
padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-edit {
background: none; border: 1px solid #c4b5fd; color: #7c3aed;
border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.btn-decline {
background: none; border: 1px solid #e5e5e5;
border-radius: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer;
}
.btn-reask {
background: none; border: 1px solid #fbbf24; color: #92400e;
border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer;
}
.diff-edit-textarea {
width: 100%; font-size: 12px; font-family: inherit;
line-height: 1.5; border: 1px solid #c4b5fd;
border-radius: 4px; padding: 6px; resize: vertical;
}
.inline-diff {
font-size: 12px; line-height: 1.7;
background: #f9fafb; border-radius: 4px;
padding: 8px 10px;
white-space: pre-wrap; word-break: break-word;
border: 1px solid #e5e5e5;
}
.diff-word-add {
background: #dcfce7; color: #166534;
border-radius: 2px; padding: 1px 1px;
}
.diff-word-remove {
background: #fee2e2; color: #991b1b;
text-decoration: line-through;
border-radius: 2px; padding: 1px 1px;
}
.text-fade { color: #aaa; }
.expand-toggle {
display: block; margin-top: 5px;
background: none; border: none; padding: 0;
font-size: 11px; font-weight: 600; color: #7c3aed;
cursor: pointer; text-decoration: underline;
}
.change-stub {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: #888;
padding: 4px 6px; border-bottom: 1px solid #f5f5f5;
}
.change-stub .stub-author { font-weight: 600; color: #555; }
.change-stub .stub-badge {
font-size: 9px; padding: 1px 5px; border-radius: 8px;
}
.change-stub .stub-badge.accepted { background: #dcfce7; color: #166534; }
.change-stub .stub-badge.declined { background: #f1f5f9; color: #94a3b8; }
.change-stub .stub-reason { flex: 1; color: #777; }
.change-stub .stub-source-link {
background: none; border: none; padding: 0;
font-size: 11px; color: #5b5bd6; cursor: pointer;
}
.contribution-cta {
border-top: 1px solid #e5e5e5;
background: #fafafa;
padding: 16px;
text-align: center;
}
.contribution-cta-count {
font-size: 13px; font-weight: 600; color: #1a1a1a;
}
.contribution-cta-desc {
font-size: 12px; color: #666; margin: 6px 0 12px;
}
.btn-start-contribution {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 8px 14px;
font-size: 13px; font-weight: 600; cursor: pointer;
}
/* ── DiffView ──────────────────────────────────────────────────── */
.diff-view-wrapper {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.diff-view-empty {
font-size: 13px; color: #999;
text-align: center; padding: 24px;
}
.diff-tooltip {
background: #fff; border: 1px solid #e5e5e5;
border-radius: 8px; padding: 10px 12px;
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
max-width: 320px; font-size: 12px;
z-index: 200;
}
.diff-tooltip-header {
display: flex; gap: 4px; flex-wrap: wrap;
margin-bottom: 6px;
}
.diff-tooltip-badge {
font-size: 10px; padding: 2px 7px; border-radius: 8px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
}
.diff-tooltip-badge--manual { background: #f1f5f9; color: #475569; }
.diff-tooltip-badge--edited { background: #faf5ff; color: #7c3aed; }
.diff-tooltip-prompt {
border-top: 1px solid #f0f0ee;
padding-top: 6px;
font-size: 11px; color: #444;
}
.diff-tooltip-quote {
font-style: italic; color: #888; margin-bottom: 4px;
}
.diff-tooltip-reason {
border-top: 1px solid #f0f0ee;
padding-top: 6px; margin-top: 6px;
color: #555;
}
.diff-tooltip-reason-label {
display: inline-block; font-size: 9px; font-weight: 700;
color: #888; text-transform: uppercase; letter-spacing: 0.04em;
margin-right: 6px;
}
.diff-tooltip-no-context {
font-size: 11px; color: #aaa; font-style: italic;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default function App() {
<main className="main-pane"> <main className="main-pane">
<Routes> <Routes>
<Route path="/" element={<Welcome viewer={me.user} />} /> <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)} />} /> <Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes> </Routes>
</main> </main>
+186
View File
@@ -68,3 +68,189 @@ export async function withdrawProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' }) const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
// ── Slice 2: active-RFC view (§8) ─────────────────────────────────────────
export async function listModels() {
return jsonOrThrow(await fetch('/api/models'))
}
export async function getRFCMain(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`))
}
export async function getBranch(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}`
))
}
export async function promoteToBranch(slug, body = {}) {
const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proposed,
was_edited_before_accept: !!wasEdited,
force_apply_stale: !!forceApplyStale,
}),
},
)
return jsonOrThrow(res)
}
export async function declineChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function reaskChange(slug, branch, changeId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
export async function manualFlush(slug, branch, { newContent, paragraphCount }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }),
},
)
return jsonOrThrow(res)
}
export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
read_public: readPublic,
contribute_mode: contributeMode,
}),
},
)
return jsonOrThrow(res)
}
export async function createThread(slug, branch, body) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
return jsonOrThrow(res)
}
export async function listThreads(slug, branch) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`,
))
}
export async function getThreadMessages(slug, branch, threadId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
))
}
export async function postThreadMessage(slug, branch, threadId, { text, quote }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote }),
},
)
return jsonOrThrow(res)
}
export async function resolveThread(slug, branch, threadId) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
// Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers
// (so the caller can pull X-Assistant-Message-Id without re-streaming).
export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote, model }),
},
)
if (!res.ok) {
const detail = await res.text()
throw new Error(`Chat failed: ${detail || res.status}`)
}
const assistantId = res.headers.get('X-Assistant-Message-Id')
const userMsgId = res.headers.get('X-User-Message-Id')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop()
for (const part of parts) {
const lines = part.split('\n')
let dataLine = null
let event = null
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim()
if (line.startsWith('data: ')) dataLine = line.slice(6).trim()
}
if (dataLine === null) continue
if (event === 'changes') {
try { onChanges?.(JSON.parse(dataLine)) } catch {}
continue
}
if (dataLine === 'DONE') { onDone?.(); break }
try {
const text = new TextDecoder().decode(
Uint8Array.from(atob(dataLine), c => c.charCodeAt(0))
)
onChunk?.(text)
} catch {
// partial chunk
}
}
}
onDone?.()
return { assistantId, userMsgId }
}
+236
View File
@@ -0,0 +1,236 @@
// ChangePanel.jsx the §8.8 change-card panel.
//
// Sits below the chat in contribute mode. Pending cards stack on top
// of resolved stubs. Each AI card carries accept / edit-before-accept /
// decline per §8.9; each manual card carries the live status line per
// §8.11. Stale cards surface the §8.11 warning + Re-ask path. Clicking
// a card's " from this message" affordance scrolls the chat back to
// the originating message.
import { useState, useEffect, useRef } from 'react'
const PREVIEW_LENGTH = 220
function diffWords(original, proposed) {
const a = (original || '').split(/(\s+)/)
const b = (proposed || '').split(/(\s+)/)
const m = a.length, n = b.length
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1]
? dp[i-1][j-1] + 1
: Math.max(dp[i-1][j], dp[i][j-1])
const tokens = []
let i = m, j = n
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i-1] === b[j-1]) {
tokens.unshift({ text: a[i-1], type: 'same' }); i--; j--
} else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
tokens.unshift({ text: b[j-1], type: 'add' }); j--
} else {
tokens.unshift({ text: a[i-1], type: 'remove' }); i--
}
}
return tokens
}
function InlineDiff({ original, proposed }) {
const [expanded, setExpanded] = useState(false)
const tokens = diffWords(original, proposed)
const fullText = tokens.map(t => t.text).join('')
const needsTruncation = fullText.length > PREVIEW_LENGTH
let shown = tokens
if (needsTruncation && !expanded) {
let count = 0
const cutoff = tokens.findIndex(t => { count += t.text.length; return count > PREVIEW_LENGTH })
if (cutoff !== -1) shown = tokens.slice(0, cutoff)
}
return (
<div className="inline-diff">
{shown.map((token, idx) =>
token.type === 'same' ? <span key={idx}>{token.text}</span> :
token.type === 'add' ? <span key={idx} className="diff-word-add">{token.text}</span> :
<span key={idx} className="diff-word-remove">{token.text}</span>
)}
{needsTruncation && !expanded && <span className="text-fade"></span>}
{needsTruncation && (
<button className="expand-toggle" onClick={() => setExpanded(e => !e)}>
{expanded ? '↑ Show less' : '↓ Show more'}
</button>
)}
</div>
)
}
export default function ChangePanel({
changes,
onAccept,
onDecline,
onReask,
onScrollToMessage,
focusedChangeId,
manualPendingStatus, // {paragraphCount, savingIn, onSaveNow} or null
}) {
const pending = changes.filter(c => c.state === 'pending')
const resolved = changes.filter(c => c.state !== 'pending')
return (
<div className="change-panel">
<div className="change-panel-header">
Changes
{pending.length > 0 && <span className="badge">{pending.length}</span>}
</div>
<div className="change-list">
{manualPendingStatus && (
<ManualPendingCard
paragraphCount={manualPendingStatus.paragraphCount}
savingIn={manualPendingStatus.savingIn}
onSaveNow={manualPendingStatus.onSaveNow}
/>
)}
{pending.length > 0 && (
<div className="change-group-label">Pending</div>
)}
{pending.map(c => (
<ChangeItem
key={c.id}
change={c}
focused={focusedChangeId === c.id}
onAccept={onAccept}
onDecline={onDecline}
onReask={onReask}
onScrollToMessage={onScrollToMessage}
/>
))}
{resolved.length > 0 && (
<div className="change-group-label muted">Resolved</div>
)}
{resolved.map(c => (
<ResolvedStub key={c.id} change={c} onScrollToMessage={onScrollToMessage} />
))}
</div>
</div>
)
}
function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) {
return (
<div className="change-item type-manual state-pending">
<div className="change-meta">
<span className="change-author">You · manual edit</span>
<span className="change-state-badge pending">unsaved</span>
</div>
<div className="change-label">
{paragraphCount} paragraph{paragraphCount === 1 ? '' : 's'} edited directly
</div>
<div className="change-manual-status">
unsaved · auto-save in {savingIn}
<button type="button" className="btn-save-now" onClick={onSaveNow}>Save now</button>
</div>
</div>
)
}
function ChangeItem({ change, focused, onAccept, onDecline, onReask, onScrollToMessage }) {
const [editing, setEditing] = useState(false)
const [edited, setEdited] = useState(change.proposed || '')
const itemRef = useRef(null)
const isStale = !!change.stale_since
useEffect(() => {
if (focused && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [focused])
const handleStartEdit = () => { setEdited(change.proposed || ''); setEditing(true) }
const handleAcceptEdited = () => {
onAccept({ change, proposed: edited, wasEdited: true })
setEditing(false)
}
const handleAcceptStraight = () => {
onAccept({ change, proposed: change.proposed, wasEdited: false })
}
return (
<div
ref={itemRef}
className={`change-item state-${change.state} type-${change.kind === 'ai' ? 'claude' : 'manual'}${focused ? ' focused' : ''}${isStale ? ' stale' : ''}`}
>
<div className="change-meta">
<span className="change-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
<span className={`change-state-badge ${change.state}`}>
{isStale ? 'stale' : change.state}
</span>
</div>
{change.reason && (
<div className="change-label">{change.reason}</div>
)}
{isStale && (
<div className="change-stale-banner">
The original text has changed since this was proposed.
</div>
)}
{change.kind === 'ai' && (
<div className="change-diff">
{editing ? (
<textarea
className="diff-edit-textarea"
value={edited}
onChange={e => setEdited(e.target.value)}
rows={Math.min(12, Math.max(3, edited.split('\n').length + 1))}
autoFocus
/>
) : (
<InlineDiff original={change.original} proposed={change.proposed} />
)}
</div>
)}
{change.source_message_id && (
<button
type="button"
className="change-source-link"
onClick={() => onScrollToMessage?.(change.source_message_id)}
> from this message</button>
)}
<div className="change-actions">
{editing ? (
<>
<button className="btn-accept" onClick={handleAcceptEdited}>Accept edit</button>
<button className="btn-edit" onClick={() => setEditing(false)}>Cancel</button>
</>
) : (
<>
<button className="btn-accept" onClick={handleAcceptStraight}>
{isStale ? 'Apply anyway…' : 'Accept'}
</button>
<button className="btn-edit" onClick={handleStartEdit}>Edit</button>
<button className="btn-decline" onClick={() => onDecline(change.id)}>Decline</button>
{isStale && (
<button className="btn-reask" onClick={() => onReask?.(change.id)}>Re-ask</button>
)}
</>
)}
</div>
</div>
)
}
function ResolvedStub({ change, onScrollToMessage }) {
const short = (change.reason || '').slice(0, 80)
return (
<div className={`change-stub state-${change.state}`}>
<span className="stub-author">{change.kind === 'ai' ? 'AI' : 'You'}</span>
<span className={`stub-badge ${change.state}`}>{change.state}</span>
<span className="stub-reason">{short || (change.kind === 'manual' ? 'manual edit' : 'change')}</span>
{change.source_message_id && (
<button
type="button"
className="stub-source-link"
onClick={() => onScrollToMessage?.(change.source_message_id)}
></button>
)}
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
// ChatPanel.jsx the §8 right-column chat surface.
//
// One feed of every message on the branch's threads (per §8.12),
// rendered in chronological order. Sub-thread messages render with a
// gutter accent and the quoted anchor preview; flag rows render with a
// flag badge. Per §8.8 each assistant message that produced changes
// carries a " N changes added below" hint that flashes the matching
// cards in the change panel.
import { useEffect, useRef } from 'react'
import { MODEL_STYLES } from '../modelStyles'
function ModelLabel({ modelId, streaming }) {
const style = MODEL_STYLES[modelId] || MODEL_STYLES.default
return (
<div className="chat-model-label" style={{ color: style.color }}>
<span className="chat-model-dot" style={{ background: style.color }} />
{style.label}{streaming ? '…' : ''}
</div>
)
}
function stripChangeTags(text) {
return text
.replace(/<change>[\s\S]*?<\/change>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export default function ChatPanel({
messages,
threads,
changes,
branchName,
isStreaming,
contributionMode,
onScrollToChange,
onStartContribution,
threadFilter,
onThreadFilterChange,
onResolveThread,
forkedFromMessage,
}) {
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length])
const openThreads = threads.filter(t => t.state === 'open')
const flagThreads = openThreads.filter(t => t.thread_kind === 'flag')
const chatThreads = openThreads.filter(t => t.thread_kind === 'chat')
return (
<div className="chat-panel">
<div className="chat-header">
<div className="chat-header-row">
<span className="chat-header-title">
Branch chat · <strong>{branchName}</strong>
</span>
{forkedFromMessage && (
<button
type="button"
className="chat-fork-link"
onClick={() => onScrollToChange?.(null, forkedFromMessage)}
title="Forked from this conversation"
>
Forked from this conversation
</button>
)}
</div>
<div className="chat-thread-disclosure">
{chatThreads.length > 0 && (
<span>{chatThreads.length} open thread{chatThreads.length === 1 ? '' : 's'}</span>
)}
{flagThreads.length > 0 && (
<span className="chat-thread-flag-count">
· {flagThreads.length} open flag{flagThreads.length === 1 ? '' : 's'}
</span>
)}
{threadFilter && (
<button
type="button"
className="chat-filter-clear"
onClick={() => onThreadFilterChange?.(null)}
>Clear filter</button>
)}
</div>
</div>
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
<p>
{contributionMode
? 'Ask the AI about this RFC. Concrete edit suggestions land in the panel below.'
: 'Ask the AI about this RFC. When suggestions are ready you can start a contribution.'}
</p>
</div>
) : (
messages.map(msg => (
<ChatMessage
key={msg.id}
message={msg}
changes={changes}
contributionMode={contributionMode}
onScrollToChange={onScrollToChange}
onStartContribution={onStartContribution}
onResolveThread={onResolveThread}
isStreaming={isStreaming}
/>
))
)}
<div ref={bottomRef} />
</div>
</div>
)
}
function ChatMessage({ message, changes, contributionMode, onScrollToChange, onStartContribution, onResolveThread, isStreaming }) {
const isUser = message.role === 'user'
const isSystem = message.role === 'system'
const isFlag = message.thread_kind === 'flag'
const displayText = isUser || isSystem ? message.text : stripChangeTags(message.text)
const messageChanges = changes.filter(c => c.source_message_id === message.id)
const subThread = message.anchor_kind && message.anchor_kind !== 'whole-doc'
if (isSystem) {
return (
<div className="chat-message system">
<div className="chat-system-bubble">{message.text}</div>
</div>
)
}
if (isFlag) {
return (
<div className="chat-message flag">
<div className="chat-flag-row">
<span className="chat-flag-icon" title="Flag"></span>
<div className="chat-flag-content">
<div className="chat-flag-author">@{message.author_login || '—'} flagged:</div>
<div className="chat-flag-text">{message.flag_label || message.text}</div>
{message.anchor_preview && (
<div className="chat-anchor-preview">quoted: "{message.anchor_preview}"</div>
)}
<button
type="button"
className="chat-flag-resolve"
onClick={() => onResolveThread?.(message.thread_id)}
>Resolve</button>
</div>
</div>
</div>
)
}
return (
<div className={`chat-message ${isUser ? 'user' : 'assistant'}${message.streaming ? ' streaming' : ''}${subThread ? ' in-thread' : ''}`}>
{subThread && message.anchor_preview && (
<div className="chat-anchor-preview">on: "{message.anchor_preview}"</div>
)}
{isUser && message.quote && (
<div className="chat-quote">"{message.quote}"</div>
)}
{!isUser && message.model_id && (
<ModelLabel modelId={message.model_id} streaming={message.streaming} />
)}
<div className="chat-bubble">
{displayText
? displayText
: message.streaming
? <span className="chat-thinking">Thinking</span>
: null}
{message.streaming && displayText && <span className="chat-cursor" />}
</div>
{!isUser && !message.streaming && messageChanges.length > 0 && (
contributionMode ? (
<button
type="button"
className="chat-change-hint"
onClick={() => onScrollToChange?.(messageChanges[0].id)}
>
{messageChanges.length} change{messageChanges.length === 1 ? '' : 's'} added below
</button>
) : (
<div className="chat-change-hint discuss">
{messageChanges.length} change{messageChanges.length === 1 ? '' : 's'} proposed {' '}
<button className="chat-change-hint-cta" onClick={onStartContribution}>
start a contribution to apply {messageChanges.length === 1 ? 'it' : 'them'}
</button>
</div>
)
)}
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
// DiffView.jsx the §8.10 read-only render surface for accepted changes.
//
// In contribute mode, a toolbar toggle replaces the editor with this
// view. We reconstruct the markup for every accepted change in branch
// history by reading the `changes` table (passed in as `changes`) plus
// the current rendered HTML; hovering any tracked span surfaces a
// tooltip with the change's type/model/prompt/reason context. Carryover
// from the prototype.
import { useState, useCallback } from 'react'
import { MODEL_STYLES } from '../modelStyles'
function tooltipStyle(x, y) {
const vw = window.innerWidth, vh = window.innerHeight
const style = {}
if (x > vw * 0.55) style.right = vw - x + 10
else style.left = x + 14
if (y > vh * 0.55) style.bottom = vh - y + 10
else style.top = y + 14
return style
}
function changeContext(change, messages) {
if (!change?.source_message_id) return {}
const idx = messages.findIndex(m => m.id === change.source_message_id)
if (idx < 0) return {}
const assistant = messages[idx]
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
return { assistant, userMsg }
}
function ChangeTooltip({ change, messages, position }) {
const { assistant, userMsg } = changeContext(change, messages)
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
return (
<div className="diff-tooltip" style={style}>
<div className="diff-tooltip-header">
{change.kind === 'ai' ? (
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
{modelStyle.label}
</span>
) : (
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
)}
{change.was_edited_before_accept && (
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
)}
</div>
{userMsg && (
<div className="diff-tooltip-prompt">
{userMsg.quote && (
<div className="diff-tooltip-quote">
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
</div>
)}
<div className="diff-tooltip-prompt-text">
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
</div>
</div>
)}
{change.reason && (
<div className="diff-tooltip-reason">
<span className="diff-tooltip-reason-label">Reason</span>
{change.reason}
</div>
)}
{!userMsg && change.kind === 'ai' && (
<div className="diff-tooltip-no-context">
No linked conversation message in this session.
</div>
)}
</div>
)
}
export default function DiffView({ html, changes, messages }) {
const [tooltip, setTooltip] = useState(null)
const handleMouseMove = useCallback((e) => {
const span = e.target.closest('[data-change-id]')
if (span) {
const id = span.getAttribute('data-change-id')
const change = changes.find(c => String(c.id) === String(id))
if (change) {
setTooltip({ change, position: { x: e.clientX, y: e.clientY } })
return
}
}
setTooltip(null)
}, [changes])
const acceptedCount = changes.filter(c => c.state === 'accepted').length
return (
<div className="diff-view-wrapper">
{acceptedCount === 0 && (
<div className="diff-view-empty">
No accepted changes yet on this branch. Accept proposals from the
change panel to see them rendered here in place.
</div>
)}
<div className="editor-content">
<div
className="tiptap diff-view-document"
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
{tooltip && <ChangeTooltip change={tooltip.change} messages={messages} position={tooltip.position} />}
</div>
)
}
+191
View File
@@ -0,0 +1,191 @@
// Editor.jsx the §8 center-column editor.
//
// Tiptap on ProseMirror per §18. Two ProseMirror plugins live alongside
// StarterKit:
//
// paragraphDiff the §8.10 paragraph-margin gutter accent. Compares
// each paragraph against an open-session baseline (the
// `originalParagraphsRef` ref the parent owns and refreshes when the
// baseline shifts e.g. on branch switch or a server-side flush).
//
// selectionHighlight keeps a selected passage highlighted while
// focus moves to the §8.12 selection tooltip. Driven by meta
// transactions dispatched from the parent.
//
// The inline tracked-delete / tracked-insert markup from §8.10 is
// session-local HTML the parent injects via `editor.commands.setContent`
// when a change is accepted; the editor itself doesn't own that state.
// On reload the markup clears and DiffView (toolbar toggle) is the
// durable read of accepted changes.
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useCallback } from 'react'
import { marked } from 'marked'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
// Paragraph diff plugin
const diffKey = new PluginKey('paragraphDiff')
function makeDiffPlugin(originalParagraphsRef) {
return new Plugin({
key: diffKey,
props: {
decorations(state) {
const originals = originalParagraphsRef?.current
if (!originals || originals.length === 0) return DecorationSet.empty
const decorations = []
let idx = 0
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
const current = node.textContent.trim()
const original = (originals[idx] ?? '').trim()
if (current !== original) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'paragraph-changed' })
)
}
idx++
}
})
return DecorationSet.create(state.doc, decorations)
},
},
})
}
function DiffExtension(originalParagraphsRef) {
return Extension.create({
name: 'paragraphDiff',
addProseMirrorPlugins() {
return [makeDiffPlugin(originalParagraphsRef)]
},
})
}
// Selection highlight plugin
export const selectionHighlightKey = new PluginKey('selectionHighlight')
function makeSelectionHighlightPlugin() {
return new Plugin({
key: selectionHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(selectionHighlightKey)
return meta !== undefined ? meta : prev
},
},
props: {
decorations(state) {
const range = selectionHighlightKey.getState(state)
if (!range || range.from >= range.to) return DecorationSet.empty
try {
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: 'selection-highlight' }),
])
} catch {
return DecorationSet.empty
}
},
},
})
}
function SelectionHighlightExtension() {
return Extension.create({
name: 'selectionHighlight',
addProseMirrorPlugins() {
return [makeSelectionHighlightPlugin()]
},
})
}
// Editor component
export default function Editor({
content,
editorRef,
originalParagraphsRef,
onSelectionChange,
onUpdate,
editable = true,
}) {
const isMouseDownRef = useRef(false)
const reportSelection = useCallback((editor) => {
if (isMouseDownRef.current) return
const { from, to } = editor.state.selection
if (from !== to) {
const text = editor.state.doc.textBetween(from, to, ' ')
const coords = editor.view.coordsAtPos(from)
onSelectionChange?.({ text, coords, from, to })
} else {
onSelectionChange?.(null)
}
}, [onSelectionChange])
const editor = useEditor({
extensions: [
StarterKit,
DiffExtension(originalParagraphsRef),
SelectionHighlightExtension(),
],
content: '<p></p>',
editable,
onUpdate: ({ editor }) => {
onUpdate?.(editor.getText(), editor.getHTML())
},
onSelectionUpdate: ({ editor }) => {
reportSelection(editor)
},
})
// Expose editor instance to the parent.
useEffect(() => {
if (editorRef) editorRef.current = editor
}, [editor, editorRef])
useEffect(() => {
if (editor) editor.setEditable(editable)
}, [editor, editable])
useEffect(() => {
if (!editor) return
const el = editor.view.dom
const onMouseDown = () => { isMouseDownRef.current = true; onSelectionChange?.(null) }
const onMouseUp = () => { isMouseDownRef.current = false; reportSelection(editor) }
el.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
}
}, [editor, onSelectionChange, reportSelection])
// Reload content + snapshot baseline paragraphs.
useEffect(() => {
if (!editor || content == null) return
const html = marked.parse(content)
editor.commands.setContent(html, false)
if (originalParagraphsRef) {
const paragraphs = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
paragraphs.push(node.textContent.trim())
}
})
originalParagraphsRef.current = paragraphs
}
}, [content, editor])
return (
<div className="editor-wrapper">
<EditorContent editor={editor} className="editor-content" />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
// ModelPicker.jsx segmented control for LLM provider switching.
// §18 carryover. Hidden when only one model is configured.
import { MODEL_STYLES } from '../modelStyles'
export default function ModelPicker({ models, selected, onChange }) {
if (!models || models.length <= 1) return null
return (
<div className="model-picker">
{models.map(m => {
const style = MODEL_STYLES[m.id] || MODEL_STYLES.default
const isActive = m.id === selected
return (
<button
key={m.id}
type="button"
className={`model-pill ${isActive ? 'active' : ''}`}
style={isActive ? { background: style.bg, color: style.color, borderColor: style.color } : {}}
onClick={() => onChange(m.id)}
title={`Use ${m.name}`}
>
<span className="model-dot" style={{ background: style.color }} />
{m.name}
</button>
)
})}
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
// PromptBar.jsx the §8.1 prompt-bar at the bottom of the center column.
//
// Carryover from the prototype. In discuss mode the contributor types
// to talk; in contribute mode the model is told to lean toward concrete
// edits. The selection-quote machinery is preserved a passage
// highlighted in the editor surfaces here as a "scoped to selection"
// badge and travels to the backend with the message.
import { useState } from 'react'
import ModelPicker from './ModelPicker.jsx'
export default function PromptBar({
selection,
onSubmit,
disabled,
models,
selectedModel,
onModelChange,
discussMode = false,
placeholder,
}) {
const [prompt, setPrompt] = useState('')
const handleSubmit = () => {
if (!prompt.trim() || disabled) return
onSubmit(prompt.trim(), selection)
setPrompt('')
}
return (
<div className="prompt-bar">
{selection && (
<div className="selection-badge">
<span className="selection-icon"></span>
Scoped to selection "{selection.slice(0, 80)}{selection.length > 80 ? '…' : ''}"
</div>
)}
<div className="prompt-row">
{models?.length > 1 && (
<ModelPicker models={models} selected={selectedModel} onChange={onModelChange} />
)}
<textarea
className="prompt-input"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() }
}}
placeholder={
placeholder ?? (
selection ? 'Ask anything about the selected text…'
: discussMode ? 'Ask anything about this RFC…'
: 'Ask anything or propose changes…'
)
}
disabled={disabled}
rows={1}
/>
<button className="prompt-submit" onClick={handleSubmit} disabled={!prompt.trim() || disabled}>
Ask
</button>
</div>
</div>
)
}
+768 -37
View File
@@ -1,55 +1,786 @@
// RFCView.jsx §9.4 super-draft view (and a stub for active RFCs). // RFCView.jsx the §8 active-RFC view.
// //
// Slice 1 ships read-only body rendering: the breadcrumb names the // Three-column shape per §8.1 (catalog left, this component's content
// entry, the body renders via marked. The discuss-vs-contribute toggle, // in the middle and right). Opens on main in discuss mode per §8.2;
// per-branch chat, change-card panel, and breadcrumb dropdown all land // supports the §8.3 discuss-vs-contribute flip on non-main branches.
// in Slice 2 per §8. // "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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { marked } from 'marked' import {
import { getRFC } from '../api' 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 { slug } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const branchParam = searchParams.get('branch') || 'main'
const [entry, setEntry] = useState(null) const [entry, setEntry] = useState(null)
const [mainView, setMainView] = useState(null)
const [branchView, setBranchView] = useState(null)
const [error, setError] = 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(() => { useEffect(() => {
setEntry(null); setError(null)
getRFC(slug).then(setEntry).catch(err => setError(err.message)) getRFC(slug).then(setEntry).catch(err => setError(err.message))
listModels()
.then(({ models, default: def }) => {
setModels(models || [])
setSelectedModel(def || models?.[0]?.id || '')
})
.catch(() => {})
}, [slug]) }, [slug])
if (error) return <div className="entry-view"><p>Error: {error}</p></div> // Slice 4 owns super-draft body editing; render a placeholder for now.
if (!entry) return <div className="entry-view">Loading</div> const isSuperDraft = entry?.state === 'super-draft'
const stateClass = entry.state === 'active' ? 'active' : '' // 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 HTMLmarkdown 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 ( return (
<article className="entry-view"> <article className="entry-view">
<div className={`entry-state-banner ${stateClass}`}> <div className="entry-state-banner">Super-draft</div>
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
</div>
<h1 className="entry-title">{entry.title}</h1> <h1 className="entry-title">{entry.title}</h1>
<div className="entry-meta"> <p className="field-help">
<span>{entry.slug}</span> Super-draft body editing on the meta repo lands in Slice 4 per
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>} <code> docs/DEV.md</code>. The Slice 2 view is scoped to active
{entry.proposed_at && <> · {entry.proposed_at}</>} RFCs chat, branches, change panel, AI participation. The
{entry.tags.length > 0 && ( super-draft body below is the pitch as merged.
<div style={{ marginTop: 6 }}> </p>
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)} <div className="entry-body" style={{ whiteSpace: 'pre-wrap' }}>{entry.body}</div>
</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>
)}
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
/>
</article> </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
return (
<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>
)
}
function focusMessage(messageId) {
const el = document.querySelector(`[data-message-id="${messageId}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function manualCountdownLabel(c) {
if (!c) return ''
const remaining = Math.max(0, c.deadline - Date.now())
const totalSec = Math.ceil(remaining / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${m}:${String(s).padStart(2, '0')}`
}
async function loadAllMessages(slug, branch, threads) {
// For Slice 2 we pull each thread's messages and stitch them in
// chronological order. The branch chat is the unified feed of
// every message across every thread per §8.12.
if (!threads || threads.length === 0) return []
const all = []
for (const t of threads) {
if (t.state !== 'open' && t.thread_kind !== 'flag') continue
try {
const { messages } = await getThreadMessages(slug, branch, t.id)
for (const m of messages) {
all.push({
...m,
thread_id: t.id,
thread_kind: t.thread_kind,
anchor_kind: t.anchor_kind,
anchor_preview: t.anchor_payload?.quote || null,
flag_label: t.thread_kind === 'flag' ? t.label : null,
})
}
if (t.thread_kind === 'flag' && (!messages || messages.length === 0)) {
all.push({
id: `flag-${t.id}`,
role: 'system',
text: t.label || '',
thread_id: t.id,
thread_kind: 'flag',
anchor_kind: t.anchor_kind,
anchor_preview: t.anchor_payload?.quote || null,
flag_label: t.label,
created_at: t.created_at,
author_login: null,
})
}
} catch {
// Tolerate per-thread fetch failures; the surface for triage
// belongs to a future error overlay.
}
}
all.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''))
return all
}
function BranchDropdown({ current, mainView, onPick }) {
const [open, setOpen] = useState(false)
const items = [{ name: 'main' }, ...(mainView?.branches || [])]
return (
<div className="branch-dropdown">
<button
type="button"
className="branch-dropdown-trigger"
onClick={() => setOpen(o => !o)}
>
{current === 'main' ? 'main' : current}
</button>
{open && (
<div className="branch-dropdown-menu" onMouseLeave={() => setOpen(false)}>
{items.map(b => (
<button
key={b.name}
type="button"
className={`branch-dropdown-item ${b.name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.name) }}
>
<span className="branch-name">{b.name}</span>
{b.visibility && b.name !== 'main' && !b.visibility.read_public && (
<span className="branch-private-icon" title="Private">🔒</span>
)}
{b.creator && (
<span className="branch-creator">@{b.creator}</span>
)}
</button>
))}
</div>
)}
</div>
)
}
function BranchVisibilityModal({ slug, branch, current, onClose, onSaved }) {
const [readPublic, setReadPublic] = useState(!!current?.read_public)
const [contributeMode, setContributeMode] = useState(current?.contribute_mode || 'just-me')
const [saving, setSaving] = useState(false)
const [err, setErr] = useState(null)
const onSave = async () => {
setSaving(true); setErr(null)
try {
await setBranchVisibility(slug, branch, { readPublic, contributeMode })
onSaved()
} catch (e) { setErr(e.message); setSaving(false) }
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Branch settings {branch}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<label>
<input type="checkbox" checked={readPublic} onChange={e => setReadPublic(e.target.checked)} />
{' '}Public read access
</label>
<p className="field-help">
§11.1: a public branch can be read by anyone, including anonymous viewers.
</p>
<label style={{ marginTop: 12 }}>Contribute mode</label>
<select value={contributeMode} onChange={e => setContributeMode(e.target.value)}>
<option value="just-me">Just me</option>
<option value="specific">Specific contributors</option>
<option value="any-contributor">Any contributor</option>
</select>
{err && <p className="field-error">{err}</p>}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
} }
@@ -0,0 +1,121 @@
// SelectionTooltip.jsx the §8.12 selection-anchored entry point.
//
// Carryover from the prototype. The contributor selects a passage in
// the editor; this floating panel appears anchored to that selection.
// Submitting creates a new range-anchored chat thread (or invokes the
// AI on the current branch chat with the selection as `quote`).
//
// Two affordances per §8.13: an "Ask" button that opens (or continues)
// a chat thread, and a "Flag" button that drops a flag thread anchored
// to the selection.
import { useState, useEffect, useRef } from 'react'
import ModelPicker from './ModelPicker.jsx'
export default function SelectionTooltip({
selection,
onAsk,
onFlag,
disabled,
models,
selectedModel,
onModelChange,
}) {
const [mode, setMode] = useState('ask') // 'ask' | 'flag'
const [prompt, setPrompt] = useState('')
const [flagText, setFlagText] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (selection) {
setPrompt('')
setFlagText('')
setMode('ask')
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [selection?.text])
if (!selection) return null
const { coords } = selection
const TOOLTIP_HEIGHT = 110
const GAP = 10
const top = Math.max(8, coords.top - TOOLTIP_HEIGHT - GAP)
const left = Math.min(window.innerWidth - 360, Math.max(12, coords.left))
const handleSubmit = () => {
if (disabled) return
if (mode === 'ask') {
const text = prompt.trim()
if (!text) return
onAsk(text, selection.text)
setPrompt('')
} else {
const label = flagText.trim()
if (!label) return
onFlag(label, selection.text)
setFlagText('')
}
}
const onKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() }
if (e.key === 'Escape') onAsk(null)
}
return (
<div
className="selection-tooltip"
style={{ top, left }}
onMouseDown={e => e.preventDefault()}
>
<div className="selection-tooltip-quote">
"{selection.text.length > 80 ? selection.text.slice(0, 80) + '…' : selection.text}"
</div>
<div className="selection-tooltip-tabs">
<button
className={`selection-tooltip-tab ${mode === 'ask' ? 'active' : ''}`}
onClick={() => setMode('ask')}
>Ask</button>
<button
className={`selection-tooltip-tab ${mode === 'flag' ? 'active' : ''}`}
onClick={() => setMode('flag')}
>Flag</button>
</div>
{mode === 'ask' && models?.length > 1 && (
<ModelPicker models={models} selected={selectedModel} onChange={onModelChange} />
)}
<div className="selection-tooltip-input-row">
{mode === 'ask' ? (
<input
ref={inputRef}
className="selection-tooltip-input"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask anything about this passage…"
disabled={disabled}
/>
) : (
<input
ref={inputRef}
className="selection-tooltip-input"
value={flagText}
onChange={e => setFlagText(e.target.value.slice(0, 200))}
onKeyDown={onKeyDown}
placeholder="What's wrong with this passage?"
disabled={disabled}
maxLength={200}
/>
)}
<button
className="selection-tooltip-btn"
onClick={handleSubmit}
disabled={disabled || (mode === 'ask' ? !prompt.trim() : !flagText.trim())}
>
{mode === 'ask' ? 'Ask' : 'Flag'}
</button>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
// modelStyles.js — colors and display labels for each LLM provider.
// §18 carryover from the prototype. Per §19.2's per-RFC-model topic,
// future per-RFC overrides land on top of this map without replacing it.
export const MODEL_STYLES = {
// Claude variants — shades of purple
'claude': { color: '#7c3aed', bg: '#faf5ff', label: 'Claude' },
'claude-sonnet': { color: '#7c3aed', bg: '#faf5ff', label: 'Sonnet' },
'claude-opus': { color: '#4c1d95', bg: '#f5f3ff', label: 'Opus' },
'claude-haiku': { color: '#a78bfa', bg: '#faf5ff', label: 'Haiku' },
// Gemini variants — shades of blue
'gemini': { color: '#1d4ed8', bg: '#eff6ff', label: 'Gemini' },
'gemini-pro': { color: '#1d4ed8', bg: '#eff6ff', label: 'Gemini Pro' },
'gemini-flash': { color: '#0284c7', bg: '#f0f9ff', label: 'Gemini Flash' },
'gemini-2-flash': { color: '#0ea5e9', bg: '#f0f9ff', label: 'Gemini 2 Flash' },
// OpenAI / Copilot
'openai': { color: '#059669', bg: '#ecfdf5', label: 'Copilot' },
// Fallback
'default': { color: '#6b7280', bg: '#f9fafb', label: 'AI' },
}