Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 698821f065 | |||
| e794523079 | |||
| 28015ed1a2 | |||
| 376a6daddc | |||
| fb9b4fa422 | |||
| daebb54f47 | |||
| a598221812 | |||
| bada72f87e | |||
| 7d8371dea1 | |||
| 3a51425ec7 | |||
| 7c6c906db2 | |||
| 2ac20b1621 | |||
| 493d6b6eee | |||
| 959fc906de | |||
| b648b3ed45 | |||
| 54736de91c | |||
| adb5d25715 | |||
| cbf02d5507 | |||
| 317738ed79 | |||
| 5be2c48afe | |||
| cbc9949972 | |||
| e0d9ed7c5a |
+308
@@ -23,6 +23,314 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.26.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no schema migration, no new secret, no config, no upgrade
|
||||||
|
steps. Additive read-time enrichment; deployments inherit it on deploy
|
||||||
|
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
|
||||||
|
**accepted** RFCs inside PR descriptions and comment text now render as
|
||||||
|
inline links to the referenced RFC. Shipped from driver session 0029.0,
|
||||||
|
in parallel with the v0.25.0 security-hardening session — hence the
|
||||||
|
version-slot gap (0.25.0 is that session's; this took the next free slot
|
||||||
|
per the roadmap's "claims the next available version number" rule).
|
||||||
|
|
||||||
|
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
|
||||||
|
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
|
||||||
|
in this release — Part 1 ships first as the easy win, exactly as the
|
||||||
|
roadmap row anticipates.
|
||||||
|
|
||||||
|
- **Where it links.** The PR review page's description and review
|
||||||
|
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
|
||||||
|
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
|
||||||
|
Branch-chat (the AI-collaboration surface) is intentionally out of
|
||||||
|
scope for Part 1 — a noted follow-up.
|
||||||
|
- **Read-time, not submit-time.** The roadmap row phrases the scan as
|
||||||
|
happening "at submit/post time"; this ships it as **read-time**
|
||||||
|
enrichment instead. The intent the row actually names — "not as live
|
||||||
|
compose preview" — holds (drafts are never scanned, only submitted
|
||||||
|
content on read). Read-time was chosen because (a) links track the
|
||||||
|
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
|
||||||
|
comments, a withdrawn one stops linking everywhere — rather than
|
||||||
|
freezing stale at submit; (b) it needs **no migration** (no derived
|
||||||
|
data to persist); (c) the active-RFC corpus is small and cache-resident,
|
||||||
|
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
|
||||||
|
the session transcript.)
|
||||||
|
- **XSS-safe by construction.** The backend returns structured *segments*
|
||||||
|
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
|
||||||
|
never HTML. The new `LinkedText` frontend component maps segments onto
|
||||||
|
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
|
||||||
|
comment author cannot inject markup through this path. Every enriched
|
||||||
|
field keeps its raw `text`/`description` alongside the `*_segments`, so
|
||||||
|
any non-segment-aware caller is unaffected.
|
||||||
|
- **Conservative matching (false-positive-averse).** A reference links
|
||||||
|
only when it is unlikely to be coincidental: an `rfc_id` token
|
||||||
|
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
|
||||||
|
**hyphenated** slug (`open-human-model`). A single common-word title or
|
||||||
|
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
|
||||||
|
would turn every prose "human" into a link. Matching is case-insensitive,
|
||||||
|
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
|
||||||
|
self-references inside its own PR/discussion. The roadmap's "curated
|
||||||
|
canonical-terms list" remains an explicit future opt-in rather than a
|
||||||
|
guessed-at default. New module: `backend/app/rfc_links.py`.
|
||||||
|
|
||||||
|
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
|
||||||
|
rules, word boundaries, longest-match, case-insensitivity, casing
|
||||||
|
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
|
||||||
|
(PR description + review comment + discussion comment all surface
|
||||||
|
`*_segments`; self-reference suppression). Full suite 363 green; frontend
|
||||||
|
builds clean.
|
||||||
|
|
||||||
|
Upgrade steps:
|
||||||
|
|
||||||
|
1. None. This release is purely additive: no migration, no new
|
||||||
|
environment variable, no secret, no overlay. A deployment picks up RFC
|
||||||
|
auto-linking the moment it deploys this version. (RFCs only link once
|
||||||
|
they are in the `active` state — proposed/super-draft and withdrawn
|
||||||
|
RFCs are never link targets, matching the §11.3 universal-public read
|
||||||
|
rule.)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
|
||||||
|
and #32 (session/transcript page polish). Pure frontend; no schema, no
|
||||||
|
backend changes, no new secret. Shipped from one driver session (0019.0)
|
||||||
|
via three parallel subagents working on disjoint surfaces.
|
||||||
|
|
||||||
|
1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
|
||||||
|
establishes the app's first coherent design system — semantic color
|
||||||
|
palette, type scale, spacing scale, radius scale, elevation, and a
|
||||||
|
motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
|
||||||
|
custom properties, imported first in `main.jsx`. Before this the app
|
||||||
|
carried ~98 distinct hardcoded hex colors, font sizes across 16
|
||||||
|
unscaled values, and radii across 13. `App.css` and `index.css` were
|
||||||
|
swept to the tokens (~630 color / 280 font-size / 128 radius
|
||||||
|
references), consolidating near-duplicate grays to the nearest ramp
|
||||||
|
step and rounding off-scale type to the nearest step. No CSS class was
|
||||||
|
renamed or removed; an additive `:focus-visible` ring and a subtle
|
||||||
|
hover/transition layer were added. 35 special-purpose hexes (true
|
||||||
|
blues/violets, status dots, deep diff-contrast shades) were
|
||||||
|
deliberately left as literals. This is the polish *foundation*; a
|
||||||
|
follow-up (#31b) covers the bespoke per-surface re-spacing that wants
|
||||||
|
operator review against screenshots.
|
||||||
|
|
||||||
|
2. **Header: "About" → "Philosophy" (#24).** The persistent header link
|
||||||
|
now reads "Philosophy" (the route `/philosophy` and its page already
|
||||||
|
existed; only the label changed).
|
||||||
|
|
||||||
|
3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
|
||||||
|
emoji is replaced with a dependency-free inline-SVG envelope icon
|
||||||
|
(`aria-label="Inbox"`); no icon library was added. The inbox panel
|
||||||
|
got a light pass — clearer unread/read distinction, mark-all-read and
|
||||||
|
per-row affordances surfaced, better empty state, tokenized spacing in
|
||||||
|
a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
|
||||||
|
§15 notification data flow are unchanged. A full inbox redesign is
|
||||||
|
deferred to a #25 follow-up (the operator's reference screenshot did
|
||||||
|
not transmit).
|
||||||
|
|
||||||
|
4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
|
||||||
|
longer dead-ends on a "select a transcript" placeholder: a
|
||||||
|
single-transcript session renders that transcript inline at the
|
||||||
|
session root; a multi-transcript session renders its `.0` driver
|
||||||
|
transcript inline and lists the siblings. Each rendered transcript now
|
||||||
|
carries a metadata header — session title, Started/Ended (parsed from
|
||||||
|
the filename's ISO segments), derived Duration, an optional one-line
|
||||||
|
TL;DR, and a "View source on git.wiggleverse.org" external link to the
|
||||||
|
canonical raw transcript. The TL;DR reads an optional `tldr` string on
|
||||||
|
the per-session `sessions.json` manifest entry and degrades gracefully
|
||||||
|
when absent.
|
||||||
|
|
||||||
|
Upgrade steps:
|
||||||
|
|
||||||
|
MAY: add a `tldr` string to any per-session entry in
|
||||||
|
`wiggleverse/ohm-session-history`'s `sessions.json`
|
||||||
|
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
|
||||||
|
a summary in each transcript's metadata header. Absent `tldr` renders
|
||||||
|
nothing — no deployment action is required. This is a data edit in the
|
||||||
|
session-history repo, not a `flotilla` gesture.
|
||||||
|
|
||||||
|
## 0.20.0 — 2026-05-28
|
||||||
|
|
||||||
|
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
||||||
|
|
||||||
|
1. **Specs on `/docs/specs/<name>`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list).
|
||||||
|
|
||||||
|
2. **Nested flyout nav hierarchy** — `/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint.
|
||||||
|
|
||||||
|
3. **`/docs/sessions/<NNNN>` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.
|
||||||
|
|
||||||
|
Upgrade steps:
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.
|
||||||
|
|
||||||
|
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL.
|
||||||
|
|
||||||
|
Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move.
|
||||||
|
|
||||||
## 0.19.0 — 2026-05-28
|
## 0.19.0 — 2026-05-28
|
||||||
|
|
||||||
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.
|
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.
|
||||||
|
|||||||
@@ -714,6 +714,47 @@ The lighter half ships the structural shape — frontmatter, consent,
|
|||||||
resolution, revocation. The heavier half ships the runtime
|
resolution, revocation. The heavier half ships the runtime
|
||||||
hardening.
|
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
|
## 7. The left pane
|
||||||
|
|||||||
@@ -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
|
# Comma-separated list of provider keys to enable. Per the §19.2
|
||||||
# per-RFC-model topic, this is app-wide until that topic lands.
|
# per-RFC-model topic, this is app-wide until that topic lands.
|
||||||
ENABLED_MODELS=claude
|
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=
|
ANTHROPIC_API_KEY=
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
OPENAI_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) ---
|
# --- Email (§15.4) ---
|
||||||
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
||||||
# tests rely on it, and a dev environment without a real SMTP provider
|
# tests rely on it, and a dev environment without a real SMTP provider
|
||||||
|
|||||||
+231
-1
@@ -31,6 +31,7 @@ from . import (
|
|||||||
device_trust as device_trust_mod,
|
device_trust as device_trust_mod,
|
||||||
docs as docs_mod,
|
docs as docs_mod,
|
||||||
docs_sessions,
|
docs_sessions,
|
||||||
|
docs_specs,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
@@ -38,6 +39,7 @@ from . import (
|
|||||||
notify,
|
notify,
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
|
tag_suggest,
|
||||||
)
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
@@ -50,6 +52,22 @@ class ProposeBody(BaseModel):
|
|||||||
slug: str = Field(min_length=1, max_length=80)
|
slug: str = Field(min_length=1, max_length=80)
|
||||||
pitch: str = Field(min_length=1)
|
pitch: str = Field(min_length=1)
|
||||||
tags: list[str] = Field(default_factory=list)
|
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):
|
class DeclineBody(BaseModel):
|
||||||
@@ -61,6 +79,18 @@ class FunderCredentialBody(BaseModel):
|
|||||||
api_key: str = Field(min_length=1, max_length=2048)
|
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):
|
class BetaRequestBody(BaseModel):
|
||||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||||
# required so the admin queue has a coherent triage shape.
|
# required so the admin queue has a coherent triage shape.
|
||||||
@@ -261,6 +291,61 @@ def make_router(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.20.0 — /api/docs/specs/*
|
||||||
|
#
|
||||||
|
# Sibling of the v0.19.0 docs-sessions surface: the framework
|
||||||
|
# mediates a fetch against the public gitea raw URL for each
|
||||||
|
# configured spec so the rendered `/docs/specs/*` route inherits
|
||||||
|
# the same chrome (and the same auth-less reach) as the user
|
||||||
|
# guide and the session-history browser. See
|
||||||
|
# backend/app/docs_specs.py for the manifest shape, the env
|
||||||
|
# knobs, and the cache.
|
||||||
|
#
|
||||||
|
# Status-to-HTTP mapping mirrors docs_sessions:
|
||||||
|
# "ok" → HTTP 200, payload as documented per endpoint
|
||||||
|
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
|
||||||
|
# the manifest is derived from env, never 404s; spec
|
||||||
|
# 404 returns HTTP 404 so the frontend can render
|
||||||
|
# "spec not yet published / unknown name")
|
||||||
|
# "error" → HTTP 502
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/docs/specs/manifest")
|
||||||
|
async def get_specs_manifest() -> dict[str, Any]:
|
||||||
|
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
|
||||||
|
# never fails — a malformed value falls back to the framework
|
||||||
|
# default at parse time. So this endpoint always returns 200
|
||||||
|
# + a list (the framework default is non-empty).
|
||||||
|
result = docs_specs.fetch_specs_manifest()
|
||||||
|
return {"specs": result["specs"]}
|
||||||
|
|
||||||
|
@router.get("/api/docs/specs/{name}")
|
||||||
|
async def get_spec(name: str) -> Response:
|
||||||
|
# Slug validation before any network — refuses `..`, `/`,
|
||||||
|
# uppercase, whitespace, etc. Same defense-in-depth posture
|
||||||
|
# as the docs-sessions transcript endpoint.
|
||||||
|
if not docs_specs._is_valid_name(name):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid spec name")
|
||||||
|
result = await docs_specs.fetch_spec(name)
|
||||||
|
if result["status"] == "ok":
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=result["body"],
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
)
|
||||||
|
if result["status"] == "404":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="spec not found",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"error": "specs fetch failed",
|
||||||
|
"detail": result.get("detail", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# Auth surface — reads role from our users table per §6.
|
# Auth surface — reads role from our users table per §6.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -293,6 +378,28 @@ def make_router(
|
|||||||
)
|
)
|
||||||
has_passcode = bool(row and row["passcode_hash"])
|
has_passcode = bool(row and row["passcode_hash"])
|
||||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
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 {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -309,6 +416,10 @@ def make_router(
|
|||||||
"needs_profile": needs_profile,
|
"needs_profile": needs_profile,
|
||||||
"has_passcode": has_passcode,
|
"has_passcode": has_passcode,
|
||||||
"passcode_set_at": passcode_set_at,
|
"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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +489,52 @@ def make_router(
|
|||||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||||
return {"ok": True}
|
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).
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
#
|
#
|
||||||
@@ -501,12 +658,36 @@ def make_router(
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(404, "Not found")
|
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
|
# §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")
|
@router.get("/api/proposals")
|
||||||
async def list_proposals() -> dict[str, Any]:
|
async def list_proposals() -> dict[str, Any]:
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
@@ -526,6 +707,7 @@ def make_router(
|
|||||||
"description": r["description"],
|
"description": r["description"],
|
||||||
"opened_by": r["opened_by"],
|
"opened_by": r["opened_by"],
|
||||||
"opened_at": r["opened_at"],
|
"opened_at": r["opened_at"],
|
||||||
|
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -574,6 +756,7 @@ def make_router(
|
|||||||
"opened_at": row["opened_at"],
|
"opened_at": row["opened_at"],
|
||||||
"entry": entry_payload,
|
"entry": entry_payload,
|
||||||
"affordances": affordances,
|
"affordances": affordances,
|
||||||
|
"proposed_use_case": _proposal_use_case(pr_number),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -650,8 +833,55 @@ def make_router(
|
|||||||
# cache write is idempotent.)
|
# cache write is idempotent.)
|
||||||
await cache.refresh_meta_pulls(config, gitea)
|
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}
|
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
|
# §9.3: merge / decline / withdraw an idea PR
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ 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, chat as chat_layer, db
|
from . import auth, chat as chat_layer, db, rfc_links
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -171,9 +171,17 @@ def make_router() -> APIRouter:
|
|||||||
""",
|
""",
|
||||||
(thread_id,),
|
(thread_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# Roadmap #28 Part 1: enrich each discussion comment with RFC
|
||||||
|
# auto-link segments scanned against the live accepted-RFC corpus
|
||||||
|
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
|
||||||
|
# to this RFC inside its own discussion.
|
||||||
|
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||||
|
messages = [_serialize_message(r) for r in rows]
|
||||||
|
for m in messages:
|
||||||
|
m["text_segments"] = link_index.segment(m["text"])
|
||||||
return {
|
return {
|
||||||
"thread": _serialize_thread(thread),
|
"thread": _serialize_thread(thread),
|
||||||
"messages": [_serialize_message(r) for r in rows],
|
"messages": messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|||||||
+52
-1
@@ -23,7 +23,7 @@ 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, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links
|
||||||
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
|
||||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
|||||||
class OpenPRBody(BaseModel):
|
class OpenPRBody(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=240)
|
title: str = Field(min_length=1, max_length=240)
|
||||||
description: str = Field(max_length=8000)
|
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):
|
class PRDescriptionBody(BaseModel):
|
||||||
@@ -173,6 +178,26 @@ def make_router(
|
|||||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
|
||||||
await _refresh_after_pr_write(rfc)
|
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}
|
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
@@ -188,6 +213,12 @@ def make_router(
|
|||||||
path = _file_path_for(rfc)
|
path = _file_path_for(rfc)
|
||||||
head_branch = pr_row["head_branch"]
|
head_branch = pr_row["head_branch"]
|
||||||
|
|
||||||
|
# Roadmap #28 Part 1: build the RFC auto-link index once for this
|
||||||
|
# PR view (read-time enrichment against the live accepted-RFC
|
||||||
|
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
|
||||||
|
# this RFC inside its own PR.
|
||||||
|
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||||
|
|
||||||
# §11.3: PRs are always public; no visibility check.
|
# §11.3: PRs are always public; no visibility check.
|
||||||
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
|
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
|
||||||
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
|
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
|
||||||
@@ -234,6 +265,13 @@ def make_router(
|
|||||||
for r in msg_rows:
|
for r in msg_rows:
|
||||||
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
|
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
|
||||||
|
|
||||||
|
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
|
||||||
|
# segments (read-time; see rfc_links.py). The description is
|
||||||
|
# enriched alongside it in the return dict below.
|
||||||
|
for _msgs in messages_by_thread.values():
|
||||||
|
for _m in _msgs:
|
||||||
|
_m["text_segments"] = link_index.segment(_m["text"])
|
||||||
|
|
||||||
# Per-user seen cursor per §10.3. Anonymous viewers get no
|
# Per-user seen cursor per §10.3. Anonymous viewers get no
|
||||||
# cursor — they always see "everything new" but cannot advance
|
# cursor — they always see "everything new" but cannot advance
|
||||||
# the cursor (no row to write to).
|
# the cursor (no row to write to).
|
||||||
@@ -300,6 +338,8 @@ def make_router(
|
|||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"title": pr_row["title"],
|
"title": pr_row["title"],
|
||||||
"description": pr_row["description"],
|
"description": pr_row["description"],
|
||||||
|
"description_segments": link_index.segment(pr_row["description"]),
|
||||||
|
"proposed_use_case": _pr_use_case(pr_number),
|
||||||
"state": pr_row["state"],
|
"state": pr_row["state"],
|
||||||
"opened_by": pr_row["opened_by"],
|
"opened_by": pr_row["opened_by"],
|
||||||
"opened_at": pr_row["opened_at"],
|
"opened_at": pr_row["opened_at"],
|
||||||
@@ -762,6 +802,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
|
|||||||
return _can_withdraw(rfc, pr_row, viewer)
|
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:
|
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||||
return {
|
return {
|
||||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""v0.20.0 — on-site framework-specs surface source.
|
||||||
|
|
||||||
|
Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the
|
||||||
|
framework mediates a gitea fetch on behalf of the browser so the
|
||||||
|
rendered `/docs/specs/*` surface inherits the same chrome as
|
||||||
|
`/docs/user-guide` and `/docs/sessions/*` and stays free of any
|
||||||
|
cross-origin gestures from the frontend.
|
||||||
|
|
||||||
|
Two read endpoints, both anonymous-reachable:
|
||||||
|
|
||||||
|
GET /api/docs/specs/manifest — the configured spec list
|
||||||
|
GET /api/docs/specs/<name> — a single spec body (markdown)
|
||||||
|
|
||||||
|
The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md
|
||||||
|
+ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason
|
||||||
|
`docs_sessions.py`'s defaults are: OHM is the only deployment to
|
||||||
|
date. A deployment running its own spec set overrides the manifest
|
||||||
|
via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay).
|
||||||
|
History is intentionally not surfaced here — the operator-stated
|
||||||
|
intent is "current version only; git is the history surface".
|
||||||
|
|
||||||
|
Per-spec entries carry three fields:
|
||||||
|
|
||||||
|
name — URL-safe slug (`[a-z0-9-]+`) — the path segment
|
||||||
|
title — human-readable label shown in the nav and the page header
|
||||||
|
url — the upstream raw URL the framework fetches
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- The configured list must be a JSON array of `{name, title, url}`
|
||||||
|
objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong
|
||||||
|
shape, invalid slug) logs a warning and falls back to the default
|
||||||
|
so a typo in the overlay doesn't crash startup.
|
||||||
|
- Each `name` is checked against `^[a-z0-9-]+$` before the manifest
|
||||||
|
is accepted. The route layer also validates the path-bound `name`
|
||||||
|
parameter before any network call, so a malformed URL never
|
||||||
|
reaches the cache or the upstream.
|
||||||
|
|
||||||
|
Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic
|
||||||
|
TTL check, negative results (404) cached, no external dep. The
|
||||||
|
manifest is cheap (parsed from an env var, no network), so it has no
|
||||||
|
TTL — every request re-derives it. Per-spec content has a 5-minute
|
||||||
|
default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`).
|
||||||
|
|
||||||
|
§3 invariant 1 is preserved: the framework holds no secret bytes;
|
||||||
|
the upstream specs are public-repo raw URLs, the fetch carries no
|
||||||
|
auth header.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# The framework-default spec set. OHM-flavored per the same precedent
|
||||||
|
# `docs_sessions.py` set: the only live deployment is OHM, so the
|
||||||
|
# default points there. A deployment running its own specs overrides
|
||||||
|
# `OHM_DOCS_SPECS` via the overlay.
|
||||||
|
_DEFAULT_SPECS: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app SPEC",
|
||||||
|
"url": (
|
||||||
|
"https://git.wiggleverse.org/ben.stull/rfc-app/"
|
||||||
|
"raw/branch/main/SPEC.md"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flotilla",
|
||||||
|
"title": "flotilla SPEC",
|
||||||
|
"url": (
|
||||||
|
"https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/"
|
||||||
|
"raw/branch/main/SPEC.md"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_DEFAULT_CONTENT_TTL_SEC = 300.0
|
||||||
|
|
||||||
|
# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit
|
||||||
|
# (rejecting anything that could resolve outside the intended layout)
|
||||||
|
# but with the lowercase-alphanumeric-plus-dash shape the manifest
|
||||||
|
# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase,
|
||||||
|
# and whitespace all fail this regex; the route layer rejects 400
|
||||||
|
# before any cache or network call.
|
||||||
|
_NAME_RE = re.compile(r"^[a-z0-9-]+$")
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT_SEC = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float) -> float:
|
||||||
|
raw = os.environ.get(name, "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _content_ttl() -> float:
|
||||||
|
return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_name(name: str) -> bool:
|
||||||
|
"""Slug guard for path-bound `name` parameters.
|
||||||
|
|
||||||
|
Mirrors `docs_sessions._is_valid_session_dir`'s contract: the
|
||||||
|
route layer calls this before any network or cache work, so a
|
||||||
|
malformed name never escapes the FastAPI surface.
|
||||||
|
"""
|
||||||
|
return bool(isinstance(name, str) and _NAME_RE.match(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_spec_entry(entry: Any) -> dict[str, str] | None:
|
||||||
|
"""Validate a single manifest entry; return None if invalid.
|
||||||
|
|
||||||
|
Required fields: `name`, `title`, `url`. All three must be
|
||||||
|
non-empty strings; `name` must match `_NAME_RE`. The validator is
|
||||||
|
strict: an entry that fails any check is dropped from the manifest
|
||||||
|
(and the caller logs at warning level).
|
||||||
|
"""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
name = entry.get("name")
|
||||||
|
title = entry.get("title")
|
||||||
|
url = entry.get("url")
|
||||||
|
if not isinstance(name, str) or not _is_valid_name(name):
|
||||||
|
return None
|
||||||
|
if not isinstance(title, str) or not title.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return None
|
||||||
|
return {"name": name, "title": title.strip(), "url": url.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configured_specs() -> list[dict[str, str]]:
|
||||||
|
"""Parse `OHM_DOCS_SPECS` (if set) or return the default list.
|
||||||
|
|
||||||
|
Malformed JSON or wrong-shape values log a warning and fall back
|
||||||
|
to the default — the deployment continues to render the spec
|
||||||
|
surface rather than crashing startup. The strict validation (each
|
||||||
|
entry's name slug, presence of all three fields) drops bad entries
|
||||||
|
one-by-one; if every entry is dropped, the default applies.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get("OHM_DOCS_SPECS", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS must be a JSON array — falling back to default"
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in parsed:
|
||||||
|
validated = _parse_spec_entry(entry)
|
||||||
|
if validated is None:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS entry %r failed validation — dropped", entry
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if validated["name"] in seen:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen.add(validated["name"])
|
||||||
|
out.append(validated)
|
||||||
|
if not out:
|
||||||
|
log.warning(
|
||||||
|
"OHM_DOCS_SPECS yielded no valid entries — falling back to default"
|
||||||
|
)
|
||||||
|
return list(_DEFAULT_SPECS)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-process TTL cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Same shape as `docs_sessions.py`: plain dict + `time.monotonic()` check,
|
||||||
|
# no external dep. The cache value is a `(stored_at, payload)` tuple;
|
||||||
|
# `payload` may carry an error-shape sentinel for negative caching (404s).
|
||||||
|
# Lock guards read-modify-write across worker tasks; entries are immutable
|
||||||
|
# once stored so reads under the lock are fast.
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
|
||||||
|
with _lock:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
stored_at, payload = entry
|
||||||
|
if time.monotonic() - stored_at > ttl_sec:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_put(key: str, payload: dict[str, Any]) -> None:
|
||||||
|
with _lock:
|
||||||
|
_cache[key] = (time.monotonic(), payload)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_cache() -> None:
|
||||||
|
"""Drop every cached entry. Test seam — not called in production."""
|
||||||
|
with _lock:
|
||||||
|
_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public fetch surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Each fetcher returns a `{status, ...}` dict, same convention as
|
||||||
|
# `docs_sessions.py`:
|
||||||
|
# "ok" — payload field carries the body / manifest
|
||||||
|
# "404" — gitea returned 404 (or the configured name doesn't exist)
|
||||||
|
# "error" — gitea returned 5xx, timed out, or returned malformed data
|
||||||
|
#
|
||||||
|
# The route layer maps these onto HTTP responses; keeping the mapping
|
||||||
|
# out of this module makes the cache transparent to the test harness.
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_get(url: str) -> tuple[int, str]:
|
||||||
|
"""Perform a single GET against `url`; return (status_code, body).
|
||||||
|
|
||||||
|
On timeout or network error, returns (599, error_message). The 599
|
||||||
|
pseudo-status maps to a 502 at the route layer the same way an
|
||||||
|
upstream 5xx does — the caller doesn't care which leg of the
|
||||||
|
network broke.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
return r.status_code, r.text
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log.warning("specs fetch failed for %s: %s", url, e)
|
||||||
|
return 599, f"fetch error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_specs_manifest() -> dict[str, Any]:
|
||||||
|
"""Return the configured spec manifest.
|
||||||
|
|
||||||
|
The manifest is derived from the `OHM_DOCS_SPECS` env var (or the
|
||||||
|
framework default if unset / malformed) and carries no network
|
||||||
|
work — it's safe to call on every request. The return shape mirrors
|
||||||
|
the docs_sessions manifest endpoint for frontend consistency:
|
||||||
|
|
||||||
|
{"status": "ok", "specs": [{"name", "title", "url"}, ...]}
|
||||||
|
|
||||||
|
The "url" field is exposed in the manifest so the frontend can
|
||||||
|
offer a "view source on gitea" affordance alongside each rendered
|
||||||
|
spec (operator-stated intent: "include the history so you can see
|
||||||
|
it in git" — that gesture lives in the source link, not on the
|
||||||
|
rendered page).
|
||||||
|
"""
|
||||||
|
specs = _load_configured_specs()
|
||||||
|
return {"status": "ok", "specs": specs}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_spec(name: str) -> dict[str, Any]:
|
||||||
|
"""Fetch a single spec body by its manifest `name`.
|
||||||
|
|
||||||
|
The caller is expected to have validated `name` against
|
||||||
|
`_is_valid_name` before calling this — an invalid name shouldn't
|
||||||
|
reach the network. We re-check inside as defense-in-depth: a
|
||||||
|
bogus name here returns the same `{status: "404"}` shape so the
|
||||||
|
route layer's `404 → HTTP 404` mapping handles it uniformly.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
{"status": "ok", "body": "..."}
|
||||||
|
{"status": "404"} — no such spec OR upstream 404
|
||||||
|
{"status": "error", "detail": "..."} — upstream 5xx / timeout
|
||||||
|
"""
|
||||||
|
if not _is_valid_name(name):
|
||||||
|
return {"status": "404"}
|
||||||
|
|
||||||
|
cache_key = f"spec:{name}"
|
||||||
|
cached = _cache_get(cache_key, _content_ttl())
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
specs = _load_configured_specs()
|
||||||
|
match = next((s for s in specs if s["name"] == name), None)
|
||||||
|
if match is None:
|
||||||
|
# Cache the negative — a deployment with an unstable manifest
|
||||||
|
# would still benefit from the TTL window, and the cached 404
|
||||||
|
# is automatically displaced when the next request happens
|
||||||
|
# after TTL expiry.
|
||||||
|
payload: dict[str, Any] = {"status": "404"}
|
||||||
|
_cache_put(cache_key, payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
url = match["url"]
|
||||||
|
status, body = await _http_get(url)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
payload = {"status": "ok", "body": body}
|
||||||
|
_cache_put(cache_key, payload)
|
||||||
|
return payload
|
||||||
|
if status == 404:
|
||||||
|
payload = {"status": "404"}
|
||||||
|
_cache_put(cache_key, payload)
|
||||||
|
return payload
|
||||||
|
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||||
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
|
|||||||
return providers
|
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]:
|
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||||
env = {
|
env = {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose.
|
||||||
|
|
||||||
|
Scans plain-text PR descriptions and comment bodies for references to
|
||||||
|
existing **accepted** (state='active') RFCs and returns a structured list
|
||||||
|
of *segments* the frontend renders: plain-text runs interleaved with
|
||||||
|
``{"type": "rfc", ...}`` link segments. The backend never emits HTML —
|
||||||
|
the frontend maps link segments onto React anchors — so the surface is
|
||||||
|
XSS-safe by construction and independent of any HTML-sanitization layer.
|
||||||
|
|
||||||
|
**Read-time enrichment, not submit-time persistence.** The roadmap row
|
||||||
|
phrases the scan as happening "at submit/post time"; this module instead
|
||||||
|
enriches on read. The intent the roadmap actually names — "not as live
|
||||||
|
compose preview" — is honored (drafts are never scanned, only submitted
|
||||||
|
content on the read paths). Read-time was chosen for three reasons:
|
||||||
|
|
||||||
|
1. Correctness — links track the *live* active-RFC set. A newly-accepted
|
||||||
|
RFC starts linking in older comments; a withdrawn RFC stops linking
|
||||||
|
everywhere. Submit-time freezing would drift stale.
|
||||||
|
2. Zero migration — no derived data to store. (A concurrent session
|
||||||
|
already holds migration 023; staying migration-free keeps this slice
|
||||||
|
conflict-free as well as simpler.)
|
||||||
|
3. Cost — the active-RFC corpus is small and cache-resident, so building
|
||||||
|
the term index and scanning a ≤20k-char body per read is cheap.
|
||||||
|
|
||||||
|
**Matching is conservative by design.** Only references that are unlikely
|
||||||
|
to be coincidental link:
|
||||||
|
|
||||||
|
* ``rfc_id`` tokens (e.g. ``RFC-0001``) — inherently specific.
|
||||||
|
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
|
||||||
|
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
|
||||||
|
|
||||||
|
Single common-word titles or slugs (e.g. a hypothetical RFC titled
|
||||||
|
"Human") are deliberately NOT auto-linked — they would turn every prose
|
||||||
|
"human" into a link. Surfacing those is the job of the roadmap's
|
||||||
|
"curated canonical-terms list", an explicit per-deployment opt-in left as
|
||||||
|
a future extension rather than guessed at here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def _is_word_char(c: str) -> bool:
|
||||||
|
"""Word-boundary test. Hyphen and underscore count as word chars so a
|
||||||
|
match can't begin or end in the middle of a kebab/snake token."""
|
||||||
|
return c.isalnum() or c in ("-", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]:
|
||||||
|
"""Split ``text`` into text / rfc-link segments against ``terms``.
|
||||||
|
|
||||||
|
``terms`` is a list of ``(key_lower, slug, title)`` tuples; callers
|
||||||
|
pass it pre-sorted longest-first so the longest match wins at any
|
||||||
|
position (so "Open Human Model" wins over a bare "Open"). Matching is
|
||||||
|
case-insensitive and respects word boundaries on both ends. The
|
||||||
|
returned ``label`` preserves the source casing.
|
||||||
|
|
||||||
|
Always returns at least one segment; for empty/None input that is a
|
||||||
|
single empty text segment, so callers can render uniformly.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return [{"type": "text", "text": text or ""}]
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
low = text.lower()
|
||||||
|
n = len(text)
|
||||||
|
i = 0
|
||||||
|
while i < n:
|
||||||
|
match: tuple[str, str, str, int] | None = None
|
||||||
|
for key, slug, title in terms:
|
||||||
|
klen = len(key)
|
||||||
|
if klen == 0 or not low.startswith(key, i):
|
||||||
|
continue
|
||||||
|
before = text[i - 1] if i > 0 else ""
|
||||||
|
after = text[i + klen] if i + klen < n else ""
|
||||||
|
if _is_word_char(before) or _is_word_char(after):
|
||||||
|
continue
|
||||||
|
match = (key, slug, title, klen)
|
||||||
|
break
|
||||||
|
if match is not None:
|
||||||
|
_key, slug, title, klen = match
|
||||||
|
if buf:
|
||||||
|
out.append({"type": "text", "text": "".join(buf)})
|
||||||
|
buf = []
|
||||||
|
out.append({
|
||||||
|
"type": "rfc",
|
||||||
|
"slug": slug,
|
||||||
|
"label": text[i:i + klen],
|
||||||
|
"title": title,
|
||||||
|
})
|
||||||
|
i += klen
|
||||||
|
else:
|
||||||
|
buf.append(text[i])
|
||||||
|
i += 1
|
||||||
|
if buf:
|
||||||
|
out.append({"type": "text", "text": "".join(buf)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||||
|
"""The match keys an active RFC contributes. See the module docstring
|
||||||
|
for why each gate exists (conservative, false-positive-averse)."""
|
||||||
|
if rfc_id:
|
||||||
|
rid = rfc_id.strip()
|
||||||
|
if len(rid) >= 2:
|
||||||
|
yield rid.lower()
|
||||||
|
if title:
|
||||||
|
t = title.strip()
|
||||||
|
# Multi-word titles only — a single common word is too noisy.
|
||||||
|
if len(t) >= 2 and (" " in t or "\t" in t):
|
||||||
|
yield t.lower()
|
||||||
|
if slug:
|
||||||
|
s = slug.strip()
|
||||||
|
# Hyphenated slugs only — a single-token slug is a bare word.
|
||||||
|
if len(s) >= 2 and "-" in s:
|
||||||
|
yield s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class LinkIndex:
|
||||||
|
"""A reusable term index built once per request and applied to many
|
||||||
|
bodies (a PR's description plus every comment on it)."""
|
||||||
|
|
||||||
|
def __init__(self, terms: list[tuple[str, str, str]]):
|
||||||
|
# Longest key first so the longest reference wins at each position.
|
||||||
|
self._terms = sorted(terms, key=lambda t: len(t[0]), reverse=True)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self._terms)
|
||||||
|
|
||||||
|
def segment(self, text: str | None) -> list[dict[str, Any]]:
|
||||||
|
return segment_text(text, self._terms)
|
||||||
|
|
||||||
|
|
||||||
|
def build_index(conn, *, exclude_slug: str | None = None) -> LinkIndex:
|
||||||
|
"""Build a :class:`LinkIndex` from the accepted (active) RFC corpus.
|
||||||
|
|
||||||
|
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
|
||||||
|
to, so an RFC's own title/id/slug don't self-link inside its own PR or
|
||||||
|
discussion. ``ORDER BY slug`` makes key de-duplication deterministic
|
||||||
|
when two RFCs would contribute the same key (first slug wins)."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
|
||||||
|
).fetchall()
|
||||||
|
terms: list[tuple[str, str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
slug = r["slug"]
|
||||||
|
if exclude_slug is not None and slug == exclude_slug:
|
||||||
|
continue
|
||||||
|
title = r["title"] or ""
|
||||||
|
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
|
||||||
|
for key in _keys_for(slug, title, rfc_id):
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
terms.append((key, slug, title))
|
||||||
|
return LinkIndex(terms)
|
||||||
@@ -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,469 @@
|
|||||||
|
"""v0.20.0 — `/api/docs/specs/*` endpoints.
|
||||||
|
|
||||||
|
Sibling of `test_docs_sessions_vertical.py`. The framework mediates
|
||||||
|
reads of the configured framework-spec URLs (default: rfc-app's own
|
||||||
|
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||||
|
`/docs/specs/*` surface inherits the same chrome as
|
||||||
|
`/docs/user-guide` and `/docs/sessions/*`.
|
||||||
|
|
||||||
|
This file covers:
|
||||||
|
|
||||||
|
- The manifest endpoint with the framework default
|
||||||
|
- The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
|
||||||
|
- Slug validation at the route layer (rejects `..`, `/`, `~`,
|
||||||
|
uppercase, whitespace, path traversal attempts)
|
||||||
|
- Gitea 200 / 404 / 5xx response mapping
|
||||||
|
- Negative caching (404 is cached, not re-fetched within TTL)
|
||||||
|
- Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged
|
||||||
|
warning (asserted by caplog)
|
||||||
|
- A manifest entry that fails per-entry validation (bad slug,
|
||||||
|
missing field) is dropped, with the rest of the list retained
|
||||||
|
|
||||||
|
Mocking approach: same as docs_sessions — `httpx.MockTransport`
|
||||||
|
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import docs_specs
|
||||||
|
|
||||||
|
# Reuse the proven app-construction fixtures from the proposal vertical
|
||||||
|
# (same shape every test file in this repo uses).
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test scaffolding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _UpstreamHandler:
|
||||||
|
"""Records every URL the docs_specs module fetched and returns
|
||||||
|
canned responses keyed by URL substring. Lets the test assert on
|
||||||
|
call count (for cache verification) without booting a full upstream
|
||||||
|
simulator.
|
||||||
|
|
||||||
|
`calls` tracks only URLs that hit a host configured in the spec
|
||||||
|
manifest under test — so unrelated httpx clients (gitea-side
|
||||||
|
fixtures, etc.) don't inflate the count we use for cache-hit
|
||||||
|
assertions. We marker-match on substrings the manifest carries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
responses: dict[str, tuple[int, str]],
|
||||||
|
host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
|
||||||
|
):
|
||||||
|
self.responses = responses
|
||||||
|
self.host_markers = host_markers
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if any(m in url for m in self.host_markers):
|
||||||
|
self.calls.append(url)
|
||||||
|
for key, (status, body) in self.responses.items():
|
||||||
|
if key in url:
|
||||||
|
return httpx.Response(status, text=body)
|
||||||
|
# Default: 404. Lets tests skip declaring "the rest is 404".
|
||||||
|
return httpx.Response(404, text="not found")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_httpx(monkeypatch):
|
||||||
|
"""Provide a hook the test can call to install a MockTransport.
|
||||||
|
|
||||||
|
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||||
|
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||||
|
construct from the unpatched class directly to avoid the
|
||||||
|
FakeGitea wrapper.
|
||||||
|
"""
|
||||||
|
from httpx._client import AsyncClient as RealAsyncClient
|
||||||
|
|
||||||
|
def install(handler):
|
||||||
|
def patched(*args, **kwargs):
|
||||||
|
kwargs["transport"] = httpx.MockTransport(handler)
|
||||||
|
return RealAsyncClient(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
|
||||||
|
return handler
|
||||||
|
|
||||||
|
yield install
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(app_with_fake_gitea):
|
||||||
|
"""Reset the docs-specs cache so cross-test state can't leak."""
|
||||||
|
docs_specs.reset_cache()
|
||||||
|
fastapi_app, _fake = app_with_fake_gitea
|
||||||
|
return fastapi_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_default(app, monkeypatch):
|
||||||
|
"""With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
|
||||||
|
framework default (rfc-app + flotilla).
|
||||||
|
"""
|
||||||
|
monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
assert "specs" in payload
|
||||||
|
names = [s["name"] for s in payload["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
# The default URLs point at the OHM-canonical gitea raw paths.
|
||||||
|
assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_overridden(app, monkeypatch):
|
||||||
|
"""A deployment overriding `OHM_DOCS_SPECS` gets its custom list.
|
||||||
|
|
||||||
|
The manifest is parsed per-request from the env var (no startup
|
||||||
|
binding) so a runtime overlay change is visible without a
|
||||||
|
restart — same shape as the docs_sessions env knobs.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "custom-spec",
|
||||||
|
"title": "Custom Spec",
|
||||||
|
"url": "https://specs.example.org/CUSTOM.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
assert payload == {
|
||||||
|
"specs": [
|
||||||
|
{
|
||||||
|
"name": "custom-spec",
|
||||||
|
"title": "Custom Spec",
|
||||||
|
"url": "https://specs.example.org/CUSTOM.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
|
||||||
|
endpoint falls back to the framework default. Startup is
|
||||||
|
unaffected — the deployment continues to render the spec surface
|
||||||
|
rather than crashing on the typo.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
names = [s["name"] for s in payload["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"OHM_DOCS_SPECS is not valid JSON" in rec.message
|
||||||
|
for rec in caplog.records
|
||||||
|
), f"expected a logged warning; got {[r.message for r in caplog.records]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
|
||||||
|
non-list value) falls back to the default + logs a warning.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"must be a JSON array" in rec.message for rec in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
|
||||||
|
"""Per-entry validation: an entry with a bad slug or missing field
|
||||||
|
is dropped; valid entries in the same list are retained.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space
|
||||||
|
{"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal
|
||||||
|
{"name": "missing-url", "title": "Missing URL"}, # no url
|
||||||
|
{
|
||||||
|
"name": "good-spec",
|
||||||
|
"title": "Good",
|
||||||
|
"url": "https://specs.example.org/GOOD.md",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["good-spec"]
|
||||||
|
# Three drop warnings (one per bad entry).
|
||||||
|
drops = [r for r in caplog.records if "failed validation" in r.message]
|
||||||
|
assert len(drops) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
|
||||||
|
"""If every entry is dropped, the framework default applies (the
|
||||||
|
surface never goes empty due to a bad overlay).
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
names = [s["name"] for s in r.json()["specs"]]
|
||||||
|
assert names == ["rfc-app", "flotilla"]
|
||||||
|
assert any(
|
||||||
|
"yielded no valid entries" in rec.message for rec in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
|
||||||
|
"""A duplicate `name` is dropped (the first occurrence wins). The
|
||||||
|
route layer's `/api/docs/specs/{name}` path lookup is by name, so
|
||||||
|
duplicates would otherwise be ambiguous.
|
||||||
|
"""
|
||||||
|
custom = json.dumps(
|
||||||
|
[
|
||||||
|
{"name": "x", "title": "First", "url": "https://specs.example/1"},
|
||||||
|
{"name": "x", "title": "Second", "url": "https://specs.example/2"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/manifest")
|
||||||
|
payload = r.json()
|
||||||
|
assert [s["title"] for s in payload["specs"]] == ["First"]
|
||||||
|
assert any("duplicate name" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec endpoint — happy + error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_happy_path(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app SPEC",
|
||||||
|
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
body = "# rfc-app SPEC\n\nSection 1...\n"
|
||||||
|
patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/rfc-app")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "text/markdown" in r.headers["content-type"]
|
||||||
|
assert r.text == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_upstream_404(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "missing-spec",
|
||||||
|
"title": "Missing",
|
||||||
|
"url": "https://specs.example.org/missing.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/missing-spec")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "broken-spec",
|
||||||
|
"title": "Broken",
|
||||||
|
"url": "https://specs.example.org/broken.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/broken-spec")
|
||||||
|
assert r.status_code == 502, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["detail"]["error"] == "specs fetch failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
|
||||||
|
"""A name that doesn't appear in the manifest returns 404 without
|
||||||
|
touching the network. The handler treats "no such configured spec"
|
||||||
|
and "upstream 404" as the same outcome — both render the same
|
||||||
|
"spec not found" empty state on the frontend.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app",
|
||||||
|
"url": "https://specs.example.org/x.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/docs/specs/does-not-exist")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert handler.calls == [], "unknown-name lookup must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spec endpoint — slug validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_name",
|
||||||
|
[
|
||||||
|
"UPPER", # uppercase
|
||||||
|
"spaces here", # whitespace (post-decoding)
|
||||||
|
"with~tilde", # tilde
|
||||||
|
"with.dot", # dot
|
||||||
|
"with_under", # underscore (not allowed by [a-z0-9-]+)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
|
||||||
|
"""Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
|
||||||
|
the route layer before any network or cache work.
|
||||||
|
|
||||||
|
Note: `..` is intentionally not in this list because the URL-
|
||||||
|
parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
|
||||||
|
before the handler is reached — the path-traversal protection is
|
||||||
|
therefore framework-level (httpx/urllib's path normalizer) rather
|
||||||
|
than route-layer. The slug-validation guard still rejects any
|
||||||
|
`..` that *would* reach the handler (e.g. via an env-configured
|
||||||
|
manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
|
||||||
|
for that path.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert handler.calls == [], "rejected name must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_rejects_slash_in_name(app, patched_httpx):
|
||||||
|
"""A literal `/` in the path can't make it through the path
|
||||||
|
parameter — FastAPI routes it as a separate segment. The check
|
||||||
|
here is that the request never reaches an upstream fetch.
|
||||||
|
"""
|
||||||
|
handler = _UpstreamHandler({})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# `/api/docs/specs/sub/path` — the second segment makes this
|
||||||
|
# not match the `/{name}` route at all; FastAPI returns 404.
|
||||||
|
r = client.get("/api/docs/specs/sub/path")
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert handler.calls == [], "non-matching path must not hit the network"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "rfc-app",
|
||||||
|
"title": "rfc-app",
|
||||||
|
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get("/api/docs/specs/rfc-app")
|
||||||
|
r2 = client.get("/api/docs/specs/rfc-app")
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(handler.calls) == 1, (
|
||||||
|
f"expected one upstream call, got {handler.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
|
||||||
|
"""Negative caching: a 404 result is cached at the content TTL so
|
||||||
|
a deployment with a misconfigured spec URL doesn't hammer the
|
||||||
|
upstream on every navigation.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OHM_DOCS_SPECS",
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "missing-spec",
|
||||||
|
"title": "Missing",
|
||||||
|
"url": "https://specs.example.org/missing.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handler = _UpstreamHandler({}) # everything 404s
|
||||||
|
patched_httpx(handler)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r1 = client.get("/api/docs/specs/missing-spec")
|
||||||
|
r2 = client.get("/api/docs/specs/missing-spec")
|
||||||
|
assert r1.status_code == 404
|
||||||
|
assert r2.status_code == 404
|
||||||
|
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||||
@@ -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,235 @@
|
|||||||
|
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
* Unit tests over the pure scanner (`rfc_links.segment_text` /
|
||||||
|
`_keys_for` / `LinkIndex`) — the matching rules and their
|
||||||
|
false-positive guards, no DB.
|
||||||
|
* End-to-end tests that the PR description, PR review comments, and
|
||||||
|
PR-less discussion comments all surface `*_segments` enriched against
|
||||||
|
the live accepted-RFC corpus, with self-references suppressed.
|
||||||
|
|
||||||
|
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||||
|
the active-RFC seed from test_rfc_view_vertical.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import rfc_links
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
grant_rfc_collaborator,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||||
|
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit — the pure scanner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _idx(*terms):
|
||||||
|
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
|
||||||
|
return rfc_links.LinkIndex(list(terms))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_is_single_empty_segment():
|
||||||
|
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
|
||||||
|
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_terms_returns_plain_text():
|
||||||
|
out = rfc_links.segment_text("hello world", [])
|
||||||
|
assert out == [{"type": "text", "text": "hello world"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiword_title_links_and_preserves_casing():
|
||||||
|
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||||
|
out = idx.segment("See the Open Human Model for details.")
|
||||||
|
assert out == [
|
||||||
|
{"type": "text", "text": "See the "},
|
||||||
|
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
|
||||||
|
"title": "Open Human Model"},
|
||||||
|
{"type": "text", "text": " for details."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_is_case_insensitive():
|
||||||
|
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||||
|
out = idx.segment("see the OPEN HUMAN MODEL")
|
||||||
|
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
|
||||||
|
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_boundary_prevents_substring_match():
|
||||||
|
# "harm" must not match inside "charming" / "harmless".
|
||||||
|
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||||
|
out = idx.segment("a charming rfc-00012 not real")
|
||||||
|
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
|
||||||
|
# so no match — the whole string stays plain text.
|
||||||
|
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rfc_id_token_links():
|
||||||
|
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||||
|
out = idx.segment("as established in RFC-0001.")
|
||||||
|
assert out[1] == {"type": "rfc", "slug": "open-human-model",
|
||||||
|
"label": "RFC-0001", "title": "Open Human Model"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_longest_match_wins():
|
||||||
|
# A bare "Open" term and the full title both present; the full title
|
||||||
|
# (longer) must win at the position.
|
||||||
|
idx = _idx(
|
||||||
|
("open", "open", "Open"),
|
||||||
|
("open human model", "open-human-model", "Open Human Model"),
|
||||||
|
)
|
||||||
|
out = idx.segment("the Open Human Model")
|
||||||
|
assert out[-1]["slug"] == "open-human-model"
|
||||||
|
assert out[-1]["label"] == "Open Human Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keys_for_gating():
|
||||||
|
keys = lambda **kw: set(rfc_links._keys_for(**kw))
|
||||||
|
# rfc_id always contributes.
|
||||||
|
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
|
||||||
|
# multi-word title contributes; single common word does NOT.
|
||||||
|
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
|
||||||
|
assert keys(slug="human", title="Human", rfc_id=None) == set()
|
||||||
|
# hyphenated slug contributes; single-token slug does NOT.
|
||||||
|
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
|
||||||
|
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# End-to-end — enrichment surfaces on the read paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr_on(client, fake, *, host_slug: str, description: str):
|
||||||
|
"""Seed branch + accepted change on host_slug and open a PR. Returns
|
||||||
|
the pr_number."""
|
||||||
|
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
|
||||||
|
branch, _ = _cut_branch_and_accept_change(
|
||||||
|
client, fake, slug=host_slug,
|
||||||
|
original="It defines consent, trait, and agency in compatible terms.",
|
||||||
|
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
|
||||||
|
json={"title": "A change", "description": description},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["pr_number"]
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc_segments(segments):
|
||||||
|
return [s for s in segments if s["type"] == "rfc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_description_autolinks_other_rfc(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")
|
||||||
|
# Two accepted RFCs: a host for the PR + a referenceable target.
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
pr_number = _open_pr_on(
|
||||||
|
client, fake, host_slug="ohm",
|
||||||
|
description="This builds on the Open Human Model definition.",
|
||||||
|
)
|
||||||
|
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pr = r.json()
|
||||||
|
links = _rfc_segments(pr["description_segments"])
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0]["slug"] == "open-human-model"
|
||||||
|
assert links[0]["label"] == "Open Human Model"
|
||||||
|
# The plain text is still present for non-segment callers (the bot
|
||||||
|
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
|
||||||
|
assert "This builds on the Open Human Model definition." in pr["description"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_comment_autolinked(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)
|
||||||
|
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
pr_number = _open_pr_on(client, fake, host_slug="ohm", description="plain.")
|
||||||
|
r = client.post(
|
||||||
|
f"/api/rfcs/ohm/prs/{pr_number}/review",
|
||||||
|
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||||
|
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
|
||||||
|
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
|
||||||
|
assert review_msgs, "review comment not found in payload"
|
||||||
|
links = _rfc_segments(review_msgs[0]["text_segments"])
|
||||||
|
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
|
||||||
|
# one referenceable RFC.
|
||||||
|
assert {s["slug"] for s in links} == {"open-human-model"}
|
||||||
|
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_discussion_comment_autolinked(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)
|
||||||
|
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||||
|
# alice is the seeded owner of ohm (owners=["alice"]); grant the
|
||||||
|
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
|
||||||
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "Compare with the Open Human Model."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
thread_id = r.json()["thread_id"]
|
||||||
|
|
||||||
|
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
msgs = r.json()["messages"]
|
||||||
|
assert msgs and "text_segments" in msgs[0]
|
||||||
|
links = _rfc_segments(msgs[0]["text_segments"])
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0]["slug"] == "open-human-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_reference_not_linked(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")
|
||||||
|
# The host RFC has a multi-word title, so absent exclude_slug it
|
||||||
|
# WOULD self-link. exclude_slug must suppress it.
|
||||||
|
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
pr_number = _open_pr_on(
|
||||||
|
client, fake, host_slug="open-human-model",
|
||||||
|
description="Refines the Open Human Model definition.",
|
||||||
|
)
|
||||||
|
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
|
||||||
|
assert _rfc_segments(pr["description_segments"]) == []
|
||||||
@@ -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"
|
||||||
@@ -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"]) == []
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.21.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/unified": "^1.1.9",
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.19.0",
|
"version": "0.26.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+838
-729
File diff suppressed because it is too large
Load Diff
+48
-3
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { getMe, subscribeToNotifications } from './api'
|
import { getMe, subscribeToNotifications } from './api'
|
||||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||||
|
import { useLastState } from './lib/useLastState'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
import Inbox from './components/Inbox.jsx'
|
import Inbox from './components/Inbox.jsx'
|
||||||
import RFCView from './components/RFCView.jsx'
|
import RFCView from './components/RFCView.jsx'
|
||||||
@@ -17,6 +18,8 @@ import DocsUserGuide from './components/DocsUserGuide.jsx'
|
|||||||
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
||||||
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
||||||
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
||||||
|
import DocsSpec from './components/DocsSpec.jsx'
|
||||||
|
import DocsSpecsIndex from './components/DocsSpecsIndex.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||||
@@ -40,6 +43,12 @@ export default function App() {
|
|||||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||||
// event that bumps this.
|
// event that bumps this.
|
||||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
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 navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||||
@@ -84,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?.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]
|
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||||
identify({ user_id: String(uid), properties: props })
|
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) {
|
} else if (uid == null && lastUserIdRef.current != null) {
|
||||||
// Sign-out edge — App-level reset is handled separately by the
|
// Sign-out edge — App-level reset is handled separately by the
|
||||||
// sign-out gesture that fires User Signed Out. Clear our local
|
// sign-out gesture that fires User Signed Out. Clear our local
|
||||||
@@ -92,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])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handler = () => setConsentReopenTick(t => t + 1)
|
const handler = () => setConsentReopenTick(t => t + 1)
|
||||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||||
@@ -105,6 +125,18 @@ export default function App() {
|
|||||||
.finally(() => setLoading(false))
|
.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
|
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||||
// so the badge counter and the toast surface stay in lockstep with
|
// so the badge counter and the toast surface stay in lockstep with
|
||||||
// the inbox. Tabs that miss an event because they were closed pick
|
// the inbox. Tabs that miss an event because they were closed pick
|
||||||
@@ -167,7 +199,7 @@ export default function App() {
|
|||||||
wonders why a conversation is public can reach the answer
|
wonders why a conversation is public can reach the answer
|
||||||
in two clicks. Anonymous viewers see it too. */}
|
in two clicks. Anonymous viewers see it too. */}
|
||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
Philosophy
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/docs" className="header-about" title="User guide">
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
Docs
|
Docs
|
||||||
@@ -186,9 +218,17 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
className="inbox-trigger"
|
className="inbox-trigger"
|
||||||
onClick={() => setInboxOpen(o => !o)}
|
onClick={() => setInboxOpen(o => !o)}
|
||||||
title="Notifications inbox (§15.2)"
|
aria-label="Inbox"
|
||||||
|
title="Inbox (§15.2)"
|
||||||
>
|
>
|
||||||
<span aria-hidden>📮</span>
|
<svg
|
||||||
|
width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
||||||
|
</svg>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
)}
|
)}
|
||||||
@@ -329,6 +369,11 @@ function DocsWithSidebar({ viewer }) {
|
|||||||
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||||||
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||||||
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||||||
|
{/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec
|
||||||
|
at runtime via gitea raw). Bare /docs/specs lands on the
|
||||||
|
client-side redirect to the first configured spec. */}
|
||||||
|
<Route path="specs" element={<DocsSpecsIndex />} />
|
||||||
|
<Route path="specs/:name" element={<DocsSpec />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+81
-4
@@ -25,6 +25,25 @@ export async function getMe() {
|
|||||||
return jsonOrThrow(res)
|
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) ─────────────────────────
|
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
|
||||||
//
|
//
|
||||||
// The legacy /auth/login → /auth/callback OAuth flow remains during the
|
// 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}`))
|
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', {
|
const res = await fetch('/api/rfcs/propose', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
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) {
|
export async function mergeProposal(prNumber) {
|
||||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
@@ -492,13 +543,14 @@ export async function draftPRText(slug, branch) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPR(slug, branch, { title, description }) {
|
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
return jsonOrThrow(res)
|
||||||
@@ -774,6 +826,31 @@ export async function getSessionIndex(nnnn) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.20.0 — /api/docs/specs/* surface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Sibling of the docs-sessions helpers above. The framework mediates
|
||||||
|
// reads against the configured spec URLs (default: rfc-app's own
|
||||||
|
// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||||
|
// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide`
|
||||||
|
// and `/docs/sessions/*`. The manifest endpoint always returns 200 +
|
||||||
|
// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to
|
||||||
|
// the framework default at parse time on the backend.
|
||||||
|
//
|
||||||
|
// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`,
|
||||||
|
// matching the docs-sessions helper convention.
|
||||||
|
|
||||||
|
export async function getSpecsManifest() {
|
||||||
|
return jsonOrThrow(await fetch('/api/docs/specs/manifest'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpec(name) {
|
||||||
|
return _textOrThrow(await fetch(
|
||||||
|
`/api/docs/specs/${encodeURIComponent(name)}`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Docs.css — docs-surface polish scoped to v0.21.0 / roadmap item #32.
|
||||||
|
*
|
||||||
|
* This sheet owns ONLY the classes introduced by item #32 (the
|
||||||
|
* transcript metadata header and the session-root sibling list). The
|
||||||
|
* pre-existing docs classes (.docs-article, .docs-empty, .docs-error,
|
||||||
|
* .docs-source-link, .philosophy-body, .muted) live in App.css and are
|
||||||
|
* deliberately NOT touched here — redefining them would race the #31
|
||||||
|
* App.css token sweep for the same selectors. Every value below reads
|
||||||
|
* a token from tokens.css so the new surfaces sit on the same
|
||||||
|
* spacing/type/color scale as the rest of the docs chrome.
|
||||||
|
*
|
||||||
|
* Imported from DocsSessionTranscript.jsx + DocsSessionIndex.jsx (the
|
||||||
|
* two components that render these elements). CSS custom properties are
|
||||||
|
* not import-order-sensitive at use time, so the import site doesn't
|
||||||
|
* matter for correctness.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Transcript metadata header ─────────────────────────────────────
|
||||||
|
* A compact card above the rendered transcript body: title, the
|
||||||
|
* started/ended/duration grid, an optional TL;DR, and the external
|
||||||
|
* "view source" link. */
|
||||||
|
.docs-transcript-meta {
|
||||||
|
margin: 0 0 var(--space-9);
|
||||||
|
padding: var(--space-7);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-title {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-grid {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--space-2) var(--space-7);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-grid dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-tldr {
|
||||||
|
margin: var(--space-6) 0 0;
|
||||||
|
padding-top: var(--space-6);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-transcript-meta-source {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Session-root sibling-transcript list ───────────────────────────
|
||||||
|
* Rendered above the inlined primary transcript when a session has
|
||||||
|
* more than one transcript (driver `.0` + subagents). The primary is
|
||||||
|
* marked "(shown below)"; the rest link to their standalone routes. */
|
||||||
|
.docs-session-siblings {
|
||||||
|
margin: 0 0 var(--space-9);
|
||||||
|
padding: var(--space-6) var(--space-7);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-session-siblings-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-session-siblings-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-5);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-session-siblings-list a {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-session-siblings-list a:hover {
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-session-siblings-current {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -1,40 +1,64 @@
|
|||||||
// DocsLayout.jsx — v0.19.0 / roadmap item #30.
|
// DocsLayout.jsx — v0.20.0 (was v0.19.0 / roadmap item #30).
|
||||||
//
|
//
|
||||||
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
||||||
//
|
//
|
||||||
// /docs → redirect to /docs/user-guide
|
// /docs → redirect to /docs/user-guide
|
||||||
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
||||||
|
// /docs/specs → client-side redirect to first configured spec
|
||||||
|
// /docs/specs/:name → a single framework spec (v0.20.0)
|
||||||
// /docs/sessions → redirect to /docs/sessions/about
|
// /docs/sessions → redirect to /docs/sessions/about
|
||||||
// /docs/sessions/about → README.md from the sessions repo
|
// /docs/sessions/about → README.md from the sessions repo
|
||||||
// /docs/sessions/:nnnn → per-session index page
|
// /docs/sessions/:nnnn → per-session overview (nav-only navigation)
|
||||||
// /docs/sessions/:nnnn/:file → per-transcript view
|
// /docs/sessions/:nnnn/:file → per-transcript view
|
||||||
//
|
//
|
||||||
|
// v0.20.0 changes (Session 0018.0):
|
||||||
|
// - Adds a "Specs" section between User Guide and Sessions, driven
|
||||||
|
// by `/api/docs/specs/manifest`.
|
||||||
|
// - Sessions render a nested tree: each session row has the
|
||||||
|
// session's transcripts nested under it as their own nav rows
|
||||||
|
// (labeled by `.N` ordinal). The transcript list is fetched per
|
||||||
|
// session via `/api/docs/sessions/:nnnn/index` (cached server-
|
||||||
|
// side, so the manifest+index fan-out is cheap on subsequent
|
||||||
|
// loads). Always-expanded; no collapse toggle (current scale is
|
||||||
|
// under twenty sessions — well under the threshold where lazy
|
||||||
|
// expansion would pay).
|
||||||
|
//
|
||||||
// The flyout is a persistent left sidebar on desktop and a slide-out
|
// The flyout is a persistent left sidebar on desktop and a slide-out
|
||||||
// drawer on mobile (toggled by the icon button in the docs header).
|
// drawer on mobile (toggled by the icon button in the docs header).
|
||||||
// The session list is driven by the `/api/docs/sessions/manifest`
|
|
||||||
// fetch:
|
|
||||||
// - loading → skeleton in the nav (three placeholder rows)
|
|
||||||
// - manifest 502 → error banner in the nav with "Try again"
|
|
||||||
// - empty manifest → only "About" under Sessions; no NNNN rows
|
|
||||||
//
|
//
|
||||||
// Amplitude analytics (per SPEC §21):
|
// Amplitude analytics (per SPEC §21):
|
||||||
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
||||||
// the sub-route component owns the fire (it knows the section).
|
// the sub-route component owns the fire.
|
||||||
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
||||||
// so autocapture rows are readable rather than ":nth-child(7)".
|
// so autocapture rows are readable rather than ":nth-child(7)".
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||||
import { getSessionsManifest } from '../api.js'
|
import { getSessionsManifest, getSessionIndex, getSpecsManifest } from '../api.js'
|
||||||
|
|
||||||
|
// Extract the `.N` ordinal from a transcript filename:
|
||||||
|
// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1"
|
||||||
|
// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent)
|
||||||
|
// Returns the bare filename as fallback if the expected shape isn't
|
||||||
|
// matched (which shouldn't happen — the backend index endpoint
|
||||||
|
// filters by the same regex).
|
||||||
|
function transcriptOrdinal(filename) {
|
||||||
|
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename)
|
||||||
|
return m ? m[1] : filename
|
||||||
|
}
|
||||||
|
|
||||||
export default function DocsLayout({ authenticated }) {
|
export default function DocsLayout({ authenticated }) {
|
||||||
const [manifest, setManifest] = useState(null)
|
const [manifest, setManifest] = useState(null)
|
||||||
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
||||||
|
const [sessionFiles, setSessionFiles] = useState({}) // { nnnn: [filename, ...] }
|
||||||
|
const [specs, setSpecs] = useState([])
|
||||||
|
const [specsState, setSpecsState] = useState('loading') // loading | ok | error
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const [reloadTick, setReloadTick] = useState(0)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Manifest fetch — drives the Sessions section.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
setManifestState('loading')
|
setManifestState('loading')
|
||||||
@@ -52,6 +76,45 @@ export default function DocsLayout({ authenticated }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [reloadTick])
|
}, [reloadTick])
|
||||||
|
|
||||||
|
// Per-session transcript lists — fan out from the manifest. Always-
|
||||||
|
// expanded means we pre-fetch every session's index alongside the
|
||||||
|
// manifest, gated on the manifest having loaded successfully. The
|
||||||
|
// backend's 5-minute content TTL makes the repeat cost negligible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (manifestState !== 'ok' || !manifest) return
|
||||||
|
let active = true
|
||||||
|
const nnnnList = Object.keys(manifest).sort()
|
||||||
|
Promise.all(
|
||||||
|
nnnnList.map(nnnn =>
|
||||||
|
getSessionIndex(nnnn)
|
||||||
|
.then(payload => [nnnn, (payload && payload.files) || []])
|
||||||
|
.catch(() => [nnnn, []])
|
||||||
|
)
|
||||||
|
).then(pairs => {
|
||||||
|
if (!active) return
|
||||||
|
setSessionFiles(Object.fromEntries(pairs))
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [manifest, manifestState])
|
||||||
|
|
||||||
|
// Specs fetch — drives the Specs section. Independent of sessions.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setSpecsState('loading')
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
setSpecs((payload && payload.specs) || [])
|
||||||
|
setSpecsState('ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setSpecs([])
|
||||||
|
setSpecsState('error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [reloadTick])
|
||||||
|
|
||||||
// Close the mobile drawer on every navigation so a click in the nav
|
// Close the mobile drawer on every navigation so a click in the nav
|
||||||
// doesn't strand the user on a drawer-open view.
|
// doesn't strand the user on a drawer-open view.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,6 +162,9 @@ export default function DocsLayout({ authenticated }) {
|
|||||||
<DocsNav
|
<DocsNav
|
||||||
manifest={manifest}
|
manifest={manifest}
|
||||||
manifestState={manifestState}
|
manifestState={manifestState}
|
||||||
|
sessionFiles={sessionFiles}
|
||||||
|
specs={specs}
|
||||||
|
specsState={specsState}
|
||||||
onRetry={retryManifest}
|
onRetry={retryManifest}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
/>
|
/>
|
||||||
@@ -119,12 +185,18 @@ export default function DocsLayout({ authenticated }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
function DocsNav({
|
||||||
|
manifest,
|
||||||
|
manifestState,
|
||||||
|
sessionFiles,
|
||||||
|
specs,
|
||||||
|
specsState,
|
||||||
|
onRetry,
|
||||||
|
currentPath,
|
||||||
|
}) {
|
||||||
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
|
||||||
|
const isExactly = (path) => currentPath === path
|
||||||
|
|
||||||
// Sort session keys ascending (newest sessions render last). The
|
|
||||||
// manifest's keys are zero-padded 4-digit strings so lexicographic
|
|
||||||
// order is the same as numeric.
|
|
||||||
const sessionKeys = Object.keys(manifest || {}).sort()
|
const sessionKeys = Object.keys(manifest || {}).sort()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,13 +217,48 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="docs-nav-section">
|
||||||
|
<div className="docs-nav-section-label">Specs</div>
|
||||||
|
{specsState === 'loading' && (
|
||||||
|
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
<li><span className="skeleton-row" /></li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{specsState === 'error' && (
|
||||||
|
<div className="docs-nav-error" role="alert">
|
||||||
|
<span>Couldn't load specs.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{specsState === 'ok' && specs.length > 0 && (
|
||||||
|
<ul className="docs-nav-list">
|
||||||
|
{specs.map(spec => {
|
||||||
|
const to = `/docs/specs/${spec.name}`
|
||||||
|
return (
|
||||||
|
<li key={spec.name}>
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={isExactly(to) ? 'active' : ''}
|
||||||
|
aria-label={`Spec: ${spec.title}`}
|
||||||
|
data-amp-track-name="Docs Nav Spec"
|
||||||
|
data-amp-track-spec={spec.name}
|
||||||
|
>
|
||||||
|
{spec.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="docs-nav-section">
|
<div className="docs-nav-section">
|
||||||
<div className="docs-nav-section-label">Sessions</div>
|
<div className="docs-nav-section-label">Sessions</div>
|
||||||
<ul className="docs-nav-list">
|
<ul className="docs-nav-list">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
to="/docs/sessions/about"
|
to="/docs/sessions/about"
|
||||||
className={currentPath === '/docs/sessions/about' ? 'active' : ''}
|
className={isExactly('/docs/sessions/about') ? 'active' : ''}
|
||||||
aria-label="About sessions"
|
aria-label="About sessions"
|
||||||
data-amp-track-name="Docs Nav Sessions About"
|
data-amp-track-name="Docs Nav Sessions About"
|
||||||
>
|
>
|
||||||
@@ -183,23 +290,45 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||||
<ul className="docs-nav-list">
|
<ul className="docs-nav-list docs-nav-list--tree">
|
||||||
{sessionKeys.map(nnnn => {
|
{sessionKeys.map(nnnn => {
|
||||||
const entry = manifest[nnnn] || {}
|
const entry = manifest[nnnn] || {}
|
||||||
const title = entry.title || ''
|
const title = entry.title || ''
|
||||||
const label = title ? `${nnnn} — ${title}` : nnnn
|
const label = title ? `${nnnn} — ${title}` : nnnn
|
||||||
const to = `/docs/sessions/${nnnn}`
|
const to = `/docs/sessions/${nnnn}`
|
||||||
|
const files = sessionFiles[nnnn] || []
|
||||||
return (
|
return (
|
||||||
<li key={nnnn}>
|
<li key={nnnn}>
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className={isActive(to) ? 'active' : ''}
|
className={isExactly(to) ? 'active' : ''}
|
||||||
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||||
data-amp-track-name="Docs Nav Session"
|
data-amp-track-name="Docs Nav Session"
|
||||||
data-amp-track-session={nnnn}
|
data-amp-track-session={nnnn}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="docs-nav-list docs-nav-list--children">
|
||||||
|
{files.map(f => {
|
||||||
|
const tTo = `/docs/sessions/${nnnn}/${f}`
|
||||||
|
return (
|
||||||
|
<li key={f}>
|
||||||
|
<Link
|
||||||
|
to={tTo}
|
||||||
|
className={isExactly(tTo) ? 'active' : ''}
|
||||||
|
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||||
|
data-amp-track-name="Docs Nav Transcript"
|
||||||
|
data-amp-track-session={nnnn}
|
||||||
|
data-amp-track-filename={f}
|
||||||
|
>
|
||||||
|
{transcriptOrdinal(f)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,44 +1,86 @@
|
|||||||
// DocsSessionIndex.jsx — v0.19.0 / roadmap item #30.
|
// DocsSessionIndex.jsx — v0.21.0 (was v0.20.0 / roadmap item #30).
|
||||||
//
|
//
|
||||||
// Per-session index page at `/docs/sessions/:nnnn`. Lists every
|
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
|
||||||
// transcript published under the session's NNNN/ folder, linked to
|
// dead-end "N transcript(s) in this session. Select one from the
|
||||||
// the per-transcript view.
|
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
|
||||||
|
// the session root now renders a transcript INLINE so the URL is never
|
||||||
|
// an empty stop.
|
||||||
//
|
//
|
||||||
// The transcript list comes from `/api/docs/sessions/:nnnn/index`,
|
// - Exactly one transcript → render it inline at the session root.
|
||||||
// which the framework derives via the gitea contents API (see
|
// - Multiple transcripts → render the `.0` driver transcript
|
||||||
// backend/app/docs_sessions.fetch_session_index). We also read the
|
// inline (fall back to the first file by
|
||||||
// session's `title` from the manifest fetch so the page header
|
// sort order if there's no `.0`), AND
|
||||||
// matches the flyout nav entry.
|
// list/link the remaining transcripts so
|
||||||
|
// the siblings are one click away.
|
||||||
|
//
|
||||||
|
// The URL stays stable to the session number — this is an inline
|
||||||
|
// render, not a 301/redirect. The per-transcript route
|
||||||
|
// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
|
||||||
|
// sibling links and the left-nav transcript rows point at.
|
||||||
|
//
|
||||||
|
// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
|
||||||
|
// so the empty-state ("no transcripts yet"), not-found, and error
|
||||||
|
// paths remain meaningful when the upstream is mid-publish or
|
||||||
|
// unreachable. The metadata header + body rendering are imported from
|
||||||
|
// DocsSessionTranscript.jsx so the inline view is byte-identical to the
|
||||||
|
// standalone per-transcript view.
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getSessionsManifest, getSessionIndex } from '../api.js'
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import {
|
||||||
|
getSessionsManifest,
|
||||||
|
getSessionIndex,
|
||||||
|
getSessionTranscript,
|
||||||
|
} from '../api.js'
|
||||||
|
import {
|
||||||
|
TranscriptMetaHeader,
|
||||||
|
transcriptOrdinal,
|
||||||
|
} from './DocsSessionTranscript.jsx'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
import './Docs.css'
|
||||||
|
|
||||||
|
// Pick the transcript to render inline at the session root: prefer the
|
||||||
|
// `.0` driver transcript; otherwise the first file by sort order. The
|
||||||
|
// backend already returns the file list sorted, so `files[0]` is a
|
||||||
|
// stable fallback.
|
||||||
|
function pickPrimary(files) {
|
||||||
|
if (!files || files.length === 0) return null
|
||||||
|
const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
|
||||||
|
return driver || files[0]
|
||||||
|
}
|
||||||
|
|
||||||
export default function DocsSessionIndex() {
|
export default function DocsSessionIndex() {
|
||||||
const { nnnn } = useParams()
|
const { nnnn } = useParams()
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [tldr, setTldr] = useState('')
|
||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
const [reloadTick, setReloadTick] = useState(0)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
// The inline body for the primary transcript.
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||||
}, [nnnn])
|
}, [nnnn])
|
||||||
|
|
||||||
// Title from manifest — cheap, manifest is cached server-side.
|
// Title + optional TL;DR from the manifest — cheap, cached server-side.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
getSessionsManifest()
|
getSessionsManifest()
|
||||||
.then(payload => {
|
.then(payload => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const entry = payload && payload[nnnn]
|
const entry = (payload && payload[nnnn]) || {}
|
||||||
setTitle((entry && entry.title) || '')
|
setTitle(entry.title || '')
|
||||||
|
// `tldr` is an optional manifest field (string). Absent ⇒ the
|
||||||
|
// header renders no TL;DR line (graceful degrade).
|
||||||
|
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Title is decorative; failure to load just leaves the header
|
// Title + TL;DR are decorative; the transcript list + body
|
||||||
// showing the bare NNNN. The transcript list fetch below is
|
// fetches below are the load-bearing ones.
|
||||||
// the load-bearing one.
|
|
||||||
})
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [nnnn])
|
}, [nnnn])
|
||||||
@@ -64,14 +106,41 @@ export default function DocsSessionIndex() {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [nnnn, reloadTick])
|
}, [nnnn, reloadTick])
|
||||||
|
|
||||||
|
const primary = status === 'ok' ? pickPrimary(files) : null
|
||||||
|
|
||||||
|
// Fetch the primary transcript body once we know which file it is.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!primary) {
|
||||||
|
setBody('')
|
||||||
|
setBodyStatus('idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
setBodyStatus('loading')
|
||||||
|
getSessionTranscript(nnnn, primary)
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setBodyStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
setBodyStatus(e.status === 404 ? 'notfound' : 'error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn, primary, reloadTick])
|
||||||
|
|
||||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||||
|
const siblings = primary ? files.filter(f => f !== primary) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="docs-article">
|
<article className="docs-article">
|
||||||
<h1 className="docs-article-title">{header}</h1>
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
|
||||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
|
||||||
{status === 'notfound' && (
|
{status === 'notfound' && (
|
||||||
<div className="docs-empty">
|
<div className="docs-empty">
|
||||||
<p>
|
<p>
|
||||||
@@ -86,6 +155,7 @@ export default function DocsSessionIndex() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="docs-error" role="alert">
|
<div className="docs-error" role="alert">
|
||||||
<p>Couldn't reach the session-history repo.</p>
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
@@ -99,27 +169,77 @@ export default function DocsSessionIndex() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'ok' && files.length === 0 && (
|
{status === 'ok' && files.length === 0 && (
|
||||||
<div className="docs-empty">
|
<div className="docs-empty">
|
||||||
<p>This session has no transcripts published.</p>
|
<p>This session has no transcripts published.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'ok' && files.length > 0 && (
|
|
||||||
<ul className="docs-session-files">
|
{status === 'ok' && primary && (
|
||||||
{files.map(f => (
|
<>
|
||||||
|
{siblings.length > 0 && (
|
||||||
|
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
|
||||||
|
<span className="docs-session-siblings-label">Transcripts</span>
|
||||||
|
<ul className="docs-session-siblings-list">
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
className="docs-session-siblings-current"
|
||||||
|
aria-current="true"
|
||||||
|
>
|
||||||
|
{transcriptOrdinal(primary)} (shown below)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{siblings.map(f => (
|
||||||
<li key={f}>
|
<li key={f}>
|
||||||
<Link
|
<Link
|
||||||
to={`/docs/sessions/${nnnn}/${f}`}
|
to={`/docs/sessions/${nnnn}/${f}`}
|
||||||
aria-label={`Open transcript ${f}`}
|
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||||
data-amp-track-name="Docs Session Transcript Open"
|
data-amp-track-name="Docs Session Sibling Transcript"
|
||||||
data-amp-track-session={nnnn}
|
data-amp-track-session={nnnn}
|
||||||
data-amp-track-filename={f}
|
data-amp-track-filename={f}
|
||||||
>
|
>
|
||||||
{f}
|
{transcriptOrdinal(f)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bodyStatus === 'loading' && <p className="muted">Loading transcript…</p>}
|
||||||
|
{bodyStatus === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This transcript isn't published yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bodyStatus === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the session-history repo.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Session Inline Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bodyStatus === 'ok' && (
|
||||||
|
<>
|
||||||
|
<TranscriptMetaHeader
|
||||||
|
nnnn={nnnn}
|
||||||
|
filename={primary}
|
||||||
|
title={title}
|
||||||
|
tldr={tldr}
|
||||||
|
/>
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
|
// DocsSessionTranscript.jsx — v0.21.0 (was v0.19.0 / roadmap item #30).
|
||||||
//
|
//
|
||||||
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||||
// transcript body via the backend mediator and renders it through the
|
// transcript body via the backend mediator and renders it through the
|
||||||
// shared MarkdownPreview.
|
// shared MarkdownPreview.
|
||||||
//
|
//
|
||||||
|
// v0.21.0 / roadmap item #32:
|
||||||
|
// - A compact metadata header now sits above the rendered body
|
||||||
|
// (title, started/ended, duration, optional TL;DR, and a
|
||||||
|
// "View source on git.wiggleverse.org" external link). The parse
|
||||||
|
// + render helpers (`parseTranscriptMeta`, `TranscriptMetaHeader`,
|
||||||
|
// `gitSourceUrl`) are exported here so the session-root inline-
|
||||||
|
// collapse view (DocsSessionIndex.jsx) reuses the exact same
|
||||||
|
// rendering for the transcript(s) it inlines.
|
||||||
|
//
|
||||||
// Empty-state contract:
|
// Empty-state contract:
|
||||||
// 404 → "This transcript isn't published yet" with a link back to
|
// 404 → "This transcript isn't published yet" with a link back to
|
||||||
// the parent session index
|
// the parent session index
|
||||||
@@ -12,12 +21,149 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
import { getSessionTranscript } from '../api.js'
|
import { getSessionTranscript, getSessionsManifest } from '../api.js'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
import './Docs.css'
|
||||||
|
|
||||||
|
// The canonical published-repo source URL for a transcript file, per
|
||||||
|
// SESSION-PROTOCOL.md §1's folder layout (one folder per session).
|
||||||
|
export function gitSourceUrl(nnnn, filename) {
|
||||||
|
return (
|
||||||
|
'https://git.wiggleverse.org/wiggleverse/ohm-session-history/src/branch/main/' +
|
||||||
|
`${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the `.N` ordinal from a transcript filename:
|
||||||
|
// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1"
|
||||||
|
// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent)
|
||||||
|
export function transcriptOrdinal(filename) {
|
||||||
|
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename || '')
|
||||||
|
return m ? m[1] : filename || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the `<start>--<end>` ISO segment out of a transcript filename.
|
||||||
|
// Per the protocol the segment is `YYYY-MM-DDTHH-MM--YYYY-MM-DDTHH-MM`
|
||||||
|
// (colons replaced by dashes for filesystem portability, minute
|
||||||
|
// precision, PST implied). Legacy renamed-letter transcripts omit the
|
||||||
|
// segment entirely; in that case every derived field comes back null
|
||||||
|
// and the header degrades gracefully.
|
||||||
|
//
|
||||||
|
// Returns { ordinal, start: Date|null, end: Date|null, durationMs: number|null }.
|
||||||
|
export function parseTranscriptMeta(filename) {
|
||||||
|
const ordinal = transcriptOrdinal(filename)
|
||||||
|
const m = /-TRANSCRIPT-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})--(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})\.md$/.exec(
|
||||||
|
filename || ''
|
||||||
|
)
|
||||||
|
if (!m) {
|
||||||
|
return { ordinal, start: null, end: null, durationMs: null }
|
||||||
|
}
|
||||||
|
const [, sDate, sH, sM, eDate, eH, eM] = m
|
||||||
|
// Parse as local wall-clock time. The filename carries no timezone
|
||||||
|
// (PST is implied per the protocol); we render the wall-clock value
|
||||||
|
// verbatim rather than shifting it, so we build a local Date and read
|
||||||
|
// it back with the same calendar fields. Duration is a difference of
|
||||||
|
// two local Dates, so the implied-timezone ambiguity cancels out.
|
||||||
|
const start = new Date(`${sDate}T${sH}:${sM}:00`)
|
||||||
|
const end = new Date(`${eDate}T${eH}:${eM}:00`)
|
||||||
|
const startOk = !Number.isNaN(start.getTime())
|
||||||
|
const endOk = !Number.isNaN(end.getTime())
|
||||||
|
const durationMs =
|
||||||
|
startOk && endOk && end.getTime() >= start.getTime()
|
||||||
|
? end.getTime() - start.getTime()
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
ordinal,
|
||||||
|
start: startOk ? start : null,
|
||||||
|
end: endOk ? end : null,
|
||||||
|
durationMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(d) {
|
||||||
|
if (!d) return null
|
||||||
|
// e.g. "May 28, 2026, 11:11 AM" — human-readable, wall-clock.
|
||||||
|
try {
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(ms) {
|
||||||
|
if (ms == null || ms <= 0) return null
|
||||||
|
const totalMin = Math.round(ms / 60000)
|
||||||
|
const h = Math.floor(totalMin / 60)
|
||||||
|
const m = totalMin % 60
|
||||||
|
if (h > 0 && m > 0) return `${h}h ${m}m`
|
||||||
|
if (h > 0) return `${h}h`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compact metadata block rendered above every transcript body.
|
||||||
|
// Shared between the standalone transcript route and the session-root
|
||||||
|
// inline-collapse view. `tldr` is optional — absent ⇒ rendered nothing
|
||||||
|
// (graceful degrade, per the manifest schema where `tldr` may be unset).
|
||||||
|
export function TranscriptMetaHeader({ nnnn, filename, title, tldr }) {
|
||||||
|
const { ordinal, start, end, durationMs } = parseTranscriptMeta(filename)
|
||||||
|
const started = fmtDateTime(start)
|
||||||
|
const ended = fmtDateTime(end)
|
||||||
|
const duration = fmtDuration(durationMs)
|
||||||
|
const heading = title ? `${ordinal} — ${title}` : `Session ${ordinal}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="docs-transcript-meta">
|
||||||
|
<h2 className="docs-transcript-meta-title">{heading}</h2>
|
||||||
|
{(started || ended || duration) && (
|
||||||
|
<dl className="docs-transcript-meta-grid">
|
||||||
|
{started && (
|
||||||
|
<div className="docs-transcript-meta-row">
|
||||||
|
<dt>Started</dt>
|
||||||
|
<dd>{started}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ended && (
|
||||||
|
<div className="docs-transcript-meta-row">
|
||||||
|
<dt>Ended</dt>
|
||||||
|
<dd>{ended}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<div className="docs-transcript-meta-row">
|
||||||
|
<dt>Duration</dt>
|
||||||
|
<dd>{duration}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
{tldr && <p className="docs-transcript-meta-tldr">{tldr}</p>}
|
||||||
|
<a
|
||||||
|
className="docs-source-link docs-transcript-meta-source"
|
||||||
|
href={gitSourceUrl(nnnn, filename)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`View transcript ${ordinal} source on git.wiggleverse.org`}
|
||||||
|
data-amp-track-name="Docs Transcript Source Link"
|
||||||
|
data-amp-track-session={nnnn}
|
||||||
|
data-amp-track-filename={filename}
|
||||||
|
>
|
||||||
|
View source on git.wiggleverse.org ↗
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DocsSessionTranscript() {
|
export default function DocsSessionTranscript() {
|
||||||
const { nnnn, filename } = useParams()
|
const { nnnn, filename } = useParams()
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [tldr, setTldr] = useState('')
|
||||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
const [reloadTick, setReloadTick] = useState(0)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
@@ -25,6 +171,22 @@ export default function DocsSessionTranscript() {
|
|||||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||||
}, [nnnn, filename])
|
}, [nnnn, filename])
|
||||||
|
|
||||||
|
// Title + optional tl;dr from the manifest — decorative metadata that
|
||||||
|
// feeds the header. Failure leaves the header showing the bare NNNN
|
||||||
|
// and no TL;DR; the body fetch below is the load-bearing one.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSessionsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const entry = (payload && payload[nnnn]) || {}
|
||||||
|
setTitle(entry.title || '')
|
||||||
|
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [nnnn])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
setStatus('loading')
|
setStatus('loading')
|
||||||
@@ -87,9 +249,17 @@ export default function DocsSessionTranscript() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'ok' && (
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
<TranscriptMetaHeader
|
||||||
|
nnnn={nnnn}
|
||||||
|
filename={filename}
|
||||||
|
title={title}
|
||||||
|
tldr={tldr}
|
||||||
|
/>
|
||||||
<div className="philosophy-body">
|
<div className="philosophy-body">
|
||||||
<MarkdownPreview content={body} />
|
<MarkdownPreview content={body} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// DocsSpec.jsx — v0.20.0.
|
||||||
|
//
|
||||||
|
// Per-spec view at `/docs/specs/:name`. Fetches a configured spec
|
||||||
|
// body via the backend mediator (which proxies the gitea raw URL)
|
||||||
|
// and renders it through the shared MarkdownPreview. The page header
|
||||||
|
// pulls the spec's `title` from the manifest so the breadcrumb-free
|
||||||
|
// page still names what you're looking at.
|
||||||
|
//
|
||||||
|
// Empty-state contract:
|
||||||
|
// 404 → "This spec isn't published yet / unknown name" with a hint
|
||||||
|
// to pick a configured spec from the nav.
|
||||||
|
// 502 → "Couldn't reach the spec source" + retry button.
|
||||||
|
//
|
||||||
|
// History view is intentionally absent — the operator's framing for
|
||||||
|
// v0.20.0 is "current version only; git is the history surface".
|
||||||
|
// A small "View on gitea" link beside the title points at the
|
||||||
|
// upstream source URL the manifest carries so the history gesture
|
||||||
|
// remains one click away.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getSpec, getSpecsManifest } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSpec() {
|
||||||
|
const { name } = useParams()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: `specs/${name}` })
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
// Title + upstream URL from the manifest (cheap — the manifest is
|
||||||
|
// derived from an env var on the backend, no network).
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const entry = (payload && payload.specs || []).find(s => s.name === name)
|
||||||
|
setTitle((entry && entry.title) || '')
|
||||||
|
setSourceUrl((entry && entry.url) || '')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Title + source link are decorative; the body fetch below
|
||||||
|
// is the load-bearing one. A failed manifest fetch just
|
||||||
|
// leaves the page rendering the bare name.
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
|
getSpec(name)
|
||||||
|
.then(text => {
|
||||||
|
if (!active) return
|
||||||
|
setBody(text || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!active) return
|
||||||
|
if (e.status === 404) {
|
||||||
|
setStatus('notfound')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [name, reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
|
const header = title || `Spec: ${name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<header className="docs-article-header">
|
||||||
|
<h1 className="docs-article-title">{header}</h1>
|
||||||
|
{sourceUrl && (
|
||||||
|
<a
|
||||||
|
className="docs-source-link"
|
||||||
|
href={sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="View spec source on gitea"
|
||||||
|
data-amp-track-name="Docs Spec Source Link"
|
||||||
|
data-amp-track-spec={name}
|
||||||
|
>
|
||||||
|
View source
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'notfound' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>This spec isn't available.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to="/docs/user-guide"
|
||||||
|
aria-label="User guide"
|
||||||
|
data-amp-track-name="Docs Spec Notfound User Guide Link"
|
||||||
|
>
|
||||||
|
← Back to the user guide
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't reach the spec source.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs Spec Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<div className="philosophy-body">
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// DocsSpecsIndex.jsx — v0.20.0.
|
||||||
|
//
|
||||||
|
// Landing route at `/docs/specs`. The operator-stated body shape for
|
||||||
|
// the parallel `/docs/sessions/:nnnn` page is "no body list — pick a
|
||||||
|
// transcript from the nav", and the same gesture applies here: the
|
||||||
|
// `/docs/specs` route either redirects to the first configured spec
|
||||||
|
// (the common case) or renders a "no specs configured" empty state
|
||||||
|
// (only reachable if a deployment overrides `OHM_DOCS_SPECS` to an
|
||||||
|
// empty list — the framework default has two entries).
|
||||||
|
//
|
||||||
|
// The redirect is client-side because the manifest is a single API
|
||||||
|
// call away; server-side redirect would require either a backend
|
||||||
|
// route for the bare `/docs/specs` path (out of scope for v0.20.0)
|
||||||
|
// or a build-time bake of the first spec name (which couples the
|
||||||
|
// frontend bundle to the deployment overlay, which we don't do).
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigate, Link } from 'react-router-dom'
|
||||||
|
import { getSpecsManifest } from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function DocsSpecsIndex() {
|
||||||
|
const [firstName, setFirstName] = useState(null)
|
||||||
|
// Tri-state: 'loading' (waiting on manifest), 'redirect' (we have a
|
||||||
|
// name to redirect to — render <Navigate>), 'empty' (no specs
|
||||||
|
// configured), or 'error' (couldn't load the manifest at all).
|
||||||
|
const [status, setStatus] = useState('loading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(EVENTS.DOC_VIEWED, { section: 'specs' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getSpecsManifest()
|
||||||
|
.then(payload => {
|
||||||
|
if (!active) return
|
||||||
|
const specs = (payload && payload.specs) || []
|
||||||
|
if (specs.length === 0) {
|
||||||
|
setStatus('empty')
|
||||||
|
} else {
|
||||||
|
setFirstName(specs[0].name)
|
||||||
|
setStatus('redirect')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setStatus('error')
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (status === 'redirect' && firstName) {
|
||||||
|
return <Navigate to={firstName} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="docs-article">
|
||||||
|
<h1 className="docs-article-title">Specs</h1>
|
||||||
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
|
{status === 'empty' && (
|
||||||
|
<div className="docs-empty">
|
||||||
|
<p>No specs are configured for this deployment.</p>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to="/docs/user-guide"
|
||||||
|
aria-label="User guide"
|
||||||
|
data-amp-track-name="Docs Specs Empty User Guide Link"
|
||||||
|
>
|
||||||
|
← Back to the user guide
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't load the spec list.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,15 +7,21 @@
|
|||||||
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||||
// so we don't introduce a second markdown library).
|
// so we don't introduce a second markdown library).
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
// v0.21.0 / roadmap item #31: the loading + error states are brought
|
||||||
|
// onto the same `.docs-empty` / `.docs-error` convention every other
|
||||||
|
// docs surface uses (was a bare `<p className="error">`), with a retry
|
||||||
|
// button so a transient `/api/docs` failure isn't a dead end.
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
import { getDocs } from '../api.js'
|
import { getDocs } from '../api.js'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function DocsUserGuide() {
|
export default function DocsUserGuide() {
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [status, setStatus] = useState('loading') // loading | ok | error
|
||||||
const [loading, setLoading] = useState(true)
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||||
@@ -23,19 +29,49 @@ export default function DocsUserGuide() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
setStatus('loading')
|
||||||
getDocs()
|
getDocs()
|
||||||
.then(r => { if (active) setBody(r.body || '') })
|
.then(r => {
|
||||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
if (!active) return
|
||||||
.finally(() => { if (active) setLoading(false) })
|
setBody(r.body || '')
|
||||||
|
setStatus('ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
setStatus('error')
|
||||||
|
})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [reloadTick])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="docs-article">
|
<article className="docs-article">
|
||||||
<h1 className="docs-article-title">User guide</h1>
|
<h1 className="docs-article-title">User guide</h1>
|
||||||
{loading && <p className="muted">Loading…</p>}
|
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
{status === 'error' && (
|
||||||
{!loading && !error && (
|
<div className="docs-error" role="alert">
|
||||||
|
<p>Couldn't load the user guide.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retry}
|
||||||
|
aria-label="Retry"
|
||||||
|
data-amp-track-name="Docs User Guide Retry"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
to="/docs/sessions/about"
|
||||||
|
aria-label="About sessions"
|
||||||
|
data-amp-track-name="Docs User Guide Error About Link"
|
||||||
|
>
|
||||||
|
About sessions
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
<div className="philosophy-body">
|
<div className="philosophy-body">
|
||||||
<MarkdownPreview content={body} />
|
<MarkdownPreview content={body} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Inbox.css — §15.2 inbox panel refinements (roadmap #25, light pass).
|
||||||
|
*
|
||||||
|
* The base inbox layout/structure lives in App.css (the §15 / Slice 6
|
||||||
|
* block). This sheet is a TOKENIZED polish layer on top of it: it does
|
||||||
|
* NOT re-lay-out the panel, it sharpens the unread/read distinction,
|
||||||
|
* adds the per-row "mark read" affordance + the unread dot, and gives
|
||||||
|
* the empty/loading states real copy and spacing.
|
||||||
|
*
|
||||||
|
* Cascade note: Inbox.jsx is imported by App.jsx (line 6) BEFORE the
|
||||||
|
* App.css import (line 30), so under ESM depth-first evaluation this
|
||||||
|
* sheet is injected FIRST and App.css wins on equal specificity. Any
|
||||||
|
* rule here that must override an App.css value is therefore written
|
||||||
|
* one notch more specific (e.g. `.inbox-list .inbox-row.unread`).
|
||||||
|
* New classes that App.css doesn't define need no such guard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== Unread vs. read distinction ===== */
|
||||||
|
|
||||||
|
/* A clear left accent bar + warmer tint on unread; read rows sit calm. */
|
||||||
|
.inbox-list .inbox-row {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background var(--motion-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.unread {
|
||||||
|
background: var(--color-warning-bg-soft, var(--c-warning-bg-soft));
|
||||||
|
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.read .inbox-summary {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.unread .inbox-summary {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dot is a NEW affordance: a filled accent dot for unread, hidden
|
||||||
|
* (but space-reserved) for read so summaries stay column-aligned. */
|
||||||
|
.inbox-unread-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
.inbox-row.read .inbox-unread-dot {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Per-row "mark read" affordance ===== */
|
||||||
|
|
||||||
|
/* The row is a flex Link followed by this button; pin the button to the
|
||||||
|
* right edge, revealed on row hover/focus and always visible on touch. */
|
||||||
|
.inbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.inbox-row .inbox-row-link {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-right: var(--space-5);
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-fast) var(--ease-out),
|
||||||
|
color var(--motion-fast) var(--ease-out),
|
||||||
|
background var(--motion-fast) var(--ease-out),
|
||||||
|
border-color var(--motion-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.inbox-row:hover .inbox-row-dismiss,
|
||||||
|
.inbox-row:focus-within .inbox-row-dismiss,
|
||||||
|
.inbox-row-dismiss:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss:hover {
|
||||||
|
color: var(--color-success-fg);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border-color: var(--color-success-bg);
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* Coarse pointers (touch) have no hover; keep the affordance discoverable. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.inbox-row-dismiss { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mark-all-read button ===== */
|
||||||
|
.inbox-mark-all {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Empty / loading states ===== */
|
||||||
|
.inbox-state {
|
||||||
|
padding: var(--space-9) var(--space-7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.inbox-empty {
|
||||||
|
padding: var(--space-11) var(--space-7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.inbox-empty-title {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
.inbox-empty .muted {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
markNotificationsReadByFilter,
|
markNotificationsReadByFilter,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
import './Inbox.css'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: '', label: 'All categories' },
|
{ value: '', label: 'All categories' },
|
||||||
@@ -56,11 +57,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
return Array.from(seen.entries())
|
return Array.from(seen.entries())
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
async function handleRowClick(item) {
|
async function markOneRead(item) {
|
||||||
if (!item.read_at) {
|
if (item.read_at) return
|
||||||
await markNotificationRead(item.id)
|
await markNotificationRead(item.id)
|
||||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
||||||
|
setUnreadCount(c => Math.max(0, c - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRowClick(item) {
|
||||||
|
await markOneRead(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllUnderFilter() {
|
async function markAllUnderFilter() {
|
||||||
@@ -121,22 +126,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-link"
|
className="btn-link inbox-mark-all"
|
||||||
onClick={markAllUnderFilter}
|
onClick={markAllUnderFilter}
|
||||||
disabled={items.every(i => i.read_at)}
|
disabled={items.every(i => i.read_at)}
|
||||||
|
title="Mark every notification matching the current filter as read"
|
||||||
>
|
>
|
||||||
Mark all read (under filter)
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inbox-body">
|
<div className="inbox-body">
|
||||||
{loading && <p className="muted">Loading…</p>}
|
{loading && <p className="inbox-state muted">Loading your inbox…</p>}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
|
<div className="inbox-empty">
|
||||||
|
<p className="inbox-empty-title">You're all caught up.</p>
|
||||||
|
<p className="muted">
|
||||||
|
{filters.unread || filters.rfcSlug || filters.category
|
||||||
|
? 'Nothing matches the current filters. Clear them to see everything.'
|
||||||
|
: 'New activity on RFCs you follow will show up here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="inbox-list">
|
<ul className="inbox-list">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
|
<InboxRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
onMarkRead={markOneRead}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,16 +164,24 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InboxRow({ item, onClick, onClose }) {
|
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||||
const unread = !item.read_at
|
const unread = !item.read_at
|
||||||
const target = deepLink(item)
|
const target = deepLink(item)
|
||||||
const handle = async () => {
|
const handle = async () => {
|
||||||
await onClick(item)
|
await onClick(item)
|
||||||
if (target) onClose?.()
|
if (target) onClose?.()
|
||||||
}
|
}
|
||||||
|
const handleMarkRead = async (e) => {
|
||||||
|
// Don't let the row's Link fire — this affordance only marks read,
|
||||||
|
// it never navigates.
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await onMarkRead(item)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
|
||||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
||||||
|
<span className="inbox-unread-dot" aria-hidden />
|
||||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||||
<span className="inbox-summary">{item.summary}</span>
|
<span className="inbox-summary">{item.summary}</span>
|
||||||
{item.bundled_count > 1 && (
|
{item.bundled_count > 1 && (
|
||||||
@@ -162,6 +189,24 @@ function InboxRow({ item, onClick, onClose }) {
|
|||||||
)}
|
)}
|
||||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{unread && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inbox-row-dismiss"
|
||||||
|
onClick={handleMarkRead}
|
||||||
|
aria-label="Mark as read"
|
||||||
|
title="Mark as read"
|
||||||
|
>
|
||||||
|
{/* check glyph — dependency-free inline SVG */}
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="2.25"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||||
|
>
|
||||||
|
<path d="m5 13 4 4 10-11" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// LinkedText.jsx — roadmap #28 Part 1.
|
||||||
|
//
|
||||||
|
// Renders a backend-provided list of text/rfc-link segments (see
|
||||||
|
// backend/app/rfc_links.py). RFC references in PR descriptions and
|
||||||
|
// comments arrive pre-scanned as structured segments — this component
|
||||||
|
// maps them onto plain text runs and anchor elements. It never renders
|
||||||
|
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
|
||||||
|
// XSS-safe regardless of what a comment author typed.
|
||||||
|
//
|
||||||
|
// `segments` is the enriched array; `text` is the raw fallback used when
|
||||||
|
// the field is absent (an older cached response, or a caller that didn't
|
||||||
|
// pass segments). Either way the visible text is identical — only the
|
||||||
|
// links differ.
|
||||||
|
|
||||||
|
export default function LinkedText({ segments, text }) {
|
||||||
|
if (!Array.isArray(segments) || segments.length === 0) {
|
||||||
|
return <>{text ?? ''}</>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.type === 'rfc') {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
className="rfc-autolink"
|
||||||
|
href={`/rfc/${seg.slug}`}
|
||||||
|
title={seg.title ? `RFC: ${seg.title}` : undefined}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span key={i}>{seg.text}</span>
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
|
|||||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
// #26: optional ground-truth use case for this change.
|
||||||
|
const [useCase, setUseCase] = useState('')
|
||||||
const [drafting, setDrafting] = useState(true)
|
const [drafting, setDrafting] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
||||||
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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
|
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||||
// and pr_number are the join keys; title/description stay out.
|
// and pr_number are the join keys; title/description stay out.
|
||||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
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
|
what was argued, what shifted, what the arbiters are asked
|
||||||
to consider.
|
to consider.
|
||||||
</p>
|
</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>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
withdrawPR,
|
withdrawPR,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
import LinkedText from './LinkedText'
|
||||||
|
|
||||||
export default function PRView({ viewer }) {
|
export default function PRView({ viewer }) {
|
||||||
const { slug, prNumber: prNumberParam } = useParams()
|
const { slug, prNumber: prNumberParam } = useParams()
|
||||||
@@ -218,8 +219,21 @@ export default function PRView({ viewer }) {
|
|||||||
<>
|
<>
|
||||||
<h1 className="pr-title">{pr.title}</h1>
|
<h1 className="pr-title">{pr.title}</h1>
|
||||||
{pr.description && (
|
{pr.description && (
|
||||||
<p className="pr-description">{pr.description}</p>
|
<p className="pr-description">
|
||||||
|
<LinkedText segments={pr.description_segments} text={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 && (
|
{pr.capabilities?.can_edit_text && (
|
||||||
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
||||||
)}
|
)}
|
||||||
@@ -429,7 +443,9 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
|
|||||||
{isNew && <span className="chat-msg-new-pip" title="New since your last visit">●</span>}
|
{isNew && <span className="chat-msg-new-pip" title="New since your last visit">●</span>}
|
||||||
</div>
|
</div>
|
||||||
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
|
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
|
||||||
<div className="chat-msg-body">{m.text}</div>
|
<div className="chat-msg-body">
|
||||||
|
<LinkedText segments={m.text_segments} text={m.text} />
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ export default function ProposalView({ viewer, onChange }) {
|
|||||||
className="entry-body"
|
className="entry-body"
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.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>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
//
|
//
|
||||||
// Title (required) and pitch (required textarea), with a slug field
|
// Title (required) and pitch (required textarea), with a slug field
|
||||||
// that auto-fills from the title via the same deterministic kebab-case
|
// 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
|
// the backend uses. Tags are chip-input (free-form), with the §9.1
|
||||||
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
|
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
|
||||||
// is wired up).
|
// 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;
|
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
|
||||||
// success navigates the proposer to the pending-idea view per §9.3.
|
// success navigates the proposer to the pending-idea view per §9.3.
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { proposeRFC } from '../api'
|
import { proposeRFC, suggestTags } from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
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) {
|
function slugify(title) {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -26,21 +33,56 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [slugEdited, setSlugEdited] = useState(false)
|
const [slugEdited, setSlugEdited] = useState(false)
|
||||||
const [pitch, setPitch] = useState('')
|
const [pitch, setPitch] = useState('')
|
||||||
|
// #26: optional ground-truth use case, sibling to the required pitch.
|
||||||
|
const [useCase, setUseCase] = useState('')
|
||||||
const [tagInput, setTagInput] = useState('')
|
const [tagInput, setTagInput] = useState('')
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!slugEdited) setSlug(slugify(title))
|
if (!slugEdited) setSlug(slugify(title))
|
||||||
}, [title, slugEdited])
|
}, [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() {
|
function addTag() {
|
||||||
const t = tagInput.trim()
|
const t = tagInput.trim()
|
||||||
if (t && !tags.includes(t)) setTags([...tags, t])
|
if (t && !tags.includes(t)) setTags([...tags, t])
|
||||||
setTagInput('')
|
setTagInput('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSuggested(tag) {
|
||||||
|
if (!tags.includes(tag)) setTags([...tags, tag])
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!title.trim() || !slug || !pitch.trim()) return
|
if (!title.trim() || !slug || !pitch.trim()) return
|
||||||
@@ -52,6 +94,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
slug,
|
slug,
|
||||||
pitch: pitch.trim(),
|
pitch: pitch.trim(),
|
||||||
tags,
|
tags,
|
||||||
|
proposedUseCase: useCase.trim() || null,
|
||||||
})
|
})
|
||||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||||
@@ -105,6 +148,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
required
|
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>
|
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||||
<input
|
<input
|
||||||
@@ -136,6 +192,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
</div>
|
</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 && (
|
{viewer && (
|
||||||
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
<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).
|
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).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
resolveDiscussionThread,
|
resolveDiscussionThread,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
import LinkedText from './LinkedText'
|
||||||
|
|
||||||
export default function RFCDiscussionPanel({ slug, viewer }) {
|
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||||
const [threads, setThreads] = useState([])
|
const [threads, setThreads] = useState([])
|
||||||
@@ -279,7 +280,9 @@ function DiscussionMessage({ message }) {
|
|||||||
{message.quote && (
|
{message.quote && (
|
||||||
<div className="discussion-message-quote">"{message.quote}"</div>
|
<div className="discussion-message-quote">"{message.quote}"</div>
|
||||||
)}
|
)}
|
||||||
<div className="discussion-message-body">{message.text}</div>
|
<div className="discussion-message-body">
|
||||||
|
<LinkedText segments={message.text_segments} text={message.text} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'}
|
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||||
</div>
|
</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' && (
|
{inDiscuss && branchParam !== 'main' && (
|
||||||
<div className="discuss-mode-banner">
|
<div className="discuss-mode-banner">
|
||||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
font-family: var(--font-sans);
|
||||||
Arial, sans-serif;
|
color: var(--color-text);
|
||||||
color: #1a1a1a;
|
background: var(--color-bg);
|
||||||
background: #fafaf8;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import './styles/tokens.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/* tokens.css — the design-token foundation for rfc-app's UI.
|
||||||
|
*
|
||||||
|
* Roadmap item #31 (comprehensive UX polish). Before this file the app
|
||||||
|
* had ~98 distinct hardcoded hex colors, font sizes scattered across 16
|
||||||
|
* values with no scale, and radii across 13 values — classic prototype
|
||||||
|
* sprawl. This module establishes ONE coherent system; the App.css sweep
|
||||||
|
* (and component-scoped CSS) reference these custom properties instead of
|
||||||
|
* literal values, so "what color/size/space is this" has a single answer.
|
||||||
|
*
|
||||||
|
* Imported FIRST in main.jsx so :root is defined before any other sheet.
|
||||||
|
* Custom properties are not cascade-order-sensitive at use time, but
|
||||||
|
* importing first keeps the dependency obvious.
|
||||||
|
*
|
||||||
|
* Conventions for anyone sweeping values to these tokens:
|
||||||
|
* - Map each literal to the NEAREST semantic token, then fall back to a
|
||||||
|
* primitive ramp step. Consolidating near-duplicate grays is the point.
|
||||||
|
* - Never invent a new literal in a component; add a token here instead.
|
||||||
|
* - Spacing/radii/type use the scales below — no off-scale px values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ===== Color primitives — neutral ramp ===== */
|
||||||
|
--c-white: #ffffff;
|
||||||
|
--c-gray-50: #fafafa;
|
||||||
|
--c-gray-100: #f3f4f6;
|
||||||
|
--c-gray-150: #f0f0ee; /* the app's warm canvas tint */
|
||||||
|
--c-gray-200: #e5e7eb;
|
||||||
|
--c-gray-300: #d1d5db;
|
||||||
|
--c-gray-400: #9ca3af;
|
||||||
|
--c-gray-500: #6b7280;
|
||||||
|
--c-gray-600: #4b5563;
|
||||||
|
--c-gray-700: #374151;
|
||||||
|
--c-gray-800: #1f2937;
|
||||||
|
--c-gray-900: #111111;
|
||||||
|
--c-ink: #1a1a1a; /* near-black used for the header + body text */
|
||||||
|
|
||||||
|
/* ===== Color primitives — accent (indigo/violet) ===== */
|
||||||
|
--c-accent: #5b5bd6;
|
||||||
|
--c-accent-strong: #4338ca;
|
||||||
|
--c-violet: #7c3aed;
|
||||||
|
|
||||||
|
/* ===== Color primitives — status ===== */
|
||||||
|
--c-success-fg: #166534;
|
||||||
|
--c-success-bg: #dcfce7;
|
||||||
|
--c-danger-fg: #991b1b;
|
||||||
|
--c-danger-fg-strong: #b91c1c;
|
||||||
|
--c-danger-bg: #fef2f2;
|
||||||
|
--c-danger-border: #fecaca;
|
||||||
|
--c-warning-fg: #92400e;
|
||||||
|
--c-warning-accent: #b45309;
|
||||||
|
--c-warning-bg: #fef3c7;
|
||||||
|
--c-warning-bg-soft:#fffbeb;
|
||||||
|
|
||||||
|
/* ===== Semantic colors ===== */
|
||||||
|
--color-bg: var(--c-gray-150);
|
||||||
|
--color-surface: var(--c-white);
|
||||||
|
--color-surface-sunken: var(--c-gray-50);
|
||||||
|
--color-surface-muted: var(--c-gray-100);
|
||||||
|
--color-header-bg: var(--c-ink);
|
||||||
|
|
||||||
|
--color-text: var(--c-ink);
|
||||||
|
--color-text-strong: var(--c-gray-900);
|
||||||
|
--color-text-muted: var(--c-gray-500);
|
||||||
|
--color-text-subtle: var(--c-gray-400);
|
||||||
|
--color-text-inverse: var(--c-white);
|
||||||
|
|
||||||
|
--color-border: var(--c-gray-200);
|
||||||
|
--color-border-strong: var(--c-gray-300);
|
||||||
|
|
||||||
|
--color-link: var(--c-accent);
|
||||||
|
--color-accent: var(--c-accent);
|
||||||
|
--color-accent-strong: var(--c-accent-strong);
|
||||||
|
--color-accent-contrast: var(--c-white);
|
||||||
|
|
||||||
|
--color-success-fg: var(--c-success-fg);
|
||||||
|
--color-success-bg: var(--c-success-bg);
|
||||||
|
--color-danger-fg: var(--c-danger-fg);
|
||||||
|
--color-danger-bg: var(--c-danger-bg);
|
||||||
|
--color-warning-fg: var(--c-warning-fg);
|
||||||
|
--color-warning-bg: var(--c-warning-bg);
|
||||||
|
|
||||||
|
/* On the dark header, translucent white is the established pattern. */
|
||||||
|
--color-on-dark-soft: rgba(255, 255, 255, 0.15);
|
||||||
|
--color-on-dark-hover: rgba(255, 255, 255, 0.25);
|
||||||
|
--color-on-dark-muted: #dddddd;
|
||||||
|
|
||||||
|
--color-focus-ring: rgba(91, 91, 214, 0.45);
|
||||||
|
|
||||||
|
/* ===== Type ===== */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
|
||||||
|
--text-2xs: 10px;
|
||||||
|
--text-xs: 11px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 13px; /* the app's dominant body size */
|
||||||
|
--text-md: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 22px;
|
||||||
|
--text-3xl: 28px;
|
||||||
|
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.65;
|
||||||
|
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
|
/* ===== Spacing scale (4-based, with the 2/6/10 half-steps the app
|
||||||
|
* already leans on heavily) ===== */
|
||||||
|
--space-0: 0;
|
||||||
|
--space-1: 2px;
|
||||||
|
--space-2: 4px;
|
||||||
|
--space-3: 6px;
|
||||||
|
--space-4: 8px;
|
||||||
|
--space-5: 10px;
|
||||||
|
--space-6: 12px;
|
||||||
|
--space-7: 16px;
|
||||||
|
--space-8: 20px;
|
||||||
|
--space-9: 24px;
|
||||||
|
--space-10: 32px;
|
||||||
|
--space-11: 48px;
|
||||||
|
--space-12: 64px;
|
||||||
|
|
||||||
|
/* ===== Radius ===== */
|
||||||
|
--radius-xs: 2px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
/* ===== Elevation ===== */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* ===== Motion ===== */
|
||||||
|
--motion-fast: 120ms;
|
||||||
|
--motion-base: 150ms;
|
||||||
|
--motion-slow: 200ms;
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* ===== Layout ===== */
|
||||||
|
--header-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honor reduced-motion globally — any transition/animation that reads
|
||||||
|
* these duration tokens collapses to instant. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--motion-fast: 0ms;
|
||||||
|
--motion-base: 0ms;
|
||||||
|
--motion-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user