Compare commits
5 Commits
ac3513a686
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a166a6f2 | |||
| bb5137f176 | |||
| 477f496cbf | |||
| 822f4266f6 | |||
| 39e57706d9 |
@@ -23,6 +23,18 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.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.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
MAY: `flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url>` if the deployment points at a non-OHM transcript repo. Default in code matches OHM's `wiggleverse/ohm-session-history`.
|
||||
|
||||
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60` and `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300` to tune cache TTLs.
|
||||
|
||||
Note: this release depends on the parallel restructure of `wiggleverse/ohm-session-history` into per-session `NNNN/` folders + `README.md` + `sessions.json` (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, `/docs/sessions/about` and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.
|
||||
|
||||
## 0.18.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; one env var now mandatory; no
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import (
|
||||
@@ -29,6 +30,7 @@ from . import (
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
docs_sessions,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
@@ -143,6 +145,122 @@ def make_router(
|
||||
payload = docs_mod.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
|
||||
#
|
||||
# The framework mediates reads against the public
|
||||
# `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
# `/docs/sessions/*` surface inherits the same chrome as the
|
||||
# /docs/user-guide route and doesn't require a cross-origin
|
||||
# gesture from the frontend. See backend/app/docs_sessions.py
|
||||
# for the cache shape and env knobs.
|
||||
#
|
||||
# The route mapping for the three `status` values returned by
|
||||
# the fetchers:
|
||||
#
|
||||
# "ok" → HTTP 200, payload as documented per endpoint
|
||||
# "404" → HTTP 200/404 depending on the endpoint (the
|
||||
# manifest's empty state is 200 + {} so the
|
||||
# frontend can short-circuit without an error
|
||||
# banner; transcripts/about return 404 so the
|
||||
# frontend can render its own empty-state)
|
||||
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
|
||||
# frontend retry surface reads as "couldn't reach
|
||||
# the session-history repo" rather than as a
|
||||
# generic 5xx.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs/sessions/manifest")
|
||||
async def get_sessions_manifest() -> dict[str, Any]:
|
||||
result = await docs_sessions.fetch_manifest()
|
||||
if result["status"] == "ok":
|
||||
return result["manifest"]
|
||||
if result["status"] == "404":
|
||||
# Empty-state contract: render no session rows in the
|
||||
# flyout but don't show an error banner. The frontend
|
||||
# treats `{}` as "no sessions published yet".
|
||||
return {}
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/about")
|
||||
async def get_sessions_about() -> Response:
|
||||
result = await docs_sessions.fetch_about()
|
||||
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="session-history README not yet published",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/{nnnn}/index")
|
||||
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
|
||||
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||
# 400 over 404: the request itself is malformed (the
|
||||
# session directory name doesn't match `^\d{4}$`),
|
||||
# distinct from "no such session published yet".
|
||||
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||
result = await docs_sessions.fetch_session_index(nnnn)
|
||||
if result["status"] == "ok":
|
||||
return {"files": result["files"]}
|
||||
if result["status"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="no transcripts published for this session",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/{nnnn}/{filename}")
|
||||
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
|
||||
# Path-shape validation before any network — refuses anything
|
||||
# that would resolve outside the `NNNN/SESSION-...md` layout
|
||||
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
|
||||
# `../etc/passwd`, or any non-numeric session dir).
|
||||
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||
if not docs_sessions._is_valid_transcript_filename(filename):
|
||||
raise HTTPException(status_code=400, detail="invalid transcript filename")
|
||||
result = await docs_sessions.fetch_transcript(nnnn, filename)
|
||||
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="transcript not found",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — reads role from our users table per §6.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""§14 + roadmap item #30 — on-site sessions-history browser source.
|
||||
|
||||
Sibling of `docs.py` / `philosophy.py` but with a different read shape:
|
||||
the bodies here live in the **public** `wiggleverse/ohm-session-history`
|
||||
gitea repo (transcripts of every OHM build session, published per the
|
||||
ohm-infra SESSION-PROTOCOL.md), not on disk. The framework mediates
|
||||
the gitea fetch on behalf of the browser so the rendered `/docs/sessions/*`
|
||||
surface inherits the same chrome as `/philosophy` and `/docs/user-guide`
|
||||
and stays free of any cross-origin gestures from the frontend.
|
||||
|
||||
Three read endpoints, all anonymous-reachable:
|
||||
|
||||
GET /api/docs/sessions/manifest — sessions.json (title manifest)
|
||||
GET /api/docs/sessions/about — README.md (the about page)
|
||||
GET /api/docs/sessions/<NNNN>/<file> — a transcript body
|
||||
GET /api/docs/sessions/<NNNN>/index — per-session file listing
|
||||
|
||||
All three sit behind a small in-process TTL cache (manifest TTL default
|
||||
60 s, content TTL default 300 s). Negative results (404 from gitea) are
|
||||
also cached at the content TTL to avoid hammering gitea when a
|
||||
deployment hasn't yet been populated with transcripts. The cache key
|
||||
is the URL path on the gitea raw base (or the contents API for the
|
||||
per-session listing); the cache lives in-process, plain dict +
|
||||
`time.monotonic()` check, no external dep.
|
||||
|
||||
Env knobs:
|
||||
|
||||
OHM_SESSION_HISTORY_RAW_BASE
|
||||
Override the gitea raw base URL. Default points at OHM's canonical
|
||||
transcript repo:
|
||||
https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main
|
||||
The framework-default value is OHM-flavored because OHM is the
|
||||
only deployment to date — a deployment running its own
|
||||
transcript repo overrides this via flotilla's overlay.
|
||||
|
||||
OHM_SESSION_HISTORY_CONTENTS_BASE
|
||||
Override the gitea contents-API base URL (for the per-session
|
||||
listing endpoint, which enumerates files inside a `NNNN/` folder).
|
||||
Default:
|
||||
https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents
|
||||
|
||||
OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC
|
||||
Cache TTL for the manifest (default 60 s). The manifest is small
|
||||
and changes when a new session is added; 60 s strikes a balance
|
||||
between freshness and gitea load.
|
||||
|
||||
OHM_DOCS_SESSIONS_CONTENT_TTL_SEC
|
||||
Cache TTL for transcript bodies + README + per-session listings
|
||||
(default 300 s = 5 minutes). Transcripts are append-only once
|
||||
published, so 5 minutes of staleness is harmless.
|
||||
|
||||
§3 invariant 1 is preserved: the framework holds no secret bytes; the
|
||||
gitea repo is public, the fetch carries no auth header.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_RAW_BASE = (
|
||||
"https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main"
|
||||
)
|
||||
_DEFAULT_CONTENTS_BASE = (
|
||||
"https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents"
|
||||
)
|
||||
_DEFAULT_MANIFEST_TTL_SEC = 60.0
|
||||
_DEFAULT_CONTENT_TTL_SEC = 300.0
|
||||
|
||||
# The transcript filename shape per SESSION-PROTOCOL.md §1. The
|
||||
# `<start>--<end>` suffix is optional so legacy renamed-letter
|
||||
# transcripts (e.g. `SESSION-0009.0-TRANSCRIPT.md` without timestamps)
|
||||
# remain reachable. The `\.\d+(\.\d+)*` after the session number
|
||||
# accommodates `0017.0`, `0017.1`, `0017.1.1`, etc.
|
||||
_TRANSCRIPT_FILENAME_RE = re.compile(
|
||||
r"^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT"
|
||||
r"(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}--\d{4}-\d{2}-\d{2}T\d{2}-\d{2})?"
|
||||
r"\.md$"
|
||||
)
|
||||
_SESSION_DIR_RE = re.compile(r"^\d{4}$")
|
||||
|
||||
_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 _raw_base() -> str:
|
||||
return os.environ.get("OHM_SESSION_HISTORY_RAW_BASE", "").strip() or _DEFAULT_RAW_BASE
|
||||
|
||||
|
||||
def _contents_base() -> str:
|
||||
return (
|
||||
os.environ.get("OHM_SESSION_HISTORY_CONTENTS_BASE", "").strip()
|
||||
or _DEFAULT_CONTENTS_BASE
|
||||
)
|
||||
|
||||
|
||||
def _manifest_ttl() -> float:
|
||||
return _env_float("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", _DEFAULT_MANIFEST_TTL_SEC)
|
||||
|
||||
|
||||
def _content_ttl() -> float:
|
||||
return _env_float("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process TTL cache
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 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:
|
||||
# Don't evict here; let _cache_put overwrite on next fetch.
|
||||
# The stale entry is gated by the TTL check, so it stays
|
||||
# invisible to readers regardless.
|
||||
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. `status` is one of:
|
||||
# "ok" — payload field carries the body
|
||||
# "404" — gitea returned 404 (or content was missing)
|
||||
# "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("gitea fetch failed for %s: %s", url, e)
|
||||
return 599, f"fetch error: {e}"
|
||||
|
||||
|
||||
def _is_valid_session_dir(nnnn: str) -> bool:
|
||||
return bool(_SESSION_DIR_RE.match(nnnn))
|
||||
|
||||
|
||||
def _is_valid_transcript_filename(filename: str) -> bool:
|
||||
return bool(_TRANSCRIPT_FILENAME_RE.match(filename))
|
||||
|
||||
|
||||
async def fetch_manifest() -> dict[str, Any]:
|
||||
"""Fetch and parse `sessions.json` from the public repo.
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "manifest": {...}} — successful parse
|
||||
{"status": "404"} — gitea 404 (empty state)
|
||||
{"status": "error", "detail": "..."} — 5xx / timeout / bad JSON
|
||||
"""
|
||||
cache_key = "manifest"
|
||||
cached = _cache_get(cache_key, _manifest_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/sessions.json"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
payload: dict[str, Any] = {
|
||||
"status": "error",
|
||||
"detail": f"sessions.json malformed: {e}",
|
||||
}
|
||||
# Don't cache parse errors — give the upstream a chance to
|
||||
# fix the file without waiting for TTL expiry.
|
||||
return payload
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": "sessions.json is not a JSON object",
|
||||
}
|
||||
payload = {"status": "ok", "manifest": data}
|
||||
_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}"}
|
||||
|
||||
|
||||
async def fetch_about() -> dict[str, Any]:
|
||||
"""Fetch the repo's README.md (rendered as the /docs/sessions/about page).
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "body": "..."}
|
||||
{"status": "404"}
|
||||
{"status": "error", "detail": "..."}
|
||||
"""
|
||||
cache_key = "about:README.md"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/README.md"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
payload: dict[str, Any] = {"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}"}
|
||||
|
||||
|
||||
async def fetch_transcript(nnnn: str, filename: str) -> dict[str, Any]:
|
||||
"""Fetch a single transcript body from `{nnnn}/{filename}` in the repo.
|
||||
|
||||
The caller is expected to have validated `nnnn` and `filename`
|
||||
against `_is_valid_session_dir` / `_is_valid_transcript_filename`
|
||||
before calling this — invalid paths shouldn't reach the network.
|
||||
"""
|
||||
cache_key = f"transcript:{nnnn}/{filename}"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/{nnnn}/{filename}"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
payload: dict[str, Any] = {"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}"}
|
||||
|
||||
|
||||
async def fetch_session_index(nnnn: str) -> dict[str, Any]:
|
||||
"""List the transcript filenames inside the `{nnnn}/` folder.
|
||||
|
||||
Uses gitea's contents API (one HTTP per session-index page-view per
|
||||
cache-TTL) rather than the raw URL — there's no flat way to list a
|
||||
folder via the raw mount.
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "files": ["SESSION-...md", ...]}
|
||||
{"status": "404"}
|
||||
{"status": "error", "detail": "..."}
|
||||
|
||||
Only filenames that match `_is_valid_transcript_filename` are
|
||||
surfaced — sibling files (e.g. an attached `notes.md`) are ignored
|
||||
so the /docs/sessions/<NNNN> page never lists a non-transcript
|
||||
masquerading as one.
|
||||
"""
|
||||
cache_key = f"index:{nnnn}"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_contents_base()}/{nnnn}"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"contents API response malformed: {e}",
|
||||
}
|
||||
if not isinstance(data, list):
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": "contents API returned non-list",
|
||||
}
|
||||
files: list[str] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("type") != "file":
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
if _is_valid_transcript_filename(name):
|
||||
files.append(name)
|
||||
files.sort()
|
||||
payload: dict[str, Any] = {"status": "ok", "files": files}
|
||||
_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}"}
|
||||
@@ -0,0 +1,379 @@
|
||||
"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
|
||||
|
||||
The framework mediates reads against the public
|
||||
`wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
|
||||
This test suite covers the four endpoints + the in-process TTL cache,
|
||||
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
|
||||
rest of the test suite uses for Gitea).
|
||||
|
||||
The tests do NOT spin up the full FakeGitea — they only need to mock
|
||||
the gitea raw URL surface (and the contents API for the session-index
|
||||
endpoint). Each test owns its mock transport so we can dial in 200 /
|
||||
404 / 5xx / timeout responses per case.
|
||||
|
||||
Path-validation tests intentionally bypass the network — a malformed
|
||||
`nnnn` or `filename` MUST be rejected at the route layer before any
|
||||
upstream call is made.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import docs_sessions
|
||||
|
||||
# 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_sessions module fetched and returns
|
||||
canned responses keyed by URL substring. Allows the test to assert
|
||||
on call count (for cache verification) without needing a full Gitea
|
||||
simulator.
|
||||
|
||||
`calls` tracks only URLs that hit the session-history host (the
|
||||
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls — which
|
||||
also pass through this handler because `httpx.AsyncClient` is a
|
||||
shared attribute the gitea-side fixture also monkeypatches — don't
|
||||
inflate the count we use for cache-hit assertions.
|
||||
"""
|
||||
|
||||
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
|
||||
|
||||
def __init__(self, responses: dict[str, tuple[int, str]]):
|
||||
self.responses = responses
|
||||
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._SESSION_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.
|
||||
|
||||
Returns a closure: `install(handler)` patches
|
||||
`app.docs_sessions.httpx.AsyncClient` so every constructed client
|
||||
uses the handler's transport.
|
||||
|
||||
NB: the upstream `app_with_fake_gitea` fixture also patches
|
||||
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
|
||||
and because `httpx` is a single shared module, that patch mutates
|
||||
the *same* `AsyncClient` attribute we're about to overwrite. We
|
||||
therefore import the unpatched class directly from the
|
||||
`httpx._client` module so our install path can construct a fresh
|
||||
real client around our MockTransport without going through 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_sessions.httpx.AsyncClient", patched)
|
||||
return handler
|
||||
|
||||
yield install
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(app_with_fake_gitea):
|
||||
"""Wrap the shared app fixture, resetting the docs-sessions cache so
|
||||
cross-test state can't leak. Returns just the FastAPI app — the
|
||||
fake-Gitea handle is irrelevant for the docs-sessions surface.
|
||||
"""
|
||||
docs_sessions.reset_cache()
|
||||
fastapi_app, _fake = app_with_fake_gitea
|
||||
return fastapi_app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_happy_path(app, patched_httpx):
|
||||
manifest_body = json.dumps(
|
||||
{
|
||||
"0001": {"title": "Bootstrap"},
|
||||
"0014": {"title": "Wave 7 driver"},
|
||||
}
|
||||
)
|
||||
patched_httpx(
|
||||
_UpstreamHandler({"sessions.json": (200, manifest_body)})
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload == {
|
||||
"0001": {"title": "Bootstrap"},
|
||||
"0014": {"title": "Wave 7 driver"},
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_empty_state(app, patched_httpx):
|
||||
"""A 404 from gitea means the manifest hasn't been published yet.
|
||||
The endpoint returns HTTP 200 + `{}` so the frontend can render the
|
||||
no-sessions-yet state without an error banner.
|
||||
"""
|
||||
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {}
|
||||
|
||||
|
||||
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 502, r.text
|
||||
body = r.json()
|
||||
assert body["detail"]["error"] == "session-history fetch failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# About endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_about_happy_path(app, patched_httpx):
|
||||
readme = "# OHM session history\n\nWelcome.\n"
|
||||
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/markdown" in r.headers["content-type"]
|
||||
assert r.text == readme
|
||||
|
||||
|
||||
def test_about_404(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_about_upstream_5xx_returns_502(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 502, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_transcript_happy_path(app, patched_httpx):
|
||||
body = "# Session 0017.1 — Transcript\n\nbody.\n"
|
||||
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
|
||||
patched_httpx(_UpstreamHandler({fname: (200, body)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/0017/{fname}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/markdown" in r.headers["content-type"]
|
||||
assert r.text == body
|
||||
|
||||
|
||||
def test_transcript_404(app, patched_httpx):
|
||||
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
|
||||
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
|
||||
network call.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get(
|
||||
"/api/docs/sessions/abcd/"
|
||||
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_rejects_path_traversal(app, patched_httpx):
|
||||
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
|
||||
is rejected. `../etc/passwd` doesn't match; neither does the legacy
|
||||
flat-root `SESSION-A-TRANSCRIPT.md`.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
# Path traversal — but FastAPI normalizes `..` in the path before
|
||||
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
|
||||
# which routes to the same handler with filename=etc/passwd, and
|
||||
# gets rejected as an invalid transcript filename. Even if the
|
||||
# normalization didn't apply (some intermediary), the regex
|
||||
# check rejects anything not matching the SESSION- prefix.
|
||||
r = client.get(
|
||||
"/api/docs/sessions/0001/etc%2Fpasswd"
|
||||
)
|
||||
# 400 (filename validation) or 404 (path didn't match the
|
||||
# route); both reject before any network call. Either is
|
||||
# acceptable — what matters is that we never fetched it.
|
||||
assert r.status_code in (400, 404), r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
|
||||
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
|
||||
numeric regex — by design, since post-#23 transcripts live in
|
||||
`NNNN/` folders with numeric names. Reject 400.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
|
||||
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
assert r.status_code == 502, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-index endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_index_happy_path(app, patched_httpx):
|
||||
"""The contents API returns a JSON list of file entries. The
|
||||
endpoint filters to entries that match the transcript regex and
|
||||
sorts them.
|
||||
"""
|
||||
# Two transcripts (driver + subagent) + a non-transcript sibling
|
||||
# that must be filtered out.
|
||||
listing = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "SESSION-0017.0-TRANSCRIPT-"
|
||||
"2026-05-28T08-30--2026-05-28T12-00.md",
|
||||
"type": "file",
|
||||
},
|
||||
{
|
||||
"name": "SESSION-0017.1-TRANSCRIPT-"
|
||||
"2026-05-28T08-50--2026-05-28T11-20.md",
|
||||
"type": "file",
|
||||
},
|
||||
{"name": "notes.md", "type": "file"}, # not a transcript
|
||||
{"name": "attached-dir", "type": "dir"}, # not a file
|
||||
]
|
||||
)
|
||||
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/0017/index")
|
||||
assert r.status_code == 200, r.text
|
||||
files = r.json()["files"]
|
||||
assert files == [
|
||||
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
|
||||
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
|
||||
]
|
||||
|
||||
|
||||
def test_session_index_404(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/9999/index")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/abc/index")
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||
"""Two consecutive manifest calls within the TTL window should
|
||||
issue exactly one HTTP request to gitea.
|
||||
"""
|
||||
# Generous TTL so the test never races.
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
|
||||
handler = _UpstreamHandler(
|
||||
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
|
||||
)
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get("/api/docs/sessions/manifest")
|
||||
r2 = client.get("/api/docs/sessions/manifest")
|
||||
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_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
handler = _UpstreamHandler({fname: (200, "# body\n")})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert len(handler.calls) == 1
|
||||
|
||||
|
||||
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
|
||||
"""Negative caching: a 404 result is cached at the content TTL so a
|
||||
deployment with no published transcripts doesn't hammer gitea on
|
||||
every navigation. Documented in `docs_sessions.fetch_transcript`.
|
||||
"""
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
handler = _UpstreamHandler({}) # everything 404s
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
assert r1.status_code == 404
|
||||
assert r2.status_code == 404
|
||||
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2193,3 +2193,199 @@
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
v0.19.0 / roadmap item #30 — /docs/* flyout + sessions browser.
|
||||
|
||||
The shell is `.docs-layout` (header + body). The body is a flex
|
||||
row: `.docs-nav` is the persistent left sidebar on desktop and a
|
||||
slide-out drawer on mobile (toggled by the `.docs-drawer-toggle`
|
||||
icon in `.docs-header`). The content area `.docs-content` mounts
|
||||
the sub-route via React Router's <Outlet/>.
|
||||
|
||||
The article body inside each sub-route reuses `.philosophy-body`
|
||||
(defined above) for the markdown rendering — same `marked` lib,
|
||||
same typography. The new classes here just handle the chrome
|
||||
(sidebar + drawer + header).
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.docs-layout {
|
||||
height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 24px 12px 16px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.docs-back, .docs-drawer-toggle {
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: #4b5563; font-size: 13px;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.docs-back:hover, .docs-drawer-toggle:hover {
|
||||
background: #f3f4f6; color: #111;
|
||||
}
|
||||
.docs-drawer-toggle {
|
||||
display: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.docs-title {
|
||||
font-size: 13px; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.docs-signin {
|
||||
margin-left: auto;
|
||||
font-size: 13px; color: #4b5563; text-decoration: none;
|
||||
}
|
||||
.docs-signin:hover { color: #111; text-decoration: underline; }
|
||||
|
||||
.docs-body {
|
||||
flex: 1; min-height: 0;
|
||||
display: flex; flex-direction: row;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-nav {
|
||||
flex-shrink: 0;
|
||||
width: 260px;
|
||||
border-right: 1px solid #f0f0ee;
|
||||
padding: 24px 20px;
|
||||
overflow-y: auto;
|
||||
background: #fafaf9;
|
||||
}
|
||||
|
||||
.docs-nav-inner { display: flex; flex-direction: column; gap: 24px; }
|
||||
.docs-nav-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
.docs-nav-section-label {
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: #6b7280;
|
||||
}
|
||||
.docs-nav-list {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.docs-nav-list a {
|
||||
display: block;
|
||||
padding: 6px 10px; border-radius: 4px;
|
||||
color: #1f2937; text-decoration: none;
|
||||
font-size: 14px; line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.docs-nav-list a:hover { background: #f0f0ee; }
|
||||
.docs-nav-list a.active {
|
||||
background: #e7e5e4; color: #111; font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-nav-skeleton .skeleton-row {
|
||||
display: block;
|
||||
height: 14px; margin: 8px 10px;
|
||||
background: linear-gradient(90deg, #f0f0ee 25%, #e7e5e4 50%, #f0f0ee 75%);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 4px;
|
||||
animation: docs-skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes docs-skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.docs-nav-error {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px; color: #9a3412;
|
||||
background: #fff7ed; border: 1px solid #fed7aa; border-radius: 4px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.docs-nav-error button {
|
||||
align-self: flex-start;
|
||||
background: #fff; border: 1px solid #fed7aa;
|
||||
color: #9a3412; font-size: 12px;
|
||||
padding: 4px 8px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.docs-nav-error button:hover { background: #fff7ed; }
|
||||
|
||||
.docs-content {
|
||||
flex: 1; min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 64px;
|
||||
}
|
||||
|
||||
.docs-article {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.docs-article-title {
|
||||
font-size: 28px; font-weight: 700; margin: 0 0 24px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #111;
|
||||
}
|
||||
.docs-breadcrumbs { margin: 0 0 16px; font-size: 13px; }
|
||||
.docs-breadcrumbs a {
|
||||
color: #4b5563; text-decoration: none;
|
||||
}
|
||||
.docs-breadcrumbs a:hover { color: #111; text-decoration: underline; }
|
||||
|
||||
.docs-session-files {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.docs-session-files a {
|
||||
display: block;
|
||||
padding: 10px 14px; border-radius: 6px;
|
||||
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||
color: #1f2937; text-decoration: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px; word-break: break-all;
|
||||
}
|
||||
.docs-session-files a:hover {
|
||||
background: #f3f4f6; border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.docs-empty, .docs-error {
|
||||
padding: 16px; border-radius: 6px;
|
||||
background: #fafaf9; border: 1px solid #f0f0ee;
|
||||
font-size: 14px; line-height: 1.6; color: #4b5563;
|
||||
}
|
||||
.docs-error { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
|
||||
.docs-error button {
|
||||
margin-top: 10px;
|
||||
background: #fff; border: 1px solid #fecaca;
|
||||
color: #991b1b; font-size: 13px;
|
||||
padding: 6px 12px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.docs-error button:hover { background: #fef2f2; }
|
||||
|
||||
.docs-drawer-scrim {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: none; padding: 0;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Mobile: the sidebar becomes a slide-out drawer. */
|
||||
@media (max-width: 720px) {
|
||||
.docs-drawer-toggle { display: inline-block; }
|
||||
.docs-nav {
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: 0;
|
||||
width: 80%; max-width: 320px;
|
||||
z-index: 10;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease-out;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.docs-body--drawer-open .docs-nav {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.docs-body--drawer-open .docs-drawer-scrim {
|
||||
display: block;
|
||||
}
|
||||
.docs-content { padding: 16px 18px 64px; }
|
||||
}
|
||||
|
||||
+29
-4
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
@@ -12,7 +12,11 @@ import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import Docs from './components/Docs.jsx'
|
||||
import DocsLayout from './components/DocsLayout.jsx'
|
||||
import DocsUserGuide from './components/DocsUserGuide.jsx'
|
||||
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
||||
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
||||
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||
@@ -233,7 +237,13 @@ export default function App() {
|
||||
itself establishes the session on success. */}
|
||||
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav.
|
||||
The bare /docs path redirects to the user guide; sessions
|
||||
browser lives at /docs/sessions/*. See DocsLayout.jsx
|
||||
for the flyout shape and CHANGELOG v0.19.0 for the
|
||||
upgrade path. */}
|
||||
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
|
||||
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||
Available to anonymous and authenticated viewers alike. */}
|
||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||
@@ -303,9 +313,24 @@ function PhilosophyWithSidebar({ viewer }) {
|
||||
}
|
||||
|
||||
function DocsWithSidebar({ viewer }) {
|
||||
// v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout
|
||||
// shell with sub-routes. The shell (sidebar + content area) is the
|
||||
// DocsLayout outlet host; the sub-routes mount their respective
|
||||
// pages into the outlet. Bare `/docs/sessions` redirects to the
|
||||
// sessions about page so deep-linkers and the flyout's "Sessions"
|
||||
// header both land somewhere coherent.
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Docs authenticated={!!viewer} />
|
||||
<Routes>
|
||||
<Route element={<DocsLayout authenticated={!!viewer} />}>
|
||||
<Route index element={<Navigate to="user-guide" replace />} />
|
||||
<Route path="user-guide" element={<DocsUserGuide />} />
|
||||
<Route path="sessions" element={<Navigate to="about" replace />} />
|
||||
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||||
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||||
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -720,6 +720,60 @@ export async function getDocs() {
|
||||
return jsonOrThrow(await fetch('/api/docs'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The framework mediates reads against the public
|
||||
// `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
// `/docs/sessions/*` surface inherits the same chrome as
|
||||
// `/docs/user-guide`. Three text-bearing endpoints return markdown
|
||||
// (Content-Type: text/markdown) and the manifest returns JSON. We
|
||||
// wrap each into a small helper.
|
||||
//
|
||||
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
|
||||
// throws an Error with `.status === 404` so the UI can render its own
|
||||
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
|
||||
// the UI can offer a retry button.
|
||||
|
||||
export async function getSessionsManifest() {
|
||||
// Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
|
||||
// helper never throws on the empty-state path.
|
||||
return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
|
||||
}
|
||||
|
||||
async function _textOrThrow(res) {
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await res.json()
|
||||
detail = body.detail || JSON.stringify(body)
|
||||
} catch {
|
||||
detail = await res.text()
|
||||
}
|
||||
const error = new Error(detail || `HTTP ${res.status}`)
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export async function getSessionsAbout() {
|
||||
return _textOrThrow(await fetch('/api/docs/sessions/about'))
|
||||
}
|
||||
|
||||
export async function getSessionTranscript(nnnn, filename) {
|
||||
return _textOrThrow(await fetch(
|
||||
`/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||
))
|
||||
}
|
||||
|
||||
export async function getSessionIndex(nnnn) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// `/docs` — the user-facing guide.
|
||||
//
|
||||
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
||||
// source file. Renders DOCS.md verbatim with light chrome around it.
|
||||
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
||||
// the guide before deciding to sign in.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getDocs } from '../api.js'
|
||||
|
||||
export default function Docs({ authenticated }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getDocs()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="philosophy-page">
|
||||
<header className="philosophy-header">
|
||||
<button
|
||||
className="philosophy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="philosophy-title">User guide</span>
|
||||
{!authenticated && (
|
||||
<Link className="philosophy-signin" to="/">Home</Link>
|
||||
)}
|
||||
</header>
|
||||
<article className="philosophy-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||
{!loading && !error && (
|
||||
<MarkdownPreview content={body} />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// DocsLayout.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Left-side flyout nav + content area for the `/docs/*` route tree:
|
||||
//
|
||||
// /docs → redirect to /docs/user-guide
|
||||
// /docs/user-guide → DOCS.md (existing v0.14.0 content)
|
||||
// /docs/sessions → redirect to /docs/sessions/about
|
||||
// /docs/sessions/about → README.md from the sessions repo
|
||||
// /docs/sessions/:nnnn → per-session index page
|
||||
// /docs/sessions/:nnnn/:file → per-transcript view
|
||||
//
|
||||
// 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).
|
||||
// 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):
|
||||
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
|
||||
// the sub-route component owns the fire (it knows the section).
|
||||
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
|
||||
// so autocapture rows are readable rather than ":nth-child(7)".
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||
import { getSessionsManifest } from '../api.js'
|
||||
|
||||
export default function DocsLayout({ authenticated }) {
|
||||
const [manifest, setManifest] = useState(null)
|
||||
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setManifestState('loading')
|
||||
getSessionsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
setManifest(payload || {})
|
||||
setManifestState('ok')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setManifest({})
|
||||
setManifestState('error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [reloadTick])
|
||||
|
||||
// Close the mobile drawer on every navigation so a click in the nav
|
||||
// doesn't strand the user on a drawer-open view.
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const retryManifest = useCallback(() => {
|
||||
setReloadTick(t => t + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="docs-layout">
|
||||
<header className="docs-header">
|
||||
<button
|
||||
className="docs-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
aria-label="Back to previous page"
|
||||
data-amp-track-name="Docs Back"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
className="docs-drawer-toggle"
|
||||
onClick={() => setDrawerOpen(o => !o)}
|
||||
aria-label="Toggle docs navigation"
|
||||
aria-expanded={drawerOpen}
|
||||
data-amp-track-name="Docs Drawer Toggle"
|
||||
>
|
||||
<span aria-hidden>☰</span>
|
||||
</button>
|
||||
<span className="docs-title">Docs</span>
|
||||
{!authenticated && (
|
||||
<Link
|
||||
className="docs-signin"
|
||||
to="/"
|
||||
aria-label="Home"
|
||||
data-amp-track-name="Docs Home"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
<div className={'docs-body' + (drawerOpen ? ' docs-body--drawer-open' : '')}>
|
||||
<aside className="docs-nav" aria-label="Docs navigation">
|
||||
<DocsNav
|
||||
manifest={manifest}
|
||||
manifestState={manifestState}
|
||||
onRetry={retryManifest}
|
||||
currentPath={location.pathname}
|
||||
/>
|
||||
</aside>
|
||||
<main className="docs-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{drawerOpen && (
|
||||
<button
|
||||
className="docs-drawer-scrim"
|
||||
aria-label="Close drawer"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
data-amp-track-name="Docs Drawer Close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
const isActive = (path) => currentPath === path || currentPath.startsWith(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()
|
||||
|
||||
return (
|
||||
<nav className="docs-nav-inner">
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Docs</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/user-guide"
|
||||
className={isActive('/docs/user-guide') ? 'active' : ''}
|
||||
aria-label="User Guide"
|
||||
data-amp-track-name="Docs Nav User Guide"
|
||||
>
|
||||
User Guide
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Sessions</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
className={currentPath === '/docs/sessions/about' ? 'active' : ''}
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs Nav Sessions About"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{manifestState === 'loading' && (
|
||||
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{manifestState === 'error' && (
|
||||
<div className="docs-nav-error" role="alert">
|
||||
<span>Couldn't load session list.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
aria-label="Retry session list"
|
||||
data-amp-track-name="Docs Nav Sessions Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
{sessionKeys.map(nnnn => {
|
||||
const entry = manifest[nnnn] || {}
|
||||
const title = entry.title || ''
|
||||
const label = title ? `${nnnn} — ${title}` : nnnn
|
||||
const to = `/docs/sessions/${nnnn}`
|
||||
return (
|
||||
<li key={nnnn}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isActive(to) ? 'active' : ''}
|
||||
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||
data-amp-track-name="Docs Nav Session"
|
||||
data-amp-track-session={nnnn}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// DocsSessionIndex.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Per-session index page at `/docs/sessions/:nnnn`. Lists every
|
||||
// transcript published under the session's NNNN/ folder, linked to
|
||||
// the per-transcript view.
|
||||
//
|
||||
// The transcript list comes from `/api/docs/sessions/:nnnn/index`,
|
||||
// which the framework derives via the gitea contents API (see
|
||||
// backend/app/docs_sessions.fetch_session_index). We also read the
|
||||
// session's `title` from the manifest fetch so the page header
|
||||
// matches the flyout nav entry.
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { getSessionsManifest, getSessionIndex } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsSessionIndex() {
|
||||
const { nnnn } = useParams()
|
||||
const [title, setTitle] = useState('')
|
||||
const [files, setFiles] = useState([])
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
|
||||
}, [nnnn])
|
||||
|
||||
// Title from manifest — cheap, manifest is cached server-side.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSessionsManifest()
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
const entry = payload && payload[nnnn]
|
||||
setTitle((entry && entry.title) || '')
|
||||
})
|
||||
.catch(() => {
|
||||
// Title is decorative; failure to load just leaves the header
|
||||
// showing the bare NNNN. The transcript list fetch below is
|
||||
// the load-bearing one.
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [nnnn])
|
||||
|
||||
// File list from the per-session index endpoint.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
getSessionIndex(nnnn)
|
||||
.then(payload => {
|
||||
if (!active) return
|
||||
setFiles((payload && payload.files) || [])
|
||||
setStatus('ok')
|
||||
})
|
||||
.catch(e => {
|
||||
if (!active) return
|
||||
if (e.status === 404) {
|
||||
setStatus('notfound')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [nnnn, reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
const header = title ? `${nnnn} — ${title}` : `Session ${nnnn}`
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">{header}</h1>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>
|
||||
No transcripts have been published for this session yet.{' '}
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs Session Empty About Link"
|
||||
>
|
||||
About sessions
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === '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 Index Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && files.length === 0 && (
|
||||
<div className="docs-empty">
|
||||
<p>This session has no transcripts published.</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && files.length > 0 && (
|
||||
<ul className="docs-session-files">
|
||||
{files.map(f => (
|
||||
<li key={f}>
|
||||
<Link
|
||||
to={`/docs/sessions/${nnnn}/${f}`}
|
||||
aria-label={`Open transcript ${f}`}
|
||||
data-amp-track-name="Docs Session Transcript Open"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={f}
|
||||
>
|
||||
{f}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// DocsSessionTranscript.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
|
||||
// transcript body via the backend mediator and renders it through the
|
||||
// shared MarkdownPreview.
|
||||
//
|
||||
// Empty-state contract:
|
||||
// 404 → "This transcript isn't published yet" with a link back to
|
||||
// the parent session index
|
||||
// 502 → "Couldn't reach the session-history repo" + retry button
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getSessionTranscript } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsSessionTranscript() {
|
||||
const { nnnn, filename } = useParams()
|
||||
const [body, setBody] = useState('')
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
|
||||
}, [nnnn, filename])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
getSessionTranscript(nnnn, filename)
|
||||
.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 }
|
||||
}, [nnnn, filename, reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<div className="docs-breadcrumbs">
|
||||
<Link
|
||||
to={`/docs/sessions/${nnnn}`}
|
||||
aria-label={`Back to session ${nnnn} index`}
|
||||
data-amp-track-name="Docs Transcript Back To Index"
|
||||
>
|
||||
← Session {nnnn}
|
||||
</Link>
|
||||
</div>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>This transcript isn't published yet.</p>
|
||||
<p>
|
||||
<Link
|
||||
to={`/docs/sessions/${nnnn}`}
|
||||
aria-label={`Back to session ${nnnn}`}
|
||||
data-amp-track-name="Docs Transcript Back To Session"
|
||||
>
|
||||
← Back to session {nnnn}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === '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 Transcript Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// DocsSessionsAbout.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Renders the README.md of the public `wiggleverse/ohm-session-history`
|
||||
// repo at `/docs/sessions/about`. The framework backend mediates the
|
||||
// gitea fetch (see backend/app/docs_sessions.py); this component
|
||||
// handles three response paths:
|
||||
//
|
||||
// 200 → render the markdown via MarkdownPreview
|
||||
// 404 → "About not yet published" empty-state (the upstream README
|
||||
// doesn't exist yet — happens when a deployment hasn't
|
||||
// restructured its session-history repo yet, expected at
|
||||
// v0.19.0 deploy time per the CHANGELOG)
|
||||
// 502 → "Couldn't reach the session-history repo" with a retry
|
||||
// button. The retry just re-invokes the fetch — no extra
|
||||
// backoff because the backend cache already smoothes
|
||||
// repeated 502s.
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getSessionsAbout } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsSessionsAbout() {
|
||||
const [body, setBody] = useState('')
|
||||
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: 'sessions/about' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setStatus('loading')
|
||||
getSessionsAbout()
|
||||
.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 }
|
||||
}, [reloadTick])
|
||||
|
||||
const retry = useCallback(() => setReloadTick(t => t + 1), [])
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">About sessions</h1>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>
|
||||
The session-history About page isn't published yet. Sessions
|
||||
are still authored — once a few have shipped, this page will
|
||||
render the canonical introduction.
|
||||
</p>
|
||||
<p>
|
||||
In the meantime, browse the source repo directly at{' '}
|
||||
<a
|
||||
href="https://git.wiggleverse.org/wiggleverse/ohm-session-history"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open session-history repo on gitea"
|
||||
data-amp-track-name="Docs Sessions About Repo Link"
|
||||
>
|
||||
wiggleverse/ohm-session-history
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === '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 Sessions About Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// DocsUserGuide.jsx — v0.19.0 / roadmap item #30.
|
||||
//
|
||||
// Renders DOCS.md at `/docs/user-guide`. Was `/docs` before v0.19.0
|
||||
// (the v0.14.0 single-route Docs.jsx surface, now superseded). The
|
||||
// content path is unchanged: backend reads `DOCS.md` from disk and
|
||||
// serves it at `/api/docs`. The body is rendered via the existing
|
||||
// `MarkdownPreview` (the same component the `/philosophy` route uses,
|
||||
// so we don't introduce a second markdown library).
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getDocs } from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function DocsUserGuide() {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getDocs()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">User guide</h1>
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||
{!loading && !error && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -97,6 +97,13 @@ export const EVENTS = Object.freeze({
|
||||
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||
USER_INVITED: 'User Invited',
|
||||
INVITE_CLAIMED: 'Invite Claimed',
|
||||
// v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries
|
||||
// `section`: 'user-guide' | 'sessions/about' | 'sessions/<NNNN>' |
|
||||
// 'sessions/<NNNN>/<filename>' so the dashboard can answer which
|
||||
// docs surfaces get read most. The transcript-section value includes
|
||||
// the filename so an aggregator can group by `sessions/<NNNN>` or by
|
||||
// exact transcript.
|
||||
DOC_VIEWED: 'Doc Viewed',
|
||||
})
|
||||
|
||||
// Internal state.
|
||||
|
||||
Reference in New Issue
Block a user