Compare commits

..

9 Commits

Author SHA1 Message Date
Ben Stull 28015ed1a2 Release v0.24.0: Claude Haiku tag suggestions on propose-RFC (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:28:12 -07:00
Ben Stull 376a6daddc Merge feature/v0.24.0-haiku-tags (#27: Claude Haiku tag suggestions on propose-RFC) 2026-05-28 13:25:50 -07:00
Ben Stull fb9b4fa422 v0.24.0: Claude Haiku tag suggestions on propose-RFC (roadmap #27)
The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose
modal landed, now wired up. As the propose-RFC draft fills in, the
backend asks Claude Haiku for tags drawn ONLY from the corpus's
existing tag set (the de-facto taxonomy — v1 tags are free-form chip
input, there is no curated list), surfaced as clickable suggestion
chips. Nothing auto-applies; the user clicks to add.

Backend:
- tag_suggest.py: universe gather (distinct corpus tags, most-common
  first), Haiku prompt + tolerant reply parser (drops invented tags,
  dedupes, clamps confidence), in-process per-user rate limit.
- providers.construct_haiku(): dedicated Haiku provider from the
  operator key, independent of ENABLED_MODELS — tag suggestion always
  uses the cheap+fast model. No RFC slug at propose time, so the §6.7
  funder path does not apply.
- POST /api/rfcs/suggest-tags: contributor-gated, rate-limited.
  Degrades to an empty list (never an error) when no Anthropic key is
  bound, the corpus has no tags, or the draft is empty.

Frontend:
- ProposeModal: debounced suggestion fetch (700ms, stale-response
  guarded), clickable suggestion chips, and the required inline
  disclosure that the draft text is sent to Anthropic.
- api.suggestTags(): forgiving — any non-OK resolves to [].

Tests: 11 new (vertical contributor-gating / filtering / no-key /
empty-corpus / 429, plus units for gather, parser tolerance, max,
short-circuit, provider-failure). Full suite 351 green.

New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps).
Disclosure copy wants a counsel pass before deploy, per #22 discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:25:39 -07:00
Ben Stull daebb54f47 Release v0.23.0: server-side sign-in state resume (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:21 -07:00
Ben Stull a598221812 Merge feature/v0.23.0-signin-resume (#29: server-side sign-in state resume) 2026-05-28 12:35:46 -07:00
Ben Stull bada72f87e v0.23.0: server-side sign-in state resume (roadmap #29)
Track each authenticated user's last-viewed route + light view state
server-side, and on next sign-in redirect them to that state, falling
back to the empty-state home only when there's no recorded state.

Backend:
- migration 022_user_session_state.sql: one row per user
  (user_id PK/FK, last_route, last_route_state JSON-as-TEXT,
  resume_enabled default 1, last_updated_at).
- PUT /api/me/last-state (require_user): upserts route + light state;
  no-ops when resume_enabled=0. Localized in the /me region.
- /api/auth/me payload now carries resume_enabled + last_route +
  decoded last_route_state (no extra round-trip).
- test_session_resume_vertical.py: auth-required, upsert/read-back,
  per-user isolation, resume_enabled=0 disable.

Frontend:
- lib/useLastState.js: debounced (~1s) route-change PUT for
  authenticated users; one-time resume redirect on sign-in, gated on
  identify having fired (preserves #21 Part C identify-then-track).
- api.js: putLastState() client call.
- App.jsx: import + call the hook; set identifyReady after identify.
  Header region untouched.

Per-user (not per-device); profile-settings opt-out toggle UI
deferred (column + default-on behavior ship now). Stored state is
route + light view state ONLY, never draft buffers — documented in
SPEC §6.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:34:19 -07:00
Ben Stull 7d8371dea1 Release v0.22.0: optional propose use-case field (#26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:32:34 -07:00
Ben Stull 3a51425ec7 Merge feature/v0.22.0-usecase-field (#26: optional propose use-case field) 2026-05-28 12:30:36 -07:00
Ben Stull 7c6c906db2 #26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.

- propose-RFC modal (ProposeModal): optional textarea below the required
  "Why is this RFC needed?" pitch, labeled "What will you be using this
  RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
  description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
  (NULL/omitted accepted, no min, 8000-char cap matching the existing
  free-text bound). Persisted to a new canonical side table
  `proposed_use_cases` keyed by PR number, mirrored onto the cache
  columns added by migration 021. Returned on the proposal list/detail,
  RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
  captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
  plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
  supplied, accepted as NULL/omitted, for both surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:52 -07:00
22 changed files with 1686 additions and 13 deletions
+164
View File
@@ -23,6 +23,170 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.24.0 — 2026-05-28
**Minor — one new secret required before this version serves tag
suggestions; no schema migration; no behavior change for deployments
that do not bind the key.** Roadmap item #27: as a contributor fills in
the propose-RFC form, the backend asks Claude Haiku to recommend tags
drawn from the collection's existing tag set, surfaced as clickable
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
"Slice 2" AI-suggested chips that the propose modal has carried a
deferred placeholder for since Slice 1.
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
rate-limited). Takes the partial draft (`title`, `pitch`,
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
…] }`. The model is constrained to the corpus's existing tags — v1
tags are free-form chip input, so "the taxonomy" is the de-facto set
of distinct tags the existing RFCs carry. The model MUST NOT invent
tags; taxonomy extension is a deliberate out-of-scope follow-up.
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
`providers.construct_haiku()` factory. There is no RFC slug at propose
time, so the §6.7 per-RFC funder credential path does not apply — the
surface runs on the operator's own `ANTHROPIC_API_KEY`.
- **Degrades to silence, never error.** No key bound, an empty corpus,
an empty draft, a rate-limited caller, or an unparseable model reply
all yield an empty list; the propose modal hides its suggestion row,
and the rest of the app is unaffected. So a deployment that does not
set the key sees no change at all.
- **Privacy disclosure.** The modal carries an inline note that the
draft text is sent to Anthropic to generate the suggestions. This is
required for honesty (the text leaves the deployment before the RFC
is submitted); cookie-consent does not gate it because the user has
actively typed into a draft surface. The exact wording wants a
counsel pass before OHM's deploy, per the #22 drafting discipline.
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
stale-response guard); suggestion chips are clickable and add to the
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
any non-OK response resolves to `[]`.
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
universe-constrained filtering, no-key / empty-corpus / rate-limit
paths, plus units for the universe gather, the tolerant reply parser,
the max cap, the empty-draft short-circuit, and provider-failure
fallback. Full suite 351 green.
Upgrade steps:
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
work. On OHM this is an operator gesture — the key never touches the
conversation. Set it from your own terminal via stdin (so the bytes
never land in shell history):
```bash
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
secret set ohm-rfc-app ANTHROPIC_API_KEY
```
(The §18 chat stack already reads this same key, so a deployment
that has chat configured **MAY** already have it bound — confirm with
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
still boots and serves normally; tag suggestions are simply
unavailable until it is set.
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
defaults apply when unset.
3. You **SHOULD** have counsel review the modal's disclosure wording
before exposing the surface to users, per the #22 drafting
discipline — the copy is honest as written, but the legal review is
the right call for any "your text is sent to a third party" notice.
## 0.23.0 — 2026-05-28
Roadmap item #29: signing in lands the user on their most recently
viewed app state instead of the empty-state home view. Shipped from
driver session 0022.0. Per-user, server-side, on by default; first-ever
sign-ins still land on home.
1. **Server-side last-state.** A new `user_session_state` table holds one
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
`last_updated_at`. Per-user (not per-device), matching the
"go to where I was last" intent.
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
useLastState.js`) debounce-posts the current route + light state to
`PUT /api/me/last-state` for authenticated users only; anonymous
sessions are a no-op. The handler upserts the user's row and no-ops
when `resume_enabled` is 0.
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
(no new GET endpoint), so the frontend reads it on boot. On a hard
sign-in landing (app booted on `/`), the app redirects to the stored
route. The redirect is gated on the #21-Part-C Amplitude `identify`
having fired (`identifyReady`), preserving identify-then-track
ordering — the first event on the resumed route carries the user_id.
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
unreadable) are a graceful no-op — existing routing falls through to
the catalog/empty-state. Opt-out ships at the column level
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
Stored state is route + light view state only, never draft contents
(documented in `SPEC.md` §6.8).
Migration `022_user_session_state.sql` creates the table. No new
environment variables, overlay keys, or secrets.
Upgrade steps:
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
runs automatically on next boot (the migration runner globs
`backend/migrations/*.sql` and applies any not yet recorded in
`schema_migrations`). The migration is additive — a new table — and
requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
action beyond the standard `flotilla deploy ohm-rfc-app`.
## 0.22.0 — 2026-05-28
Roadmap item #26: an optional **"What will you be using this for?"**
field on both propose surfaces. Shipped from driver session 0022.0.
Additive and backward-compatible — no behavior changes for anyone who
leaves the field blank.
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
field (`pitch`) sits a new optional **"What will you be using this
RFC for?"** textarea. "Needed" is the abstract justification; "using
it for" is the concrete ground-truth use case — captured without
being forced.
2. **Propose-PR modal.** Below the required "Why is this change needed?"
field (`description`) sits a new optional **"What will you be using
this change for?"** textarea.
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
view (`PRView`) render the captured use case alongside the existing
"why" text, with a muted "left blank" treatment when absent.
4. **Persistence (deployment note).** In this deployment the framework's
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
path is endpoint → Gitea → reconcile (and the reconciler doesn't
carry the new field), the durable home is a canonical, reconcile-proof
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
the propose/open endpoints write directly and the view endpoints read
back. The literal nullable `proposed_use_case` columns named by the
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
any future reconciler that learns to carry the field. NULL/blank use
cases simply never write a side-table row — absence is "left blank".
Migration `021_proposed_use_case.sql` adds the two cache columns
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
`proposed_use_cases` canonical table, and its lookup indexes. The
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
so the added cache columns survive reconciles. Backend validators accept
the field as NULL/omitted (no required-validation) with an 8000-char cap
matching the existing PR `description` bound; blank/whitespace is treated
as absent.
Upgrade steps:
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
runs automatically on next boot (the migration runner globs
`backend/migrations/*.sql` and applies any not yet recorded in
`schema_migrations`). The migration is additive — nullable cache
columns plus a new table — and requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
action beyond the standard `flotilla deploy ohm-rfc-app`.
## 0.21.0 — 2026-05-28
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
+41
View File
@@ -714,6 +714,47 @@ The lighter half ships the structural shape — frontmatter, consent,
resolution, revocation. The heavier half ships the runtime
hardening.
### 6.8 Sign-in state resume (roadmap item #29, v0.23.0)
Signing in lands the user back on their most recently-viewed app
state instead of always on the empty-state home view. The model is
**per-user**, not per-device — the safe default the roadmap calls
for: a sign-in on any device resumes the most-recently-recorded
route. A per-device split and a profile-settings toggle UI are
follow-ups; v0.23.0 ships the column-level opt-out flag
(`resume_enabled`, default on) and the default-on behavior.
**Storage.** A single row per user in `user_session_state`
(migration 022): `user_id` (PK, FK → `users.id`, cascade-delete),
`last_route` (TEXT, the frontend pathname), `last_route_state`
(TEXT, JSON-encoded — SQLite has no native JSONB, so JSON-as-TEXT
matches how the app stores every other JSON blob), `resume_enabled`
(INTEGER, default 1), and `last_updated_at`.
**Wiring.** A debounced (~1s) frontend route-change hook posts the
current route to `PUT /api/me/last-state` for authenticated users
(anonymous: no-op). The stored `last_route` is folded onto the
existing `/api/auth/me` payload (no extra round-trip); on sign-in,
after the §21-Part-C Amplitude `identify` fires, the frontend
`navigate()`s to it. The identify-then-redirect ordering is
preserved: the redirect is gated on identify having fired.
**Stale state.** If the stored route is an RFC since withdrawn or
one the user lost rights to read, the redirect is a graceful no-op:
`navigate(last_route)` lands on whatever that route renders today,
and the existing routing already falls through to the
catalog/empty-state for a missing/unreadable RFC. No special-casing
on the server.
**Privacy (binding).** The stored state is **route + light view
state ONLY** — scroll anchors, open-tab selection, filter chips, and
the like. It **MUST NOT** carry draft-buffer contents, PR/comment
draft text, or any user-typed content. The frontend never sends such
content; the `last_route_state` column comment in migration 022 and
this paragraph are the contract. Resume state is purposely cheap to
discard: a deliberate "clear" (or `resume_enabled = 0`) drops the
user back to today's empty-state behavior.
---
## 7. The left pane
+1 -1
View File
@@ -1 +1 @@
0.21.0
0.24.0
+12
View File
@@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# Comma-separated list of provider keys to enable. Per the §19.2
# per-RFC-model topic, this is app-wide until that topic lands.
ENABLED_MODELS=claude
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
# (roadmap #27) — that surface always uses Claude Haiku for cost,
# independent of ENABLED_MODELS. With no key set, tag suggestions are
# simply unavailable (the modal hides the row); the rest of the app is
# unaffected.
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
# --- Tag suggestions (§9.1 / roadmap #27) ---
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
# modal debounces and the endpoint is contributor-gated). Optional —
# these defaults apply when unset.
TAG_SUGGEST_RATE_MAX=30
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
# --- Email (§15.4) ---
# Leave SMTP_HOST unset to use the stdout fallback — the integration
# tests rely on it, and a dev environment without a real SMTP provider
+175 -1
View File
@@ -39,6 +39,7 @@ from . import (
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -51,6 +52,22 @@ class ProposeBody(BaseModel):
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
# Roadmap #26: optional "What will you be using this RFC for?" — the
# concrete ground-truth use case, distinct from the `pitch`'s abstract
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
# generous cap matching the pitch's free-text body bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class SuggestTagsBody(BaseModel):
# Roadmap #27: the partial propose-RFC draft, sent as the user types
# (debounced on the frontend). All fields optional — suggestions
# refine as the draft fills in. `pitch` is the "why is this needed"
# rationale; `use_case` is the #26 optional ground-truth field.
# Bounds mirror the propose body's free-text caps.
title: str = Field(default="", max_length=200)
pitch: str = Field(default="", max_length=8000)
use_case: str = Field(default="", max_length=8000)
class DeclineBody(BaseModel):
@@ -62,6 +79,18 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class LastStateBody(BaseModel):
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
# `route` is a frontend pathname the user was last on (bounded so a
# hostile client can't stuff arbitrary blobs through). `state` is an
# optional bag of *light* view state (scroll anchors, open tab,
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
# the frontend only ever sends ephemeral view state, and the column
# comment in migration 022 + SPEC §6.2 are the binding contract.
route: str = Field(min_length=1, max_length=2048)
state: dict[str, Any] | None = None
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
@@ -349,6 +378,28 @@ def make_router(
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
# v0.23.0 / item #29: fold the sign-in-resume state onto the
# same round-trip the frontend already makes on boot. When
# resume is disabled (resume_enabled = 0) we hand back a null
# route so the client never redirects; the stored row stays put
# so re-enabling later resumes the last-known route.
state_row = db.conn().execute(
"SELECT last_route, last_route_state, resume_enabled "
"FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
last_route = (
state_row["last_route"]
if (state_row and resume_enabled)
else None
)
last_route_state = None
if state_row and resume_enabled and state_row["last_route_state"]:
try:
last_route_state = json.loads(state_row["last_route_state"])
except (ValueError, TypeError):
last_route_state = None
return {
"authenticated": True,
"user": {
@@ -365,6 +416,10 @@ def make_router(
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
# v0.23.0 / item #29 — sign-in state resume.
"resume_enabled": resume_enabled,
"last_route": last_route,
"last_route_state": last_route_state,
},
}
@@ -434,6 +489,52 @@ def make_router(
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
# resume. The frontend debounce-posts the user's current route +
# a small bag of light view state here on every route change; the
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
#
# Per-user (NOT per-device) — one row per user, keyed on user_id.
# `resume_enabled` is the opt-out flag (default on); when it's 0
# this endpoint no-ops so a user who turned resume off doesn't keep
# silently rewriting their stored route. PRIVACY: the body carries
# route + light state ONLY, never draft-buffer contents (migration
# 022 column comment + SPEC §6.2 are the binding contract).
#
# `require_user` (not `require_contributor`) — a pending/granted
# distinction is irrelevant for "remember where I was", and a
# pending user navigating read-only surfaces should still resume.
# Anonymous callers get the 401 `require_user` raises.
# ---------------------------------------------------------------
@router.put("/api/me/last-state")
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
# Respect the opt-out: if a row already exists with resume
# disabled, leave it untouched and report the no-op. A first-
# ever POST (no row yet) defaults to enabled and stores.
existing = db.conn().execute(
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
if existing is not None and not existing["resume_enabled"]:
return {"ok": True, "stored": False}
state_json = json.dumps(body.state) if body.state is not None else None
db.conn().execute(
"""
INSERT INTO user_session_state
(user_id, last_route, last_route_state, last_updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
last_route = excluded.last_route,
last_route_state = excluded.last_route_state,
last_updated_at = excluded.last_updated_at
""",
(user.user_id, body.route, state_json),
)
return {"ok": True, "stored": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
@@ -557,12 +658,36 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
# `proposed_use_cases` persists; look it up by slug (the latest
# 'rfc'-scope row for this slug). NULL == "left blank".
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ?
ORDER BY id DESC LIMIT 1
""",
(slug,),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
def _proposal_use_case(pr_number: int) -> str | None:
"""Roadmap #26: read the optional use case for an idea PR from the
canonical side table. Returns None when none was supplied (the
"left blank" sentinel the frontend renders tastefully)."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
rows = db.conn().execute(
@@ -582,6 +707,7 @@ def make_router(
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
@@ -630,6 +756,7 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
"proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
@@ -706,8 +833,55 @@ def make_router(
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the idea PR number. NULL/
# blank simply writes no row (absence == "left blank"). Done after
# the refresh so the cache row exists; the mirror onto cached_prs
# keeps the cache column in parity for any read that uses it.
use_case = (payload.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
(use_case, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial
# draft; we constrain Haiku to the corpus's existing tag set and
# return a short ranked list of clickable chips. Gated to
# contributors (same gate as propose) so the cost surface is bounded
# to people who can actually file an RFC; rate-limited per user as a
# backstop. Degrades to an empty list (no error) when no Anthropic
# key is bound, the corpus has no tags yet, or the draft is empty —
# so the modal simply shows nothing extra.
# ---------------------------------------------------------------
@router.post("/api/rfcs/suggest-tags")
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
if not tag_suggest.rate_limit_ok(user.user_id):
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
provider = tag_suggest.haiku_provider(config)
if provider is None:
return {"suggestions": []}
universe = tag_suggest.gather_tag_universe()
draft = tag_suggest.Draft(
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
)
suggestions = tag_suggest.suggest(provider, draft, universe)
return {"suggestions": suggestions}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
+37
View File
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
class OpenPRBody(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(max_length=8000)
# Roadmap #26: optional "What will you be using this change for?" —
# the concrete ground-truth use case sibling to the required
# "why is this change needed" (the `description`). Optional, generous
# cap matching the description bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class PRDescriptionBody(BaseModel):
@@ -173,6 +178,26 @@ def make_router(
raise HTTPException(502, f"Gitea: {e.detail}")
await _refresh_after_pr_write(rfc)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the PR number. Blank/omitted
# writes no row (absence == "left blank"). The mirror onto
# cached_prs keeps the cache column in parity.
use_case = (body.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
(use_case, slug, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -300,6 +325,7 @@ def make_router(
"pr_number": pr_number,
"title": pr_row["title"],
"description": pr_row["description"],
"proposed_use_case": _pr_use_case(pr_number),
"state": pr_row["state"],
"opened_by": pr_row["opened_by"],
"opened_at": pr_row["opened_at"],
@@ -762,6 +788,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
return _can_withdraw(rfc, pr_row, viewer)
def _pr_use_case(pr_number: int) -> str | None:
"""Roadmap #26: the optional propose-PR use case from the canonical
side table, or None when the change was opened without one ("left
blank")."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
return {
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
+15
View File
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
return providers
def construct_haiku(api_key: str) -> AnthropicProvider:
"""A dedicated Claude Haiku provider, independent of the
`ENABLED_MODELS` chat-picker universe.
The §9.1 tag-suggestion surface (roadmap #27) always wants the
cheap + fast model regardless of which models the operator surfaces
in the §8.12 picker, so it constructs Haiku directly from the
operator's Anthropic key rather than going through `load_providers`.
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
picker uses, so a model-string bump lands in one place.
"""
model, name = _CLAUDE_VARIANTS["claude-haiku"]
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
+244
View File
@@ -0,0 +1,244 @@
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list tags are free-form chip
input (§9.1) so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.
Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.
The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from . import db
from .providers import BaseProvider, construct_haiku
log = logging.getLogger(__name__)
# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200
# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6
@dataclass
class Draft:
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
rationale; `use_case` is the #26 optional ground-truth field."""
title: str = ""
pitch: str = ""
use_case: str = ""
def is_empty(self) -> bool:
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
def haiku_provider(config) -> BaseProvider | None:
"""Construct a dedicated Claude Haiku provider from the operator's
Anthropic key, or return None when no key is configured.
None means "suggestions unavailable" the caller returns an empty
list and the modal shows nothing. This is the seam tests monkeypatch
to inject a fake provider without a real key. There is no RFC slug at
propose time, so the §6.7 per-RFC funder path deliberately does not
apply: tag suggestion always runs on the operator's own key.
"""
key = getattr(config, "anthropic_api_key", "") or ""
if not key:
return None
return construct_haiku(key)
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
"""Every distinct tag in use across the cached corpus, most-common
first (ties broken alphabetically for determinism), capped.
This is the universe the model is constrained to. An empty corpus
yields an empty list, which short-circuits suggestion to silence.
"""
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
counts: dict[str, int] = {}
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
return ranked[:cap]
# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------
_CALLS: dict[int, list[float]] = {}
def _rate_config() -> tuple[int, float]:
try:
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
except ValueError:
max_calls = 30
try:
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
except ValueError:
window = 60.0
return max_calls, window
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
"""True if this call is within the per-user window; records the call.
`now` is injectable for tests (defaults to a monotonic clock)."""
max_calls, window = _rate_config()
t = time.monotonic() if now is None else now
calls = _CALLS.setdefault(user_id, [])
cutoff = t - window
calls[:] = [c for c in calls if c > cutoff]
if len(calls) >= max_calls:
return False
calls.append(t)
return True
def reset_rate_limits() -> None:
"""Test seam — clear the in-process window state."""
_CALLS.clear()
# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------
_SYSTEM = (
"You suggest topic tags for a draft RFC — a structured proposal "
"document in a collection. You are given the draft's title, its "
"rationale, an optional use case, and the exact set of tags already "
"in use across the collection. Choose the tags from that set that "
"best fit the draft.\n"
"Rules:\n"
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
"- Return at most {max} tags.\n"
"Respond with ONLY a JSON array, no prose, of the form:\n"
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
"If no tag in the set fits the draft, return []."
)
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
system = _SYSTEM.format(max=max_suggestions)
parts: list[str] = []
if draft.title.strip():
parts.append(f"Title: {draft.title.strip()[:300]}")
if draft.pitch.strip():
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
if draft.use_case.strip():
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
parts.append(
"Tags already in use (choose only from these):\n" + ", ".join(universe)
)
return system, [{"role": "user", "content": "\n\n".join(parts)}]
def _extract_json_array(text: str):
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(text[start : end + 1])
except ValueError:
return None
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
"""Parse the model reply into a clean, universe-constrained list.
Tolerant of the model returning bare strings or objects, extra prose
around the JSON, unknown/invented tags (dropped), duplicate tags
(deduped), and missing/garbage confidences (defaulted + clamped).
"""
data = _extract_json_array(text or "")
if not isinstance(data, list):
return []
# Map back to the canonical spelling in the universe, case-insensitively,
# so a model that lowercases a tag still resolves to the real one.
canonical = {t.lower(): t for t in universe}
out: list[dict] = []
seen: set[str] = set()
for item in data:
if isinstance(item, dict):
raw = item.get("tag")
conf = item.get("confidence")
elif isinstance(item, str):
raw, conf = item, None
else:
continue
if not isinstance(raw, str):
continue
tag = canonical.get(raw.strip().lower())
if tag is None or tag in seen:
continue
try:
c = float(conf) if conf is not None else 0.5
except (ValueError, TypeError):
c = 0.5
c = max(0.0, min(1.0, c))
out.append({"tag": tag, "confidence": round(c, 3)})
seen.add(tag)
if len(out) >= max_suggestions:
break
return out
def suggest(
provider: BaseProvider,
draft: Draft,
universe: list[str],
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
"""Orchestrate one suggestion call. Returns [] for an empty draft or
empty universe (no model call), and for any provider/parse failure."""
if draft.is_empty() or not universe:
return []
system, history = _build_messages(draft, universe, max_suggestions)
try:
text = provider.send(system, history)
except Exception as exc: # provider/network failure → silent empty
log.warning("tag-suggest provider failed: %s", exc)
return []
return parse_reply(text, universe, max_suggestions)
@@ -0,0 +1,47 @@
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
-- this for?" capture on the two propose surfaces.
--
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
-- (002_cache.sql). We add the nullable column to each, matching the
-- existing naming convention (no NOT NULL, no default — NULL is the
-- "left blank" sentinel the view surfaces render tastefully).
--
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
-- sets only the columns it knows about, so an unlisted column is
-- preserved on the update path — yet a propose/open never *writes* the
-- column through the cache (the write path is endpoint -> Gitea ->
-- reconcile, and the reconciler does not carry this field). So the cache
-- column alone would always read NULL.
--
-- The durable home is therefore a dedicated app-truth table the propose
-- /open endpoints write directly (keyed by the PR number, which is the
-- stable identity for both idea PRs and rfc_branch PRs) and the view
-- endpoints read back. This is not cache — it is canonical and survives
-- any reconcile. The cache columns are added too for parity with the
-- roadmap's literal shape and for any future reconciler that learns to
-- carry the field, but the side table is the source of truth read at
-- view time.
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
-- Canonical, reconcile-proof store. One row per propose/open that
-- supplied a use case. `scope` distinguishes the propose-RFC surface
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
-- is the join key the endpoints already have in hand. NULL/omitted use
-- cases simply never write a row here, so absence == "left blank".
CREATE TABLE proposed_use_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope, pr_number)
);
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
@@ -0,0 +1,54 @@
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
--
-- Track each authenticated user's last-viewed route + a small bag of
-- "light" component state so that the *next* sign-in can land the user
-- back where they left off, rather than always dropping them on the
-- empty-state home view.
--
-- Storage shape (one row per user — per-user, NOT per-device, per the
-- #29 "safe default"):
--
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
-- AUTOINCREMENT). A deleted user automatically loses their stored
-- resume state. One row per user means a later sign-in on any
-- device resumes the most-recently-recorded route — the per-user
-- model the roadmap asks for.
--
-- * `last_route` — the frontend pathname the user was last on
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
-- route-change POST lands. NEVER contains draft-buffer contents —
-- it is a route only. See SPEC §6.2 "Sign-in state resume
-- (privacy)".
--
-- * `last_route_state` — a JSON-encoded bag of *light* component
-- state (scroll anchors, open-tab selection, filter chips, etc.).
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
-- cf. permission_events.details, actions.details). Nullable.
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
-- PR bodies, comment drafts, or any user-typed content — only
-- ephemeral view state safe to replay. The PUT handler is the
-- enforcement point; the column comment is the contract.
--
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
-- "resume me where I left off"; 0 means "always land on home". The
-- PUT handler no-ops the upsert when this is 0, and the read path
-- refuses to hand back a stored route when this is 0. A
-- profile-settings toggle UI to flip this is a follow-up (the
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
--
-- * `last_updated_at` — TEXT timestamp, app convention
-- `datetime('now')`, matching device_trust.last_seen_at /
-- users.last_seen_at. Refreshed on every successful upsert.
--
-- No new env vars. The debounce interval for the frontend route-change
-- POST is a frontend constant (~1s), not a server knob.
CREATE TABLE user_session_state (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_route TEXT,
last_route_state TEXT, -- JSON-encoded light state, nullable
resume_enabled INTEGER NOT NULL DEFAULT 1,
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,161 @@
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
"What will you be using this for?" capture on the two propose surfaces.
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
the active-RFC seed from test_rfc_view_vertical.py. Proves:
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
and the value survives onto the merged super-draft's RFC view;
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
(c) propose-PR persists and returns `proposed_use_case` when supplied,
and accepts a NULL / omitted one.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_pr_flow_vertical import _cut_branch_and_accept_change
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# propose-RFC
# ---------------------------------------------------------------------------
def test_propose_rfc_persists_and_returns_use_case(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=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "open-human-model",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity"],
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The pending-idea list carries the use case.
items = client.get("/api/proposals").json()["items"]
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# The pending-idea detail view carries it too.
proposal = client.get(f"/api/proposals/{pr_number}").json()
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# Merge as owner; the use case survives onto the RFC view (looked
# up by slug from the canonical side table, since the idea PR
# closes on merge).
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
view = client.get("/api/rfcs/open-human-model").json()
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
def test_propose_rfc_use_case_optional(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=3, login="carol", role="contributor")
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
# Omitted entirely.
r = client.post("/api/rfcs/propose", json={
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
})
assert r.status_code == 200, r.text
pr_a = r.json()["pr_number"]
# Explicit null.
r = client.post("/api/rfcs/propose", json={
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
"tags": [], "proposed_use_case": None,
})
assert r.status_code == 200, r.text
pr_b = r.json()["pr_number"]
# Blank/whitespace — treated as "left blank", no row written.
r = client.post("/api/rfcs/propose", json={
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
"tags": [], "proposed_use_case": " ",
})
assert r.status_code == 200, r.text
pr_c = r.json()["pr_number"]
for pr in (pr_a, pr_b, pr_c):
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
# ---------------------------------------------------------------------------
# propose-PR (against an active RFC)
# ---------------------------------------------------------------------------
def test_propose_pr_persists_and_returns_use_case(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")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="Open Human Model is a framework for representing humans.",
proposed="Open Human Model is a framework for representing humans across systems.",
)
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={
"title": "Tighten the opening",
"description": "Scope to systems.",
"proposed_use_case": "Building a cross-system consent registry.",
},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
def test_propose_pr_use_case_optional(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")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="It defines consent, trait, and agency in compatible terms.",
proposed="It defines consent, trait, harm, and agency in compatible terms.",
)
# No proposed_use_case key at all.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Name harm explicitly."},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] is None
@@ -0,0 +1,140 @@
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
vertical (§6.2, roadmap item #29).
New behavior: each authenticated user's last-viewed route + a small bag
of light view state is tracked server-side, so the next sign-in can land
them back where they left off rather than on the empty-state home view.
The tests below prove:
* `PUT /api/me/last-state` requires auth an anonymous client gets
401, and nothing is stored.
* An authenticated PUT upserts the route, and the stored route is
read back for that user off `GET /api/auth/me` (`last_route`).
* A second PUT overwrites (upsert, one row per user) the latest
route wins.
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
* Per-user isolation: user A's stored route is not visible to user B.
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
rewrite the stored route) and `/api/auth/me` hands back a null
`last_route` even though a stored row exists.
The fakes from `test_propose_vertical` give us a working app harness +
the `sign_in_as` / `provision_user_row` seams.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_put_last_state_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Anonymous — no session cookie set.
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
assert r.status_code == 401
# Nothing landed in the table.
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
assert row["n"] == 0
def test_put_last_state_upserts_and_reads_back(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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# First POST stores a route + light state.
r = client.put(
"/api/me/last-state",
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
)
assert r.status_code == 200
assert r.json()["stored"] is True
# /api/auth/me hands the route + decoded state back.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["resume_enabled"] is True
assert me["user"]["last_route"] == "/rfc/open-human-model"
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
# A later POST overwrites — one row per user, latest wins.
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
assert r.status_code == 200
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/proposals/7"
# state was omitted on the second POST → cleared to null.
assert me["user"]["last_route_state"] is None
def test_last_state_is_per_user(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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
# Switch to bob — he has no stored route yet.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] is None
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/bob-route"
# Back to alice — her route is untouched by bob's write.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/alice-route"
def test_resume_disabled_no_ops_put_and_hides_route(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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# Seed a stored row, then flip resume_enabled off directly (the
# profile-settings toggle UI to do this from the client is a
# follow-up; the column + behavior ship now).
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
db.conn().execute(
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
(2,),
)
# /api/auth/me reports resume off and hands back a null route
# even though a stored row exists.
me = client.get("/api/auth/me").json()
assert me["user"]["resume_enabled"] is False
assert me["user"]["last_route"] is None
# A PUT while disabled no-ops: stored=False and the stored route
# is NOT rewritten.
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
assert r.status_code == 200
assert r.json()["stored"] is False
row = db.conn().execute(
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
).fetchone()
assert row["last_route"] == "/rfc/before-disable"
+264
View File
@@ -0,0 +1,264 @@
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
Haiku tag suggestions on the propose-RFC modal.
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
The Anthropic call is never made for real tests monkeypatch the
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
returns canned text, so the HTTP contract is exercised without a key.
Proves:
(a) the endpoint is contributor-gated (anon 401);
(b) a contributor gets suggestions, filtered to the corpus tag
universe, with invented tags dropped;
(c) no Anthropic key bound empty list, not an error;
(d) an empty corpus empty list (model is never even called);
(e) the per-user rate limit surfaces as a 429;
plus unit coverage of the universe gather, the reply parser's tolerance,
and the suggest() orchestration short-circuits.
"""
from __future__ import annotations
import json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class StubProvider:
"""A BaseProvider stand-in whose send() returns a fixed string (or
raises, to exercise the graceful-failure path)."""
def __init__(self, reply: str = "[]", *, raises: bool = False):
self.reply = reply
self.raises = raises
self.calls: list[tuple[str, list]] = []
def send(self, system, history):
self.calls.append((system, history))
if self.raises:
raise RuntimeError("boom")
return self.reply
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
(slug, title, state, json.dumps(tags)),
)
def _use_provider(monkeypatch, provider) -> None:
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
@pytest.fixture(autouse=True)
def _reset_rate_limits():
from app import tag_suggest
tag_suggest.reset_rate_limits()
yield
tag_suggest.reset_rate_limits()
# ---------------------------------------------------------------------------
# Endpoint (vertical)
# ---------------------------------------------------------------------------
def test_anonymous_cannot_suggest(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
assert r.status_code == 401
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
_seed_tags("other", "Other", ["identity", "governance"])
# Model returns two real tags (one lowercased to test canonical
# mapping is exact-set anyway), plus one invented tag that MUST
# be dropped.
reply = json.dumps([
{"tag": "identity", "confidence": 0.9},
{"tag": "consent", "confidence": 0.7},
{"tag": "totally-invented", "confidence": 0.99},
])
stub = StubProvider(reply=reply)
_use_provider(monkeypatch, stub)
r = client.post("/api/rfcs/suggest-tags", json={
"title": "Consent and identity",
"pitch": "We need a shared definition of consent tied to identity.",
})
assert r.status_code == 200, r.text
tags = [s["tag"] for s in r.json()["suggestions"]]
assert tags == ["identity", "consent"]
# the model was actually invoked
assert len(stub.calls) == 1
# the universe (deduped distinct tags) was handed to the model
user_msg = stub.calls[0][1][0]["content"]
assert "identity" in user_msg and "governance" in user_msg
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
# (Explicitly assert the seam returns None given the test config.)
from app import tag_suggest
assert tag_suggest.haiku_provider(app.state.config) is None
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
_use_provider(monkeypatch, stub)
# No cached_rfcs rows → empty universe → suggest() short-circuits.
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
assert stub.calls == [] # model never invoked on an empty universe
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
_use_provider(monkeypatch, StubProvider(reply="[]"))
body = {"title": "X", "pitch": "y"}
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
# Third call inside the window trips the limit.
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
# ---------------------------------------------------------------------------
# Units
# ---------------------------------------------------------------------------
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import tag_suggest
app, _fake = app_with_fake_gitea
# The db is initialized in the app's lifespan; enter the client
# context so cached_rfcs exists before we seed it directly.
with TestClient(app):
_seed_tags("a", "A", ["identity", "schema"])
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
_seed_tags("c", "C", ["identity"])
universe = tag_suggest.gather_tag_universe()
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
# empties dropped.
assert universe == ["identity", "schema", "consent"]
def test_parse_reply_tolerates_junk():
from app import tag_suggest
universe = ["identity", "schema", "consent"]
# Prose around the JSON, an invented tag, a bare string, a dup, a
# missing confidence, and a garbage confidence.
text = (
"Sure! Here are the tags:\n"
'[{"tag": "identity", "confidence": 0.9}, '
'{"tag": "invented", "confidence": 1}, '
'"schema", '
'{"tag": "identity", "confidence": 0.5}, '
'{"tag": "consent"}, '
'{"tag": "consent", "confidence": "high"}]\n'
"Hope that helps!"
)
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
tags = [s["tag"] for s in out]
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
by_tag = {s["tag"]: s["confidence"] for s in out}
assert by_tag["identity"] == 0.9
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
def test_parse_reply_empty_on_unparseable():
from app import tag_suggest
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
assert tag_suggest.parse_reply("", ["a"], 6) == []
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
def test_parse_reply_respects_max():
from app import tag_suggest
universe = ["a", "b", "c", "d", "e"]
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
assert [s["tag"] for s in out] == ["a", "b", "c"]
def test_suggest_short_circuits_empty_draft():
from app import tag_suggest
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
assert tag_suggest.suggest(stub, draft, ["a"]) == []
assert stub.calls == [] # never called for an empty draft
def test_suggest_returns_empty_on_provider_failure():
from app import tag_suggest
stub = StubProvider(raises=True)
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.21.0",
"version": "0.24.0",
"type": "module",
"scripts": {
"dev": "vite",
+30
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import { useLastState } from './lib/useLastState'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -42,6 +43,12 @@ export default function App() {
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
// v0.23.0 / item #29 flips true once the #21-Part-C identify effect
// has fired (or once we've confirmed there's no authenticated user to
// identify). useLastState gates its resume redirect on this so the
// redirect always happens AFTER identify, preserving identify-then-
// track ordering.
const [identifyReady, setIdentifyReady] = useState(false)
const navigate = useNavigate()
const location = useLocation()
// v0.15.0 Page Viewed event taxonomy. We fire on every
@@ -86,6 +93,9 @@ export default function App() {
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
identify({ user_id: String(uid), properties: props })
// v0.23.0 / item #29 identify has now fired for this sign-in;
// release useLastState's resume redirect (it waits on this).
setIdentifyReady(true)
} else if (uid == null && lastUserIdRef.current != null) {
// Sign-out edge App-level reset is handled separately by the
// sign-out gesture that fires User Signed Out. Clear our local
@@ -94,6 +104,14 @@ export default function App() {
}
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
// v0.23.0 / item #29 once `me` has resolved, if there's no
// authenticated user there is nothing to identify, so release the
// resume gate immediately (anonymous boots have no resume to do, but
// the hook still needs the gate resolved to be a clean no-op).
useEffect(() => {
if (me != null && !me.authenticated) setIdentifyReady(true)
}, [me])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
@@ -107,6 +125,18 @@ export default function App() {
.finally(() => setLoading(false))
}, [])
// v0.23.0 / item #29 server-side sign-in state resume. The hook
// debounce-posts the current route for authenticated users and, once
// identify has fired, redirects a fresh sign-in (which hard-lands on
// "/") to the user's stored last route. Anonymous users: no-op.
useLastState({
authenticated: !!me?.authenticated,
pathname: location.pathname,
identifyReady,
lastRoute: me?.authenticated ? me.user?.last_route : null,
navigate,
})
// §15.3 subscribe to the live SSE stream for authenticated viewers
// so the badge counter and the toast surface stay in lockstep with
// the inbox. Tabs that miss an event because they were closed pick
+56 -4
View File
@@ -25,6 +25,25 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
//
// The route-change hook (useLastState) debounce-posts the user's current
// route + a small bag of *light* view state here for authenticated users.
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
// offline/401 POST must never disrupt navigation).
export async function putLastState(route, state) {
const body = { route }
if (state != null) body.state = state
const res = await fetch('/api/me/last-state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
@@ -166,15 +185,47 @@ export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags }) {
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
// #26: proposed_use_case is optional; send null when blank so the
// backend treats it as "left blank".
body: JSON.stringify({
title,
slug,
pitch,
tags: tags || [],
proposed_use_case: proposedUseCase || null,
}),
})
return jsonOrThrow(res)
}
// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
// forgiving — any non-OK response (rate limit, transient error, no key
// configured server-side) resolves to [] so the modal just shows nothing
// rather than surfacing an error for what is a best-effort assist.
export async function suggestTags({ title, pitch, useCase }) {
try {
const res = await fetch('/api/rfcs/suggest-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title || '',
pitch: pitch || '',
use_case: useCase || '',
}),
})
if (!res.ok) return []
const data = await res.json()
return Array.isArray(data.suggestions) ? data.suggestions : []
} catch {
return []
}
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
@@ -492,13 +543,14 @@ export async function draftPRText(slug, branch) {
return jsonOrThrow(res)
}
export async function openPR(slug, branch, { title, description }) {
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
// #26: proposed_use_case is optional; null when blank.
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
},
)
return jsonOrThrow(res)
+21 -1
View File
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
// #26: optional ground-truth use case for this change.
const [useCase, setUseCase] = useState('')
const [drafting, setDrafting] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setSubmitting(true)
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
const { pr_number } = await openPR(slug, branch, {
title: title.trim(),
description: description.trim(),
proposedUseCase: useCase.trim() || null,
})
// v0.15.0 analytics: fire on §10.2 PR-open success. slug
// and pr_number are the join keys; title/description stay out.
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
@@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
what was argued, what shifted, what the arbiters are asked
to consider.
</p>
<label className="modal-label">What will you be using this change for? (optional)</label>
<textarea
className="modal-textarea"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing this change unlocks for you. Optional."
disabled={drafting || submitting}
rows={3}
maxLength={8000}
/>
<p className="field-help">
#26: the concrete ground-truth use case distinct from "why
it's needed" above. Leave blank if you'd rather not say.
</p>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
+11
View File
@@ -220,6 +220,17 @@ export default function PRView({ viewer }) {
{pr.description && (
<p className="pr-description">{pr.description}</p>
)}
{/* #26: the optional ground-truth use case for this change,
captured when the PR was opened. Muted "left blank"
treatment when none was supplied. */}
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
Intended use case:
</span>{' '}
{pr.proposed_use_case
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
</div>
{pr.capabilities?.can_edit_text && (
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
)}
+8
View File
@@ -163,6 +163,14 @@ export default function ProposalView({ viewer, onChange }) {
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
/>
{/* #26: the optional ground-truth use case the proposer supplied. */}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Intended use case
</h3>
{data.proposed_use_case
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
</article>
)
}
+100 -5
View File
@@ -2,17 +2,24 @@
//
// Title (required) and pitch (required textarea), with a slug field
// that auto-fills from the title via the same deterministic kebab-case
// the backend uses. Tags are chip-input (free-form for slice 1; the
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
// is wired up).
// the backend uses. Tags are chip-input (free-form), with the §9.1
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
// in, the backend asks Claude Haiku for tags drawn from the corpus's
// existing tag set, surfaced as clickable suggestion chips. The assist
// is best-effort it stays silent when unavailable.
//
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
// success navigates the proposer to the pending-idea view per §9.3.
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
import { useEffect, useRef, useState } from 'react'
import { proposeRFC, suggestTags } from '../api'
import { EVENTS, track } from '../lib/analytics'
// How long the draft must sit unchanged before we ask for suggestions
// long enough to fire on typing pauses / field-blur, not on every
// keystroke (the backend is also per-user rate-limited as a backstop).
const SUGGEST_DEBOUNCE_MS = 700
function slugify(title) {
return title
.toLowerCase()
@@ -26,21 +33,56 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('')
// #26: optional ground-truth use case, sibling to the required pitch.
const [useCase, setUseCase] = useState('')
const [tagInput, setTagInput] = useState('')
const [tags, setTags] = useState([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
// #27: Claude Haiku tag suggestions. `suggestions` is the latest
// ranked list from the backend ({ tag, confidence }); we render the
// subset not already chosen. `suggestedOnce` gates the disclosure +
// row so they only appear after the assist has actually run.
const [suggestions, setSuggestions] = useState([])
const [suggestedOnce, setSuggestedOnce] = useState(false)
useEffect(() => {
if (!slugEdited) setSlug(slugify(title))
}, [title, slugEdited])
// #27: debounced tag-suggestion fetch. Fires after the draft sits
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
// on (a title). A stale-response guard keeps an earlier in-flight
// request from clobbering a newer one.
const suggestSeq = useRef(0)
useEffect(() => {
if (!title.trim()) {
setSuggestions([])
return
}
const handle = setTimeout(async () => {
const seq = ++suggestSeq.current
const result = await suggestTags({ title, pitch, useCase })
if (seq !== suggestSeq.current) return // a newer request superseded us
setSuggestions(Array.isArray(result) ? result : [])
if (result && result.length) setSuggestedOnce(true)
}, SUGGEST_DEBOUNCE_MS)
return () => clearTimeout(handle)
}, [title, pitch, useCase])
// Suggestions the user hasn't already added.
const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag))
function addTag() {
const t = tagInput.trim()
if (t && !tags.includes(t)) setTags([...tags, t])
setTagInput('')
}
function addSuggested(tag) {
if (!tags.includes(tag)) setTags([...tags, tag])
}
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim() || !slug || !pitch.trim()) return
@@ -52,6 +94,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
slug,
pitch: pitch.trim(),
tags,
proposedUseCase: useCase.trim() || null,
})
// v0.15.0 analytics: fire on the §9.1 propose-RFC submit.
// Slug is a stable, low-cardinality identifier (kebab-case
@@ -105,6 +148,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
required
/>
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
<textarea
id="propose-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
rows={3}
/>
<p className="field-help">
The concrete ground-truth use case distinct from "why it's
needed" above. Leave blank if you'd rather not say.
</p>
<label htmlFor="propose-tag">Tags (optional)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<input
@@ -136,6 +192,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
</div>
)}
{/* #27: Claude Haiku suggested tags. Clickable chips that add
to the tag list; nothing auto-applies. The disclosure is
required the draft text is sent to Anthropic to generate
these so it renders whenever the suggestion row does. */}
{(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{freshSuggestions.length > 0 && (
<>
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
Suggested tags click to add:
</p>
<div>
{freshSuggestions.map(s => (
<button
key={s.tag}
type="button"
className="entry-tag"
onClick={() => addSuggested(s.tag)}
title={`Add "${s.tag}"`}
style={{
display: 'inline-block',
marginRight: 4,
marginBottom: 4,
border: '1px dashed var(--color-border, #ccc)',
background: 'none',
cursor: 'pointer',
}}
>+ {s.tag}</button>
))}
</div>
</>
)}
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
Suggestions are generated by Claude (Anthropic). The text you've
entered above is sent to Anthropic to produce them.
</p>
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
+13
View File
@@ -669,6 +669,19 @@ export default function RFCView({ viewer }) {
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
</div>
)}
{/* #26: the optional ground-truth use case captured at propose
time. Shown on the canonical (main) view; muted "left blank"
treatment when the proposer didn't supply one. */}
{branchParam === 'main' && (
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
Intended use case
</div>
{entry.proposed_use_case
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
</div>
)}
{inDiscuss && branchParam !== 'main' && (
<div className="discuss-mode-banner">
Discuss mode on <strong>{branchParam}</strong> chat freely;
+91
View File
@@ -0,0 +1,91 @@
// useLastState — v0.23.0 / roadmap item #29: server-side sign-in state
// resume. Two responsibilities, kept in one small hook so App.jsx's
// surface stays minimal:
//
// 1. Debounce-post the authenticated user's current route to
// `PUT /api/me/last-state` on every route change (~1s debounce so
// a fast click-through doesn't spray the endpoint). Anonymous
// users: no-op. Best-effort: a failed POST never disrupts nav.
//
// 2. On the first authenticated load, read the stored `last_route`
// (handed back on `/api/auth/me`) and `navigate()` to it ONCE.
// Stale routes (a withdrawn RFC, a slug the user lost rights to)
// are not special-cased here: navigating lands on whatever that
// route renders today, and the existing routing already falls
// through to the catalog/empty-state for a missing RFC. Keeping
// this dumb is deliberate (see SPEC §6.2 + the #29 scope note).
//
// Ordering with #21 Part C (Amplitude identify): App.jsx fires
// `identify` in its own effect when `me.user.id` first appears. This
// hook's resume redirect is gated on `identifyReady` — App.jsx flips it
// true only after the identify effect has run — so the redirect always
// happens AFTER identify, preserving the identify-then-track ordering
// the roadmap calls out.
import { useEffect, useRef } from 'react'
import { putLastState } from '../api'
// ~1s debounce on the route-change POST. A frontend constant, not a
// server knob — the backend takes whatever lands.
const DEBOUNCE_MS = 1000
// Routes we never want to resume *to* — auth/landing surfaces that
// would be nonsensical or hostile to drop a returning user onto. We
// still record them (cheap, and the user may legitimately be sitting on
// /docs), but the resume redirect skips them and falls through to the
// default landing. Anything not listed resumes normally.
const NON_RESUMABLE_PREFIXES = ['/login', '/welcome', '/invites/', '/invitations/']
function isResumable(route) {
if (!route || typeof route !== 'string') return false
if (route === '/') return false // "/" is already the default landing
return !NON_RESUMABLE_PREFIXES.some(p => route.startsWith(p))
}
/**
* @param {object} opts
* @param {boolean} opts.authenticated whether a viewer is signed in
* @param {string} opts.pathname current location.pathname
* @param {boolean} opts.identifyReady App flips true after identify fires
* @param {string|null} opts.lastRoute stored route from /api/auth/me
* @param {function} opts.navigate react-router navigate()
*/
export function useLastState({ authenticated, pathname, identifyReady, lastRoute, navigate }) {
// ── 1. Debounced route-change POST ────────────────────────────────
const timerRef = useRef(null)
useEffect(() => {
if (!authenticated) return undefined
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
// Best-effort — swallow failures so an offline/401 POST never
// surfaces as a navigation error.
putLastState(pathname).catch(() => {})
}, DEBOUNCE_MS)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [authenticated, pathname])
// ── 2. One-time resume redirect ───────────────────────────────────
// Fires once, after identify is ready, when there's a resumable
// stored route AND the user is currently sitting on the default
// landing ("/"). We only redirect from "/" so we never yank a user
// who deep-linked somewhere specific (or refreshed mid-RFC) back to
// their last route.
const resumedRef = useRef(false)
useEffect(() => {
if (resumedRef.current) return
if (!authenticated || !identifyReady) return
// Only resume when the app booted on the default landing — a hard
// sign-in nav lands on "/", which is exactly the case we want.
if (pathname !== '/') {
resumedRef.current = true // user deep-linked; don't resume later either
return
}
if (isResumable(lastRoute)) {
resumedRef.current = true
navigate(lastRoute, { replace: true })
} else {
resumedRef.current = true // nothing to resume to; keep default landing
}
}, [authenticated, identifyReady, lastRoute, pathname, navigate])
}