Compare commits

..
6 Commits
Author SHA1 Message Date
ducoterra 495d042a98 chore(agent): phase roadmap from TODO.md — 4 phases (77–80)
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Successful in 13s
Protocol B append: navbar refresh + History refresh button (77, TODO L3),
static background — glow layers removed (78, TODO L4), admin-issued API
tokens with the in-app gate + browser caching, only shared chats stay
anonymous (79, TODO L5), onboarding chips as the last 3 questions asked
with the env seed only before the first (80, TODO L6).

TODO.md cleared — its items now live in .agents/phases/todo/.
Owner-confirmed assumptions recorded in each phase overview
(A1–A7, chat 2026-09-06).
2026-09-06 23:54:11 -04:00
ducoterra b78afc08f2 docs(bench): add chat model results to CSV benchmark
Added 6 rows for chat model results (lite + turbo, fixture + derived) to benchmarks/model_benchmarks.csv.
2026-09-06 21:59:06 -04:00
ducoterra f221b40fce feat(agent): add CSV benchmark recorder + summary/embedding test scripts and skills
New files:
- scripts/model_benchmark.py — shared CSV recorder for all model tests
- scripts/test_summary_model.py — summary model quality benchmark (coherence, coverage, brevity, hallucination)
- scripts/test_embed_model.py — embedding model benchmark (dimension, cosine accuracy, speed)
- .agents/skills/test-summary-model/SKILL.md — skill for testing summary models
- .agents/skills/test-embed-model/SKILL.md — skill for testing embedding models
- benchmarks/README.md — schema documentation

Updated:
- .agents/skills/test-chat-model/SKILL.md — now also records to CSV

All three scripts write to benchmarks/model_benchmarks.csv with one row
per run per check. The CSV accumulates results across runs for comparison.
2026-09-06 21:58:35 -04:00
ducoterra 70ba8710f3 docs(agent): record the turbo sanity check on the controlled fixture battery
2026-09-06 fixture runs: contract 100 %, executed 100 %, wall ~113 s (2 runs). Derived battery: FAIL only on usage floor (5/10 tool-turns) — answers seeded questions from context, which is ideal grounded behavior. Wall time ~2.8× lite (113 s vs 40 s). Model is clean.
2026-09-06 21:49:57 -04:00
ducoterra bf64c0d7e4 docs(agent): record the lite comparison on the controlled fixture battery
2026-09-06 fixture runs: contract 92–93 %, executed 64–75 %, wall ~40.5 s (2 runs). Derived battery: FAIL, 36 % executed (38.3 s). Same pattern — copy-invariant re-read habit blocks the ≥90 % executed bar under current ALREADY_IN_CONTEXT refusal semantics. Model is working correctly; the bottleneck is the app's dedupe refusal, not the model.
2026-09-06 21:42:14 -04:00
ducoterra ffa919b8bf fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the
five navbar views (Chat, RAG, Sources, Tuning, History) were separate
HTML documents, so a navbar click was a REAL cross-document navigation
— the chat page unloaded, the in-flight SSE fetch was aborted, and the
phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled")
stopped the model. Observed: send question -> click RAG mid-stream ->
click Chat -> the answer never finished: no `query_log` row, and on
return a dangling question with no brain record (the pre-token pagehide
partial persist skips because `acc` is empty).

Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06,
flagged per AGENTS.md rule 3, not silently deviated): "real navigation
cancels the fetch" now means LEAVING THE APP — tab close,
external/other-document navigation, the Stop button. In-app navbar
switches are client-side view switches and no longer cancel.

Fix — Option A (SPA shell), chosen over B (Service Worker owns the
stream) and C (server-side turn registry + resume):
- frontend/index.html is the shell: ONE `<main id="main">` holds the
  five `<section class="view">` blocks; hidden views carry BOTH
  `hidden` and `inert` (WCAG — no focus/keyboard traversal). The
  shared header, the single `doc-modal-*` skeleton, and the
  `#app-version` footer each exist exactly once; the per-view copies
  from the four folded pages are dropped.
- New frontend/assets/router.js (vanilla module — no framework, no
  bundler, No-CDN rule intact): lazy-imports a view module on FIRST
  show only (mount-once, hide-forever — the chat view's in-flight SSE
  reader persists across switches; that persistence IS the fix);
  intercepts same-shell navbar links with preventDefault +
  history.pushState (never a document load); handles popstate; single
  writer of `.nav-link` active state (is-active + aria-current),
  document.title, and the per-view meta description (values carried
  over from the old pages' heads, brand-resolved at write time).
- Each folded page's JS becomes `export async function mount(root)` —
  root-scoped queries; `initSharedHeader()` dropped (the header boots
  once in the shell via the chat module; the admin flag comes from the
  same cached `fetchIsAdmin()` promise — zero extra requests).
- app/main.py: a small list-driven route factory serves the shell for
  /tuning.html, /sources.html, /git-sources.html, /history.html —
  registered AFTER the API routers and BEFORE the static catch-all
  (routes-first). The phase-33 caching middleware applies no-cache +
  `?v=` rewriting unchanged; app/core/caching.py needed NO change
  (the view paths did not change — pinned by the integration tests).
- The four old view .html files are DELETED (one source of truth);
  deep links to the old URLs keep working (the router picks the view
  from the pathname); `/?chat=<id>` is unaffected; the Containerfile
  bundles router.js (inlining the lazy view modules) and drops the
  folded page files.
- app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell
  keeps long saved answers in the chat, and the old cap (stricter than
  the 24_000-char total history budget) 422-rejected any second turn
  in such a chat (found by the phase-42 E2E suite on the shell).

Boundaries: login.html, shared.html, doc-edit.html, document.html
REMAIN separate documents (flow pages, not navbar tabs); a mid-stream
navigation to doc-edit/document.html still cancels per phase 48
(follow-up candidate, out of scope). The SSE API is unchanged. Real
departures still cancel the turn — phase 48 intact (pinned by
tests/e2e/test_stop_generation.py, unchanged, and by the new suite's
real-departure control).

Tests:
- Phase-20 suite REWRITTEN to the new semantics
  (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer
  cancels — the stream survives the switch and the FULL answer
  settles; the pagehide partial persist REMAINS for real departures
  (the partial's exact shape — first streamed chunk prefix, no done
  metadata — is still pinned there).
- NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock
  LLM): the owner repro (send -> RAG mid-stream -> Chat: window
  sentinel survives = same document, FULL answer, exactly one brain
  turn in bor.chat.v1, exactly one settled query_log row, auto-saved
  row matches) + the same mid-stream switch against the other three
  views + the real-departure-still-cancels control + the no-switch
  baseline.
- tests/unit/test_frontend_router.py: source-level pins of the router
  invariants (click interceptor targets ONLY same-shell view paths,
  pushState-only switches, mount-once guard, hidden+inert pair,
  single-writer active state/title); shell-route integration tests
  (each folded path serves the shell with no-cache + `?v=` body; a
  non-view path still 404s); the file-reading unit pins re-pointed at
  the shell (the four view files are gone — the shell is the source
  of truth).

Verification (this commit): full suite green — 1565 unit+integration
tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the
phase's E2E suites green in isolation (house protocol, AGENTS.md rule
9). Owner repro verified in a real browser against the real LLM
(dev server :8010, headful Chromium): "tell me about everquest" ->
RAG mid-stream -> Chat — the answer completed with one brain bubble
and no error banner, `query_log` gained exactly one settled row
(deflected=True: the dev KB holds no EverQuest docs — the settle, not
the topic, is the proof), zero "chat: turn cancelled" lines for that
turn; the control (real navigation to /shared.html mid-stream) still
cancelled (no settled row, the cancel line logged, the partial
persisted on return). Screenshots: .agents/screenshots/76_manual_*.

Phase 76 (76_spa_nav_shell) complete — moved to
.agents/phases/complete/.
2026-09-06 06:31:31 -04:00
108 changed files with 7248 additions and 3254 deletions
@@ -0,0 +1,50 @@
# Phase 76 — In-app view switches never halt a generating answer (the navbar views become one document)
**Source:** Owner repro, verified in a real browser 2026-09-06: send a question → click **RAG** in the navbar mid-stream → click **Chat** → the answer never finishes (turn cancelled: no `query_log` row, and on return a dangling question with no brain record — the pre-token `pagehide` partial persist skips because `acc` is empty). Root cause: every navbar view is a separate HTML document (`RAG` = `<a href="/sources.html">`), so a navbar click is a REAL cross-document navigation — the chat page unloads, the in-flight `fetch` is aborted, and the phase-48 teardown (`app/api/chat.py` `finally`, `chat: turn cancelled`) stops the model.
**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/sources-midstream.md` (phase 20) and `TODO.md` L3 (phase 73))
**Context:** `frontend/index.html` (chat page; the shared `<header class="app-header">` markup is copy-pasted into every page), `frontend/assets/app.js` (the chat logic — owns the in-flight SSE stream that must SURVIVE view switches; phase-20/48/73 machinery must stay intact and its source-level unit pins in `tests/unit/test_frontend_*.py` must keep passing), `frontend/assets/{sources,git-sources,tuning,history}.js` (page modules that boot at document load and call `initSharedHeader()`), `frontend/assets/header.js` (shared header: auth-gated links, active state, mobile hamburger), `app/main.py` (explicit routes + catch-all `StaticFiles` mount, API routes first), `app/core/caching.py` (`HTML_PAGES` list — the view PATHS do not change, so the no-cache + `?v=` rewrite contract applies to the shell routes untouched), `tests/e2e/test_sources_midstream_bug.py` (phase-20 suite that encodes the OLD "partial survives the navigation" semantics — rewritten in task 02), `tests/e2e/test_hidden_tab_stream.py` (phase 73 — must stay green unchanged), `tests/e2e/test_stop_generation.py` (phase 48 — must stay green unchanged).
## Objective
Collapse the five navbar views (Chat, RAG, Sources, Tuning, History) into views of ONE HTML shell so a navbar click is a client-side view switch (`history.pushState` + show/hide), never a document load. An in-flight answer keeps streaming through any navbar switch and completes when the user returns to Chat — the owner repro (send → RAG → Chat) must finish the FULL answer, settled server-side (one `query_log` row), with exactly one brain turn persisted. Real departures (tab close, leaving the app, the Stop button) still cancel the fetch and stop the model — the phase-48 contract, intact.
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
- **Option A** (SPA-ify the navbar views) chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume).
- **LOCKED-DECISION REFINEMENT (phase 48):** "real navigation cancels the fetch" now means **leaving the app** (tab close, external/other-document navigation, Stop). In-app navbar switches no longer cancel. Owner-confirmed refinement — flagged, not silently deviated.
- **Boundaries:** `login.html`, `shared.html`, `doc-edit.html`, `document.html` REMAIN separate documents (flow pages, not navbar tabs). A mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged.
- **No new technology:** vanilla JS modules, no framework, no bundler — consistent with the existing architecture and the No-CDN rule (AGENTS.md #6).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Shell:** `frontend/index.html` becomes the shell. The shared header stays at the top of the shell (it is copy-pasted into every page today; in the shell it exists ONCE, so the header/nav/auth/steering ids — `#app-nav`, `#nav-*`, `#sign-in-link*`, `#sign-out-btn*`, `#steering-*`, `#nav-toggle` — deduplicate automatically). ONE `<main id="main" class="app-main" tabindex="-1">` container (skip-link target unchanged) holds the five `<section class="view" id="view-<name>" aria-label="…" tabindex="-1">` blocks; each folded page's own `<main class="app-main">` wrapper (all five pages carry the identical one — layout CSS is class-based, so nothing is lost) is DROPPED when its content moves. Only the active view is rendered: hidden views carry BOTH `hidden` AND `inert` (WCAG — hidden views must not receive focus or keyboard traversal; AGENTS.md #5). ONE shell footer (the chat page's `#app-version` footer): per-page footers from folded views are dropped (`app-version` otherwise duplicates — it exists in both `index.html` and `history.html`).
- **Duplicate-id resolution (audited 2026-09-06, whole-file id scan of the five pages, comments stripped):** the only cross-page id collisions in VIEW markup are (a) the per-view `<main id="main">` — resolved by the single-main container above, and (b) the `doc-modal-*` family (9 ids: `doc-modal`, `-backdrop`, `-close`, `-content`, `-desc`, `-meta`, `-open`, `-panel`, `-title`), present in BOTH `index.html` and `sources.html` — the shell keeps EXACTLY ONE instance (the chat's, already in `index.html`), placed at body level (the modal is a `position: fixed` overlay; its DOM position is cosmetic). `document-modal.js` resolves all of them with document-level `querySelector` at MODULE IMPORT (its lines 44–52), so the single skeleton must be static markup present before any import — it is. `app.js` (chat chips) and `sources.js` (RAG rows) both call `openDocumentModal(...)` and work UNCHANGED against the one shared instance. The moved sources view markup DROPS its skeleton copy (task 02).
- **Same-document proof (canonical E2E assertion for "no document load"):** set a `window` sentinel before the nav click (`window.__shell_boot = "phase76"`) and assert it is still readable after the switch + return — a real navigation wipes `window` globals. Do NOT use `performance.getEntriesByType("navigation").length` — a real load resets that counter to 1 in the fresh document, so it cannot distinguish pushState from a reload.
- **View-scoped E2E absence pattern:** in the shell, hidden views' elements REMAIN IN THE DOM (hidden+inert). Any E2E absence assertion (`to_have_count(0)`) on a view-scoped id must be re-scoped to the visible view (e.g. `#view-rag #new-chat-btn`) or switched to a visibility assertion (`to_be_hidden`). Known instance: `tests/e2e/test_chat_persistence.py::test_persists_across_page_navigation` asserts `#new-chat-btn` count 0 on the Sources page (task 02 fixes it).
- **Router (new `frontend/assets/router.js`, module, ~200 lines):** a `VIEW` map of `pathname → view name` (`"/"` → chat, plus one entry per folded view); on boot it reads `location.pathname`, lazy-`import()`s the view module on FIRST show only, calls `await module.mount(root)`, then shows it. **Mount-once, hide-forever:** a view's DOM (and JS state — for chat, the in-flight SSE reader) persists across switches; that persistence IS the fix. A delegated click handler on the navbar intercepts same-shell view links (`preventDefault` + `pushState` + switch — no document load); `popstate` switches views for back/forward. The router is the SINGLE WRITER of `.nav-link` active state (`is-active` + `aria-current="page"`), `document.title`, and the per-view `<meta name="description">` (values carried over from the old pages' `<head>`s).
- **View modules:** each folded page's JS changes from top-level boot to an exported `async function mount(root)`; DOM queries scope to `root` (view element ids stay unique across the shell — verify, don't rename, to keep E2E selectors stable); the `initSharedHeader()` call is dropped (the header boots once in the shell via the chat module, as today).
- **Boot order in the shell:** `brand.js` (classic) → `app.js` (module — the chat view; runs at shell boot exactly as today, so its boot/restore/streaming behavior is untouched) → `router.js` (module — lazy-imports non-chat views only).
- **Server:** one small route factory in `app/main.py` serves the shell (`frontend/index.html`) for the folded view paths, registered BEFORE the static catch-all mount (API routes first, per existing comment). The phase-33 caching middleware already wraps the whole app and lists these paths in `HTML_PAGES`, so no-cache + `?v=` asset rewriting applies unchanged — verify, don't rewire. The old per-view `.html` files are DELETED in the same task that lands their shell route (one source of truth).
- **Deep links:** every old view URL (`/sources.html`, …) keeps working as a direct load — the server serves the shell, the router picks the view from the pathname. `/?chat=<id>` (the saved-chat deep link read by `app.js` at boot) is unaffected — it already lives on the shell's own path.
- **Auth flag in view modules:** the folded view modules today call `await initSharedHeader()` and (in `sources.js`, `git-sources.js`) USE ITS RETURN VALUE as the admin flag. In the shell the header boots once via the chat module — so each view module DROPS `initSharedHeader()` and reads the admin flag from `fetchIsAdmin()` instead (the SAME cached `/api/whoami` promise `header.js` exports — zero extra requests; `sources.js`/`history.js`/`tuning.js` already import it, `git-sources.js` must add it to its import).
## Dependencies
— (none)
## Tasks
1. `01_shell_router_tuning.md` — shell + router + shell-route factory; fold Tuning as the first non-chat view (proves the pattern end-to-end, gate included).
2. `02_fold_rag_sources_views.md` — fold RAG (sources) + Sources (git-sources); REWRITE the phase-20 `test_sources_midstream_bug.py` to the new "the stream survives" semantics.
3. `03_fold_history_view.md` — fold History; verify the `/?chat=<id>` deep link and history suites.
4. `04_header_shell_wiring.md` — header is shell-owned (single-writer active state, auth gate, hamburger, per-view title/meta); the surviving documents keep their header copies; update the header/nav consistency suites.
5. `05_stream_survival_e2e.md` — new story suite `test_nav_switch_keeps_stream.py`: the owner repro pinned on the mock LLM (mid-stream switch → FULL answer, one brain turn, settled turn) + the real-departure-still-cancels control.
6. `06_regression_sweep_commit.md` — full suite + coverage floor + ruff/pyright; manual verification of the owner repro in a real browser; atomic commit; phase → complete.
## Testing & Quality
- Unit: `tests/unit/test_frontend_router.py` — source-level pins of the router invariants (house pattern: `tests/unit/test_frontend_hidden_tab.py` reads JS source without a browser): the click interceptor targets ONLY same-shell view paths; switches use `pushState` (no document load); mount-once guard; hidden views get `hidden` + `inert`; the router writes active state/title.
- Integration: the shell-route tests — each folded path serves the shell (200, `text/html`, body is the index page carrying `?v=`-tagged asset refs, `Cache-Control: no-cache`), and a non-view path (e.g. `/nonexistent.html`) still 404s. `app/core/caching.py` must need NO change (the paths are unchanged) — pin that in the test's assertion set.
- Coverage: **>90%** on `app/` (server delta is the small route factory + tests).
- E2E: new story suite `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation (`uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov`) against the deterministic mock LLM (the ~8 s long stream gives the guaranteed mid-stream window — house pattern from `tests/e2e/test_hidden_tab_stream.py`). The phase-20 suite is REWRITTEN in task 02 (its premise — navbar click = navigation — is the behavior this phase removes); phase 73 (`test_hidden_tab_stream.py`) and phase 48 (`test_stop_generation.py`) must stay green UNCHANGED.
## Completion Criteria
- [ ] Owner repro passes in a REAL browser (real LLM): send → RAG mid-stream → Chat → the FULL answer completes, one brain turn, one `query_log` row (settled, no `turn cancelled`).
- [ ] All five navbar views render in the shell; direct loads of `/`, `/sources.html`, `/git-sources.html`, `/tuning.html`, `/history.html` deep-link to the right view; the four old view `.html` files are deleted; the cache-busting E2E (`test_cache_busting.py`, `test_asset_cache_revalidation.py`) is green with the shell routes.
- [ ] `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` green in isolation, including the real-departure control (external navigation / Stop still cancels per phase 48).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): keep in-flight answers alive across in-app view switches`) whose body records the owner repro + the phase-48 refinement; phase dir moved to `.agents/phases/complete/`.
@@ -0,0 +1,30 @@
# Task 01 — Shell + router + shell-route factory; Tuning becomes the first non-chat view
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Prove the whole architecture end-to-end with ONE folded view: the shell structure, the new router (pushState view switches, mount-once, hidden+inert, single-writer active state/title), the server shell-route factory, and `tuning.js` as a `mount(root)` module. After this task, clicking Tuning in the navbar switches views WITHOUT a document load, and direct loads of `/tuning.html` still deep-link to the Tuning view.
## Work
1. `app/main.py` — add a small shell-route factory: `def _shell_routes(app, static_dir, paths)` registering `GET <path>` handlers that return `frontend/index.html` (`text/html`), added AFTER `app.include_router(...)` calls and BEFORE the `app.mount("/", StaticFiles(...))` catch-all (API/routes-first invariant). Call it with `("/tuning.html",)` for this task (tasks 02/03 extend the list — keep the factory list-driven). VERIFY (curl + the integration test) that the phase-33 caching middleware already applies to these responses: `Cache-Control: no-cache` and `?v=<token>`-tagged asset refs (the path is already in `app/core/caching.py` `HTML_PAGES` — do NOT edit that file; if the header is missing from the route response, set it on the route instead of rewiring the middleware).
2. `frontend/index.html` — restructure the body per the phase design: the existing `<main id="main" class="app-main" tabindex="-1">` becomes the single view container; INSIDE it, wrap the chat content in `<section class="view" id="view-chat" aria-label="Chat" tabindex="-1">` and add `<section class="view" id="view-tuning" hidden inert aria-label="Global Tuning" tabindex="-1">`. The tuning section gets the content of `frontend/tuning.html`'s `<main>` with its `<main>` wrapper DROPPED (the single container main is the shared layout; the class-based `.app-main` styling is unaffected — all five pages' mains carry the identical class). Drop the tuning file's `<head>`/`<header>`/`<script>` blocks when moving. Verify the moved markup's element ids against the shell's (the id audit in the phase overview is the reference: the only cross-page view-markup collisions are `#main` and the `doc-modal-*` family — neither is in the tuning content; do not rename any id, E2E selectors depend on them).
3. `frontend/assets/router.js` (NEW, ES module, ~200 lines) — per the phase design: `VIEW` map (`"/" → "chat"`, `"/tuning.html" → "tuning"`); boot from `location.pathname`; lazy `import("./tuning.js")` on first show; `await module.mount(root)`; show = remove `hidden` + `inert`, hide = add both; focus the target section (its `tabindex="-1"`) ONLY on user-initiated switches (navbar click / popstate) — never on initial boot (no focus steal on load); delegated `click` handler on the navbar: same-origin `a.nav-link` whose `pathname` is in `VIEW` → `preventDefault()` + `history.pushState` + switch (never a document load); `popstate` → switch; SINGLE WRITER of `.nav-link` `is-active`/`aria-current` and of `document.title` (chat: `"Brain of Reese"`, tuning: `"Global Tuning · Brain of Reese"`) + `<meta name="description">` (carry the old tuning value over). The shell markup starts with ONLY the Chat link statically `is-active` (the default view) — no view other than chat may carry a static active stamp. The chat view needs NO module import — `app.js` already runs at shell boot.
4. `frontend/index.html` — load `router.js` as a module AFTER `app.js` (boot order per phase design: `brand.js` classic → `app.js` module → `router.js` module).
5. `frontend/assets/tuning.js` — convert the top-level boot into `export async function mount(root)`: wrap the existing boot body (today's `(async () => { await initSharedHeader(); if (await fetchIsAdmin()) loadNotes(); })()` at the bottom); scope its DOM lookups to `root` where they are not already unique-id-based; DROP the `initSharedHeader()` call (the shell's header boots via the chat module, as today) but KEEP `fetchIsAdmin()` for the admin gate (same cached whoami promise — see the phase overview's auth-flag note; tuning.js already imports it).
6. DELETE `frontend/tuning.html` (the shell route now serves the shell for that path).
7. `tests/unit/test_frontend_router.py` (NEW, house source-pin pattern — read `tests/unit/test_frontend_hidden_tab.py` for the idiom) — pin: the interceptor matches only `VIEW`-map paths (no other links are intercepted); the switch uses `history.pushState` and NOT `location.assign`/`location.href`/`reload`; a mount-once guard exists per view; hidden views get both `hidden` and `inert`; the router sets `is-active`/`aria-current` and `document.title`.
8. `tests/integration/test_api.py` — extend the page-serving tests (see `test_index_html_variant_no_cache_versioned`, ~L211, and the per-page `<title>` table ~L153–158): (a) NEW test — `GET /tuning.html`: 200, `text/html`, `Cache-Control: no-cache`, `?v=`-tagged asset refs, body is the SHELL (contains `id="view-tuning"`, not the old page's content); `GET /nonexistent.html` still 404s (the catch-all is intact). (b) The title table entry `("/tuning.html", "Global Tuning")` no longer holds — the shell route serves the shell whose static `<title>` is `"Brain of Reese"` (the per-view title is set CLIENT-side by the router, invisible to httpx): change the entry to assert the body is the shell (e.g. contains `id="view-tuning"`) instead of the page title.
9. `tests/integration/test_caching_revalidation.py` — `_page_file(path)` maps a page path to its backing file on disk and asserts `file.is_file()`; it breaks the moment `tuning.html` is deleted. Add the shell-served paths to a path→backing-file override mapping (`"/tuning.html" → "index.html"`; tasks 02/03 extend it) so the etag computation uses the file that actually backs the response. The page CONTRACT itself (200, no-cache, `?v=`, no validators on the response) is unchanged — the phase-33 middleware wraps the whole app and lists the path in `HTML_PAGES`, so the shell-route response is normalized the same way as a static page (verify this in the new test: no `etag`/`last-modified` on the shell-route response).
10. `tests/unit/test_chat_persistence.py::test_new_chat_button_lives_only_on_the_chat_page` — the per-file negative check iterates `(SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML)`; the file-level "only on the chat page" semantics die with the shell. Convert to the final shell form in ONE step (later tasks then leave it alone): positive — the button markup sits inside the shell's chat view section (`id="view-chat"` region of `index.html`); negative — the button markup is absent from the SURVIVING separate documents (`document.html`, `login.html`, `shared.html`) — and stop iterating the to-be-folded view files. Also `tests/unit/test_history_page.py` (it enumerates all eight page files — the shared-markers audit): update its page list to the post-shell set (the shell + surviving documents; drop the folded view files as they are deleted across tasks 01–03 — in THIS task, drop `TUNING_HTML`).
11. Run the tuning-adjacent E2E in isolation and fix ONLY what genuinely breaks from the view fold (URL assertions like `to_have_url(app_url + "/tuning.html")` should still pass — pushState sets the same URL): `uv run pytest tests/e2e/test_global_tuning.py tests/e2e/test_tuning_nav_link.py tests/e2e/test_tuning_toggle_flash.py tests/e2e/test_cache_busting.py tests/e2e/test_asset_cache_revalidation.py -v --no-cov` (DB up: `podman compose up -d db`).
## Testing & Quality
- Unit: `tests/unit/test_frontend_router.py` (new invariants above).
- Integration: shell-route test (item 8); `app/core/caching.py` unchanged (assert the path list was not edited — the test's own expectations are the pin).
- Coverage: **>90%** on the new/modified `app/` code (the route factory is exercised by the integration test).
## Completion Criteria
- [ ] In a browser: `GET /tuning.html` deep-links to the Tuning view (admin); the Chat view is hidden (`hidden` + `inert`); clicking Chat in the navbar returns to chat with NO document load (window-sentinel pattern from the phase overview — `window.__shell_boot` set before the click is still readable after the switch); clicking Tuning again re-shows the cached view (no refetch).
- [ ] The full suite is green (`uv run pytest --cov=app`) and `uv run ruff check . && uv run pyright` is clean — the validation gate runs after this task, so NOTHING may be left half-migrated.
- [ ] `tests/e2e/test_global_tuning.py`, `test_tuning_nav_link.py`, `test_tuning_toggle_flash.py`, `test_cache_busting.py`, `test_asset_cache_revalidation.py` green in isolation.
- [ ] No behavior change in completed phases: chat boot/restore/streaming (phases 20/48/49/50/55/66/73/74) untouched — `tests/e2e/test_chat_rag.py` and `tests/e2e/test_hidden_tab_stream.py` still green.
@@ -0,0 +1,33 @@
# Task 02 — Fold RAG (sources) + Sources (git-sources); rewrite the phase-20 midstream suite to the new semantics
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Fold the two biggest admin views — RAG (`sources.js`, 559 lines, includes the document modal mount points) and Sources (`git-sources.js`, 889 lines, live upload-progress UI) — into the shell, and REWRITE `tests/e2e/test_sources_midstream_bug.py` (the phase-20 suite): from this task on, a mid-stream navbar click NO LONGER navigates, so the pinned behavior is "the stream survives and the answer completes" instead of "a partial survives the navigation".
## Work
1. `app/main.py` — extend the task-01 shell-route factory call to `("/tuning.html", "/sources.html", "/git-sources.html")`; extend the integration work from task 01 items 8–9 to the two new paths (shell-route test + the title-table entry for each path changed to the shell-body assertion + the `_page_file` override map).
2. `frontend/index.html` — add `<section class="view" id="view-rag" hidden inert aria-label="RAG" tabindex="-1">` and `<section class="view" id="view-git-sources" hidden inert aria-label="Sources" tabindex="-1">` with the view content moved out of `frontend/sources.html` / `frontend/git-sources.html` — their `<main>` wrappers DROPPED (single container main, per the phase design). CRITICAL dedup while moving: the sources view markup carries a SECOND copy of the `doc-modal-*` skeleton (9 ids — the audit in the phase overview) — DROP that copy entirely: the shell keeps the ONE chat skeleton (already in `index.html`, body level), and `document-modal.js` (document-level `querySelector` at import, its lines 44–52) plus `openDocumentModal(...)` calls from BOTH `app.js` and `sources.js` target that single instance UNCHANGED. Drop any per-page footer from the moved markup (only `index`/`history` have one — history lands in task 03). Verify the moved views' ids against the shell (the only collisions the audit found are `#main` and `doc-modal-*` — both resolved above; do not rename any other id, E2E selectors depend on them). Extend the router `VIEW` map (`"/sources.html" → "rag"`, `"/git-sources.html" → "git-sources"`) and the title/meta table (carry the old `<title>`/`<meta name="description">` values; the meta descriptions are `"Documents indexed in Brain of Reese."` and `"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only)."`).
3. `frontend/assets/sources.js` — top-level boot → `export async function mount(root)`; scope DOM lookups to `root`; DROP the `initSharedHeader()` call (line ~547: `const admin = await initSharedHeader()`) and read the flag from `fetchIsAdmin()` instead — sources.js ALREADY imports both from `header.js`, so this is a one-line change with zero extra whoami requests (see the phase overview's auth-flag note). The document modal needs NO wiring change (single shared skeleton — item 2).
4. `frontend/assets/git-sources.js` — same conversion: top-level boot → `export async function mount(root)`, scope DOM lookups to `root`, and line ~876 `const admin = await initSharedHeader()` → `const admin = await fetchIsAdmin()` (ADD `fetchIsAdmin` to the `header.js` import — it currently imports only `initSharedHeader`). The upload-progress state machine (phase 64/65 UI) must work when the view is mounted ONCE and re-shown without re-mount: its poller is a self-chaining `setTimeout(tick, UPLOAD_POLL_MS)` started when an upload begins (not at boot) — it keeps running across view switches in the same document (progress continues while the user is on another view), and nothing may refetch on re-show.
5. DELETE `frontend/sources.html` and `frontend/git-sources.html`.
6. **REWRITE `tests/e2e/test_sources_midstream_bug.py`** (keep the file + story mapping; update the docstring: from this phase on, a navbar click is a client-side view switch — the stream SURVIVES; the phase-20 pagehide partial persist remains for REAL departures only, and its coverage home is scenario 1 below in its renamed form). The rewrite, mapped to the suite's CURRENT tests:
- `test_partial_answer_survives_real_departure_midstream` (RENAMED from `test_partial_answer_survives_sources_nav_midstream` — the phase-20 partial-persist pin, now exercised via a genuine departure): keep the test body and ALL of its assertions (mid-stream state, the partial rendered on return, and the storage shape: `whos == ["user", "brain"]`, text `startswith(FIRST_CHUNK_RAW)`, shorter than `FULL_LONG`, NO done metadata — no `sources`/`deflected`/`suggestions`/`thinking` keys), but change the departure: `page.click("#nav-sources")` becomes `page.goto(app_url + "/shared.html")` (a REAL cross-document departure — the fetch is aborted by the unload, which is the point; DO NOT use `/login.html` here — the test session is already signed in and `login.js` auto-redirects an admin (`window.location.replace(safeNext())`), so the login page bounces straight back into the shell; `/shared.html` is a real document with a stable state for every session state — assert its marker `#shared-title` visible instead of the old "landed on the catalog" assertion (`#docs-tbody tr` visible)), and ADD: `query_log` gained NO settled row for the question (the turn was cancelled — the phase-48 line). Return via `page.goto(app_url + "/")` exactly as today.
- `test_full_answer_completes_after_rag_nav_midstream` (NEW — the phase-76 semantics): admin login; the mock's `write a long answer` question (~9 s stream, house pattern from `tests/e2e/test_hidden_tab_stream.py`); wait for visible streaming (the `FIRST_LINE_DOM` + `Stop`-button pattern scenario 1 already uses); set the window sentinel (`page.evaluate("() => { window.__shell_boot = 'phase76'; }")`); `page.click("#nav-sources")` MID-STREAM; assert the URL is `app_url + "/sources.html"` AND the SAME document (the sentinel is still readable — the canonical pattern from the phase overview; do NOT use the navigation-entries length, it resets on a real load) AND the RAG view is visible (`#docs-tbody tr` first visible); wait ~2 s on RAG; click Chat back; assert the bubble carries the FULL mock answer (every step line + `LONG-ANSWER-END`), no error banner, `bor.chat.v1` holds EXACTLY ONE brain turn with the full text (done metadata present — it is the settle, not a partial), and `query_log` gained a settled row (no phase-48 `turn cancelled`).
- `test_nav_switch_before_first_token_completes` (NEW — navbar switch in the pre-token window): the `think out loud then hesitate` question; during the thinking window (button `Stop`, no answer bubble yet), set the sentinel and `page.click("#nav-sources")`; back to Chat; the answer completes (full text) and `bor.chat.v1` holds EXACTLY ONE brain turn — the no-orphan invariant in its new form (a pre-token navbar switch neither kills the turn nor persists a partial).
- `test_no_orphan_brain_message_when_navigated_before_first_token` (KEPT — it already uses a direct `page.goto(app_url + "/sources.html")`, which REMAINS a real departure in the SPA): update only its docstring to say it now pins the real-departure pre-token convention (nothing brain-side persisted before the first token).
- scenarios 3–4 (`test_completed_turn_unaffected`, `test_new_chat_still_clears_conversation`) — keep, verify still green (they use direct gotos / the New Chat button — unaffected by the fold).
7. `tests/e2e/test_chat_persistence.py::test_persists_across_page_navigation` — it lands on the Sources page via `login(page, app_url, next="/sources.html")` and asserts `expect(page.locator("#new-chat-btn")).to_have_count(0)`: in the shell the chat view (with the button) is in the DOM on every view (hidden+inert), so the element EXISTS — change the assertion to `to_be_hidden()` (the view-scoped absence pattern from the phase overview). The rest of that test (conversation integrity across a real navigation) must pass UNCHANGED — it is now the pin that real departures still work for the conversation store.
8. `tests/unit/test_document_viewer.py::test_both_pages_carry_the_modal_skeleton` — it loops `(INDEX_HTML, SOURCES_HTML)` asserting each carries the skeleton; after the fold there is ONE page and ONE skeleton: assert `index.html` (the shell) contains the skeleton and that `id="doc-modal"` occurs EXACTLY ONCE in it (the dedup pin). Sibling unit tests in that file that read `SOURCES_JS`/`APP_JS` (e.g. `test_viewer_url_builder_present_in_chat_and_sources`) read JS modules, not the deleted HTML — verify they pass unchanged. Also `tests/unit/test_history_page.py`'s page-file list drops `SOURCES_HTML` + `GIT_SOURCES_HTML` (both files deleted in this task — task 01 dropped `TUNING_HTML`, task 03 drops `HISTORY_HTML`), and any file-level constant for the deleted pages in `tests/unit/test_chat_persistence.py` that the task-01 conversion left behind is removed (ruff flags the unused imports).
9. Run the affected E2E in isolation (DB up): `uv run pytest tests/e2e/test_sources_midstream_bug.py tests/e2e/test_chat_persistence.py tests/e2e/test_chat_rag.py tests/e2e/test_git_sources_admin.py tests/e2e/test_local_directory_sources.py tests/e2e/test_archive_upload_sources.py tests/e2e/test_edit_summaries.py tests/e2e/test_source_removal_cleanup.py -v --no-cov` — direct `goto` to the two paths still works (shell served → view rendered); fix ONLY fold breakage (e.g. title/meta assertions, header expectations, hidden-view absence assertions per the phase overview's pattern), not semantics.
## Testing & Quality
- Unit: `tests/unit/test_frontend_router.py` stays mechanism-level (pins the interceptor/pushState/mount-once/inert behavior, not the map size) — no edit expected; if a pin hard-codes the map contents, generalize it to the mechanism in this task.
- Integration: shell-route assertions extended (item 1).
- Coverage: **>90%** on modified `app/` code.
## Completion Criteria
- [ ] Mid-stream `#nav-sources` click: no document load, RAG view renders, and on return the FULL answer has completed (the rewritten phase-20 test passes in isolation).
- [ ] `sources.html` / `git-sources.html` deleted; direct loads of both paths render the right view with the old titles; the cache-busting suites still green.
- [ ] The full suite green (`uv run pytest --cov=app`), `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
- [ ] No behavior change in completed phases: `tests/e2e/test_stop_generation.py` (phase 48) and `tests/e2e/test_hidden_tab_stream.py` (phase 73) green UNCHANGED.
@@ -0,0 +1,26 @@
# Task 03 — Fold History; verify the `/?chat=<id>` deep link and the history suites
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Fold the History view (phase 50, `history.js`, 462 lines) into the shell the same way as tasks 01–02, delete `frontend/history.html`, and prove the saved-chat deep link `/?chat=<id>` (read by `app.js` at boot) still works — it already lives on the shell's own path, so it must be untouched by the fold.
## Work
1. `app/main.py` — extend the shell-route factory call to include `"/history.html"`.
2. `frontend/index.html` — add `<section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1">` with the view content moved out of `frontend/history.html` — its `<main>` wrapper DROPPED (single container main, per the phase design) and its `<footer>` DROPPED: the history page carries the `#app-version` footer, which would DUPLICATE the shell's single (chat) footer — the shell keeps the chat's one. Verify the remaining moved ids against the shell (the audit in the phase overview found no other cross-page view-markup collisions; do not rename any id, E2E selectors depend on them); extend the router `VIEW` map (`"/history.html" → "history"`) and the title/meta table (carry the old values: `"Saved chats — every conversation is saved automatically, one click back."`).
3. `frontend/assets/history.js` — top-level boot → `export async function mount(root)`; scope DOM lookups to `root`; the boot's `await initSharedHeader(); if (!(await fetchIsAdmin())) { …gate… }` becomes `if (!(await fetchIsAdmin())) { …gate… }` (history.js already imports `fetchIsAdmin` — the same cached whoami promise, zero extra requests; see the phase overview's auth-flag note). The row actions (open `/?chat=<id>` link, copy-link, delete) are plain anchors/`location.assign` targets that REMAIN real navigations — do not route them through the router (opening a saved chat is a chat-view concern handled by `app.js` at boot via `?chat=`; leaving them as document loads is the existing behavior and is out of scope).
4. DELETE `frontend/history.html`.
5. Verify the deep link: a direct load of `/?chat=<id>` boots the CHAT view with the saved conversation opened (existing `app.js` boot behavior — `app.js` reads `new URLSearchParams(window.location.search).get("chat")` at module load, and `"/"` is the chat view in the router). No code change expected; the E2E below is the proof.
6. Extend the integration/unit updates from tasks 01–02 to the third path: the `tests/integration/test_api.py` title-table entry for `/history.html` becomes the shell-body assertion (per task 01 item 8b); the `tests/integration/test_caching_revalidation.py::_page_file` override map gains `/history.html -> index.html`; `tests/unit/test_history_page.py`'s page-file list drops `HISTORY_HTML` (the file is deleted) — its shared-markers assertions then run over the shell + surviving documents only.
7. Run the affected E2E in isolation (DB up): `uv run pytest tests/e2e/test_chat_history.py tests/e2e/test_history_copy.py tests/e2e/test_stale_saved_chats.py -v --no-cov` — fix ONLY fold breakage (titles, header expectations), not semantics.
## Testing & Quality
- Unit: `tests/unit/test_frontend_router.py` mechanism pins unchanged (the map grew — the pins are mechanism-level).
- Integration: shell-route assertion extended (item 1).
- Coverage: **>90%** on modified `app/` code.
## Completion Criteria
- [ ] Direct load of `/history.html` renders the History view (admin) with the old title; `frontend/history.html` is deleted; the cache-busting suites still green.
- [ ] `test_chat_history.py` (which includes the `/?chat=<id>` open flow) green in isolation — the deep link is intact.
- [ ] Full suite green (`uv run pytest --cov=app`), `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
- [ ] All FIVE navbar views now live in the shell; the four old view `.html` files are gone.
@@ -0,0 +1,23 @@
# Task 04 — Header is shell-owned; the surviving documents keep their header copies; consistency suites updated
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Settle the header ownership now that four of the five pages with copy-pasted headers are gone: the shell's header is the canonical one (auth-gated links, router-driven active state, mobile hamburger — all unchanged in behavior), the surviving separate documents (`login.html`, `shared.html`, `doc-edit.html`, `document.html`) keep their own header copies and full-load the shell when their navbar links are clicked, and the header/nav consistency E2E suites are updated to the new page set.
## Work
1. `frontend/assets/header.js` — NO active-state change is needed here: header.js never writes `is-active` (each old page stamped it statically in its own markup, which is gone — only the shell markup remains). The router (task 01) is the SINGLE WRITER of `is-active`/`aria-current` after boot; the shell markup starts with exactly ONE static `is-active` (the Chat link, the default view). PIN that: extend `tests/unit/test_frontend_router.py` (or add a small source-pin) asserting the shell's `index.html` contains `is-active` exactly once, on the Chat link, and that `header.js` contains no `is-active` write. Keep untouched in header.js: the `whoami` auth-gate that reveals the admin links (`#nav-sources`, `#nav-git-sources`, `#nav-tuning`, `#nav-history`) — the whoami promise is cached per page load and every auth transition in the SPA is a real load (sign-in via the login document, sign-out via reload), so the cache never goes stale — and the mobile hamburger toggle (phase 46). Those behaviors are owner-locked and E2E-pinned.
2. Sign-out in the shell: header.js's sign-out handler does `POST /api/logout` then `window.location.reload()` (~L175–183) — in the SPA that reload is a REAL departure: `pagehide` fires, so an in-flight turn is cancelled per phase 48 (acceptable and documented: signing out leaves the app, per the phase-76 owner decision). No code change; confirm the auth E2E still passes.
3. Verify the surviving documents' navbar links: `login.html`, `shared.html`, `doc-edit.html`, `document.html` keep their copy-pasted headers; their `href="/sources.html"` etc. links full-load the shell, whose router then renders the target view (works by construction — the shell route + router pathname read). Add ONE E2E assertion (in the existing shared-header or nav-consistency suite, cheapest home): from `/document.html?…` (viewer), clicking the RAG nav link lands on the RAG view at URL `/sources.html`.
4. Update the consistency suites to the new page set (shell + the 4 surviving documents; the 4 folded view pages no longer exist as documents): `tests/e2e/test_header_consistency.py`, `tests/e2e/test_shared_header.py`, `tests/e2e/test_nav_consistency.py`, `tests/e2e/test_sticky_navbar.py`, `tests/e2e/test_mobile_hamburger_nav.py`, `tests/e2e/test_nav_rename_sources.py` — replace per-page iteration with the new set; keep every behavioral assertion (link labels/hrefs, hidden-until-admin gating, hamburger, sticky positioning) identical.
5. Run the suites in isolation (DB up): `uv run pytest tests/e2e/test_header_consistency.py tests/e2e/test_shared_header.py tests/e2e/test_nav_consistency.py tests/e2e/test_sticky_navbar.py tests/e2e/test_mobile_hamburger_nav.py tests/e2e/test_nav_rename_sources.py tests/e2e/test_admin_auth.py -v --no-cov` — fix ONLY the page-set changes, not behaviors.
## Testing & Quality
- Unit: the active-state pin from item 1 (shell markup: exactly one static `is-active` on the Chat link; `header.js` carries no `is-active` write; the router is the runtime writer). Check `tests/unit/test_hamburger_nav.py` and any other header source-pins for assumptions this task breaks — they read source, so they pass as long as the pinned strings survive; fix only genuine breakage.
- Integration: unchanged (no `app/` delta in this task).
- Coverage: **>90%** floor holds (run the suite; no `app/` change expected).
## Completion Criteria
- [ ] In the shell: after sign-in the admin links appear (whoami gate) and the active link tracks view switches (router-driven); the hamburger works ≤640 px; sign-out still works and is a real departure.
- [ ] From `/document.html` (or any surviving document), a navbar click full-loads the shell into the right view (new E2E assertion green).
- [ ] All six suites in item 5 green in isolation; `uv run pytest --cov=app` green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,25 @@
# Task 05 — New E2E story suite: in-app view switches keep the stream alive (the owner repro, pinned)
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Pin the phase-76 contract in a dedicated Playwright story suite against the deterministic mock LLM: a mid-stream navbar switch (any of the four non-chat views) never stops the answer — on return the bubble carries the FULL answer, `bor.chat.v1` holds exactly one brain turn, and the SERVER settled the turn (a `query_log` row, i.e. no phase-48 `turn cancelled`) — while the real-departure control proves the phase-48 teardown still fires for a genuine navigation away.
## Work
1. `tests/e2e/test_nav_switch_keeps_stream.py` (NEW — app-boot + mock-LLM + KB-seeding house pattern: copy the boilerplate from `tests/e2e/test_hidden_tab_stream.py` (`_import_fixtures`, `_reset_db`, `LONG_QUESTION` = the on-topic `write a long answer` phrasing, `LONG_ANSWER`/`LONG_ANSWER_END` from `e2e.mock_llm`, admin `login()` from `e2e.auth_helpers`, `_admin_cookies`/`_wait_row_full` for the auto-saved row):
- `test_rag_switch_mid_stream_completes` — THE OWNER REPRO: admin login; send `LONG_QUESTION`; wait until visibly streaming (the `_wait_streaming` house pattern — ≥8 words rendered AND button `is-stop`); set the window sentinel (`page.evaluate("() => { window.__shell_boot = 'phase76'; }")`); mid-stream `page.click("#nav-sources")`; assert URL `app_url + "/sources.html"` AND the SAME document (the sentinel is still readable — the canonical pattern from the phase overview; do NOT use the navigation-entries length, a real load resets it to 1) AND the RAG view is visible (its documents table/heading in the viewport); wait ~2 s ON RAG (the stream fills the hidden chat view meanwhile); click Chat back (`a.nav-link[href="/"]`); assert: the bubble carries the FULL mock answer (every `Step N: configure node-N` line + `LONG-ANSWER-END`), no error banner, `bor.chat.v1` holds EXACTLY ONE brain turn with text == the full answer (the settle, not a partial), `query_log` gained exactly one row (settled — the phase-48 cancel line did NOT fire), and the auto-saved row (admin context, the `persistConversation` path — `_wait_row_full`) carries the same single full brain turn (clean it up in a `finally`, house pattern).
- `test_every_nav_view_keeps_stream` — the same mid-stream switch, but against the other views: loop `#nav-git-sources`, `#nav-tuning`, `#nav-history` (fresh turn per view: one send, one switch, one return, full-answer assertion each time — the mock's long stream is ~8 s and the switch window is wide).
- `test_real_departure_still_cancels` — the phase-48 CONTROL (the locked contract survives this phase): send `LONG_QUESTION`; wait for streaming; `page.goto(app_url + "/shared.html")` (a REAL cross-document departure — not a navbar link; `/shared.html` renders a stable state for a signed-in session — DO NOT use `/login.html`: the admin session auto-redirects it straight back into the shell; assert the shared page's `#shared-title` marker is visible as proof of the landing); assert: the turn is cancelled (NO new `query_log` row), return via `page.goto(app_url + "/")`, and the page-20/73 leave-save behavior is intact — the partial is persisted as exactly one brain turn with the EXACT shape pinned by `tests/e2e/test_sources_midstream_bug.py::test_partial_answer_survives_real_departure_midstream` (the task-02 renamed scenario 1: text starts with the first streamed chunk, is shorter than the full answer, and carries NO done metadata — no `sources`/`deflected`/`suggestions`/`thinking` keys). Mirror that assertion; do not invent a new shape. (This overlaps the phase-20 suite on purpose — different stories: phase 20 pins the partial shape, this phase pins that the navbar-switch path no longer cancels while real departures still do.)
- `test_baseline_no_switch_still_completes` — the long question with NO navigation completes identically (guards against the router/view fold changing the ordinary path).
2. Run in isolation (DB up): `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov`.
3. Regression runs in isolation (the suites whose premises this phase touched): `uv run pytest tests/e2e/test_stop_generation.py tests/e2e/test_chat_persistence.py tests/e2e/test_hidden_tab_stream.py tests/e2e/test_sources_midstream_bug.py -v --no-cov` — all must pass UNCHANGED (the phase-20 suite in its task-02 rewritten form).
## Testing & Quality
- E2E: the suite above IS this task's test artifact (Playwright is the frontend gate — no JS unit infra in this repo).
- Integration: the `query_log` assertions double as server-side proof (settle vs cancel) — no new `app/` logic, coverage floor unaffected (still run the full suite).
- Coverage: **>90%** on `app/` (held by the full suite; no `app/` delta expected in this task).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` green in isolation: all four tests pass, including the same-document assertion and the settled-`query_log` proof.
- [ ] The four regression suites in item 3 green in isolation, unchanged.
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,31 @@
# Task 06 — Full regression sweep, manual verification of the owner repro, atomic commit
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
## Objective
Run the complete quality gates, verify the OWNER'S EXACT repro in a real browser against the real LLM (not just the mock), and land the phase as one atomic Conventional-Commits commit with the phase-48 refinement and root cause in the message body.
## Work
1. Full gates (DB up):
- `uv run pytest` (unit + integration + E2E) green. WATCH LIST (the suites most likely to catch a fold regression late — the full suite runs them, but if one fails, start there): the file-reading unit pins `tests/unit/test_chat_persistence.py`, `tests/unit/test_history_page.py`, `tests/unit/test_document_viewer.py`, `tests/unit/test_caching.py` (they read `frontend/*.html` from disk — the four view files are gone and the shell is the new source of truth), the integration page tests `tests/integration/test_api.py` + `tests/integration/test_caching_revalidation.py`, and the E2E nav/header/cache suites.
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`.
- `uv run ruff check . && uv run pyright` clean.
- Isolation spot-checks of the suites whose premises this phase touched (AGENTS.md rule 9): `test_nav_switch_keeps_stream.py`, `test_sources_midstream_bug.py`, `test_hidden_tab_stream.py`, `test_stop_generation.py`, `test_chat_persistence.py`, `test_chat_history.py`, `test_header_consistency.py`, `test_cache_busting.py`.
2. Manual verification of the owner repro (real browser, visible — e.g. the interactive-browser skill). Dev server: the owner may already run one on :8000 — if so, run the verification against the committed code on a DIFFERENT port (`uv run uvicorn app.main:app --port 8010`), or restart the owner's dev server from the committed tree first (it runs with `--reload`, so uncommitted test scratch could skew the check):
- Signed in as admin: send `tell me about everquest`; while it is generating, click **RAG** in the navbar; stay a few seconds; click **Chat**. The answer MUST finish — full answer rendered, one brain bubble, no error banner.
- `query_log` gains a settled row for the question (no `chat: turn cancelled` line in the server log for that turn).
- Control: repeat with a REAL departure (open `/login.html` in a fresh navigation mid-stream, or the Stop button) — the turn is cancelled there (phase-48 intact).
- Screenshot each state into `.agents/screenshots/` (tracked, house pattern from phase 73's reports).
3. Commit (AGENTS.md rule 8 — one atomic, professional commit, `--no-gpg-sign`), e.g.:
`fix(chat): keep in-flight answers alive across in-app view switches`
Body MUST record: (a) the root cause — navbar views were separate documents, so a navbar click was a real navigation whose phase-48 teardown cancelled the turn (owner repro verified 2026-09-06: RAG → Chat mid-stream, no `query_log` row, dangling question); (b) the phase-48 LOCKED-DECISION REFINEMENT, owner-confirmed 2026-09-06 — "real navigation cancels" now means leaving the app (tab close, external navigation, Stop); in-app navbar switches are client-side view switches; (c) the Option-A decision (SPA shell) chosen over the Service-Worker and server-resume alternatives; (d) boundaries — `login/shared/doc-edit/document` remain documents; mid-stream navigation to doc-edit/document.html still cancels (follow-up candidate); (e) the rewritten phase-20 suite semantics (stream survives navbar switches; the pagehide partial persist remains for real departures).
4. Move `.agents/phases/todo/76_spa_nav_shell/` → `.agents/phases/complete/` (include the move in the same commit; `.agents/` is tracked per AGENTS.md rule 8).
## Testing & Quality
- This task adds no new logic — it is the verification + hand-off pass; the gate is the gate set in item 1.
- Coverage: **>90%** on `app/` (item 1).
## Completion Criteria
- [ ] All item-1 gates green (full suite, coverage >90%, ruff + pyright, isolation spot-checks).
- [ ] The owner repro passes in a real browser against the real LLM, with the settled-`query_log` proof and screenshots in `.agents/screenshots/`.
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit with the body items (a)–(e); phase directory in `.agents/phases/complete/`; `git status` clean.
@@ -0,0 +1,41 @@
# Phase 77 — Navbar clicks refresh the view's data (fresh list on re-show + a History refresh button)
**Source:** `TODO.md` L3 — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh. The history page should also have a refresh button."
**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/chat-history.md` (phase 50) and the phase-76 shell)
**Context:** `frontend/assets/router.js` (the phase-76 shell router: the `VIEW` map, mount-once / hide-forever, `switchTo(name, { userInitiated })`, the delegated nav click handler with its `name === current` early return, the `popstate` handler), `frontend/assets/history.js` (the History view module — `loadChats()` APPENDS rows and is called once at mount), `frontend/assets/sources.js` (`loadDocs()` ~line 472 — already clears `tbody` before rendering), `frontend/assets/git-sources.js` (`loadSources()` ~line 230 — render + announce), `frontend/assets/tuning.js` (`loadNotes()` ~line 116 — `renderNotes` clears), `frontend/index.html` (`#view-history`'s `.page-head` — h1 "Saved chats" + sub; the `#history-status` live region), `tests/unit/test_frontend_router.py` (the source-level router pins), `tests/e2e/test_nav_switch_keeps_stream.py` (the phase-76 suite — must stay green UNCHANGED).
## Objective
Since the phase-76 shell, a view's data is fetched exactly once, at mount (mount-once, hide-forever) — a History view opened at 10:00 still shows 10:00's data at 10:30. Make every user-initiated re-show of a view re-fetch its list, and give History an explicit Refresh button. The Chat view is deliberately out of scope: its in-flight stream and local conversation must survive (the phase-76 contract).
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
- **A1 confirmed:** "the relevant page" = the four data views (History, RAG, Sources, Tuning). **Chat is EXCLUDED from the refresh hook** — the in-flight SSE stream and the local conversation persist (the phase-76 LOCKED refinement).
- The refresh fires on: (a) a switch TO the view when it is already mounted, (b) a re-click of the active view's own nav link (today a no-op), (c) back/forward (`popstate`) onto an already-mounted view. The FIRST show (the mount) and the boot never fire it — the mount's own load is the first fetch.
- The History refresh button lives in the view's page-head and announces through the existing `#history-status` live region.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Mechanism — a DOM event, zero router-state changes:** on any user-initiated re-show, the router dispatches `new CustomEvent("bor:view-refresh")` on the view's `<section>` root. A view module that wants fresh data listens on its own `root` inside `mount()` and re-runs its existing load function. Modules that do not listen are unaffected — the chat view never listens.
- **First-show exemption (the no-double-fetch rule):** in `switchTo`, capture `const wasMounted = mounted[name]` BEFORE the mount block. After the view is shown and the head/nav state is written, dispatch the event `if (wasMounted)` — a re-show. The first show (mount) loads once and dispatches nothing; boot (`userInitiated: false`) can never dispatch (boot always finds an unmounted view or the chat view, and the dispatch site is gated on `wasMounted`).
- **Active-view re-click:** the click handler's `if (name === current) return;` becomes: dispatch `bor:view-refresh` on `viewEls[name]` and return — NO `pushState` (the URL already IS that view's path); the mobile menu still closes (the container handler runs regardless).
- **Re-entrance of the load functions:** each must be safe to call repeatedly. Verified: `sources.js` `loadDocs` clears via `tbody.replaceChildren()` (check its `showEmpty()` path also clears the rows — if not, clear at the top of `loadDocs`); `tuning.js` `renderNotes` clears via `tuneList.textContent = ""`; `git-sources.js` `renderSources` replaces the list (verify the error/empty states reset on a re-call — `showLoadError` hides table AND empty state). `history.js` `loadChats` **APPENDS** — it must remove the data rows (every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row`) before re-fetching.
- **Gate guard:** a view only re-fetches after its whoami gate has passed (History: anonymous shows `#history-gate` and NEVER calls `/api/chats` — the phase-50 contract the story E2E pins; the listener must respect the same branch).
- **Focus/scroll unchanged:** the router still lands the viewport at the top on user-initiated switches; the refresh is a background re-fetch behind the already-shown view.
- **E2E "freshness" proof:** create new backing data via the API AFTER a view has loaded, nav back (or re-click / refresh-button), assert the new row — with the phase-76 canonical same-document sentinel (`window` global set before the clicks is still readable after — no document load).
## Dependencies
— (none; builds on the completed phase-76 shell)
## Tasks
1. `01_router_refresh_hook.md` — the `bor:view-refresh` dispatch in `router.js` (re-show + active re-click + popstate; first show exempt) and the History view re-fetching on it.
2. `02_refresh_other_views.md` — RAG, Sources, and Tuning listen and re-fetch; the Chat view stays untouched (with a comment pinning the exclusion).
3. `03_history_refresh_button.md` — the History Refresh button + the story E2E suite `test_navbar_refresh.py` + the regression sweep + the atomic commit.
## Testing & Quality
- Unit: `tests/unit/test_frontend_router.py` — new source-level pins: the `bor:view-refresh` literal exists; the dispatch is gated on the pre-mount `mounted` state (first show exempt); the re-click branch dispatches instead of a bare `return` (no `pushState`); the three other view modules each carry a listener and `app.js` does NOT (negative pin).
- E2E: new story suite `tests/e2e/test_navbar_refresh.py` run in isolation (the scenarios live in task 03).
- Coverage: **>90%** on `app/` — this phase is frontend-only; the floor is preserved by not regressing.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation (DB up).
- [ ] `tests/e2e/test_nav_switch_keeps_stream.py` still green UNCHANGED (the stream-survival contract holds with the hook in place).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(ui): refresh view data on navbar re-show + History refresh button`) whose body cites TODO.md L3; phase dir moved to `.agents/phases/complete/`.
@@ -0,0 +1,24 @@
# Task 01 — The `bor:view-refresh` hook; History re-fetches on a user-initiated re-show
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh."
**Story:** n/a (TODO-derived)
## Objective
A user-initiated re-show of an already-mounted view re-fetches its data: `router.js` dispatches `bor:view-refresh` on the view's section (re-shows and active re-clicks only — never on the first mount or boot), and the History view listens and re-loads its chat list.
## Work
1. `frontend/assets/router.js` — in `switchTo`: capture `const wasMounted = mounted[name]` BEFORE the mount block; after the show + head/nav state is written (before the focus/scroll tail is fine — the event order is: visible → refresh dispatched), `if (wasMounted) root.dispatchEvent(new CustomEvent("bor:view-refresh"))`. In the delegated nav click handler, replace the `if (name === current) return;` early return with a dispatch of `bor:view-refresh` on `viewEls[name]` + `return` (no `pushState` — the URL is already this view's path; the mobile menu still closes via the container handler). The `popstate` path flows through `switchTo`, so it inherits the `wasMounted` gating automatically. Update the file-header contract comment with the refresh rule: the event fires exactly when an already-mounted view is shown again — first show and boot never (the mount's own load is the first fetch).
2. `frontend/assets/history.js` — inside `mount(root)`:
- make `loadChats()` re-entrant: at its top, remove the data rows — every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row` — so a re-load replaces the list instead of appending a duplicate set;
- add the listener in the ADMIN branch (after the `fetchIsAdmin()` gate passes — anonymous shows `#history-gate` and never fetches, per the phase-50 contract the story E2E pins): `root.addEventListener("bor:view-refresh", () => { if (started) loadChats(); })` where `started` flips to `true` once the first `loadChats()` call is made (the gate branch that `return`s early must not arm a listener that fetches).
3. `tests/unit/test_frontend_router.py` — new source pins (house pattern — read the JS source, no browser): the `bor:view-refresh` literal exists in `router.js`; the dispatch site is guarded by a pre-mount `mounted` capture (assert the `wasMounted`-style capture appears before the mount block — e.g. the capture assignment precedes the `mounted[name]` set); the re-click branch dispatches (assert the `name === current` branch contains the dispatch literal, not a bare return); `history.js` contains the listener.
## Testing & Quality
- Unit: item 3 (mechanism-level pins; the existing router pins — pushState switches, mount-once, hidden+inert, single-writer head/nav — must stay green UNCHANGED).
- E2E: none yet — the story suite lands in task 03.
- Coverage: n/a for this task (frontend-only) — the `app/` floor must not regress.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_frontend_router.py -v` green (old + new pins).
- [ ] Manual spot check (optional): load History as admin, save a new chat via the API, click another view then History again — the new row is there without a document reload.
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,23 @@
# Task 02 — RAG, Sources, and Tuning join the refresh (Chat stays out)
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page."
**Story:** n/a (TODO-derived)
## Objective
The three remaining data views re-fetch on `bor:view-refresh` with the same gate-guarded, re-entrant pattern as History. The Chat view deliberately does NOT listen — its in-flight stream and local conversation persist (the phase-76 contract).
## Work
1. `frontend/assets/sources.js` — in `mount(root)`, in the admin branch (after the `fetchIsAdmin()` gate passes): `root.addEventListener("bor:view-refresh", () => loadDocs())`. Verify `loadDocs()` re-entrance end to end: the populated path already clears (`tbody.replaceChildren()`); check the `showEmpty()` path — if it does not clear the tbody rows, a refresh from a populated list into an empty result would leave ghost rows: add the row-clear at the top of `loadDocs()` (the hidden empty-row stays in place, exactly the History pattern from task 01).
2. `frontend/assets/git-sources.js` — same: `root.addEventListener("bor:view-refresh", () => loadSources())` in the admin branch. Verify a re-call resets all three list states (populated render, the empty state, and the `showLoadError` state — `showLoadError` already hides the table AND the empty state, so an error followed by a successful refresh must clear the error: `loadSources` already calls `hideLoadError()` on success — confirm).
3. `frontend/assets/tuning.js` — same: `root.addEventListener("bor:view-refresh", () => loadNotes())` in the admin branch; `renderNotes` already clears (`tuneList.textContent = ""`). Note: `loadNotes` keeps the last rendered list on a failed fetch (its documented contract) — a refresh that fails must behave the same way (no change needed; the listener just calls the function).
4. `frontend/assets/app.js` (the chat view) — add NO listener. Add a one-line comment where the chat view's module-scope state begins: the `bor:view-refresh` exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never re-fetches on a show.
5. `tests/unit/test_frontend_router.py` (or the neighboring source-pin file if the house split the pins — match where the task-01 pins landed) — source pins: `sources.js`, `git-sources.js`, and `tuning.js` each contain the `bor:view-refresh` listener; `app.js` does NOT (negative pin — the exclusion is a contract, not an oversight).
## Testing & Quality
- Unit: item 5.
- E2E: covered by the task-03 story suite — one assertion per view (mutate the backing data via the API while the view is hidden, nav back, the row reflects the mutation; where a view's backing data has no cheap API mutation path, assert via the Playwright request log that the re-fetch happened on re-show).
- Coverage: n/a (frontend) — the `app/` floor preserved.
## Completion Criteria
- [ ] All four data views re-fetch on a re-show; the chat view is untouched (stream survival still holds — `test_nav_switch_keeps_stream.py` green).
- [ ] The new unit pins are green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,29 @@
# Task 03 — History refresh button + the story E2E suite + the commit
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "…The history page should also have a refresh button."
**Story:** n/a (TODO-derived)
## Objective
The explicit half of the item: a visible Refresh control on the History view — plus the phase's Playwright story suite proving every refresh path, the regression sweep, and the atomic commit.
## Work
1. `frontend/index.html` — in `#view-history`'s `.page-head`: add a right-aligned actions slot (`.page-head` becomes a flex row — title block left, actions right; wraps below 640px): `<button type="button" class="history-refresh" id="history-refresh" aria-label="Refresh saved chats">` with the house inline-SVG refresh glyph (`aria-hidden="true"`) + a visible "Refresh" text label (the phase-46 auth-link convention: label visible ≥640px, icon-only below — the `aria-label` keeps the accessible name in both).
2. `frontend/assets/history.js` — bind `#history-refresh` (admin branch only — the button lives in the view, which is admin-gated): on click: disable the button (no double-fire while in flight) → `loadChats()` → announce the outcome in `#history-status` (`Saved chats refreshed.` on success; the existing failure lines on a non-2xx / network error — reuse the exact copy `loadChats`'s callers would see) → re-enable the button. The button stays reachable while the empty state is showing (it sits in the page-head, outside the table wrap).
3. `frontend/assets/styles.css` — `.history-refresh` (reuse the `.new-chat-btn` visual language: brand background, WCAG 4.5:1, `focus-visible` ring, hover state) + the `.page-head` flex layout (no layout change for views that have no actions slot — the other four views' page-heads are untouched).
4. `tests/e2e/test_navbar_refresh.py` (NEW story suite — run in isolation, DB up, the mock-LLM fixture from `tests/e2e/conftest.py`; admin via `auth_helpers.login`):
- **re-show refresh:** sign in → create saved chat A via `POST /api/chats` (authed httpx or the UI) → nav to History (row A visible) → create chat B via the API → nav to Chat → nav back to History → row B visible; a `window` sentinel set before the nav clicks is still readable afterwards (the phase-76 canonical no-document-load proof).
- **active-view re-click:** with History visible (rows A, B) → create C via the API → click the History nav link AGAIN → C appears; the URL is still `/history.html` (no new history entry — `history.length` unchanged).
- **refresh button:** create D via the API → click `#history-refresh` → D appears; `#history-status` announces `Saved chats refreshed.`; the button is `disabled` during the in-flight request (assert via the Playwright request hook or a short-poll on the disabled state).
- **popstate:** Chat → History (loads) → create E via the API → `page.go_back()` → History view → E present.
- **the four views:** one assertion per data view that a re-show re-fetches (RAG / Sources / Tuning / History — the request-log or row-delta pattern from task 02).
- **stream-survival control:** send a question (mock LLM — the ~8 s stream) → mid-stream nav to RAG → back to Chat → the FULL answer completes (the phase-76 contract holds with the hook in place). The unchanged `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation is the additional control.
5. Regression sweep + commit: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`, `tests/e2e/test_nav_switch_keeps_stream.py` green unchanged in isolation, then ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(ui): refresh view data on navbar re-show + History refresh button` — body cites TODO.md L3; move the phase dir to `.agents/phases/complete/`.
## Testing & Quality
- E2E: item 4 is the story gate (AGENTS.md rules 4 + 9 — one file, run in isolation).
- Coverage: **>90%** on `app/` (frontend-only phase — the floor is preserved).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation.
- [ ] The Refresh button is visible, keyboard-reachable, and announces (WCAG 2.1 AA basics — AGENTS.md rule 5).
- [ ] Full suite + coverage + lint green; the phase-76 suite unchanged-green; one atomic commit; phase dir in `.agents/phases/complete/`.
@@ -0,0 +1,42 @@
# Phase 78 — Static background (the animated glow layers are removed)
**Source:** `TODO.md` L4 — "Remove the animated css background, it's too resource intensive"
**Story:** n/a (TODO-derived — supersedes the fading-glow contract of `.agents/user_stories/background-no-motion.md` (phase 25), which itself superseded `background-animation.md` (phase 08))
**Context:** `frontend/assets/styles.css` (the background block, ~lines 70–155: the STATIC grid texture on `body::before`, the three opacity-fading glow spots on `body::after` / `html::before` / `html::after`, the `@keyframes bg-glow-a/b/c` blocks, and the `prefers-reduced-motion` rule that stills those layers), `tests/unit/test_background_animation.py` + `tests/unit/test_background_no_motion.py` (source-level pins of the glow contract), `tests/e2e/test_background_no_motion.py` (the phase-25 suite — computed-style fade assertions) + `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — same story, `background-no-motion.md` — its fade assertions directly contradicted by the new contract), `tests/e2e/test_dark_tech_theme.py` (palette assertions — must stay green UNCHANGED), `frontend/assets/themes/` (configurable theme files — the built-in palette is the scope; verify they carry no glow rules).
## Objective
Delete the animated background: the three opacity-fading glow pseudo-layers and their keyframes stop existing — the owner reports they are too resource-intensive (the infinite CSS animations run continuously on every page, in every tab). The background becomes fully static: the 44px grid texture stays (it is static — zero animation cost).
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
- **A2 confirmed:** "the animated css background" = the three fading glow spots (the ONLY animated part of the background). The static grid (`body::before`) STAYS. If the owner later wants the grid gone too, that is a one-line follow-up — out of scope here.
- Other UI animations (the mobile nav slide, modal fades, typing dots, …) are NOT the background — they stay untouched.
## Design (shared by both tasks)
- `body::before` (the grid) stays BYTE-IDENTICAL: 44px cells, 1px lines at 60% `--line` alpha, the widened radial mask.
- Deleted from `styles.css`: the `body::after`, `html::before`, and `html::after` glow rules; the three `@keyframes bg-glow-*` blocks; the `prefers-reduced-motion` rule whose only job was stilling these background layers — FIRST verify it references nothing else (if any non-background selector sits in it, strip only the background selectors).
- The block's comment header (the phase-08/25 owner-direction prose) is replaced with a 3–4 line phase-78 note: the animated layers were removed at owner direction (TODO.md L4) as too resource-intensive; the static grid remains.
- **Test rewrites (the house pattern — the premise changed, so the suites pin the NEW contract; the phase-76 precedent rewrote the phase-20 suite the same way):**
- `tests/unit/test_background_no_motion.py` → rewritten as the static-contract source pin: no `@keyframes bg-glow-*` in `styles.css`; none of the four pseudo-element selectors (`body::before/after`, `html::before/after`) carries an `animation:` declaration; `body::before` (the grid) is present with its 44px `background-size` and no animation.
- `tests/unit/test_background_animation.py` → **DELETED** (the phase-25 unit source-pin suite — its entire premise, three fading glows / exactly three `bg-glow-*` keyframe blocks, is gone; superseded chain 08 → 25 → 78).
- `tests/e2e/test_background_no_motion.py` → rewritten: Playwright computed-style checks — the three glow pseudo-elements report `animation-name: none` and no background-image; `body::before` still carries the grid background. Docstring updated to the phase-78 contract.
- `tests/e2e/test_background_animation.py` → **DELETED** (the phase-22 E2E repurposed as the phase-25 regression — its premise, that the glow layers fade, is removed; its story (`background-no-motion.md`) is carried on by the rewritten `test_background_no_motion.py`, the single story suite going forward).
- `tests/e2e/test_dark_tech_theme.py` must stay green UNCHANGED — the PALETTE is untouched; only the light spots go.
- `tests/unit/test_themes.py` + `frontend/assets/themes/indigo.css` — verify no glow/keyframe carry-over; if a theme file re-introduces animated background layers, the owner's direction applies to the whole background (report before deviating — the built-in palette is the confirmed scope).
## Dependencies
— (none)
## Tasks
1. `01_remove_glow_layers.md` — the CSS deletion + the four test-file rewrites/deletions.
2. `02_regression_sweep_commit.md` — theme + smoke E2E, the visual check, the full gate, the atomic commit.
## Testing & Quality
- Unit: the rewritten source-pin file + `tests/unit/test_themes.py` green.
- E2E: the rewritten static-background suite in isolation (DB up) + `test_dark_tech_theme.py` + `test_smoke.py` unchanged-green.
- Coverage: **>90%** on `app/` (CSS-only phase — the floor is preserved).
## Completion Criteria
- [ ] No `animation` / `@keyframes` remains for the background in `styles.css` (`rg "bg-glow" frontend/ tests/ app/` → zero hits); the grid renders as before (visual check in a real browser).
- [ ] The rewritten suites are green; the deleted suites are gone; `test_dark_tech_theme.py` green UNCHANGED.
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `perf(ui): remove the animated background glow layers — static grid only`); phase dir moved to `.agents/phases/complete/`.
@@ -0,0 +1,25 @@
# Task 01 — Delete the glow layers + keyframes; re-pin the background contract
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
**Story:** n/a (TODO-derived)
## Objective
The animated background no longer exists: the three glow pseudo-layers, their keyframes, and their reduced-motion rule are deleted from `styles.css`, and the unit/e2e background suites are rewritten to pin the new static contract (or deleted where the premise is gone).
## Work
1. `frontend/assets/styles.css` — delete the three glow rules (`body::after`, `html::before`, `html::after`), the `@keyframes bg-glow-a` / `bg-glow-b` / `bg-glow-c` blocks, and the `prefers-reduced-motion` block that stills the background — the file carries EIGHT `@media (prefers-reduced-motion: reduce)` blocks; delete the ONE that contains only `body::before, body::after, html::before, html::after { animation: none; }` (~line 1353) whole, and leave the other seven (typing dots, spinner, toasts, nav slide, etc. — unrelated UI) untouched. Keep the `body::before` grid rule byte-identical (44px cells, the 60% `--line` alpha gradients, the radial mask). Replace the large phase-08/25 comment header with a 3–4 line phase-78 note: owner direction (TODO.md L4) — the animated background was removed as too resource-intensive; the static grid remains.
2. `tests/unit/test_background_no_motion.py` — rewrite to the static contract (source-level, house pattern): `styles.css` contains no `@keyframes bg-glow-*`; none of `body::before` / `body::after` / `html::before` / `html::after` carries an `animation:` declaration (assert the three glow selectors are ABSENT or animation-free — they will be absent); `body::before` is present with `background-size: 44px 44px` and no animation. Update the docstring to the phase-78 contract.
3. DELETE `tests/unit/test_background_animation.py` (the phase-25 unit source-pin suite — its premise, exactly three fading glows with named keyframe cycles, is removed; superseded chain 08 → 25 → 78).
4. `tests/e2e/test_background_no_motion.py` — rewrite: Playwright computed-style checks on a loaded page — `body::before` still carries the grid (background-image non-empty, animation-name `none`); `body::after`, `html::before`, `html::after` report no background-image and `animation-name: none` (or the pseudo-element has no box). Update the docstring.
5. DELETE `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — its fade assertions contradict the new contract; the story `background-no-motion.md` is carried on by the rewritten `test_background_no_motion.py`).
6. Verify nothing else references the removed names: `rg "bg-glow" app/ frontend/ tests/` → zero hits; `tests/unit/test_themes.py` and `frontend/assets/themes/*.css` do not re-introduce glow/animated-background rules (grep for `bg-glow` / `@keyframes` on background selectors — report before deviating if a theme file carries them).
## Testing & Quality
- Unit: the rewritten pin file (item 2) + `tests/unit/test_themes.py` green.
- E2E: the rewritten suite (item 4) in isolation (DB up): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`.
- Coverage: n/a (CSS only) — the `app/` floor preserved.
## Completion Criteria
- [ ] `rg "bg-glow" frontend/ tests/ app/` → no hits; the three glow rules + keyframes are gone from `styles.css`; the grid rule is untouched.
- [ ] Rewritten unit + e2e suites green; the two animation suites deleted; `tests/e2e/test_dark_tech_theme.py` green UNCHANGED.
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,20 @@
# Task 02 — Regression sweep + the commit
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
**Story:** n/a (TODO-derived)
## Objective
Prove the deletion regressed nothing (palette, theme, smoke, full pipeline) and land the atomic commit.
## Work
1. E2E (DB up, each in isolation): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`, then `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` (UNCHANGED file — if it breaks, the deletion touched the palette; fix the deletion, not the test), then `uv run pytest tests/e2e/test_smoke.py -v --no-cov`.
2. Visual check in a real browser (real server, not the mock): the page background is the static grid over the flat `--bg` canvas — no pulsing light spots at the top-left / bottom-right / bottom-left corners; a `prefers-reduced-motion` browser profile sees the same static page; the theme CSS (indigo) still applies cleanly if set.
3. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/`), `uv run ruff check . && uv run pyright`.
4. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `perf(ui): remove the animated background glow layers — static grid only` — body cites TODO.md L4 + the resource-intensity reason + the confirmed scope (glow spots gone, static grid stays). Move the phase dir to `.agents/phases/complete/`.
## Testing & Quality
- The full suite IS the test; coverage **>90%** on `app/`.
## Completion Criteria
- [ ] Items 1–3 all green.
- [ ] Committed; phase dir in `.agents/phases/complete/`.
@@ -0,0 +1,53 @@
# Phase 79 — API tokens: admin-issued access to the app (only shared chats stay open)
**Source:** `TODO.md` L5 — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
**Story:** n/a (TODO-derived — extends the phase-16 single-admin auth, `.agents/user_stories/admin-auth.md`)
**Context:** `app/core/auth.py` (the SessionMiddleware cookie session, `require_admin`, `sign_in`/`sign_out`, `ADMIN_SESSION_KEY`), `app/api/auth.py` (`/api/login`, `/api/logout`, `/api/whoami` — `WhoamiResponse{authenticated, role: "admin"|"anonymous"}`), `app/models.py` (SQLAlchemy 2.0 mapped-column models — `SavedChat` is the last one) + `alembic/versions/` (latest is `0011_doc_drafts.py` — the format to mirror), `app/api/chat.py` (`POST /api/chat` — public today), `app/api/suggestions.py` (public today), `app/api/docs.py` (`GET /api/documents/content` — the phase-16 soft rule: deliberately public), `app/schemas.py` (`LoginRequest`, `WhoamiResponse`, …), `frontend/assets/header.js` (the single `/api/whoami` call site — `fetchIsAdmin()` returns `authenticated === true`; the admin-link reveal; the sign-out binding), `frontend/index.html` (the shell — `#app-nav`, the `#view-*` sections, the sign-in/out links, `#main`), `frontend/document.html` + `frontend/assets/document.js` (the document viewer — `fetchIsAdmin` gates the admin-only edit affordance; the viewer itself is public today), `tests/e2e/auth_helpers.py` (the real-form `login` helper), `tests/integration/test_auth_api.py` (pins the phase-16 contract — "viewer stays public (soft rule) and `POST /api/chat` still streams" — that soft rule is SUPERSEDED by this phase).
## Objective
The admin can generate named API tokens and hand them out; a token holder signs in at the in-app gate and uses the app — chat, suggestion chips, cited documents. The ONLY anonymous content is the shared chats (plus the login/infra endpoints the gate itself needs). Every existing admin-only surface stays admin-only.
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
- **A3 confirmed — token-user scope:** a token user (role `user`) may: `POST /api/chat`, `GET /api/suggestions`, `GET /api/documents/content`, `GET /api/whoami`, `POST /api/logout`. Admin-only UNCHANGED: the docs list/import/sync, tuning, git sources, doc drafts, and the saved-chats list/save/share/delete (saved chats have no per-user attribution — token users get NO History view; only the admin sees saved chats).
- **A4 confirmed — token shape & lifecycle:** `bor_` + 32 hex chars (`secrets.token_hex(16)`); only the SHA-256 hex digest of the FULL token string is stored (unique index) — the plaintext is returned EXACTLY ONCE at creation. `revoked_at` set = dead, and revocation is enforced IMMEDIATELY on the user's next request (the session stores the token id; `require_user` live-checks the row is unrevoked — no server-side session store is added, just a PK lookup).
- **A5 confirmed — the gate:** an in-app token-entry overlay on the shell + the same inline gate on `document.html`; `login.html` (admin password) and `shared.html` (anonymous) are UNCHANGED. The entered token is cached in `localStorage["bor.token"]` and silently re-sent to `POST /api/token-auth` on every page load (a failed silent re-auth — revoked token — drops the key and shows the gate). Sign out clears the key. `/api/config` stays public (the gate UI itself needs the branding).
- **Auth error semantics:** an unauthenticated (or revoked) caller to a `require_user` endpoint gets 401 `{"detail": "authentication required"}` — 401, not 403 (there is no higher privilege that would unblock them); `require_admin` keeps its 403 `admin only`. `POST /api/token-auth` failures (malformed / unknown / revoked) all get ONE generic 401 `{"detail": "invalid token"}` (no enumeration — the phase-16 pattern).
- **`whoami` shape:** `WhoamiResponse{authenticated: bool, role: "admin"|"user"|"anonymous"}` — `authenticated` is true for admin AND user; ALL UI gating switches from `authenticated` to `role === "admin"` (the frontend change is owned by task 05). An admin-signed-in session keeps working exactly as today (a browser that holds BOTH an admin and a user session reports admin; `sign_out` clears everything — one session dict, one logout).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Model — `api_tokens` (migration `0012_api_tokens.py`):** `id` UUID PK (uuid4 default); `label` String(120) NOT NULL (the hand-out name, e.g. "alice" — display-only, no index, not unique); `token_hash` String(64) NOT NULL UNIQUE (the sha256 hex digest of the full `bor_…` string — the `documents.content_hash` String(64) precedent); `created_at` TIMESTAMPTZ NOT NULL server-default now; `last_used_at` TIMESTAMPTZ NULL; `revoked_at` TIMESTAMPTZ NULL.
- **Service — `app/core/tokens.py` (new):** `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token) -> str` (sha256 hexdigest of the FULL token — hashing the full string, not the suffix, so a stripped prefix can never collide); `create_token(db, label) -> tuple[ApiToken, str]` (returns the row + the plaintext exactly once — the row only ever carries the hash); `find_active_by_token(db, token) -> ApiToken | None` (hash → `token_hash ==` lookup → `revoked_at IS NULL`); `mark_used(tok)` (bump `last_used_at` to now — the caller commits); `revoke(db, token_id) -> bool` (set `revoked_at` when not already — False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256's pre-image resistance means there is no token-enumeration or timing surface beyond the DB lookup (the contrast with `check_password`'s constant-time compare is documented, not replicated — there is nothing to compare in constant time here, only to look up).
- **Admin API — `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):** `POST /tokens` body `TokenCreateRequest{label}` → 201 `TokenCreated{id, label, token, created_at}` — the ONLY response that ever carries the plaintext; `GET /tokens` → `TokenList{tokens: [TokenListItem{id, label, created_at, last_used_at, revoked: bool}]}` newest-first (no hashes, no plaintext); `POST /tokens/{id}/revoke` → 204, idempotent (already-revoked → still 204; unknown id → 404 `token not found`). Registered in `app/main.py` with the other API routers (before the static mount).
- **Auth API — `app/api/auth.py`:** new `POST /token-auth` (PUBLIC — it is the login): body `TokenAuthRequest{token}` → `find_active_by_token` → miss → 401 `invalid token`; hit → `mark_used` + commit + `session[USER_SESSION_KEY] = True` + `session[USER_TOKEN_ID_KEY] = str(token.id)` → 204. `whoami` reports the three roles. `logout` is unchanged (its `session.clear()` already wipes both roles).
- **`require_user` (in `app/core/auth.py`):** `def require_user(request: Request, db: Session = Depends(get_db))` — admin key set → pass; `user` key set → fetch the `ApiToken` row by `user_token_id` (PK hit) — row missing OR `revoked_at` set → pop BOTH user keys from the session + raise 401 `authentication required`; else pass; neither key → 401 same detail. Applied to exactly three endpoints: `POST /api/chat` (`app/api/chat.py`), `GET /api/suggestions` (`app/api/suggestions.py`), `GET /api/documents/content` (`app/api/docs.py` — update its docstring: the phase-16 "deliberately PUBLIC soft rule" is SUPERSEDED — the shared chats page is now the anonymous surface). Everything else: unchanged.
- **Public list (the only anonymous access — the owner's sentence):** `/api/health`, `/api/config`, `/api/whoami`, `/api/login`, `/api/token-auth`, `/api/shared/<token>` (JSON snapshot) + the `/shared/<token>` page + `shared.html`, the static assets, and the page documents themselves (`login.html`, `document.html`, the shell — the documents load; their GATED DATA does not: the shell shows the gate, `document.html` shows its inline gate).
- **Frontend gate (task 05):** new `frontend/assets/token-gate.js` (module) exposing `mountGate(lockRoot, onAuthed)`: at call — (1) if `localStorage["bor.token"]` exists → `POST /api/token-auth` with it (silent; on failure remove the key — it may have been revoked — and fall through); (2) `fetchWhoami()` → `user` or `admin` → `onAuthed()` (the gate never shows); `anonymous` → show the gate AND `lockRoot.inert = true` (the shell passes `#main`; `document.html` passes its content wrapper) + focus the token input. Submit → token-auth → 204 → `localStorage.setItem("bor.token", …)` → whoami → user → hide the gate (`hidden` + `inert` on the gate — the ship-hidden pattern), `lockRoot.inert = false`, `onAuthed()`. 401 → `#auth-gate-error` (`role="alert"`) visible, input cleared + re-focused. All `localStorage` access in try/catch (private mode → the gate still works, caching is a no-op — the fail-silence storage contract). `header.js`: the single whoami now caches the FULL `{authenticated, role}` in one module promise (`fetchWhoami()`); `fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single request, all existing callers keep working; `initSharedHeader()` switches its admin variable to `role === "admin"` (byte-identical behavior for admin/anonymous; a `user` gets: sign-in hidden, sign-out visible, all admin nav links hidden, steering panel removed — the anonymous branch); the sign-out binding gains `localStorage.removeItem("bor.token")` (try/catch, before the reload).
- **Gate markup (shell — `index.html`):** body-level `<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">` AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent): the `#sources-gate` visual language (glyph, h2 `#auth-gate-title` "Enter your access token", sub-text pointing at the admin, a `<form id="auth-gate-form">` with a visually-hidden label + `<input id="auth-gate-input" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>` (mono), a [Sign in] submit, `#auth-gate-error` (`role="alert"`, hidden), and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention). `document.html` carries the same markup as `#doc-auth-gate` (task 05).
- **Tokens admin view (task 06):** a sixth navbar view folded per the phase-76 pattern — `#nav-tokens` (ships hidden; `header.js` reveals it for role admin, same contract as the other four links), `#view-tokens` section in `index.html`, new `frontend/assets/tokens.js` (`export async function mount(root)`, admin-gated via `fetchIsAdmin()` like `history.js`), `router.js` entries in `VIEW` / `VIEW_PATH` / `VIEW_MODULES` / `TITLES` / `DESCRIPTIONS` (the brand-composition `replaceAll` contract carries over), `"/tokens.html"` in BOTH `app/main.py`'s `_shell_routes` tuple and `app/core/caching.py`'s `HTML_PAGES` (the no-cache + `?v=` contract — the phase-76 task-03 integration-test updates apply: the shell-route / title-table / `_page_file` override assertions gain the path). UI: a create row (label input + [Generate]) → the plaintext appears ONCE in a mono read-only field + [Copy] (the clipboard + inline-fallback house pattern — `tokens.js` keeps its own ~10-line copy, the per-page duplication house style); the once-block is NOT re-shown on a re-render/re-show (the plaintext is gone); a full-width table Label | Created | Last used | Status (Active em-dash vs rose Revoked pill — the stale-pill visual language) | Actions (Revoke — the inline two-step confirm, the `history-confirm-*` pattern, focus to Yes); a `role="status"` live region.
- **E2E migration (task 04):** ten chat suites POST to `/api/chat` anonymously today and must sign in first (`auth_helpers.login(page, app_url, next="/")`): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`. `auth_helpers.py` gains `login_with_token(page, app_url, token)` — it drives the REAL gate (fill `#auth-gate-input` → submit → wait for the gate to hide); `tests/e2e/test_admin_auth.py`'s anonymous pins that the app is open (chat streams, viewer public) are updated to the 401/gate contract (its password-flow assertions stay). The shared-chat suites stay ANONYMOUS — that is the point of the item.
- **Integration test updates (task 03):** the tests that hit the three gated endpoints anonymously (`tests/integration/test_api.py`, `test_chat_api.py`, `test_auth_api.py`, …) sign in as admin first or assert the new 401 where the test's purpose IS the auth contract.
## Dependencies
— (none; extends the completed phase-16 auth; phase 80 builds on this phase's `/api/suggestions` gating)
## Tasks
1. `01_token_model_migration.md` — the `api_tokens` model + migration `0012_api_tokens.py`.
2. `02_token_admin_api.md` — the token service + the admin create/list/revoke endpoints.
3. `03_token_auth_enforcement.md` — `POST /api/token-auth`, the three-role whoami, `require_user` (live revoke check), enforcement on chat/suggestions/document-content, the integration-contract updates.
4. `04_migrate_anonymous_e2e.md` — the `login_with_token` helper + the ten anonymous chat suites sign in; the E2E inventory is green against the gated app.
5. `05_frontend_token_gate.md` — the header role plumbing + the shell gate + the localStorage caching + the `document.html` gate.
6. `06_tokens_admin_view.md` — the admin Tokens view (phase-76 fold pattern) with generate / list / revoke.
7. `07_e2e_story_suite.md` — `tests/e2e/test_api_tokens.py` — the owner's sentence, pinned in a browser.
8. `08_regression_sweep_commit.md` — the full pipeline + the README auth section + the atomic commit.
## Testing & Quality
- Unit: `tests/unit/test_tokens.py` (the service — shape, hash, create/find round-trip, revocation, last-used, the malformed/unknown/revoked miss paths) + `tests/unit/test_auth.py` extended (the `require_user` matrix: admin pass, active user pass, revoked user 401 + session keys popped, missing row 401, anonymous 401).
- Integration: the admin API (201 plaintext-once, list shape without hashes, revoke idempotency, 403 anonymous, 403 token-user); token-auth (valid / invalid / revoked / malformed); the enforcement matrix on the three endpoints; whoami's three roles; logout clearing the token session; the existing anonymous-chat pins updated to the 401 contract.
- E2E: the new story suite (task 07) + the migrated suites (task 04) + `test_admin_auth.py` updated + the shared-chat suites green ANONYMOUS.
- Coverage: **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified auth/chat/suggestions/docs files — every new branch tested).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
- [ ] A token user (fresh browser context) can chat end-to-end (mock LLM) and open a cited document; an anonymous caller gets the gate in the UI and 401s on the API; shared chats open anonymously; every admin surface 403s the token user.
- [ ] The cached token survives a reload with no re-entry; sign out clears it; a revoked token is refused on the next request AND on a fresh login attempt.
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean; one atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
@@ -0,0 +1,21 @@
# Task 01 — The `api_tokens` model + migration 0012
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app."
**Story:** n/a (TODO-derived)
## Objective
The storage for admin-issued access tokens: an `api_tokens` table (hashed token, label, lifecycle timestamps) behind alembic migration `0012_api_tokens.py`.
## Work
1. `app/models.py` — `class ApiToken(Base)` after `SavedChat` (the house mapped-column style): `id` UUID PK default uuid4; `label` String(120) NOT NULL (display-only — the hand-out name; no index, not unique); `token_hash` String(64) NOT NULL with `unique=True, index=True` (the sha256 hex digest of the full token — the `documents.content_hash` String(64) precedent); `created_at` DateTime(timezone=True) server-default `func.now()`; `last_used_at` DateTime(timezone=True) NULL; `revoked_at` DateTime(timezone=True) NULL. Docstring: the trust model — the plaintext exists only in the 201 create response; the hash is the stored credential (the `saved_chats.share_token` / `doc_drafts.token` lineage, but HASHED because these are long-lived hand-out credentials, unlike the unguessable uuid4 link tokens).
2. `alembic/versions/0012_api_tokens.py` — `revision = "0012"`, `down_revision = "0011"`; the module docstring mirrors the `0011_doc_drafts.py` format (phase citation, per-column rationale, the hashed-credential decision); upgrade: `op.create_table("api_tokens", …)` mirroring the model, the `token_hash` unique index (match how `0009_saved_chat_share_token.py` created its unique index — check whether it used the column's `unique=True` or an explicit `op.create_index`, and follow the same shape); downgrade: `op.drop_table("api_tokens")`.
3. `tests/unit/test_api_tokens_model.py` (new) — follow the existing model-test precedent in `tests/unit/` (find how other models are unit-tested — schema-level assertions vs a test-DB flush): the table name, the column set + nullability, `token_hash` uniqueness (two tokens with the same hash collide), `label` not unique.
## Testing & Quality
- Unit: item 3.
- Migration: `uv run alembic upgrade head` applies 0012 cleanly on the dev DB (Postgres up via `podman compose up -d db`) and `uv run alembic downgrade -1 && uv run alembic upgrade head` round-trips; the integration-suite schema bootstrap (however the existing integration tests create the schema — verify in `tests/conftest.py` — `create_all` or migrations) picks up the new table for tasks 02/03.
- Coverage: **>90%** on the new model code (import-level; the behavior lands with the service in task 02).
## Completion Criteria
- [ ] `uv run alembic upgrade head` / `downgrade -1` / `upgrade head` round-trips cleanly.
- [ ] Unit tests green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,27 @@
# Task 02 — The token service + the admin create/list/revoke API
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
**Story:** n/a (TODO-derived)
## Objective
The admin surface: generate a named token (the plaintext shown exactly once), list tokens (no secrets), revoke one. All behind `require_admin`.
## Work
1. `app/core/tokens.py` (new) — per the phase design: `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token: str) -> str` (`hashlib.sha256(token.encode("utf-8")).hexdigest()` — the FULL token string); `create_token(db, label: str) -> tuple[ApiToken, str]` (strip the label; the API layer guarantees non-empty — the service trusts it; returns the row + plaintext exactly once); `find_active_by_token(db, token: str) -> ApiToken | None` (hash → `token_hash ==` → `revoked_at IS None` — ANY other shape is a miss: the hash of a malformed string simply matches no row); `mark_used(tok: ApiToken) -> None` (bump `last_used_at` to `datetime.now(timezone.utc)` — the caller commits); `revoke(db, token_id: uuid.UUID) -> bool` (set `revoked_at` when not already; False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256 pre-image resistance means no token-enumeration surface beyond the DB lookup (document the contrast with `check_password`'s constant-time compare — there is nothing to compare in constant time here, only to look up).
2. `app/schemas.py` — `TokenCreateRequest{label: str}` (validator: 1–120 chars after strip — fail loud, the house `ValueError` pattern); `TokenCreated{id, label, token: str, created_at}` (the ONLY schema that carries `token` — the plaintext, once); `TokenListItem{id, label, created_at, last_used_at: datetime | None, revoked: bool}`; `TokenList{tokens: list[TokenListItem]}`; `TokenAuthRequest{token: str}` (non-empty after strip — the 401-vs-422 choice: an empty/whitespace token is a MALFORMED login attempt → 401 `invalid token` from the endpoint, NOT a 422 — so NO min-length validator here; the endpoint checks `token.strip()` and 401s).
3. `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):
- `POST /tokens` → 201 `TokenCreated` — `create_token` + commit, then respond (the plaintext in this response is the one and only moment);
- `GET /tokens` → `TokenList` — newest-first (`created_at` desc, `id` desc tiebreak); `revoked` derived from `revoked_at is not None`; NO `token` or `token_hash` field ever appears;
- `POST /tokens/{id}/revoke` → 204 — idempotent (already-revoked → still 204, no re-stamp); unknown id → 404 `token not found`.
4. `app/main.py` — register: `app.include_router(tokens_router, prefix="/api")` with the other API routers (before the static mount, the existing comment's "API routes first" contract).
5. `tests/unit/test_tokens.py` (new) — `generate_token` shape (`^bor_[0-9a-f]{32}$`, two calls differ); `hash_token` determinism + 64-hex length + full-string semantics (hashing `bor_X` ≠ hashing `X`); `create_token`/`find_active_by_token` round-trip (active hit); `find_active_by_token` returns None for: revoked token, unknown well-formed token, empty string, short string, wrong prefix (these are all "hash matches no row" — the generic-miss contract); `revoke` sets the stamp once (second call idempotent, returns False only for a missing id); `mark_used` stamps `last_used_at`.
6. `tests/integration/test_tokens_api.py` (new, the house TestClient + admin-login pattern from `tests/integration/test_auth_api.py`) — anonymous: 403 on all three endpoints; admin (signed in via `POST /api/login`): create → 201, body's `token` matches `^bor_[0-9a-f]{32}$`, and `GET /tokens` NEVER exposes it (no `token` key in items, no `token_hash`, the hash string itself absent from the serialized body); two tokens with the same label → both created (labels are not unique); create with blank label → 422; revoke → 204, the list item shows `revoked: true` + the row keeps its `last_used_at`; re-revoke → 204; revoke unknown id → 404; list is newest-first.
## Testing & Quality
- Unit: item 5. Integration: item 6.
- Coverage: **>90%** on `app/core/tokens.py` + `app/api/tokens.py` (every branch — the miss paths, the idempotency, the 404).
## Completion Criteria
- [ ] The admin can create / list / revoke tokens through the API; the plaintext appears exactly once (in the 201 body) and never in the list.
- [ ] Anonymous is 403 on all three (router-level dependency — a token user, once task 03 lands, will be 403 too; pin that in task 03's matrix).
- [ ] Unit + integration green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,27 @@
# Task 03 — Token login, the `user` role, and the auth gate on the app API
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…so people can log in to use the app. The only thing that should be accessible without an API token is shared chats."
**Story:** n/a (TODO-derived)
## Objective
A token becomes a session: `POST /api/token-auth` signs a token holder in (role `user`), `whoami` reports the three roles, and `require_user` (admin OR live token) guards the app surface — chat, suggestions, document content. Shared chats + login infra stay public; every admin surface stays admin-only.
## Work
1. `app/core/auth.py` — `USER_SESSION_KEY = "user"`, `USER_TOKEN_ID_KEY = "user_token_id"`; `def require_user(request: Request, db: Session = Depends(get_db)) -> None` (import `get_db` from `app.db`, the house dependency pattern): admin key set → return (an admin always passes, token state irrelevant); `user` key set → `select(ApiToken).where(ApiToken.id == uuid.UUID(session[USER_TOKEN_ID_KEY]))` — row missing OR `revoked_at is not None` → `request.session.pop(USER_SESSION_KEY, None)` + `request.session.pop(USER_TOKEN_ID_KEY, None)` (the dead session is dropped NOW — the next `whoami` is anonymous) + raise `HTTPException(401, detail="authentication required")`; else return; neither key → 401 same detail. `sign_in` (admin) is UNCHANGED — coexistence is deliberate: an admin key does not erase the user keys; `whoami` reports admin whenever the admin key is set; `sign_out`'s `session.clear()` already wipes both roles.
2. `app/api/auth.py` — `POST /token-auth` (PUBLIC — it is the login route): `TokenAuthRequest` body → `find_active_by_token(get_db_session, payload.token)` (obtain the DB session via the house `get_db` dependency) → miss (or `payload.token` empty/whitespace) → ONE generic 401 `{"detail": "invalid token"}` (malformed / unknown / revoked are indistinguishable — the phase-16 no-enumeration pattern) → hit: `mark_used` + commit + `request.session[USER_SESSION_KEY] = True` + `request.session[USER_TOKEN_ID_KEY] = str(row.id)` → 204 (the signed cookie is emitted by the SessionMiddleware on the session write — same mechanism as `/api/login`). `whoami`: `role` = `"admin"` if the admin key is set, else `"user"` if the user key is set, else `"anonymous"`; `authenticated = role != "anonymous"`. `WhoamiResponse.role` is a plain `str` with a `# "admin" | "anonymous"` comment (`app/schemas.py` ~line 92) — update the comment to `# "admin" | "user" | "anonymous"` (no type change needed). `logout` unchanged.
3. Enforcement — add the `_user: None = Depends(require_user)` parameter (the `_admin` naming precedent in `app/api/docs.py`) to exactly three endpoints:
- `app/api/chat.py` — `POST /chat`;
- `app/api/suggestions.py` — `GET /suggestions` (the endpoint gains the `db` dependency — needed by the dependency's signature; `get_db` is already the house pattern);
- `app/api/docs.py` — `GET /documents/content` — update the docstring: the phase-16 "Deliberately PUBLIC (soft rule)" note is SUPERSEDED by this phase — the shared chats page is the anonymous surface; the viewer content is token-or-admin.
4. `tests/unit/test_auth.py` — extend with the `require_user` matrix (the house unit pattern for dependencies — check how `require_admin` is unit-tested today and match it): admin session passes; active-user session passes; revoked-user session → 401 AND both user keys popped from the session dict; user session pointing at a missing row → 401 + popped; anonymous → 401 `authentication required`; admin + user coexistence → passes as admin.
5. `tests/integration/test_auth_api.py` — update the phase-16 pins to the new contract + add the token flows: `POST /token-auth` valid → 204 + `GET /api/whoami` → `{authenticated: true, role: "user"}`; invalid / revoked / malformed (short, wrong prefix, empty) → 401 `invalid token` (ALL the same body); whoami anonymous → `{authenticated: false, role: "anonymous"}`; whoami admin unchanged; logout after token-auth → whoami anonymous; REVOCATION MID-SESSION: token-auth → chat 200 → admin revokes via `POST /api/tokens/{id}/revoke` → next chat request 401 AND whoami is now anonymous (the live check cleared the keys). The old "chat still streams anonymously" pin becomes: anonymous `POST /api/chat` → 401 `authentication required`; admin `POST /api/chat` still streams (the existing streaming assertions survive under a signed-in client).
6. The other integration tests that hit the three gated endpoints anonymously — find them precisely: `rg -n '"/api/chat"|"/api/suggestions"|"/api/documents/content"' tests/integration` — sign in as admin first (the house TestClient login helper from `test_auth_api.py`) or assert the new 401 where the test's purpose IS the auth contract. `tests/integration/test_api.py` (the `client.post("/api/chat", json={"message": ""})` 4xx-shape check at ~line 377 — verify which status it pins: an empty message was a 422 validation; with `require_user` the ANONYMOUS client now 401s BEFORE validation — update to sign in first so the validation assertion keeps testing validation).
## Testing & Quality
- Unit: item 4. Integration: items 5–6.
- Coverage: **>90%** on the modified `app/` files (`app/core/auth.py`, `app/api/auth.py`, and the three enforcement sites — every new branch exercised).
## Completion Criteria
- [ ] The matrix holds: anonymous — chat 401, suggestions 401, document content 401, `/api/shared/<token>` 200, `/api/whoami` anonymous, `/api/config` 200, `/api/health` 200, `/api/login` + `/api/token-auth` reachable. Token user — chat 200 (stream), suggestions 200, document content 200, `/api/tokens` 403, `/api/docs` 403, `/api/chats` 403, `/api/steering` 403, `/api/git-sources` 403. Admin — everything as before.
- [ ] Revocation is enforced on the next request (chat 401 + whoami drops to anonymous).
- [ ] Full unit + integration suite green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,22 @@
# Task 04 — The E2E suites meet the new auth contract
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The only thing that should be accessible without an API token is shared chats."
**Story:** n/a (TODO-derived)
## Objective
No E2E suite may drive the gated app anonymously: the ten chat suites that POST to `/api/chat` without signing in sign in as admin first, `auth_helpers.py` gains the real token-gate helper (task 07 uses it), `test_admin_auth.py`'s anonymous pins are updated, and the full E2E inventory is green against the gated app.
## Work
1. `tests/e2e/auth_helpers.py` — `login_with_token(page, app_url, token, next="/")`: `page.goto(f"{app_url}/")` → `expect(#auth-gate).to_be_visible()` (anonymous with no cached token — the test contexts are fresh, so no stored key) → `page.fill("#auth-gate-input", token)` → click the gate's submit → `expect(#auth-gate).to_be_hidden()` + the app is interactive (the composer reachable). Wrong-token contract (the `login` wrong-password mirror): a helper parameter or a second small function `login_with_token(page, app_url, token="bor_" + "0" * 32)` → `#auth-gate-error` (`role="alert"`) visible, the gate stays visible, `whoami` still anonymous (assert via the UI state — the gate is the proof).
2. Sign the TEN suites in — each chat-driving test gets `login(page, app_url, next="/")` at the top (the suites already import or can import `e2e.auth_helpers.login`; touch only the anonymous flows, leave any admin-context flows as they are): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`.
3. `tests/e2e/test_admin_auth.py` — update the phase-16 anonymous pins that assert the app is open (anonymous chat streams; the document viewer opens anonymously) to the new contract (401 via the API / the gate visible in the UI); the password sign-in / sign-out / wrong-password assertions stay green UNCHANGED.
4. Audit sweep + full inventory: scan every remaining e2e file for anonymous use of a now-gated endpoint (`rg -n 'goto\(f?"\{app_url\}/?"|/api/chat|/api/suggestions|/api/documents/content' tests/e2e/*.py`) — each hit either signs in or is a deliberate anonymous-surface test: the shared-chat suites (`test_share_chat.py` et al.) MUST stay anonymous (that is the point of the item — assert they still pass), the smoke suite's page-loads are fine (the PAGES load; the gate shows — update smoke's expectations only if it asserts on gated content). Run the FULL E2E inventory (DB up, mock LLM) and fix only auth-contract breakage — no semantic changes to story behavior.
## Testing & Quality
- E2E: the full inventory green per AGENTS.md rule 9 — at minimum each of the ten migrated files, `test_admin_auth.py`, `test_share_chat.py` (anonymous), `test_smoke.py`, and `test_nav_switch_keeps_stream.py` in isolation.
- Coverage: n/a (tests only) — the `app/` floor preserved.
## Completion Criteria
- [ ] No suite drives `/api/chat`, `/api/suggestions`, or `/api/documents/content` anonymously.
- [ ] The shared-chat suites pass as ANONYMOUS — the one open surface, the owner's sentence, pinned.
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,34 @@
# Task 05 — The in-app token gate + the browser caching
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
**Story:** n/a (TODO-derived)
## Objective
An anonymous visitor meets a token-entry gate on the shell (and the document viewer); a correct token unlocks the app and is cached in `localStorage` so the next visit re-auths silently; sign out clears it. The header learns the three roles without a second whoami request.
## Work
1. `frontend/assets/header.js` — the single whoami now caches the FULL response: a module-level `whoamiPromise` storing `{ authenticated, role }` (network failure / non-2xx → `{ authenticated: false, role: "anonymous" }` — the anonymous-safe contract, unchanged in spirit); `export function fetchWhoami()` returns that promise (the new canonical call); `export function fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single `/api/whoami` request (the string `/api/whoami` appears in this file exactly once), all existing callers keep working with zero changes. `initSharedHeader()` switches its local `admin` variable to `role === "admin"`: admin/anonymous behavior is byte-identical; a `user` gets the anonymous branch (sign-in hidden, sign-out visible, `#nav-sources` / `#nav-git-sources` / `#nav-tuning` / `#nav-history` hidden, the steering panel REMOVED — `/api/steering` is 403 for a user, so it must never be fetched). The sign-out binding gains `try { localStorage.removeItem("bor.token"); } catch {}` BEFORE the `window.location.reload()` (the fail-silence storage contract).
2. `frontend/index.html` — a body-level gate AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent; the gate is the only interactive surface while visible):
```html
<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">…</section>
```
Content (the `#sources-gate` visual language — glyph, heading, sub, action): h2 `#auth-gate-title` "Enter your access token"; a sub line ("Ask the admin for a token — it opens chat, the answers, and the documents they cite. Shared chats stay open."); a `<form id="auth-gate-form">` with `<label class="visually-hidden" for="auth-gate-input">Access token</label>`, `<input id="auth-gate-input" name="token" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>`, a submit button (the house button styling) labeled "Sign in"; `<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden>`; and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention — the static href is the no-JS fallback).
3. `frontend/assets/token-gate.js` (new module) — `export function mountGate(lockRoot, onAuthed)` (reusable — the shell passes `#main`, `document.html` passes its content wrapper):
- at call: (1) if `localStorage["bor.token"]` exists (try/catch) → `POST /api/token-auth` with it — SILENT; on any failure `localStorage.removeItem("bor.token")` (it may have been revoked) and fall through to the whoami check; (2) `fetchWhoami()` → role `user` or `admin` → `onAuthed()` (the gate NEVER shows — no flash for a cached valid token); role `anonymous` → show the gate (drop `hidden` AND `inert` on `#auth-gate`), `lockRoot.inert = true` (the locked app must not receive focus or keyboard traversal — WCAG, the inert-pair contract), focus `#auth-gate-input`;
- form submit (preventDefault): `POST /api/token-auth` → 204 → `localStorage.setItem("bor.token", token)` (try/catch) → `fetchWhoami()` re-fetch (the promise cache must be invalidated for THIS re-fetch — either re-fetch directly or clear the module cache; document the choice) → role `user` → hide the gate (re-add `hidden` + `inert`), `lockRoot.inert = false`, `onAuthed()`; → 401 → `#auth-gate-error` visible with "That token isn't valid — check it with the admin.", input cleared + re-focused.
- the gate ships `hidden` + `inert` (the phase-16 ship-hidden pattern — an authenticated boot never shows it for a frame).
4. `frontend/index.html` — load `token-gate.js` (module, AFTER `app.js` and `router.js` — the boot-order comment updates) with its boot call: `mountGate(document.getElementById("main"), () => {})` — in the shell, `onAuthed` needs no view work: the lazy views mount on first show exactly as today (mount-once, hide-forever untouched), and the already-mounted views keep their state.
5. `frontend/document.html` + `frontend/assets/document.js` — the content endpoint is now `require_user`-gated, so a direct anonymous URL shows the inline gate instead of a content error: add the same gate markup to `document.html` as `<section class="auth-gate" id="doc-auth-gate" hidden inert …>` (reusing the shell's copy, the id renamed), load `token-gate.js`, and wire `mountGate(document.getElementById("main"), onAuthed)` — document.html's content root is `<main id="main" class="app-main">` (the same id as the shell's — separate documents, so no collision) where `onAuthed` runs the EXISTING boot sequence (whoami → load content). An admin (or a validly cached token user) on `document.html` gets `onAuthed` immediately — the gate never shows. The admin-only edit affordance (`docAdminReady()` → `role === "admin"`) stays admin-only.
6. `frontend/assets/styles.css` — `.auth-gate` (fixed overlay, `z-index` above the app content but below the doc-modal — check the existing z-index ladder; solid `--bg` + the grid is inherited from `html`, so the gate reads as the app's own surface; centered inner card on the `--surface` with the `sources-gate` spacing), the input (mono font, `--surface` background, visible focus ring — WCAG, 4.5:1 text), the error line (the rose/danger family used by `#history-status`-style alerts), the admin link (`.sources-gate-link` reuse), the button (the house submit-button language). Mobile: the overlay scrolls when the viewport is short.
7. `tests/unit/test_token_gate.py` (new, source-level house pattern — read the JS sources, no browser): `token-gate.js` contains the `bor.token` localStorage key literal; the silent re-auth attempt happens BEFORE the whoami check (source ordering); a failed silent re-auth removes the key (the `removeItem` call sits in the failure path); `header.js` — `fetchWhoami` is exported, `fetchIsAdmin` delegates to it, and the string `fetch("/api/whoami")` appears in `header.js` exactly once (the single-request contract — the file's comments also mention whoami, so pin the fetch call, not the word); the sign-out binding removes `bor.token`; `index.html` loads `token-gate.js` after `router.js`.
## Testing & Quality
- Unit: item 7.
- E2E: the story suite (task 07) covers the gate flows end to end; a quick manual pass now (real server): anonymous → gate; wrong token → error; right token → unlock + reload with no gate; sign out → gate back.
- Coverage: n/a (frontend) — the `app/` floor preserved.
## Completion Criteria
- [ ] Anonymous first visit: the gate is the only interactive surface (Tab never reaches the `#message-input` composer — the lock is `#main.inert`); a valid token unlocks WITHOUT a reload.
- [ ] A cached valid token re-auths silently on reload — the gate never shows.
- [ ] A REVOKED cached token is dropped (localStorage empty) + the gate reappears.
- [ ] `login.html` and `shared.html` are UNTOUCHED and their suites stay green; the header's admin/anonymous behavior is byte-identical (the phase-16 + phase-19 suites green).
@@ -0,0 +1,32 @@
# Task 06 — The admin Tokens view (generate · list · revoke)
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
**Story:** n/a (TODO-derived)
## Objective
The admin UI for tokens: a sixth navbar view (admin-only, folded per the phase-76 pattern) where the admin generates a named token (the plaintext shown once, copyable), lists all tokens with their lifecycle, and revokes with an inline two-step confirm.
## Work
1. `frontend/index.html` — (a) `#app-nav`: after the History link, `<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>` (ships hidden — the phase-16 anonymous-safe pattern; the mobile dropdown copy is NOT needed — the link lives in the same `#app-nav` element the hamburger opens, exactly like the other four); (b) after `#view-history`: `<section class="view" id="view-tokens" hidden inert aria-label="Tokens" tabindex="-1">` containing `.container > .page-head` (h1 "Access tokens", sub: "Generate a token and hand it out — it opens chat, the answers, and the documents they cite. Shared chats stay open."), a `#tokens-gate` (the `#history-gate` pattern — the `sources-gate` visual language, sign-in link to `/login.html?next=/tokens.html`), a `<span class="tokens-status" id="tokens-status" role="status" aria-live="polite">` live region, a create row: `<input id="token-label" maxlength="120" placeholder="e.g. alice" aria-label="Token label">` + `<button type="button" class="token-generate" id="token-generate">Generate</button>`, a `#token-once` block (`hidden`): the "shown once" copy line, a mono read-only field `<input id="token-once-value" readonly>` + `<button type="button" id="token-once-copy" aria-label="Copy token">Copy</button>`, and the full-width table (AGENTS.md rule 5 — no skinny list): `#tokens-table` with thead Label | Created | Last used | Status | Actions (the Actions header visually-hidden, the row buttons carry aria-labels — the history-table convention), `#tokens-tbody` + a hidden `#tokens-empty-row`.
2. `frontend/assets/tokens.js` (new — `export async function mount(root)`, the `history.js` structure as the template, ALL cells via textContent — labels are admin-derived, still textContent, the XSS-safe-by-construction house rule):
- admin gate: `if (!(await fetchIsAdmin()))` → show `#tokens-gate`, hide the table, NO fetch (the router 403s anonymous — the same request-log contract as the history view);
- `loadTokens()` → `GET /api/tokens` → rows: label; created (locale date+time, full ISO in `title`); last used (locale or "never"); Status — an "Active" em-dash vs a rose "Revoked" pill (the stale-pill visual language, `aria-label` on the cell in BOTH states — WCAG); Actions — Revoke (the inline two-step, the `history-confirm-*` pattern: first click swaps to "Revoke? [Yes] [No]", focus to Yes, Yes → `POST /api/tokens/<id>/revoke` → row re-renders Revoked + announce; No / failure restores) — Revoked rows show NO action (nothing left to revoke);
- generate: label from `#token-label` (blank → send `"token"` — the placeholder documents the fallback; the API's 1–120 validator is satisfied) → `POST /api/tokens` → 201 → `#token-once` visible with the plaintext in `#token-once-value` + [Copy] (clipboard + the inline-fallback house pattern — `tokens.js` keeps its OWN ~10-line copy, the per-page duplication house style) + announce "Token created — copy it now; it won't be shown again." → `loadTokens()` (the new row appears Active) → the once-block HIDES on the next `loadTokens()` / re-show (the plaintext is NOT stored anywhere client-side — no localStorage, no data attribute);
- the re-fetch contract from phase 77: `root.addEventListener("bor:view-refresh", () => { if (loaded) loadTokens(); })` — a re-show re-lists (and re-hides the once-block, if one was up);
- every action lands a line in `#tokens-status` (success or failure — the never-stale feedback contract).
3. `frontend/assets/router.js` — the phase-76 fold entries: `VIEW["/tokens.html"] = "tokens"`; `VIEW_PATH.tokens = "/tokens.html"`; `VIEW_MODULES.tokens = () => import("./tokens.js")`; `TITLES.tokens = "Access tokens · Brain of Reese"`; `DESCRIPTIONS.tokens = "Generate and revoke the API tokens that let people use the app."` (the `replaceAll` brand-composition contract applies — no hardcoded-name write).
4. `frontend/assets/header.js` — reveal `#nav-tokens` for role admin in `initSharedHeader()` (the SAME ship-hidden / reveal-for-admin contract as the other four links — one more line, same pattern).
5. `app/main.py` — `"/tokens.html"` into the `_shell_routes` tuple (the list is caller-driven — the phase-76 comment documents exactly this extension); `app/core/caching.py` — `"/tokens.html"` into `HTML_PAGES` (the no-cache + `?v=` contract for the deep link). Then the phase-76 task-03 test updates: run `uv run pytest tests/integration` and extend whatever asserts the shell-route / title-table / `_page_file`-override map (the phase-76 task 03 work items named these — follow the same shape for the sixth path).
6. `frontend/assets/styles.css` — the create row (flex, wraps ≤640px), the once-block (mono field, the copy button — the `share-link-fallback` visual language), the table (the `history-table` visual language — full-width, the AGENTS.md rule-5 shape), the Active/Revoked pills (the `stale-pill` rose for Revoked, a plain em-dash for Active), `focus-visible` + 4.5:1 throughout.
7. `tests/unit/test_frontend_router.py` — the view-map pins adapt to the sixth entry (mechanism-level pins should hold as-is — verify; if a pin enumerates the views, extend the enumeration).
## Testing & Quality
- Unit: item 7 + the source-pin convention for view modules (if the house pins the other four modules' `export async function mount`, `tokens.js` gets the same pin).
- Integration: the `/tokens.html` shell-route + caching assertions (item 5).
- E2E: covered by the task-07 story suite (admin UI: generate → once-field + copy, list, revoke two-step).
- Coverage: **>90%** on the modified `app/` code (`main.py` tuple + `caching.py` list — one line each, exercised by the integration assertions).
## Completion Criteria
- [ ] The admin sees the Tokens nav link (desktop + mobile menu); anonymous and token users never do (ship-hidden + role check — even mid-DOM, the link is `hidden`).
- [ ] Direct load of `/tokens.html` deep-links to the view (admin: the table; anonymous: the gate) and carries the no-cache + `?v=` contract (the cache-busting suites green with the new page).
- [ ] Generate → plaintext once + copy works (clipboard + the http fallback); revoke → two-step → Revoked; the cache-busting + nav-switch suites stay green.
@@ -0,0 +1,28 @@
# Task 07 — The story E2E suite: `tests/e2e/test_api_tokens.py`
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
**Story:** n/a (TODO-derived)
## Objective
The owner's sentence, pinned in a real browser: the admin generates a token and hands it out (a fresh context); the holder uses the app; the ONLY anonymous content is shared chats; the cached token removes the re-entry; revocation closes the door.
## Work
1. `tests/e2e/test_api_tokens.py` (NEW — run in isolation: `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov`; DB up, the mock-LLM fixture; fresh browser contexts per scenario — no cached token leaks between tests):
- **anonymous is locked out:** fresh context → chat page: `#auth-gate` visible; the composer (`#message-input`) is NOT keyboard-reachable (the `#main` inert lock — the Tab-order assertion pattern from `test_suggestion_chips.py`'s keyboard walk, inverted); direct API with the context's (empty) cookies: `POST /api/chat` → 401, `GET /api/suggestions` → 401, `GET /api/documents/content?source=…&path=…` → 401.
- **shared stays open:** as admin, create + share a saved chat (`POST /api/chats` + `POST /api/chats/<id>/share` — the house API pattern from `test_share_chat.py`) → a FRESH context opens `/shared/<token>` anonymously → the conversation renders (no gate anywhere on that page).
- **admin generates (UI):** signed-in admin (`auth_helpers.login`) → nav to Tokens → label "e2e-alice" → Generate → `#token-once-value` carries `^bor_[0-9a-f]{32}$` (read it into the test) → the table shows an Active row "e2e-alice"; the once-block hides on a re-show (nav away + back → `#token-once` hidden — the plaintext is gone).
- **the token flow (fresh context):** `login_with_token(page, app_url, token)` (the task-04 helper) → gate hidden → ask a question (mock LLM) → the brain bubble renders → a cited source chip opens the document (the same-page modal) → the admin nav links (RAG, Sources, Tuning, History, Tokens) are ALL absent from `#app-nav` (the role-`user` contract) and Sign out is visible.
- **caching:** `page.reload()` → NO gate (`#auth-gate` hidden) — the silent re-auth from localStorage; the chat UI is interactive without re-entry.
- **admin-only walls (the token user's cookies, httpx):** `GET /api/tokens` 403, `GET /api/chats` 403, `GET /api/docs` 403, `POST /api/steering` 403, `GET /api/git-sources` 403.
- **sign out:** the token user clicks Sign out → back to the gate; `localStorage.getItem("bor.token")` is `null` (Playwright `page.evaluate`).
- **revocation:** admin revokes the token (UI two-step: Revoke → Yes) → the token user's NEXT action 401s (ask a question → the error banner, or assert `POST /api/chat` 401 with the context cookies) and a FRESH `login_with_token` attempt with the same token fails (`#auth-gate-error` visible, gate stays).
- **wrong token:** fresh context, `login_with_token(…, token="bor_" + "0" * 32)` → `#auth-gate-error` visible, still anonymous (the API also 401s — the generic message, no enumeration: the error body for a wrong-format token equals the one for a well-formed unknown token).
2. Run in isolation until green; fix app bugs the suite exposes (the suite is the spec — the owner's sentence).
## Testing & Quality
- This file IS the story gate (AGENTS.md rules 4 + 9 — one file per story, run in isolation).
- Coverage: n/a (E2E) — the `app/` floor preserved.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
- [ ] Every clause of TODO.md L5 is asserted: generate (UI), hand out (fresh context), use the app (chat + document), only-shared-chats-open (the anonymous matrix), cached token (reload without re-entry), revocation (immediate refusal).
@@ -0,0 +1,23 @@
# Task 08 — Regression sweep + the commit
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5`
**Story:** n/a (TODO-derived)
## Objective
The full pipeline green against the gated app, the operator docs updated, one atomic commit, the phase closed.
## Work
1. Full unit + integration: `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified `app/core/auth.py` / `app/api/auth.py` / `app/api/chat.py` / `app/api/suggestions.py` / `app/api/docs.py` — every new branch tested per tasks 02/03).
2. `uv run ruff check . && uv run pyright`.
3. E2E inventory spot-checks in isolation (the high-touch files): `test_api_tokens.py`, `test_admin_auth.py`, `test_share_chat.py` (ANONYMOUS), `test_chat_rag.py` (migrated), `test_nav_switch_keeps_stream.py` (the phase-76 contract under the gate), `test_smoke.py`.
4. Manual verification in a real browser (real LLM, real server): the admin generates a token; a private window enters it at the gate, chats, opens a cited document, reloads WITHOUT re-entry; an anonymous window meets the gate and opens a shared chat; the admin revokes the token and the private window's next question fails.
5. Operator docs: `README.md` — the "Admin & sign-in" section gains a short "API tokens" subsection (how to generate one in the Tokens view, what a token user can and cannot do, the gate + the browser cache, revocation semantics — immediate on the next request). `.env.example` — UNCHANGED (no new environment variable: tokens live in the DB, generated by the admin — verify no settings were added; if any task added one, the doc goes here).
6. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(auth): admin-issued API tokens gate the app; only shared chats stay anonymous` — body cites TODO.md L5 + the confirmed scope decision (A3–A5: token users get chat/suggestions/document viewer; every admin surface untouched). Move the phase dir to `.agents/phases/complete/`.
## Testing & Quality
- The full suite IS the test; coverage **>90%** on `app/`.
## Completion Criteria
- [ ] Items 1–4 all green/verified.
- [ ] README documents the token flow for the operator.
- [ ] Committed; phase dir in `.agents/phases/complete/`.
@@ -0,0 +1,40 @@
# Phase 80 — Onboarding chips: the last 3 questions asked (the env var seeds a fresh deployment)
**Source:** `TODO.md` L6 — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
**Story:** n/a (TODO-derived — extends `.agents/user_stories/suggestion-chips.md` (phase 05))
**Context:** `app/api/suggestions.py` (`GET /api/suggestions` → `settings.suggestions` — a static list, 15 lines today), `app/config.py` (`suggestions: list[str]` — the 4 built-in defaults + the `BOR_SUGGESTIONS` env override; `.env.example:55`; `README.md:782` env-table row), `app/models.py` (`SavedChat.messages` — the JSONB `bor.chat.v1` record: a list of `{who, text, sources?, …}` entries, conversational order oldest→newest; `updated_at` stamped on save), `app/rag/suggestions.py` (the deflection "Maybe try" chips — `derive_suggestions`, a SEPARATE contract, untouched by this phase), `frontend/assets/app.js` (`loadSuggestions()` at boot → `renderChips` into `#suggestions` (role="list") inside `#empty-state` (~line 159 of `index.html`); `startNewChat()` — the empty state comes back with STALE chips), `tests/e2e/test_suggestion_chips.py` (the phase-05 suite — pins the settings-defaults contract; REWRITTEN in task 04 per the phase-76 precedent), `tests/integration/test_api.py` (current `/api/suggestions` integration pins).
## Objective
The onboarding chips reflect the deployment's recent activity: the 3 most recent questions actually asked (across saved chats), newest first. A brand-new deployment — zero saved questions — gets the seed list instead (`BOR_SUGGESTIONS`, or the built-in default while unset). The chips also refresh when the empty state comes back (New chat), so the row is never stale. The LLM "Maybe try" deflection chips are untouched.
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
- **A6 confirmed:** "the last 3 questions asked" = the 3 most recent USER questions across ALL `saved_chats` — chats walked newest-`updated_at` first, each chat's `messages` walked newest-first, collecting entries with `who == "user"` and a non-blank `text` — de-duplicated (exact, case-sensitive match; a verbatim re-ask counts once), cap 3 applied AFTER dedup. 1–2 saved questions → exactly those show (NO mixing with the seed). ZERO saved questions → the full seed list.
- **A7 confirmed:** only the onboarding row (`#suggestions` in the empty state) changes. The deflection "Maybe try" chips (title-derived via `app.rag/suggestions.derive_suggestions`, carried in the chat response) keep their contract.
- **Scope is deployment-wide:** saved chats have no per-user attribution and are created only by the admin (phase 79 keeps the save surface admin-only) — the chips reflect the admin's recent questions; there is no per-user question history to scope by (and no new attribution is added in this phase).
## Design (shared by all tasks)
- **Extraction — `app/api/suggestions.py`:** the endpoint gains the `db` dependency and a module-level `last_questions(db, limit: int = 3) -> list[str]`: `SELECT … FROM saved_chats ORDER BY updated_at DESC, created_at DESC` (the tiebreak keeps the order deterministic when timestamps collide); for each chat, walk `chat.messages` (the JSONB column deserializes to a Python list of dicts — NO SQL JSON ops needed; the message counts are the `bor.chat.v1` conversation sizes) in REVERSE (newest first), collecting `m["text"].strip()` for entries where `m.get("who") == "user"`, skipping blanks, stopping once `limit` UNIQUE texts are collected (exact case-sensitive dedup — the docstring notes the choice: case-insensitive dedup would drop a legitimate differently-cased re-ask). Result order = encounter order (newest first).
- **The endpoint:** `qs = last_questions(db)` → `SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — the seed (`BOR_SUGGESTIONS` override or the built-in default) appears ONLY when the walk yields zero questions. The endpoint is `require_user`-gated by phase 79 (admin OR token user) — no auth change here; the tests sign in first.
- **The deflection path is untouched:** `app/rag/suggestions.py` (`derive_suggestions` — used by the chat turn for the "Maybe try" row) does not call this endpoint and is not modified; its tests stay green.
- **Frontend — the refetch (task 03):** `startNewChat()` (the `bor:new-chat` handler in `app.js`) gains a `loadSuggestions()` call when it restores the empty state — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, replacing the previous chips in place; progressive enhancement, swallows its own failures). The boot fetch stays. No other frontend change: the chips' one-tap submit, the role="list" semantics, and the "Maybe try" row are all existing contracts.
- **Docs (task 02):** `BOR_SUGGESTIONS` is documented as the SEED — shown only before any question has ever been saved — in the `config.py` docstring, `.env.example:55`, and the README (the env-table row + the chat feature description).
## Dependencies
- `79_api_tokens` (todo) — `/api/suggestions` is `require_user`-gated there; this phase's tests sign in first and build on that contract (a token user ALSO sees the deployment-wide chips — consistent with A6's deployment-wide scope).
## Tasks
1. `01_last_questions_endpoint.md` — the `last_questions` extraction + the seed fallback + the integration state matrix.
2. `02_seed_semantics_docs.md` — the config / `.env.example` / README doc updates.
3. `03_chips_new_chat_refetch.md` — the app.js refetch on New chat + the source pin.
4. `04_e2e_suite_commit.md` — the story-suite rewrite to the new semantics + the full gate + the atomic commit.
## Testing & Quality
- Integration: the full state matrix for `/api/suggestions` (empty DB → the built-in seed; `BOR_SUGGESTIONS` override → the override while empty; 4 questions in one chat → the 3 newest; two chats → the `updated_at` order respected; dedup; exactly 2 questions → 2 chips, no top-up; brain-only messages never picked).
- E2E: the REWRITTEN `tests/e2e/test_suggestion_chips.py` run in isolation (the phase-76 precedent — a semantic change rewrites the story suite in place).
- Coverage: **>90%** on `app/` (the delta is the ~30-line helper + endpoint in `app/api/suggestions.py` — every branch covered).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation.
- [ ] The deflection chips are UNCHANGED (`derive_suggestions` + its suites green).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first`); phase dir moved to `.agents/phases/complete/`.
@@ -0,0 +1,37 @@
# Task 01 — `/api/suggestions` returns the last 3 questions asked (the seed before the first)
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6` — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
**Story:** n/a (TODO-derived)
## Objective
The onboarding chips become the 3 most recent user questions across saved chats (newest first, de-duplicated, cap 3); a fresh deployment with zero saved questions gets the seed list (`BOR_SUGGESTIONS` / the built-in default).
## Work
1. `app/api/suggestions.py` — the endpoint gains `db: Session = Depends(get_db)` (import from `app.db` — the house pattern) and a module-level helper:
```python
def last_questions(db: Session, limit: int = 3) -> list[str]:
```
- query `SavedChat` ordered by `updated_at.desc(), created_at.desc()` (the tiebreak — deterministic when timestamps collide; `from app.models import SavedChat`);
- for each chat, walk `chat.messages` in REVERSE (conversational order is oldest→newest — the `bor.chat.v1` shape) collecting `str(m.get("text", "")).strip()` for entries where `m.get("who") == "user"`; skip blanks;
- de-dup EXACT (case-sensitive) against the collected window; stop once `limit` unique texts are collected; return in encounter order (newest first).
- docstring: the JSONB column deserializes to a Python list of dicts (no SQL JSON ops — the `SavedChat.messages` model docstring says the record shape is the `bor.chat.v1` list); the exact-dedup choice is documented (case-insensitive would drop a legitimately differently-cased re-ask); the helper is pure-DB (unit-testable without the endpoint).
- endpoint body: `qs = last_questions(db)` → `return SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — update the module/endpoint docstring: this endpoint owns the "last-3-questions-or-seed" contract; the deflection "Maybe try" chips (`app.rag.suggestions.derive_suggestions`) are a separate contract, untouched.
- note: phase 79 gates this endpoint with `require_user` — if phase 79 has NOT landed yet when this task runs (it is queued before this phase, so it has), the endpoint already carries the dependency; do not remove it.
2. `tests/integration/test_suggestions_api.py` (new — the house integration pattern, admin sign-in first — the endpoint is authed):
- **empty DB** (no saved chats) → exactly the built-in default list (assert equality with `get_settings().suggestions` — and `tests/unit/test_config.py` keeps pinning that default);
- **seed override:** the `BOR_SUGGESTIONS` JSON parsing is ALREADY pinned at unit level by `tests/unit/test_config.py::test_suggestions_env_override_is_json_list` (`monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(…))`) — do NOT hack per-test env into the integration app; the integration contract is the env-agnostic one below it (empty DB → exactly `get_settings().suggestions`, whatever the environment makes that);
- **cap + order:** one saved chat with 4 user questions q1..q4 (plus brain replies between them) → chips == `[q4, q3, q2]` (newest first, cap 3);
- **chat order:** two saved chats with DISTINCT `updated_at` (set the timestamps explicitly on the rows) → the newer chat's questions are walked first — a question from the newer chat outranks a newer-LOOKING question from the older chat;
- **dedup:** the same question text asked in two chats → appears exactly once;
- **partial:** exactly 2 saved questions deployment-wide → exactly 2 chips (NO seed top-up — the A6 contract);
- **brain-only:** a chat whose messages are all `who == "brain"` (or blank user texts) → contributes nothing; an all-brain deployment → the seed.
- anonymous → 401 (phase-79 contract — one assertion so the auth state of this endpoint is pinned HERE too).
## Testing & Quality
- Integration: item 2 (every branch of `last_questions` + the fallback + the auth pin).
- Coverage: **>90%** on the modified `app/api/suggestions.py` (the helper's every branch: empty, partial, cap, dedup, blank-skip, brain-skip).
## Completion Criteria
- [ ] `GET /api/suggestions` (authed) returns exactly the designed contract in every state of item 2.
- [ ] `app/rag/suggestions.py` (`derive_suggestions`) is UNTOUCHED and its tests green; the deflection "Maybe try" E2E behavior unchanged.
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,20 @@
# Task 02 — Document the seed semantics (config, `.env.example`, README)
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
**Story:** n/a (TODO-derived)
## Objective
The docs describe `BOR_SUGGESTIONS` as what it now is: the pre-first-question SEED — not "the" onboarding chips.
## Work
1. `app/config.py` — the comment block above `suggestions: list[str]` becomes: "Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no saved chat has ever asked a question — after that, `GET /api/suggestions` serves the last 3 questions asked (deployment-wide, newest first). `BOR_SUGGESTIONS` overrides this seed for a new deployment."
2. `.env.example` (line ~55) — the comment becomes: `# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80)`
3. `README.md` — the env-table row (`BOR_SUGGESTIONS`, ~line 782): "JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80)".
4. `README.md` — wherever the chat / suggestion-chip feature is described (the feature list row ~line 263 and the deflection note ~line 829 stay accurate — the deflection chips are UNCHANGED; add/adjust ONE line in the chat feature description: the onboarding chips follow the last 3 questions asked, seeding from `BOR_SUGGESTIONS` on a fresh deployment).
## Testing & Quality
- Docs only — `tests/unit/test_config.py` and the stale-copy suites must stay green (no behavior change; the `suggestions` default list itself is UNTOUCHED).
## Completion Criteria
- [ ] The three doc sites (config, `.env.example`, README) agree with the implemented contract.
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,21 @@
# Task 03 — The chips refresh when the empty state comes back (New chat)
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
**Story:** n/a (TODO-derived)
## Objective
The onboarding row is fetched once at boot; after "New chat" (or clearing a restored conversation) the empty state returns with STALE chips — the last-3 state moved on while the user was chatting. Refetch on `bor:new-chat` so the row always reflects the current last-3 state.
## Work
1. `frontend/assets/app.js` — in `startNewChat()` (the `bor:new-chat` handler, ~line 1848): after `if (emptyState) emptyState.hidden = false;`, add `loadSuggestions();` — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, which clears the previous chips in place; progressive enhancement — swallows its own failures, no error spam). The in-flight-turn guard at the top of `startNewChat` means the refetch only runs for a real new chat.
- The boot path is UNCHANGED: `loadSuggestions()` still runs at shell boot (first paint of the empty state). The `/?chat=<id>` boot opens a saved chat (empty state hidden) — when the user then clicks New chat, this refetch covers it. No other frontend change: the chips' one-tap submit, `role="list"` semantics, and the "Maybe try" deflection row are existing contracts.
2. Source pin (house pattern — read the JS source, no browser): in the app.js source-pin file that covers the new-chat flow (find where `bor:new-chat` / `startNewChat` is pinned today — `tests/unit/test_chat_persistence.py` or a `test_frontend_*.py` sibling; if none exists, add the pin to the most app.js-adjacent frontend source-pin file): `startNewChat` calls `loadSuggestions()` (the two literals, `startNewChat`'s function body containing the `loadSuggestions()` call — a containment assertion on the function's source slice, the house style).
## Testing & Quality
- Unit: item 2 (the source pin).
- E2E: covered by the task-04 suite (the new-chat refetch assertion).
- Coverage: n/a (frontend) — the `app/` floor preserved.
## Completion Criteria
- [ ] After New chat, `#suggestions` reflects a FRESH `/api/suggestions` response (the Playwright request log shows a second GET after the boot fetch).
- [ ] The source pin is green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
@@ -0,0 +1,27 @@
# Task 04 — Rewrite the story suite to the new semantics + sweep + the commit
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
**Story:** n/a (TODO-derived)
## Objective
`tests/e2e/test_suggestion_chips.py` pins the NEW contract (the phase-76 precedent — a semantic change rewrites the story suite in place), the full pipeline is green, and the phase closes.
## Work
1. `tests/e2e/test_suggestion_chips.py` — REWRITE (keep the file name — one suite per story, run in isolation; the endpoint is authed, so sign in as admin first via `auth_helpers.login`):
- **seed state:** fresh DB (no saved chats) → the chip texts equal the built-in default list — assert against a test-local constant copied from the `Settings.suggestions` default in `app/config.py` (the unit suite `test_config.py` pins only the SHAPE — ≥3 non-blank distinct strings — so this E2E literal is the pin for the exact seed list; keep it in sync with the config default); the chips render in `#suggestions` (role="list", the chip buttons) exactly as today.
- **last-3 state:** as admin, save two chats via `POST /api/chats` with known distinct questions (5 user questions total; set `updated_at` apart so the order is deterministic — the API stamps it; save the older one first) → a fresh page load → the chips are EXACTLY the 3 newest questions, in newest-first order.
- **partial state:** a DB with exactly 2 saved questions → exactly 2 chips (no seed top-up — the A6 contract, visible in the UI).
- **new-chat refetch:** load the page with the seed chips visible → via the API save a chat whose newest question is Q → click New chat (`#new-chat-btn`) → the chips now include Q (the refetch happened — the request log shows the second `GET /api/suggestions`).
- **carry over the story behavior** (unchanged semantics, same assertions as the old suite where they still hold): one-tap submit (chip click → composer filled → submitted — the mock-LLM brain bubble), Tab+Enter keyboard reachability of the chips (the original suite's keyboard-walk assertion).
- Run in isolation until green: `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov`.
2. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`; the deflection-related suites green UNCHANGED (their chips are the LLM/title path — find them: `test_honest_deflection.py` and any "Maybe try" assertions — they must not have been touched by this phase).
3. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first` — body cites TODO.md L6 + the A6/A7 decisions (deployment-wide, exact dedup, no mixing, deflection untouched). Move the phase dir to `.agents/phases/complete/`.
## Testing & Quality
- The full suite IS the test; coverage **>90%** on `app/`.
- E2E: the rewritten story suite in isolation (AGENTS.md rule 9).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation — all four states (seed / last-3 / partial / refetch) + the carried-over story behavior.
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean.
- [ ] Committed; phase dir in `.agents/phases/complete/`.
@@ -0,0 +1,14 @@
All gates green. Final report:
**Task 01 complete — shell + router + shell-route factory; Tuning folded as the first non-chat view.**
- `app/main.py`: list-driven `_shell_routes()` factory (GET+HEAD, routes-first, before the static catch-all) serving the shell for `("/tuning.html",)`; phase-33 middleware applies no-cache + `?v=` untouched (`app/core/caching.py` unchanged).
- `frontend/index.html`: single `#main` now holds `#view-chat` + `#view-tuning` (hidden+inert, chat's steering panel kept, per-view copies dropped, no duplicate ids — verified well-formed); `router.js` loads after `app.js`.
- `frontend/assets/router.js` (new): VIEW map (incl. `/index.html`→chat), boot deep-link without focus steal, pushState-only switches, mount-once lazy `import("./tuning.js")`, hidden+inert pair, single-writer `is-active`/`aria-current`/title/meta, focus only on user-initiated switches.
- `tuning.js` → `export async function mount(root)` (root-scoped lookups, `initSharedHeader()` dropped, `fetchIsAdmin()` gate kept); `tuning.html` deleted; Containerfile builds `router.js` (bundles the lazy view module) and drops the stale `tuning.html`/`tuning.js` lines.
- Tests: new `tests/unit/test_frontend_router.py` (11 source pins), shell-route + title-table update in `test_api.py`, `SHELL_BACKED_PAGES` in `test_caching_revalidation.py`, post-shell page lists in `test_chat_persistence`/`test_history_page`/`test_shared_header`/brand/hamburger/steering-toggle/stale-copy/save-chat-ui, containerfile pin 4 excludes the router; e2e `test_steering` script count 3→4.
- Results: `uv run pytest --cov=app --cov-report=term-missing` → **1560 passed, 99%**; `ruff` + `pyright` → clean; gated E2E (global_tuning, tuning_nav_link, tuning_toggle_flash, cache_busting, asset_cache_revalidation) → **23/23**; `test_chat_rag` 3/3 + `test_hidden_tab_stream` 4/4 green in isolation; manual browser: sentinel survives switches, no refetch on re-show, title/meta/focus correct.
- Decisions: router bundled (keeps the phase-23 bundle⇄HTML pin exact); note — in the *image* build, first tuning mount re-evaluates header.js's top-level bindings (esbuild inlines it per bundle); dev is unaffected (module dedupe) — flagged for task 04's header-shell wiring.
- Known non-gated (task-04 owned, per phase design): `test_nav_consistency.py` (2) + `test_shared_header.py::test_new_chat_button_is_chat_page_only` fail on the `#new-chat-btn` view-scoped absence pattern only; all other tests in those suites pass.
Next pending task: `.agents/phases/todo/76_spa_nav_shell/02_fold_rag_sources_views.md`
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1 @@
The router's lazy `switchTo` writes the title *after* brand.js's one-shot DOM pass, with a hardcoded literal. Let me check how task 01 handled this for `/tuning.html`:
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,11 @@
Everything is green. Final report:
**Task 02 (fold RAG + Sources views into the SPA shell) — complete.**
Implemented: `sources.html`/`git-sources.html` views folded into `index.html` shell (`#view-rag`/`#view-git-sources`), both standalone pages deleted, shell routes added in `app/main.py`, `sources.js`/`git-sources.js` converted to `mount(root)` view modules (tuning.js house pattern), `Containerfile` asset set updated, phase-20 midstream e2e rewritten to new semantics (6 tests).
Fixed this round: (1) fold breakage — router stamped hardcoded titles past brand.js's one-time pass; now composes title/meta from `window.BOR_BRAND` at write time (`test_configurable_brand` red → green); (2) fold breakage — new `#view-chat` wrapper broke the full-height flex chain; one CSS rule restores it (composer-pinning/autoscroll suites red → green); (3) pre-existing defect (fails on HEAD, verified via worktree) — `HistoryTurn.text` 4000-char cap stricter than the 24k config budget, 422-ing any second turn in a chat with a long answer; raised to the 32k boundary cap; (4) pre-existing — nav test expected 4 links vs the phase-53 5th admin-only History link; (5) e2e locators scoped to `#view-rag` where the shell carries both views' `.table-wrap`/`.page-sub`.
Gates: `uv run pytest --cov=app --cov-report=term-missing` → 1562 passed, app coverage 99% (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov` → 6/6 in isolation; all 19 bulk-failed e2e suites re-verified green in isolated runs.
Next pending task: `.agents/phases/todo/76_spa_nav_shell/03_fold_history_view.md`.
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,24 @@
Everything is green and verified. Final state confirmed — all remaining `history.html` references are the legitimate path constants (caching `HTML_PAGES`, the shell route, the router map) and docstring history.
## Task 03 — Fold History: complete
**Implemented**
- `app/main.py`: `/history.html` added to the shell-route factory — all four non-chat navbar views now serve the shell (routes-first, caching middleware untouched).
- `frontend/index.html`: new `#view-history` section (`hidden` + `inert` + `tabindex="-1"`), content moved from the old page with its `<main>` wrapper, header-owned steering-panel/announcer copies, and per-page footer dropped (shell keeps its single `#app-version` footer). ID audit: every moved id occurs exactly once in the shell — no collisions, no renames.
- `frontend/assets/router.js`: `VIEW`/`VIEW_PATH`/`VIEW_MODULES` + `TITLES`/`DESCRIPTIONS` extended with the carried-over values (`"Saved chats · Brain of Reese"`, `"Saved chats — every conversation is saved automatically, one click back."`).
- `frontend/assets/history.js`: rewritten to `export async function mount(root)` — DOM lookups scoped to `root`, `initSharedHeader()` dropped (gate uses the cached `fetchIsAdmin()`); row actions (`/?chat=<id>` links, copy-link) remain real navigations, untouched by the router.
- Deleted `frontend/history.html`; `Containerfile` dropped the history.js esbuild line (router lazy-imports it) and the history.html `cp` — containerfile parity pins hold.
- Tests extended per the tasks 01–02 patterns: integration (shell-route assertion + `_page_file` override), unit (`test_history_page.py` shell form + brace-balanced `_fn`, `test_history_copy.py` redirected to shell+router, page lists in brand/hamburger/steering-toggle/stale-copy, shared-header view-module pin, router map pin, save-chat-ui negatives), E2E `test_history_copy.py` re-scoped page-wide `.page-sub`/`h1` to `#view-history`.
**Test / lint / coverage**
- `uv run pytest` — 1563 passed; `uv run pytest --cov=app` — **99%** (`app/main.py` 100%), floor >90% met.
- `uv run ruff check .` clean; `uv run pyright` — 0 errors.
- `uv run pytest tests/e2e/test_chat_history.py tests/e2e/test_history_copy.py tests/e2e/test_stale_saved_chats.py -v --no-cov` — **9 passed** (incl. the `/?chat=<id>` deep-link open flow — deep link intact).
- 25 further E2E suites verified (cache-busting, nav-consistency, shared-header, midstream/stop/hidden-tab stream, tuning, save/share, sources, git-sources, etc.) — all green in isolation.
- Manual browser check: direct load of `/history.html` renders the History view (admin, old title/meta, gate lifted, nav active); mid-view navbar switch is same-document (window sentinel survives).
**Notable**
- Pre-existing e2e infra limitation found (reproduced on the pre-task-03 baseline, NOT caused by this fold): module-app suites share the session app's port, so batching them in one pytest process after a session-app suite runs the module suite against the wrong app. Project protocol runs each suite in isolation, where everything is green — left as-is (out of task scope).
- `.agents/PLAN.md` is absent from the repo (referenced by AGENTS.md but never committed); the phase overview `00_phase.md` served as the binding design.
**Next pending task:** `.agents/phases/todo/76_spa_nav_shell/04_header_shell_wiring.md`
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,19 @@
Task 04 is complete and fully green.
## Report — Task 04: header shell wiring
**Implemented** (test-only task — the shell header was already canonical and `header.js` already a non-writer, per the task's "no code change" items):
- `tests/unit/test_frontend_router.py`: two new single-writer pins — `is-active` occurs **exactly once** in the shell `index.html` (on the Chat link, `href="/"`) and **no** `is-active` write exists in `header.js` (router is the runtime writer, already pinned)
- `tests/e2e/test_nav_consistency.py`: new test 7 `test_viewer_nav_click_full_loads_the_shell_rag_view` — from `/document.html?…` (a surviving document with its own header copy) clicking the RAG nav link is a **real departure**: full-loads the shell at `/sources.html` (window sentinel set in the viewer doc is wiped — inverse of the phase-76 same-document pattern), RAG view renders (first table row visible), Chat view hidden+inert, `#nav-sources` carries the router's active stamp
- Item 4 page-set update: already done in tasks 01–03 for the suites that iterated files (unit suites + shared_header/nav_consistency/nav_rename_sources); `test_header_consistency.py`, `test_sticky_navbar.py`, `test_mobile_hamburger_nav.py` visit URLs only (still valid shell routes) — behavioral assertions kept identical
- Item 2 (sign-out in shell = real departure): no code change; verified via `test_admin_auth.py` (sign-out on shell `/` and viewer)
**Test / lint / coverage results:**
- `uv run pytest tests/e2e/test_header_consistency.py test_shared_header.py test_nav_consistency.py test_sticky_navbar.py test_mobile_hamburger_nav.py test_nav_rename_sources.py test_admin_auth.py -v --no-cov` → **36 passed** (isolated, DB up)
- `uv run pytest --cov=app --cov-report=term-missing` → **1565 passed**, `app/` TOTAL **99%** (>90% floor; no `app/` delta this task)
- `uv run ruff check . && uv run pyright` → **clean** (All checks passed / 0 errors)
- Regression sweep (phases 20/48/73/14/54/50/27/40: `test_sources_midstream_bug`, `test_stop_generation`, `test_hidden_tab_stream`, `test_chat_persistence`, `test_cache_busting`, `test_asset_cache_revalidation`, `test_chat_history`, `test_global_tuning`, `test_tuning_nav_link`, `test_tuning_toggle_flash`) → **45 passed**
**Decisions/deviations:** none — no defects found in prior work; unit+integration baseline was green before my changes.
**Next pending task:** `.agents/phases/todo/76_spa_nav_shell/05_stream_survival_e2e.md` (new story suite `test_nav_switch_keeps_stream.py`).
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
..................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,19 @@
All gates are green. The task is complete.
**Task 05 — Stream survival E2E (phase 76) — report**
Implemented:
- NEW `tests/e2e/test_nav_switch_keeps_stream.py` (4 tests, house boilerplate copied from `test_hidden_tab_stream.py`):
- `test_rag_switch_mid_stream_completes` — the owner repro: send → `#nav-sources` mid-stream → Chat; window-sentinel same-document proof, RAG view visible, FULL answer (all 40 steps + `LONG-ANSWER-END`), exactly one full brain turn in `bor.chat.v1`, one settled `query_log` row, auto-saved row carries the same single full turn (cleaned up in `finally`)
- `test_every_nav_view_keeps_stream` — same mid-stream switch for git-sources / tuning / history: one send/switch/return each, full answer, cumulative settled-row count (one per turn), latest storage pair = question + full brain turn
- `test_real_departure_still_cancels` — phase-48 control: real `page.goto("/shared.html")` (asserts `#shared-title`), NO `query_log` row, partial persisted in the EXACT phase-20 shape (mirrored, not re-invented)
- `test_baseline_no_switch_still_completes` — ordinary path unchanged
Results:
- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → 4 passed (55s)
- `uv run pytest tests/e2e/test_stop_generation.py tests/e2e/test_chat_persistence.py tests/e2e/test_hidden_tab_stream.py tests/e2e/test_sources_midstream_bug.py -v --no-cov` → 17 passed, unchanged (80s)
- `uv run pytest --cov=app --cov-report=term-missing` → 1565 passed, TOTAL 99% (>90% floor)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
Decisions: error-banner pin uses the shell-aware `[role="alert"]:visible` count-0 form (hidden views carry inert alert surfaces); DB reset truncates `steering_notes`/`kb_overview` so exact-text holds. No `app/`/frontend changes in this task.
Next pending task: `06_regression_sweep_commit.md`.
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
........................................................................ [ 96%]
..................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 60 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2997 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+23 -7
View File
@@ -96,18 +96,34 @@ prints the `MISS` lines naming the condition. Interpret:
- **caps > 0** — the phase-72 incident signature; a real regression,
say so explicitly.
### 5. Record + commit
### 5. Record to CSV + commit
Append the model's results to the model-comparison subsection of
`TOOL_CALLING_TESTING.md` §3, keeping the `gate:` lines **byte-exact
verbatim**, plus 2–4 sentences of interpretation against the reference
rates above and the wall-time baseline (lite ~43–55 s, turbo ~105–135 s
per full loop).
**a. CSV record** — append to `benchmarks/model_benchmarks.csv` via
`scripts/model_benchmark.bench_write`. For each of the 3 runs, call:
```python
from scripts.model_benchmark import bench_write
bench_write(
script="chat", model="<model>", mode="fixture",
gate_status="PASS", turns=10, answered=10, caps=0,
tool_turns=<N>, emitted=<E>, executed=<X>,
contract=<C>, wall_s=<wall>,
contract_denom=<C_denom>, executed_denom=<E_denom>,
)
```
For the derived run, use `mode="derived"`.
**b. Append to TOOL_CALLING_TESTING.md** — keep the `gate:` lines
**byte-exact verbatim**, plus 2–4 sentences of interpretation against
the reference rates above and the wall-time baseline (lite ~40–55 s,
turbo ~97–135 s per full loop).
Commit — docs only, house style:
```bash
git add TOOL_CALLING_TESTING.md
git add TOOL_CALLING_TESTING.md benchmarks/model_benchmarks.csv
git commit --no-gpg-sign \
-m "docs(agent): record the <model> comparison on the controlled fixture battery" \
-m "<one-paragraph body: the numbers, the re-read rate, the wall time, any MISS nuance>"
+98
View File
@@ -0,0 +1,98 @@
---
name: test-embed-model
description: Tests an embedding model (BOR_LLM_EMBED_MODEL in .env) across three dimensions — dimension consistency, cosine accuracy on semantic pairs, and embedding speed — then records results in benchmarks/model_benchmarks.csv. Use when the user asks to test or benchmark an embedding model, check embedding quality, or compare embedding models (e.g. "test the embed model", "check embedding dimension", "benchmark embed speed").
---
# Test an Embedding Model (dimension + cosine + speed)
Tests the configured `BOR_LLM_EMBED_MODEL` (default `embed`) against
three independent checks:
1. **Dimension check** — output vector length matches `BOR_EMBEDDING_DIM`
2. **Cosine accuracy** — semantically similar text pairs have higher
cosine similarity than dissimilar pairs (5 pairs tested)
3. **Speed** — vectors produced per second (50 vectors)
## Rules (non-negotiable)
- **Do not touch** the semantic pair texts — they are the controlled
benchmark corpus. Changing them is a methodology change: flag it.
- **Do not edit app code** (`app/`, `tests/`). This skill tests a model,
not the app. If the model exposes an app defect, report it — don't fix it.
- `.env` is gitignored and is **not committed** — the model switch stays
a live dev setting, and the summary must say which model `.env` is left
on (default: the tested model).
## Procedure
All commands run from the repo root with `uv run`.
### 1. Preconditions
```bash
podman compose up -d db
grep -E "BOR_LLM_(BASE_URL|API_KEY|EMBED_MODEL|EMBEDDING_DIM)" .env
```
### 2. Switch the model (optional)
Edit only `BOR_LLM_EMBED_MODEL` in `.env` (leave chat and summary alone):
```
BOR_LLM_EMBED_MODEL=<model>
```
### 3. Run the benchmark
```bash
# Single run (all three checks)
uv run python -m scripts.test_embed_model
# Specify a model explicitly
uv run python -m scripts.test_embed_model --model embed-v2
# Multiple runs for variance
uv run python -m scripts.test_embed_model --runs 3
```
Wall time: ~0.5–2 s per run (fast — the embedding endpoint is lightweight).
### 4. Read the verdicts
Each check prints `✓` (PASS) or `✗` (FAIL):
- **dimension**: `actual_dim == expected_dim` (from `BOR_EMBEDDING_DIM`, default 768)
- **cosine**: similar-pair margin ≥ 0.05 (similar > dissimilar by at least 0.05 cosine)
- **speed**: no NaN/Inf vectors in output; rate reported as vec/s
Gate: PASS if all three checks pass.
### 5. Record + commit
Results are automatically appended to `benchmarks/model_benchmarks.csv`
(one row per check per run). Commit:
```bash
git add benchmarks/model_benchmarks.csv
git commit --no-gpg-sign \
-m "docs(agent): record the <model> embedding benchmark" \
-m "<one-paragraph body: dimension match, cosine margin, speed, any failures>"
```
### 6. Summary
Table of dimension / cosine margin / speed per run, the one-line
conclusion, and a note that `.env` is now on `<model>`.
## Troubleshooting
- **dimension mismatch** — the model's output vectors are a different
size than expected. This is a configuration error: check the model's
docs for its embedding dimension and update `BOR_EMBEDDING_DIM`.
- **cosine margin < 0.05** — the embeddings don't separate similar from
dissimilar texts well. The model may be a poor embedding model or
the semantic pairs are too generic. Report the margin.
- **NaN/Inf vectors** — the model's embedding endpoint is broken or
the input text is malformed. Check the endpoint directly.
- **very slow** — >100 vec/s is typical for a local endpoint; remote
endpoints may be slower due to network latency. Report the rate.
@@ -0,0 +1,99 @@
---
name: test-summary-model
description: Tests a summary model (BOR_LLM_SUMMARY_MODEL in .env) against a fixed set of 8 source texts from the fixture KB — evaluates coherence, coverage, brevity, and hallucination detection — then records results in benchmarks/model_benchmarks.csv. Use when the user asks to test or benchmark a summary model, compare summary models, or evaluate summary quality (e.g. "test lite summary", "compare summary models", "how good is turbo at summarizing").
---
# Test a Summary Model (quality benchmark)
Tests the configured `BOR_LLM_SUMMARY_MODEL` (default `lite`) against
8 source texts extracted from the fixture KB. Each text is a realistic
documentation excerpt (~300–500 chars). The model is asked to summarize
each in 2–4 sentences.
## Rules (non-negotiable)
- **Do not touch** the fixture source texts — they are the controlled
benchmark corpus. Changing them is a methodology change: flag it.
- **Do not edit app code** (`app/`, `tests/`). This skill tests a model,
not the app. If the model exposes an app defect, report it — don't fix it.
- `.env` is gitignored and is **not committed** — the model switch stays
a live dev setting, and the summary must say which model `.env` is left
on (default: the tested model).
## Procedure
All commands run from the repo root with `uv run`.
### 1. Preconditions
```bash
podman compose up -d db
grep -E "BOR_LLM_(BASE_URL|API_KEY|SUMMARY_MODEL)" .env
```
### 2. Switch the model (optional)
Edit only `BOR_LLM_SUMMARY_MODEL` in `.env` (leave chat and embed alone):
```
BOR_LLM_SUMMARY_MODEL=<model>
```
### 3. Run the benchmark
```bash
# Single run
uv run python -m scripts.test_summary_model
# Specify a model explicitly
uv run python -m scripts.test_summary_model --model turbo
# Multiple runs for variance
uv run python -m scripts.test_summary_model --runs 3
```
Wall time: ~3–5 s per text, ~25–40 s total for 8 texts.
### 4. Read the verdicts
The `gate:` line: `PASS|FAIL turns=N answered=N quality=Q hallucinations=H/N coherence=C/5 coverage=V/5 brevity=B/5 (wall Ts)`.
Scoring rubric:
- **coherence** (0–5): non-empty, multi-sentence, starts with capital
- **coverage** (0–5): captures ≥2 key numbers/facts from source
- **brevity** (0–5): summary length / source length ratio 0.10–0.25 = 5, 0.05–0.35 = 4, etc.
- **hallucination**: detected when summary contains >3 uncommon tokens not in source
Quality score = coherence×0.35 + coverage×0.40 + brevity×0.25, scaled 0–100,
with a 5-point penalty per hallucination.
Gate: PASS if quality ≥ 70 and hallucination rate < 25%.
### 5. Record + commit
Results are automatically appended to `benchmarks/model_benchmarks.csv`.
Also append a summary line to `benchmarks/README.md` if it exists, or
create it:
```bash
git add benchmarks/model_benchmarks.csv
git commit --no-gpg-sign \
-m "docs(agent): record the <model> summary benchmark" \
-m "<one-paragraph body: quality score, hallucination count, wall time, comparison to other models>"
```
### 6. Summary
Table of quality / hallucinations / coherence / coverage / brevity / wall
per run, the one-line conclusion, and a note that `.env` is now on
`<model>`.
## Troubleshooting
- **endpoint down / all turns error** — check
`curl -s $BOR_LLM_BASE_URL/models` with the key; the gate will exit 1
with `answered<8`. Report, don't retry-loop.
- **low brevity** — the model is restating rather than condensing.
This is model-specific; try a more explicit prompt or a different model.
- **hallucinations** — the model is adding details not in the source.
This is the most common failure mode; tighten the prompt or switch models.
+2 -5
View File
@@ -15,19 +15,16 @@ RUN npm install --no-audit --no-fund -g esbuild@0.25.5
COPY frontend ./
RUN mkdir -p /out/assets \
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
&& esbuild ./assets/router.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/router.js \
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
&& esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp -r ./assets/themes /out/assets/themes \
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
# ---------- Stage 2: python dependencies ----------
FROM docker.io/python:3.12-slim AS python
+9 -3
View File
@@ -203,9 +203,11 @@ there is to **not** re-read what is already in the prompt.
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/11 executed (73%) contract 11/11 (100%) (wall 43.4s)
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (62%) contract 12/13 (92%) (wall 50.6s)
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) (wall 46.8s)
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=9 calls 9/12 executed (75%) contract 11/12 (92%) 2026-09-06 (wall 40.4s)
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 9/14 executed (64%) contract 13/14 (93%) 2026-09-06 (wall 40.5s)
```
Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93). The executed
Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93 / 92 / 93). The executed
ratio sits at 58–73 % for the reason documented in §5 — an app
semantics choice, not a model defect, and the open design question in
§7.
@@ -217,6 +219,9 @@ chat model switched to `turbo`):**
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-05 (wall 105.1s)
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-05 (wall 135.5s)
gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-05 (wall 77.1s) [derived battery — MISS: 5/10 tool-turn floor]
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-06 (wall 113.7s)
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-06 (wall 112.8s)
gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-06 (wall 97.2s) [derived battery — MISS: 5/10 tool-turn floor]
```
Reads: the re-read habit is model-specific. `lite` re-reads a seeded
@@ -233,8 +238,8 @@ derived battery `turbo` fails only the *usage floor* condition (≥ 6/10
turns with ≥ 1 emitted call: 5/10) — it answers the seeded read-target
questions from context instead of making the (refusable) read call the
trap design expects; accuracy on every call it does make is still
100 %. The cost: **2–3× slower wall time** (105–135 s per full loop
vs 43–55 s, with individual slow turns up to ~34 s).
100 %. The cost: **~2.5× slower wall time** (97–114 s per full loop
vs 40–41 s for `lite`, with individual turns 7–20 s).
---
@@ -340,6 +345,7 @@ executed/emitted ≥ 0.90. Against the fixture KB (2026-09-04):
```
gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) (wall 47.7s)
gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/14 executed (36%) contract 10/14 (71%) 2026-09-06 (wall 38.3s)
```
Reading that result: the teaching works — **every bare-path trap
+48
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse
from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
@@ -51,6 +52,39 @@ settings = get_settings()
logger = logging.getLogger("app")
def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> None:
"""Phase 76: the navbar views are views of ONE shell document.
Every registered path serves ``frontend/index.html`` (the shell)
instead of its own page file: the client-side router
(``frontend/assets/router.js``) reads ``location.pathname`` at boot
and shows the matching view, so a direct load of e.g.
``/tuning.html`` deep-links to the Tuning view. Registered AFTER the
API routers and BEFORE the static catch-all mount (routes-first),
so the phase-33 caching middleware — which wraps the whole app and
already lists every one of these paths in ``HTML_PAGES`` — applies
the no-cache + ``?v=<token>`` contract to the response untouched.
The list is driven by the caller: tasks 02/03 fold the remaining
views in by extending the tuple (task 03 lands History — all four
non-chat navbar views are in; the old per-view ``.html`` files are
deleted in the same change as their shell route lands — one source
of truth).
"""
shell_file = static_dir / "index.html"
async def _shell_view() -> FileResponse:
return FileResponse(shell_file, media_type="text/html")
for path in paths:
# GET (document loads, the browser path) + HEAD — the pre-fold
# static file answered both, so the shell route keeps that
# method parity (the body is the same FileResponse; HEAD ships
# headers only).
app.api_route(
path, methods=["GET", "HEAD"], include_in_schema=False
)(_shell_view)
def create_app() -> FastAPI:
# Fail loud BEFORE serving anything (phase 16): missing
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
@@ -98,6 +132,20 @@ def create_app() -> FastAPI:
static_dir = Path(settings.static_dir).resolve()
if static_dir.is_dir():
# Phase 76: the folded navbar views serve the shell — the
# router picks the view from the pathname. Task 01 landed
# Tuning; task 02 folds RAG + Sources; task 03 lands History
# (list-driven — all four non-chat navbar views are in).
_shell_routes(
app,
static_dir,
(
"/tuning.html",
"/sources.html",
"/git-sources.html",
"/history.html",
),
)
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
else:
logger.warning("static dir %s not found — serving API only", static_dir)
+11 -7
View File
@@ -40,16 +40,20 @@ class HistoryTurn(BaseModel):
``thinking`` travels to the model as ``reasoning_content`` on the
assistant message (the wire convention :mod:`app.rag.llm` already
documents for the response side) — only when non-empty (A4).
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; the
thinking cap is looser (scratchpads run longer than answers). These
are boundary sanity caps only — the real trimming budget is the
settings pair ``history_max_turns`` / ``history_max_chars``
(``app.config``, A3: a capped-out turn is dropped whole, never
truncated).
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
answers (and the scratchpads that ride along as ``thinking``) both
run to tens of kilobytes of text, so both share the same loose
boundary cap. These are boundary sanity caps only — the real
trimming budget is the settings pair ``history_max_turns`` /
``history_max_chars`` (``app.config``, A3: a capped-out turn is
dropped whole, never truncated). The old 4000-char text cap was
stricter than the 24_000-char default total budget and rejected
any second turn in a chat whose history held a long answer (422 —
found by the phase-42 E2E suite on the phase-76 shell).
"""
who: Literal["user", "brain"]
text: str = Field(min_length=1, max_length=4000)
text: str = Field(min_length=1, max_length=32_000)
thinking: str | None = Field(default=None, max_length=32000)
+34
View File
@@ -0,0 +1,34 @@
# Model Benchmark Results
CSV recordings from all model tests (chat, summary, embedding).
## Files
- `model_benchmarks.csv` — all benchmark results, one row per run per check
## Schema
| Column | Description |
|---|---|
| `date` | Test date (YYYY-MM-DD) |
| `script` | `chat` | `summary` | `embed` |
| `model` | Model name (e.g. `lite`, `turbo`, `embed`) |
| `mode` | `fixture` / `derived` / `quality` / `dimension` / `cosine` / `speed` |
| `gate_status` | `PASS` / `FAIL` |
| `turns` | Number of questions/turns |
| `answered` | Turns that produced an answer |
| `caps` | Turns that hit the round cap |
| `tool_turns` | Turns emitting ≥1 tool call (chat only) |
| `emitted` / `executed` | Tool-call counts (chat only) |
| `contract` | Numerator for accuracy (chat: well-formed calls; summary: quality 0–100; embed: 1=pass) |
| `wall_s` | Total wall seconds |
| `contract_denom` / `executed_denom` / `tool_turns_denom` | Denominators for percentages |
| `extra1_col` / `extra1_val` | Script-specific metric 1 |
| `extra2_col` / `extra2_val` | Script-specific metric 2 |
## Scripts
- `scripts/agent_realmodel_check.py` — chat model tool-calling battery
- `scripts/test_summary_model.py` — summary model quality benchmark
- `scripts/test_embed_model.py` — embedding model dimension/cosine/speed benchmark
- `scripts/model_benchmark.py` — shared CSV recorder (imported by all scripts)
+13
View File
@@ -0,0 +1,13 @@
date,script,model,mode,gate_status,turns,answered,caps,tool_turns,emitted,executed,contract,wall_s,contract_denom,executed_denom,tool_turns_denom,extra1_col,extra1_val,extra2_col,extra2_val
2026-09-06,summary,lite,quality,FAIL,8,8,0,0,0,0,58,26.9,58,0,8,coherence,4.0/5,hallucinations,4/8
2026-09-06,summary,lite,quality,FAIL,8,8,0,0,0,0,53,25.4,53,0,8,coherence,4.0/5,hallucinations,5/8
2026-09-06,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.1,1,0,1,actual_dim,768,expected_dim,768
2026-09-06,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.2,1,0,1,margin,0.283,quality,56
2026-09-06,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.5,1,0,1,rate,93.8 vec/s,bad_vectors,0
2026-09-06,summary,turbo,quality,FAIL,8,8,0,0,0,0,63,25.3,63,0,8,coherence,4.0/5,hallucinations,3/8
2026-09-06,chat,lite,fixture,PASS,10,10,0,9,9,9,11,40.4,12,12,10,,,,
2026-09-06,chat,lite,fixture,PASS,10,10,0,10,9,9,13,40.5,14,14,10,,,,
2026-09-06,chat,lite,derived,FAIL,10,10,0,10,5,5,10,38.3,14,14,10,,,,
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,9,9,9,113.7,9,9,10,,,,
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,7,7,7,112.8,7,7,10,,,,
2026-09-06,chat,turbo,derived,FAIL,10,10,0,5,5,5,5,97.2,5,5,10,,,,
1 date script model mode gate_status turns answered caps tool_turns emitted executed contract wall_s contract_denom executed_denom tool_turns_denom extra1_col extra1_val extra2_col extra2_val
2 2026-09-06 summary lite quality FAIL 8 8 0 0 0 0 58 26.9 58 0 8 coherence 4.0/5 hallucinations 4/8
3 2026-09-06 summary lite quality FAIL 8 8 0 0 0 0 53 25.4 53 0 8 coherence 4.0/5 hallucinations 5/8
4 2026-09-06 embed embed dimension PASS 1 1 0 0 0 0 1 0.1 1 0 1 actual_dim 768 expected_dim 768
5 2026-09-06 embed embed cosine PASS 1 1 0 0 0 0 1 0.2 1 0 1 margin 0.283 quality 56
6 2026-09-06 embed embed speed PASS 1 1 0 0 0 0 1 0.5 1 0 1 rate 93.8 vec/s bad_vectors 0
7 2026-09-06 summary turbo quality FAIL 8 8 0 0 0 0 63 25.3 63 0 8 coherence 4.0/5 hallucinations 3/8
8 2026-09-06 chat lite fixture PASS 10 10 0 9 9 9 11 40.4 12 12 10
9 2026-09-06 chat lite fixture PASS 10 10 0 10 9 9 13 40.5 14 14 10
10 2026-09-06 chat lite derived FAIL 10 10 0 10 5 5 10 38.3 14 14 10
11 2026-09-06 chat turbo fixture PASS 10 10 0 7 9 9 9 113.7 9 9 10
12 2026-09-06 chat turbo fixture PASS 10 10 0 7 7 7 7 112.8 7 7 10
13 2026-09-06 chat turbo derived FAIL 10 10 0 5 5 5 5 97.2 5 5 10
File diff suppressed because it is too large Load Diff
+393 -372
View File
@@ -1,11 +1,12 @@
/* Brain of Reese — History page (saved chats, phase 50 task 04).
/* Brain of Reese — History view (saved chats, phase 50 task 04;
* phase 76 task 03: shell view module).
*
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
* history in a new page, then return to that history with a click."
*
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
* endpoints (phase 50 task 02; phase 51 task 01+02) into the page's
* endpoints (phase 50 task 02; phase 51 task 01+02) into the view's
* full-width table:
*
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
@@ -55,408 +56,428 @@
* • admin → the gate hides and `loadChats()` renders the rows; a
* 0-row fetch reveals the empty-state row.
*
* Phase 19/34: the page joins the shared header — initSharedHeader()
* runs first (whoami + nav reveal + the steering panel), and the gate
* below reuses the SAME cached /api/whoami promise (one request per
* page).
* Phase 76 (task 03) — shell view module (the "History" view of the
* ONE-document shell; /history.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-history">, and every DOM
* lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the binding + state survive every switch.
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The admin gate keeps
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
* exports (zero extra requests; the flag decides whether the
* table loads at all, the Sources-page gate pattern).
* • the row actions stay REAL navigations: the Open link
* (?chat=<id>) and the copy-link field are plain anchor targets —
* opening a saved chat is a chat-view concern handled by app.js
* at boot via ?chat=, and the router never intercepts them (they
* are not navbar links, and their query string keeps them out of
* the VIEW map).
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin } from "./header.js";
const tableWrap = document.querySelector("#history-table-wrap");
const tbody = document.querySelector("#history-tbody");
const emptyRow = document.querySelector("#history-empty-row");
const gateEl = document.querySelector("#history-gate");
const statusEl = document.querySelector("#history-status");
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const tableWrap = root.querySelector("#history-table-wrap");
const tbody = root.querySelector("#history-tbody");
const emptyRow = root.querySelector("#history-empty-row");
const gateEl = root.querySelector("#history-gate");
const statusEl = root.querySelector("#history-status");
/* Action feedback — the role="status" live region above the table
(the "never stale" contract: every row action lands a line here,
success or failure alike). */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
/* One row. The Title cell carries the Open link (/?chat=<id> — the
"return to that history with a click" requirement); the Updated
cell renders the locale date+time with the full ISO on hover. */
function makeRow(chat) {
const tr = document.createElement("tr");
const titleTd = document.createElement("td");
titleTd.className = "history-title-cell";
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
const link = document.createElement("a");
link.className = "history-title-link";
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
link.textContent = chat.title; // user-derived — textContent only
titleTd.appendChild(link);
tr.appendChild(titleTd);
const countTd = document.createElement("td");
countTd.className = "history-count-cell";
countTd.textContent = String(chat.message_count);
tr.appendChild(countTd);
const updatedTd = document.createElement("td");
updatedTd.className = "history-updated-cell";
updatedTd.title = chat.updated_at; // full ISO on hover
updatedTd.textContent = fmtDate(chat.updated_at);
tr.appendChild(updatedTd);
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
// the READ-ONLY staleness marker. `chat.stale` is computed server-
// side (task 03), so this branches on the flag, never on versions.
// Stale rows get the rose pill (the exact hover copy points at the
// Regenerate action on the chat page, task 05); fresh rows get a
// plain em-dash. The <td> carries its own aria-label in BOTH states
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
const staleTd = document.createElement("td");
staleTd.className = "history-stale-cell";
if (chat.stale) {
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
pill.textContent = "Stale";
staleTd.appendChild(pill);
} else {
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
}
tr.appendChild(staleTd);
// Phase 51: the Share cell (between Updated and Actions) — the
// three-state share control (unshared / shared / confirming-unshare).
const shareTd = document.createElement("td");
shareTd.className = "history-share-cell";
shareTd.appendChild(makeShareControl(chat));
tr.appendChild(shareTd);
const actionsTd = document.createElement("td");
actionsTd.className = "history-actions-cell";
actionsTd.appendChild(makeDeleteControl(chat, tr));
tr.appendChild(actionsTd);
return tr;
}
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
confirm dialog anywhere on this page). The Delete button is
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
→ the row is removed + the live region line; No or a failed
request keeps the row (+ the error line on failure). */
function makeDeleteControl(chat, row) {
const cell = document.createElement("span");
cell.className = "history-actions";
const del = document.createElement("button");
del.type = "button";
del.className = "history-delete";
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
del.textContent = "Delete";
function restoreDelete() {
cell.replaceChildren(del);
del.focus(); // focus returns to the (restored) control
/* Action feedback — the role="status" live region above the table
(the "never stale" contract: every row action lands a line here,
success or failure alike). */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
del.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Delete?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmDelete(chat, row, yes, restoreDelete));
no.addEventListener("click", restoreDelete);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(del); // the shipped state IS the Delete button
return cell;
}
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
(+ the empty-state row reappears when it was the last one) and the
live region gets `Deleted "<title>".` A 404 means the row is gone
(deleted elsewhere) — drop the stale row and say so. Any other
failure or a network error keeps the row, restores the Delete
button (retryable), and lands the error line. */
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
} catch {
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
restoreDelete();
return;
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
if (r.status === 404) {
/* One row. The Title cell carries the Open link (/?chat=<id> — the
"return to that history with a click" requirement); the Updated
cell renders the locale date+time with the full ISO on hover. */
function makeRow(chat) {
const tr = document.createElement("tr");
const titleTd = document.createElement("td");
titleTd.className = "history-title-cell";
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
const link = document.createElement("a");
link.className = "history-title-link";
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
link.textContent = chat.title; // user-derived — textContent only
titleTd.appendChild(link);
tr.appendChild(titleTd);
const countTd = document.createElement("td");
countTd.className = "history-count-cell";
countTd.textContent = String(chat.message_count);
tr.appendChild(countTd);
const updatedTd = document.createElement("td");
updatedTd.className = "history-updated-cell";
updatedTd.title = chat.updated_at; // full ISO on hover
updatedTd.textContent = fmtDate(chat.updated_at);
tr.appendChild(updatedTd);
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
// the READ-ONLY staleness marker. `chat.stale` is computed server-
// side (task 03), so this branches on the flag, never on versions.
// Stale rows get the rose pill (the exact hover copy points at the
// Regenerate action on the chat page, task 05); fresh rows get a
// plain em-dash. The <td> carries its own aria-label in BOTH states
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
const staleTd = document.createElement("td");
staleTd.className = "history-stale-cell";
if (chat.stale) {
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
pill.textContent = "Stale";
staleTd.appendChild(pill);
} else {
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
}
tr.appendChild(staleTd);
// Phase 51: the Share cell (between Updated and Actions) — the
// three-state share control (unshared / shared / confirming-unshare).
const shareTd = document.createElement("td");
shareTd.className = "history-share-cell";
shareTd.appendChild(makeShareControl(chat));
tr.appendChild(shareTd);
const actionsTd = document.createElement("td");
actionsTd.className = "history-actions-cell";
actionsTd.appendChild(makeDeleteControl(chat, tr));
tr.appendChild(actionsTd);
return tr;
}
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
confirm dialog anywhere on this page). The Delete button is
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
→ the row is removed + the live region line; No or a failed
request keeps the row (+ the error line on failure). */
function makeDeleteControl(chat, row) {
const cell = document.createElement("span");
cell.className = "history-actions";
const del = document.createElement("button");
del.type = "button";
del.className = "history-delete";
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
del.textContent = "Delete";
function restoreDelete() {
cell.replaceChildren(del);
del.focus(); // focus returns to the (restored) control
}
del.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Delete?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmDelete(chat, row, yes, restoreDelete));
no.addEventListener("click", restoreDelete);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(del); // the shipped state IS the Delete button
return cell;
}
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
(+ the empty-state row reappears when it was the last one) and the
live region gets `Deleted "<title>".` A 404 means the row is gone
(deleted elsewhere) — drop the stale row and say so. Any other
failure or a network error keeps the row, restores the Delete
button (retryable), and lands the error line. */
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
} catch {
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
restoreDelete();
return;
}
if (r.status === 404) {
row.remove();
showEmptyIfLast();
announce("That chat was already deleted.");
return;
}
if (!r.ok) {
announce(`Couldn't delete "${chat.title}" — try again.`);
restoreDelete();
return;
}
row.remove();
showEmptyIfLast();
announce("That chat was already deleted.");
return;
announce(`Deleted "${chat.title}".`);
}
if (!r.ok) {
announce(`Couldn't delete "${chat.title}" — try again.`);
restoreDelete();
return;
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
/* Select every text node in the link field (an <a> has no .select();
a range does the job) — best-effort: a selection failure only means
the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
}
}
row.remove();
showEmptyIfLast();
announce(`Deleted "${chat.title}".`);
}
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
/* Select every text node in the link field (an <a> has no .select();
a range does the job) — best-effort: a selection failure only means
the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
/* Clipboard + the owner-locked inline-link fallback: a non-secure
(http) homelab origin rejects navigator.clipboard, so the failure
path renders a TRANSIENT <a> link field in the row's share cell —
input-like, it selects its full URL on focus (click or Tab, then
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
Returns true when the clipboard took it. (The per-page duplication
house style — this is history.js's OWN copy of the ~10-line helper;
app.js keeps the chat page's, no new shared module.) */
async function copyShareLink(cell, absoluteUrl) {
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
cell.appendChild(field);
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
}
}
}
/* Clipboard + the owner-locked inline-link fallback: a non-secure
(http) homelab origin rejects navigator.clipboard, so the failure
path renders a TRANSIENT <a> link field in the row's share cell —
input-like, it selects its full URL on focus (click or Tab, then
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
Returns true when the clipboard took it. (The per-page duplication
house style — this is history.js's OWN copy of the ~10-line helper;
app.js keeps the chat page's, no new shared module.) */
async function copyShareLink(cell, absoluteUrl) {
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
cell.appendChild(field);
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
/* The unshared state: the [Create link] button (a failed share keeps
the cell here — Create link is retryable). */
function renderShareUnshared(chat, cell) {
const create = document.createElement("button");
create.type = "button";
create.className = "history-share-create";
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
create.textContent = "Create link";
create.addEventListener("click", () => void createShareLink(chat, cell, create));
cell.replaceChildren(create);
}
}
/* The unshared state: the [Create link] button (a failed share keeps
the cell here — Create link is retryable). */
function renderShareUnshared(chat, cell) {
const create = document.createElement("button");
create.type = "button";
create.className = "history-share-create";
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
create.textContent = "Create link";
create.addEventListener("click", () => void createShareLink(chat, cell, create));
cell.replaceChildren(create);
}
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
focus moves to Yes so the confirm is keyboard-reachable); No or a
failed request restores this state (retryable). */
function renderShareShared(chat, cell) {
const copy = document.createElement("button");
copy.type = "button";
copy.className = "history-share-copy";
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
copy.textContent = "Copy";
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
const unshare = document.createElement("button");
unshare.type = "button";
unshare.className = "history-unshare";
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
unshare.textContent = "Unshare";
function restoreShared() {
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
focus moves to Yes so the confirm is keyboard-reachable); No or a
failed request restores this state (retryable). */
function renderShareShared(chat, cell) {
const copy = document.createElement("button");
copy.type = "button";
copy.className = "history-share-copy";
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
copy.textContent = "Copy";
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
const unshare = document.createElement("button");
unshare.type = "button";
unshare.className = "history-unshare";
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
unshare.textContent = "Unshare";
function restoreShared() {
cell.replaceChildren(copy, unshare);
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
}
unshare.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Unshare?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
no.addEventListener("click", restoreShared);
cell.replaceChildren(label, yes, no);
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
});
cell.replaceChildren(copy, unshare);
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
}
unshare.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Unshare?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
no.addEventListener("click", restoreShared);
cell.replaceChildren(label, yes, no);
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
});
cell.replaceChildren(copy, unshare);
}
/* The Share cell (phase 51): the span the row's Share <td> carries.
The shipped state comes from the row's share_url (the list endpoint
populates it — no second fetch): shared → Copy + Unshare, unshared →
Create link. No share action ever removes the ROW (only Delete
does) — the cell just re-renders between its states. */
function makeShareControl(chat) {
const cell = document.createElement("span");
cell.className = "history-share";
if (chat.share_url) {
/* The Share cell (phase 51): the span the row's Share <td> carries.
The shipped state comes from the row's share_url (the list endpoint
populates it — no second fetch): shared → Copy + Unshare, unshared →
Create link. No share action ever removes the ROW (only Delete
does) — the cell just re-renders between its states. */
function makeShareControl(chat) {
const cell = document.createElement("span");
cell.className = "history-share";
if (chat.share_url) {
renderShareShared(chat, cell);
} else {
renderShareUnshared(chat, cell);
}
return cell;
}
/* Create the link: POST /api/chats/<id>/share → the response's
share_url becomes the row's data (chat.share_url — the later Copy
uses it), the cell re-renders to the shared state, and the ABSOLUTE
link (the row's own origin supplies the scheme/host) is offered for
copying — clipboard → the inline-field fallback in the cell. A
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
network error keeps the unshared state (Create link re-enabled,
retryable) and lands the error line. */
async function createShareLink(chat, cell, createBtn) {
createBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
} catch {
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
createBtn.disabled = false;
return;
}
if (!r.ok) {
announce(`Couldn't share "${chat.title}" — try again.`);
createBtn.disabled = false;
return;
}
const { share_url } = await r.json();
chat.share_url = share_url; // the row is shared from now on
renderShareShared(chat, cell);
} else {
const copied = await copyShareLink(
cell,
new URL(share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* Copy (shared state): re-copy the row's share_url — the per-page
clipboard + fallback helper; the live region lands the outcome. */
async function copyRowShareLink(chat, cell) {
const copied = await copyShareLink(
cell,
new URL(chat.share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
NULL (revoked — the public link 404s from now on), the cell
re-renders to the unshared state (Create link) and the live region
gets `Unshared "<title>".` A non-2xx / a network error keeps the
shared state (restoreShared — Copy + Unshare, retryable) and lands
the error line. */
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
} catch {
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
restoreShared();
return;
}
if (!r.ok) {
announce(`Couldn't unshare "${chat.title}" — try again.`);
restoreShared();
return;
}
chat.share_url = null; // revoked: the row is unshared again
renderShareUnshared(chat, cell);
announce(`Unshared "${chat.title}".`);
}
return cell;
}
/* Create the link: POST /api/chats/<id>/share → the response's
share_url becomes the row's data (chat.share_url — the later Copy
uses it), the cell re-renders to the shared state, and the ABSOLUTE
link (the row's own origin supplies the scheme/host) is offered for
copying — clipboard → the inline-field fallback in the cell. A
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
network error keeps the unshared state (Create link re-enabled,
retryable) and lands the error line. */
async function createShareLink(chat, cell, createBtn) {
createBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
} catch {
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
createBtn.disabled = false;
return;
/* The empty-state row reappears exactly when the last data row was
removed (the empty row itself ships in the tbody, hidden). */
function showEmptyIfLast() {
if (!emptyRow || !tbody) return;
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
}
if (!r.ok) {
announce(`Couldn't share "${chat.title}" — try again.`);
createBtn.disabled = false;
return;
}
const { share_url } = await r.json();
chat.share_url = share_url; // the row is shared from now on
renderShareShared(chat, cell);
const copied = await copyShareLink(
cell,
new URL(share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* Copy (shared state): re-copy the row's share_url — the per-page
clipboard + fallback helper; the live region lands the outcome. */
async function copyRowShareLink(chat, cell) {
const copied = await copyShareLink(
cell,
new URL(chat.share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* 0-row fetches, non-2xx, and network failures all land on the
empty-state row (the sources.js house fallback — the safe state
in every case). */
function showEmptyState() {
if (!tbody) return;
tbody.replaceChildren(emptyRow);
if (emptyRow) emptyRow.hidden = false;
}
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
NULL (revoked — the public link 404s from now on), the cell
re-renders to the unshared state (Create link) and the live region
gets `Unshared "<title>".` A non-2xx / a network error keeps the
shared state (restoreShared — Copy + Unshare, retryable) and lands
the error line. */
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
} catch {
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
restoreShared();
return;
/* GET /api/chats → render the rows (latest activity first — the
server's order). A 0-row fetch shows the empty-state row. */
async function loadChats() {
if (emptyRow) emptyRow.hidden = true;
let r;
try {
r = await fetch("/api/chats");
} catch {
showEmptyState();
return;
}
if (!r.ok) {
showEmptyState();
return;
}
const { chats } = await r.json();
if (!chats.length) {
showEmptyState();
return;
}
for (const chat of chats) {
tbody.appendChild(makeRow(chat));
}
}
if (!r.ok) {
announce(`Couldn't unshare "${chat.title}" — try again.`);
restoreShared();
return;
}
chat.share_url = null; // revoked: the row is unshared again
renderShareUnshared(chat, cell);
announce(`Unshared "${chat.title}".`);
}
/* The empty-state row reappears exactly when the last data row was
removed (the empty row itself ships in the tbody, hidden). */
function showEmptyIfLast() {
if (!emptyRow || !tbody) return;
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
}
/* 0-row fetches, non-2xx, and network failures all land on the
empty-state row (the sources.js house fallback — the safe state
in every case). */
function showEmptyState() {
if (!tbody) return;
tbody.replaceChildren(emptyRow);
if (emptyRow) emptyRow.hidden = false;
}
/* GET /api/chats → render the rows (latest activity first — the
server's order). A 0-row fetch shows the empty-state row. */
async function loadChats() {
if (emptyRow) emptyRow.hidden = true;
let r;
try {
r = await fetch("/api/chats");
} catch {
showEmptyState();
return;
}
if (!r.ok) {
showEmptyState();
return;
}
const { chats } = await r.json();
if (!chats.length) {
showEmptyState();
return;
}
for (const chat of chats) {
tbody.appendChild(makeRow(chat));
}
}
(async () => {
// Phase 19/34: the shared header first (whoami + nav reveal + the
// steering panel) — the whoami promise is cached, so the gate below
// reuses the SAME single /api/whoami request.
await initSharedHeader();
/* ---------- view boot (phase 76 task 03) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
* the header uses (zero extra requests). Anonymous: the gate in,
* the table out — and NO /api/chats request at all (the router
* 403s anonymous, so the view must never call it; the story E2E
* pins the request log). */
if (!(await fetchIsAdmin())) {
// Anonymous: the gate in, the table out — and NO /api/chats
// request at all: the router 403s anonymous, so the page must
// never call it (the story E2E pins the request log).
if (tableWrap) tableWrap.hidden = true;
if (gateEl) gateEl.hidden = false;
return;
}
if (gateEl) gateEl.hidden = true;
loadChats();
})();
}
+241
View File
@@ -0,0 +1,241 @@
/* Brain of Reese — shell router (phase 76, task 01).
*
* The five navbar views are views of ONE HTML shell (index.html), not
* five documents: this module makes a navbar click a CLIENT-SIDE view
* switch — history.pushState + show/hide — never a document load, so
* the in-flight chat stream in the hidden view keeps streaming
* through any switch and completes when the user returns to Chat.
* Real departures (tab close, leaving the app, the Stop button) still
* cancel the fetch and stop the model — the phase-48 contract, owned
* by app.js and untouched here. The phase-48 LOCKED refinement
* (owner-confirmed 2026-09-06): "real navigation cancels the fetch"
* now means LEAVING THE APP — in-app navbar switches no longer cancel.
*
* The contract (pinned at source level in
* tests/unit/test_frontend_router.py):
*
* • VIEW — the pathname → view name map for the folded views
* ("/" → chat, "/index.html" → chat — the shell's own two URLs,
* "/tuning.html" → tuning, "/sources.html" → rag, "/git-sources.html"
* → git-sources, "/history.html" → history). Only a link whose href
* is IN this map is intercepted; every other link (login, document
* viewer, a /?chat=<id> deep link — its query string keeps it out
* of the map) still performs its real, document-level navigation.
* • boot from location.pathname: the matching view is shown WITHOUT
* focus (no focus steal on load) — a direct load of /tuning.html
* deep-links to the Tuning view (the shell route in app/main.py
* serves this shell for that path).
* • mount-once, hide-forever: a non-chat view's module is
* lazy-imported on FIRST show only, and `await module.mount(root)`
* runs once (the `mounted` guard) — the view's DOM and JS state
* (for chat, the in-flight SSE reader; for the Sources view, the
* upload-progress poller) persist across every switch; that
* persistence IS the phase-76 fix. The chat view needs no module:
* app.js already ran at shell boot.
* • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view
* must not receive focus or keyboard traversal — the inert pair
* pins the [hidden] contract in the a11y tree, AGENTS.md rule 5).
* • SINGLE WRITER of the .nav-link active state (is-active +
* aria-current="page"), of document.title, and of the per-view
* <meta name="description"> (values carried over from the old
* pages' <head>s) — no page script stamps any of these.
* • focus the target view (its tabindex="-1") ONLY on
* user-initiated switches (navbar click / popstate back-forward);
* a switch also lands the viewport at the top of the document,
* the same way the old per-view page loads did (user intent — the
* no-reply-autoscroll contract is about streaming frames, not
* navigation the user performs).
*
* Boot order (index.html): brand.js (classic) → app.js (module — the
* chat view, runs at shell boot exactly as before) → router.js
* (module — this file). No CDN, no framework, no bundler dependency:
* a plain ES module whose dynamic imports (./tuning.js, task 01;
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03)
* resolve relatively in dev and are inlined by the Containerfile's
* esbuild stage in the image.
*/
/* ---------- the view map (pathname → view name) ----------
* The shell's own two URLs are the chat view (the shell IS the chat
* page — app.js boots it); every folded view adds one entry. The
* values are the <section class="view" id="view-<name>"> slugs in
* index.html. */
const VIEW = {
"/": "chat",
"/index.html": "chat", // the shell's alternate URL (HTML_PAGES)
"/tuning.html": "tuning", // phase 76 task 01: the first folded view
"/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base)
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
};
/* The nav-link href the router stamps active for each view (the
Chat link is href="/", the RAG link href="/sources.html", …). */
const VIEW_PATH = {
chat: "/",
tuning: "/tuning.html",
rag: "/sources.html",
"git-sources": "/git-sources.html",
history: "/history.html",
};
/* The lazy view modules — ONLY the non-chat views (chat needs no
import: app.js already ran at shell boot). Static specifiers so the
Containerfile's esbuild stage can inline each module into the
router bundle (the browser still defers its code until the first
import() — mount-once semantics are preserved in the image). */
const VIEW_MODULES = {
tuning: () => import("./tuning.js"),
rag: () => import("./sources.js"), // phase 76 task 02
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
history: () => import("./history.js"), // phase 76 task 03
};
/* Per-view document.head values, carried over from the old pages'
<head>s (the router is the single writer of both). The values are
the DEFAULT-deployment form: at write time they are composed through
brandName() (below) so a configured deployment keeps its name. */
const TITLES = {
chat: "Brain of Reese",
tuning: "Global Tuning · Brain of Reese",
rag: "Sources · Brain of Reese", // old sources.html <title>
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
history: "Saved chats · Brain of Reese", // old history.html <title>
};
const DESCRIPTIONS = {
chat:
"Ask anything about your indexed documents — every answer cites the exact doc.",
tuning:
"Manage the global tuning notes that steer every Brain of Reese answer.",
rag: "Documents indexed in Brain of Reese.", // old sources.html meta
"git-sources":
"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).",
history:
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
};
/* The brand-resolved display name (phase 39 — brand.js is the single
owner: window.BOR_BRAND is "Brain of Reese" from parse time and is
updated once /api/config settles). The router composes the per-view
title/meta from it instead of stamping the hardcoded literal: the
lazy view import defers switchTo PAST brand.js's one-time
DOMContentLoaded pass, so a literal stamp would overwrite a
configured deployment's name (e.g. "Brain of Testy") in the
client-side head. Composing at write time keeps the name correct
for every config/switch ordering (an unset deployment — the name IS
the literal — stays byte-identical: replaceAll is a no-op). */
const brandName = () => window.BOR_BRAND || "Brain of Reese";
const titleFor = (view) => TITLES[view].replaceAll("Brain of Reese", brandName());
const descFor = (view) => DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName());
/* The view sections — one per view name (index.html: #view-chat is
visible at boot, the folded views ship hidden + inert). */
const viewEls = {};
for (const name of new Set(Object.values(VIEW))) {
viewEls[name] = document.getElementById(`view-${name}`);
}
/* The mount-once guard: a view is imported + mounted at most ONCE per
document life — re-shows are show/hide only (no refetch, no
re-mount; the view's state persists). Chat starts mounted: app.js
owns it and ran at shell boot. */
const mounted = { chat: true };
const nav = document.getElementById("app-nav");
const metaDesc = document.querySelector('meta[name="description"]');
let current = null; // the visible view name (null until boot resolves)
/* ---------- show / hide (the single writer of the view state) ---------- */
/* Show `name`, hide every other view, and write the single-writer
head/nav state. `userInitiated` marks navbar-click / popstate
switches: only those focus the target view (its tabindex="-1") and
land the viewport at the top — a boot switch never steals focus. */
async function switchTo(name, { userInitiated }) {
const root = viewEls[name];
if (!root) return;
/* Mount-once: the lazy module is imported on FIRST show only, then
mounted into the view's section. The guard runs BEFORE the import
(a re-show never re-imports) and is set only after mount resolves
(a failed mount may retry on the next show). */
if (!mounted[name]) {
const load = VIEW_MODULES[name];
if (load) {
const mod = await load();
await mod.mount(root);
}
mounted[name] = true;
}
/* Show = drop hidden AND inert; hide = add BOTH (a hidden view must
not receive focus or keyboard traversal — the inert pair makes the
[hidden] contract hold in the a11y tree, not just the layout). */
for (const [viewName, el] of Object.entries(viewEls)) {
el.hidden = viewName !== name;
el.inert = viewName !== name;
}
/* SINGLE WRITER: the active nav link (is-active + aria-current),
the document title, and the per-view meta description. */
const path = VIEW_PATH[name];
if (nav) {
for (const a of nav.querySelectorAll("a.nav-link")) {
const active = (a.getAttribute("href") || "") === path;
a.classList.toggle("is-active", active);
if (active) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
}
document.title = titleFor(name);
if (metaDesc) metaDesc.content = descFor(name);
current = name;
/* Focus the target view ONLY on user-initiated switches (navbar
click / popstate) — never on initial boot (no focus steal on
load). The top landing mirrors what the old per-view page loads
did (user intent, not a streaming-frame autoscroll). */
if (userInitiated) {
window.scrollTo(0, 0);
root.focus({ preventScroll: true });
}
}
/* ---------- navbar click: same-shell links become view switches ----------
* Delegated on the nav (covers the mobile dropdown too — it is the
* same #app-nav element): a same-shell a.nav-link (href in VIEW) is
* intercepted — preventDefault + history.pushState + switch, so the
* click is a view switch, NEVER a document load. Every other link
* (login, the document viewer, the not-yet-folded views in tasks
* 02/03) keeps its real navigation untouched. */
if (nav) {
nav.addEventListener("click", (e) => {
const a = e.target instanceof Element ? e.target.closest("a.nav-link") : null;
if (!a) return;
const href = a.getAttribute("href") || "";
if (!(href in VIEW)) return; // not a same-shell view — real navigation
e.preventDefault();
const name = VIEW[href];
if (name === current) return; // already visible (the menu still closes)
history.pushState({ view: name }, "", href);
switchTo(name, { userInitiated: true });
});
/* Back / forward: popstate switches views (the history entries were
written by the pushState above — same-document, no page load). */
window.addEventListener("popstate", () => {
const name = VIEW[window.location.pathname];
if (name && name !== current) switchTo(name, { userInitiated: true });
});
}
/* ---------- boot: deep-link from the pathname, no focus steal ---------- */
/* A direct load of any shell path shows its view (chat for "/" and
"/index.html", tuning for "/tuning.html"); an unexpected pathname
falls back to chat (the shell's default view). userInitiated:false
— boot never focuses (no focus steal on load). */
const bootName = VIEW[window.location.pathname] ?? "chat";
switchTo(bootName, { userInitiated: false });
+539 -530
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -404,6 +404,21 @@ html::after {
padding-block: 1.25rem;
}
/* Phase 76 (task 02): the shell's #view-chat wrapper sits between
.app-main and .chat-shell — it must CONTINUE the full-height column
chain (body's min-height: 100dvh flex → .app-main → #view-chat →
.chat-shell, phase 52) or .chat-shell's flex:1 loses its flex
parent and the column sizes to content: the composer's sticky pin
(phases 43/55/65) then has no tall scroll range and the empty chat
stops resting at the screen bottom. The folded document views
(tuning/rag/git-sources) size to content in normal flow — they need
no chain. */
#view-chat {
flex: 1;
display: flex;
flex-direction: column;
}
/* Chat is a vertical conversation: a centered, capped column is the
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
from collapsing into a hairline on wide screens. The cap is the
+300 -289
View File
@@ -1,8 +1,9 @@
/* Brain of Reese — Global Tuning page (phase 27, task 03).
/* Brain of Reese — Global Tuning view (phase 27; phase 76 task 01:
* shell view module).
*
* The standalone manager for steering notes: create / list / edit /
* delete WITHOUT a chat conversation. This module is the single owner
* of the page's behaviour:
* of the view's behaviour:
*
* • loadNotes() — GET /api/steering → the newest-first note list
* (#tune-list) + the empty state. A failed fetch (API down, or the
@@ -25,317 +26,327 @@
* announce a retry. The empty state is re-checked on every removal.
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
* the screen-reader confirmation for create / edit / delete.
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page). Phase 34 task 02: the New chat binding is module-owned
* (assets/header.js, the SINGLE one) — on a non-chat page "new
* chat" means going to the chat, fresh (the module clears the
* phase-14 conversation key and navigates to "/").
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
* page — loadNotes() only runs when the cached whoami says admin (the
* Sources page gate pattern), the list stays on its empty state, and
* the create form 403s gracefully on submit (the inline error carries
* the API detail). Note text is always rendered with textContent —
* never innerHTML (XSS-safe, like app.js's steering panel).
* Phase 76 (task 01) — shell view module (the "Global Tuning" view of
* the ONE-document shell; /tuning.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-tuning">, and every DOM
* lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the binding + state survive every switch.
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The admin gate keeps
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
* exports (zero extra requests; the flag decides whether the note
* list loads at all, the Sources-page gate pattern).
*
* Anonymous-safe (phase 27 task 03, unchanged in the shell): the
* header hides the "Tuning" nav link for anonymous visitors; a DIRECT
* anonymous URL still gets a safe view — loadNotes() only runs when
* the cached whoami says admin, the list stays on its empty state,
* and the create form 403s gracefully on submit (the inline error
* carries the API detail). Note text is always rendered with
* textContent — never innerHTML (XSS-safe, like app.js's steering
* panel).
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build).
* runs (single-evaluation design: no direct <script> tag; in the
* image the Containerfile's esbuild stage inlines it — today into the
* router bundle, phase 76 task 01).
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
const tuneNote = document.querySelector("#tune-note");
const tuneSave = document.querySelector("#tune-save");
const tuneList = document.querySelector("#tune-list");
const tuneEmpty = document.querySelector("#tune-empty");
const tuneAnnouncer = document.querySelector("#tune-announcer");
export async function mount(root) {
/* ---------- page elements (the view's section, scoped to root) ---------- */
const tuneForm = root.querySelector("#tune-form");
const tuneNote = root.querySelector("#tune-note");
const tuneSave = root.querySelector("#tune-save");
const tuneList = root.querySelector("#tune-list");
const tuneEmpty = root.querySelector("#tune-empty");
const tuneAnnouncer = root.querySelector("#tune-announcer");
/* The create form's inline error (role=alert) — created once, hidden
by default, and kept between attempts: a failed POST keeps the form
AND its message until the next submit. */
const createError = document.createElement("p");
createError.className = "tuning-error";
createError.setAttribute("role", "alert");
createError.hidden = true;
if (tuneForm) tuneForm.appendChild(createError);
/* The create form's inline error (role=alert) — created once, hidden
by default, and kept between attempts: a failed POST keeps the
form AND its message until the next submit. */
const createError = document.createElement("p");
createError.className = "tuning-error";
createError.setAttribute("role", "alert");
createError.hidden = true;
if (tuneForm) tuneForm.appendChild(createError);
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
carry their own labels), the same marks as app.js's steering panel. */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
const DELETE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as app.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
carry their own labels), the same marks as app.js's steering panel. */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
const DELETE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
an anonymous direct-URL visit) keeps the last rendered list —
progressive enhancement, never a blanked panel. */
async function loadNotes() {
let r;
try {
r = await fetch("/api/steering");
} catch {
return; // API unreachable: keep the last rendered list
}
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
let notes;
try {
notes = (await r.json()).notes || [];
} catch {
return; // corrupt body: keep the last rendered list
}
renderNotes(notes);
}
function renderNotes(notes) {
if (!tuneList) return;
tuneList.textContent = "";
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
syncEmptyState(notes.length);
}
/* The empty state tracks the list's rendered rows (the HTML ships on
the "No tuning notes yet" text; it hides as soon as one row shows). */
function syncEmptyState(count) {
if (!tuneEmpty || !tuneList) return;
const rows = typeof count === "number" ? count : tuneList.children.length;
tuneEmpty.hidden = rows > 0;
}
/* One list row: the note text (textContent — XSS-safe, never
innerHTML) + the Edit and Delete buttons. */
function makeNoteRow(n) {
const li = document.createElement("li");
li.className = "tuning-note";
const text = document.createElement("span");
text.className = "tuning-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "tuning-edit";
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
editBtn.addEventListener("click", () => openEditForm(li, n));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "tuning-delete";
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
li.append(editBtn, delBtn);
return li;
}
/* ---------- create (POST /api/steering) ---------- */
if (tuneForm) {
tuneForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (tuneSave) tuneSave.disabled = true; // one note per click
createError.hidden = true;
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as app.js. */
async function apiDetail(r, fallback) {
try {
const r = await fetch("/api/steering", {
method: "POST",
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
an anonymous direct-URL visit) keeps the last rendered list —
progressive enhancement, never a blanked panel. */
async function loadNotes() {
let r;
try {
r = await fetch("/api/steering");
} catch {
return; // API unreachable: keep the last rendered list
}
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
let notes;
try {
notes = (await r.json()).notes || [];
} catch {
return; // corrupt body: keep the last rendered list
}
renderNotes(notes);
}
function renderNotes(notes) {
if (!tuneList) return;
tuneList.textContent = "";
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
syncEmptyState(notes.length);
}
/* The empty state tracks the list's rendered rows (the HTML ships on
the "No tuning notes yet" text; it hides as soon as one row shows). */
function syncEmptyState(count) {
if (!tuneEmpty || !tuneList) return;
const rows = typeof count === "number" ? count : tuneList.children.length;
tuneEmpty.hidden = rows > 0;
}
/* One list row: the note text (textContent — XSS-safe, never
innerHTML) + the Edit and Delete buttons. */
function makeNoteRow(n) {
const li = document.createElement("li");
li.className = "tuning-note";
const text = document.createElement("span");
text.className = "tuning-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "tuning-edit";
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
editBtn.addEventListener("click", () => openEditForm(li, n));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "tuning-delete";
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
li.append(editBtn, delBtn);
return li;
}
/* ---------- create (POST /api/steering) ---------- */
if (tuneForm) {
tuneForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (tuneSave) tuneSave.disabled = true; // one note per click
createError.hidden = true;
try {
const r = await fetch("/api/steering", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
});
if (r.ok) {
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
announce("Tuning note added. Future answers will follow it.");
await loadNotes(); // the new note lands in the list, newest first
} else {
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
createError.hidden = false; // form kept — the instruction survives
}
} catch {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
}
});
}
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
* The row swaps to the inline form — the is-editing class does the
* visual swap (styles.css hides the text + the row buttons). One open
* form view-wide: opening a new one reverts the others. Cancel reverts
* to the text span; a failed save keeps the form + the inline error.
*/
let editSeq = 0; // unique ids for the edit forms' labeled textareas
function openEditForm(li, n) {
if (li.classList.contains("is-editing")) return; // one per row
// One open form view-wide: close any other row's first.
root.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
other.classList.remove("is-editing");
other.querySelector(".tuning-edit-form")?.remove();
});
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
li.classList.add("is-editing");
editSeq += 1;
const inputId = `tuning-edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "tuning-edit-form";
form.dataset.noteId = n.id; // the note id rides on the form
const label = document.createElement("label");
label.className = "visually-hidden";
label.htmlFor = inputId;
label.textContent = `Edit tuning note: ${n.note}`;
const textarea = document.createElement("textarea");
textarea.id = inputId;
textarea.className = "tuning-edit-input";
textarea.rows = 2;
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
textarea.required = true;
textarea.value = n.note; // prefilled with the current text
const actions = document.createElement("div");
actions.className = "tuning-edit-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const error = document.createElement("p");
error.className = "tuning-error";
error.setAttribute("role", "alert");
error.hidden = true;
form.append(label, textarea, actions, error);
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
cancelBtn.addEventListener("click", () => {
li.classList.remove("is-editing"); // revert to the text span
form.remove();
li.querySelector(".tuning-edit")?.focus();
});
li.insertBefore(form, li.querySelector(".tuning-edit"));
textarea.focus();
}
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
e.preventDefault();
saveBtn.disabled = true;
error.hidden = true;
const id = form.dataset.noteId;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
body: JSON.stringify({ note: textarea.value }),
});
if (r.ok) {
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
announce("Tuning note added. Future answers will follow it.");
await loadNotes(); // the new note lands in the list, newest first
} else {
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
createError.hidden = false; // form kept — the instruction survives
let saved = textarea.value.trim();
try {
saved = (await r.json()).note ?? saved;
} catch {
/* keep the trimmed local text */
}
const textEl = li.querySelector(".tuning-note-text");
if (textEl) textEl.textContent = saved; // the list shows the stored text
const savedPill = document.createElement("p");
savedPill.className = "tuning-saved";
savedPill.setAttribute("role", "status");
savedPill.textContent = "Saved";
form.replaceWith(savedPill);
li.classList.remove("is-editing"); // updated text + row buttons come back
announce("Tuning note updated.");
return;
}
error.textContent = await apiDetail(r, "Could not update the note — try again.");
error.hidden = false; // form kept — the edit survives the failure
saveBtn.disabled = false;
} catch {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.disabled = false;
}
});
}
}
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
* The row swaps to the inline form — the is-editing class does the
* visual swap (styles.css hides the text + the row buttons). One open
* form page-wide: opening a new one reverts the others. Cancel reverts
* to the text span; a failed save keeps the form + the inline error.
*/
let editSeq = 0; // unique ids for the edit forms' labeled textareas
function openEditForm(li, n) {
if (li.classList.contains("is-editing")) return; // one per row
// One open form page-wide: close any other row's first.
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
other.classList.remove("is-editing");
other.querySelector(".tuning-edit-form")?.remove();
});
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
li.classList.add("is-editing");
editSeq += 1;
const inputId = `tuning-edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "tuning-edit-form";
form.dataset.noteId = n.id; // the note id rides on the form
const label = document.createElement("label");
label.className = "visually-hidden";
label.htmlFor = inputId;
label.textContent = `Edit tuning note: ${n.note}`;
const textarea = document.createElement("textarea");
textarea.id = inputId;
textarea.className = "tuning-edit-input";
textarea.rows = 2;
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
textarea.required = true;
textarea.value = n.note; // prefilled with the current text
const actions = document.createElement("div");
actions.className = "tuning-edit-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const error = document.createElement("p");
error.className = "tuning-error";
error.setAttribute("role", "alert");
error.hidden = true;
form.append(label, textarea, actions, error);
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
cancelBtn.addEventListener("click", () => {
li.classList.remove("is-editing"); // revert to the text span
form.remove();
li.querySelector(".tuning-edit")?.focus();
});
li.insertBefore(form, li.querySelector(".tuning-edit"));
textarea.focus();
}
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
e.preventDefault();
saveBtn.disabled = true;
error.hidden = true;
const id = form.dataset.noteId;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: textarea.value }),
});
if (r.ok) {
let saved = textarea.value.trim();
try {
saved = (await r.json()).note ?? saved;
} catch {
/* keep the trimmed local text */
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
* The row leaves the DOM the moment the server agrees (204); a 404
* (already gone) also drops the row and reloads to resync; any other
* failure re-enables the button and says to retry. */
async function deleteNote(id, btn, li) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
li.remove(); // already gone on the server — drop it and resync
syncEmptyState();
announce("That note was already removed.");
await loadNotes();
return;
}
const textEl = li.querySelector(".tuning-note-text");
if (textEl) textEl.textContent = saved; // the list shows the stored text
const savedPill = document.createElement("p");
savedPill.className = "tuning-saved";
savedPill.setAttribute("role", "status");
savedPill.textContent = "Saved";
form.replaceWith(savedPill);
li.classList.remove("is-editing"); // updated text + row buttons come back
announce("Tuning note updated.");
return;
}
error.textContent = await apiDetail(r, "Could not update the note — try again.");
error.hidden = false; // form kept — the edit survives the failure
saveBtn.disabled = false;
} catch {
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.disabled = false;
}
}
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
* The row leaves the DOM the moment the server agrees (204); a 404
* (already gone) also drops the row and reloads to resync; any other
* failure re-enables the button and says to retry. */
async function deleteNote(id, btn, li) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
li.remove(); // already gone on the server — drop it and resync
if (!r.ok) {
announce("Could not delete the note — try again.");
btn.disabled = false;
return;
}
li.remove(); // 204: the server confirmed — the row goes now
syncEmptyState();
announce("That note was already removed.");
await loadNotes();
return;
}
if (!r.ok) {
announce("Could not delete the note — try again.");
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
btn.disabled = false;
return;
}
li.remove(); // 204: the server confirmed — the row goes now
syncEmptyState();
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* ---------- header boot (task 02) ---------- */
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state.
*
* Boot: the shared header FIRST (Sign in/out + the admin-only nav
* links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */
(async () => {
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
/* ---------- view boot (phase 76 task 01) ----------
* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding) and lives in the chat view only.
*
* Boot: the shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The note
* list is admin data (the Sources page gate pattern): the gate reads
* fetchIsAdmin() — the SAME cached whoami promise the header uses
* (zero extra requests). An anonymous visitor gets the view frame
* with the empty state, and the create form 403s gracefully on
* submit if one tries. */
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
})();
}
-316
View File
@@ -1,316 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
<title>Git sources · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Phase 35: the SAME full header block every other page ships
(phase 34, owner confirmation 2026-08-26) — one shared owner of
the controls (assets/header.js via git-sources.js's relative
import). The admin-only "Git sources" nav link (#nav-git-sources)
joins this nav in phase 35 task 05, so it is NOT in this file
yet — the page lands without it, exactly like the other pages
land without the links task 05 adds to them. -->
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above; this page IS the current one, so the
link carries is-active + aria-current like Tuning on
tuning.html. -->
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Tuning link above. -->
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/git-sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel — first child of <main>
on the non-chat pages, rendered + driven by assets/header.js
(shared), not the page script. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container git-sources-shell">
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
pattern (phase 16) and the same .sources-gate visual
language: the page is the same shape as Sources. Visible
for anonymous, hidden for the admin (git-sources.js). The
catalog of git sources is what the login locks — chat stays
open to everyone (the soft rule). -->
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
<p class="sources-gate-sub">
The list of repositories cloned and indexed by the sync service
clones and indexes is admin-only. Chat — and any document an
answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
</section>
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
gate is what anonymous visitors see). git-sources.js
reveals it once the cached whoami says admin, then loads
the list. Full-width table on the 72rem frame — the
Sources-page pattern, no skinny single-column list. -->
<div id="git-sources-content" hidden>
<div class="page-head">
<h1>Git sources</h1>
<p class="page-sub">
The git repositories and local directories the Sync button
imports. Add or remove them here — no <code>.env</code>, no
restart.
</p>
</div>
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
non-2xx or network failure must never leave a stuck page.
git-sources.js fills #git-sources-load-error-text. -->
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
<span id="git-sources-load-error-text"></span>
<button type="button" id="git-sources-retry">Try again</button>
</div>
<!-- Env-fallback note (phase locked decision): while the
git_sources table is EMPTY the list above comes from
BOR_GIT_SOURCES in .env (from_env: true) — the note says
so, and that adding or removing here switches management
to the database. Hidden by default; git-sources.js shows
it off the API's from_env flag. -->
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
These sources currently come from <code>BOR_GIT_SOURCES</code> in
<code>.env</code> — adding or removing one here switches management
to the database.
</p>
<!-- Add form: visible label + mono URL input + brand button
(dark ink on brand 5.2:1). §7.4 never-stale: the button
disables + relabels "Adding…" while the POST is in flight
and re-enables on success AND failure (the input is kept
on failure, same as the tuning forms). -->
<form id="git-source-form">
<label for="git-source-url">Add a git source</label>
<input
id="git-source-url"
name="url"
type="text"
maxlength="500"
autocomplete="off"
placeholder="https://github.com/you/your-repo.git"
required
>
<button type="submit" id="git-source-add">Add source</button>
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
</form>
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
form replaces the phase-38 local-directory form — an
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
BOR_UPLOAD_DIR and scanned; the same filename replaces the
source in place (no new folder, no duplicate row). The file
control is labeled (visible <label for=…> — WCAG
input-label rule); the button runs the §7.4 never-stale
lifecycle ("Uploading…" while the POST is out). Phase 64
(task 05) reworks the rest to the 202 contract (the
phase-49 synchronous 200 paragraph is superseded): the 202
arrives the moment the archive is safely on disk (A1) — a
JS-created "Successfully uploaded — <file>" toast fires
then (A2 — the phase-55 .toast node, no markup here; safe
to navigate away) and the button settles into the live
"Processing… <file> (n/m)" label (A4 — the full path rides
the button title) driven by the 2 s poll of
GET /api/git-sources/upload/status, until the success line
(role=status) or the sanitized error banner (role=alert)
lands; 409 re-attaches to the in-flight run — no error
banner; the other non-2xx still show the server detail
inline. -->
<form id="archive-upload-form">
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
<input id="archive-upload-file" name="file" type="file"
accept=".tar,.tar.gz,.tgz,.zip" required>
<button type="submit" id="archive-upload-btn">Upload &amp; scan</button>
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
<p class="git-source-result" id="archive-upload-result" role="status"
aria-live="polite" hidden></p>
</form>
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
<table class="git-sources-table" id="git-sources-table">
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Added</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="git-sources-tbody"></tbody>
</table>
</div>
<!-- Empty state — no stored rows AND no env fallback. With
from_env, the env note above already explains where the
active list comes from. -->
<p class="git-sources-empty" id="git-sources-empty" hidden>No sources stored yet.</p>
<!-- Phase 69 (owner request 2026-09-02): removal is a TOTAL
removal — the row, the source's indexed documents, and —
for git clones and uploaded archives — the files on the
server's disk, all immediately (the confirmation modal
below spells it out; foreign local directories are never
touched). Adding still does not clone — the Sync button
mirrors the remaining sources (upstream file churn is
pruned on that run); the phase-49 upload is the
in-place exception (it unpacks and scans, and a
same-name re-upload replaces the source in place). -->
<p class="git-source-hint" id="git-sources-hint" role="note">
Removing a source is a total removal, done immediately: its
entry, its indexed documents, and — for git clones and
uploaded archives — its files on the server's disk (the
confirmation modal spells out exactly what will be deleted;
files in your own local directories are never touched).
Uploads unpack and scan immediately — re-uploading the same
filename replaces that source in place (no new folder, no
duplicate row). The Sync button still mirrors the remaining
sources (files removed upstream are pruned on that run).
</p>
<!-- Phase 69 (owner request 2026-09-02): the remove
confirmation — a real in-app alertdialog (the native
confirm() retired): a row's Remove button opens it
(git-sources.js).
It names the source (#remove-confirm-source — ALWAYS
populated via textContent: URLs may embed user:pass@
credentials, the phase-32 masking discipline) and states
the full-removal policy. Focus lands on Cancel (the safe
default for a destructive action); Escape, the Cancel
button, and the dim backdrop all close as cancel (no
request — focus returns to the row's Remove button); only
"Remove source" sends the DELETE, in the §7.4 "Removing…"
in-flight state. The .doc-modal overlay contract: a fixed
full-viewport dim backdrop + a centered panel (no blur).
Static markup so the E2E suite gets stable selectors (the
#git-sources-hint / gate convention). -->
<div class="remove-confirm" id="remove-confirm-dialog" role="alertdialog"
aria-modal="true" aria-labelledby="remove-confirm-title"
aria-describedby="remove-confirm-copy" hidden>
<div class="remove-confirm-backdrop" aria-hidden="true"></div>
<div class="remove-confirm-panel">
<h2 class="remove-confirm-title" id="remove-confirm-title">Remove this source?</h2>
<code class="remove-confirm-source" id="remove-confirm-source"></code>
<p class="remove-confirm-copy" id="remove-confirm-copy">
This permanently removes the source entry, all of its
indexed documents from the knowledge base, and — for git
clones and uploaded archives — the files on the server's
disk. Files in your own local directories are never
touched. This cannot be undone.
</p>
<p class="remove-confirm-error" id="remove-confirm-error" role="alert" hidden></p>
<div class="remove-confirm-actions">
<button type="button" class="remove-confirm-btn remove-confirm-cancel"
id="remove-confirm-cancel">Cancel</button>
<button type="button" class="remove-confirm-btn remove-confirm-remove"
id="remove-confirm-remove">Remove source</button>
</div>
</div>
</div>
</div>
<!-- Polite live region: the screen-reader confirmation for list
loads, adds, and removals (git-sources.js owns the text). -->
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span class="footer-text">Powered by self-hosted models</span>
</div>
</footer>
<!-- Phase 35: the page module loads the shared header through its
own `import "./header.js"` — a hoisted import evaluated before
this body runs (the single-evaluation design: no direct
header.js <script> tag; esbuild inlines it into the page
bundle in the image build). -->
<!-- Phase 39: the brand layer — classic script, first on the page:
window.BOR_BRAND at parse time, refreshed from /api/config. -->
<script src="assets/brand.js"></script>
<script type="module" src="/assets/git-sources.js"></script>
</body>
</html>
-186
View File
@@ -1,186 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Saved chats — every conversation is saved automatically, one click back.">
<title>Saved chats · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Tuning link above. This page IS the current one, so the
link carries is-active + aria-current like Tuning on
tuning.html. -->
<a href="/history.html" class="nav-link is-active" aria-current="page" id="nav-history" hidden>History</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/history.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container history-shell">
<div class="page-head">
<h1>Saved chats</h1>
<p class="page-sub">
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
</p>
</div>
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
gate — the EXACT #sources-gate pattern (phase 16) and the
same .sources-gate visual language (phase 35, git-sources):
the saved-chat list is what the login locks. Visible for
anonymous, hidden for the admin (history.js) — and the
page never fetches /api/chats for an anonymous visitor
(the router 403s them; the story E2E pins the request
log). -->
<section class="sources-gate" id="history-gate" aria-labelledby="history-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="history-gate-title">Sign in to view your saved chats</h2>
<p class="sources-gate-sub">
Saved conversations are admin-only. Chat — and any document an
answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
</section>
<!-- Live-region feedback for row actions (the "never stale"
contract): history.js sets textContent here — a delete's
outcome, its error line, nothing else. -->
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages |
Updated | Stale (phase 53: the READ-ONLY staleness marker —
the rose pill when the row predates the last KB-changing
sync; the Regenerate action lives on the chat-page banner,
task 05) | Share (phase 51: Create link / Copy / Unshare —
the row's share_url comes from GET /api/chats itself, no
second fetch) | Actions (Delete, inline two-step confirm).
history.js fills #history-tbody; #history-empty-row ships
hidden and is revealed by a 0-row fetch. The Actions column
header is visually-hidden — the row buttons carry their own
aria-labels. -->
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
<table class="history-table">
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Messages</th>
<th scope="col">Updated</th>
<th scope="col">Stale</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden>
<td colspan="6">No saved chats yet — start a conversation and it will be saved automatically.</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span class="footer-text">Powered by self-hosted models</span>
<span class="footer-version" id="app-version"></span>
</div>
</footer>
<!-- Phase 50: the shared header module loads through the page script's
own `import "./header.js"` — a hoisted import that is evaluated
before the page script body calls initSharedHeader() at boot
(no direct header.js <script> tag — single-evaluation design).
Phase 39: the brand layer — classic script, first on the page:
window.BOR_BRAND at parse time, refreshed from /api/config. -->
<script src="assets/brand.js"></script>
<script type="module" src="/assets/history.js"></script>
</body>
</html>
+480
View File
@@ -82,7 +82,20 @@
</div>
</header>
<!-- Phase 76 (task 01): the shell's single <main> holds the navbar
views as <section class="view"> blocks — only the active one is
shown (the others carry hidden + inert, so focus and keyboard
traversal never enter them). A navbar click is a client-side
view switch (assets/router.js — pushState + show/hide), never a
document load; the in-flight chat stream in the hidden view
keeps streaming through any switch. Each folded page's own
<main class="app-main"> wrapper (identical on all five pages —
the layout CSS is class-based) is dropped with the move, and
the per-view copies of the header-owned steering panel are
dropped too (this shell's ONE panel — the chat one, inside
#view-chat — is the instance header.js drives). -->
<main id="main" class="app-main" tabindex="-1">
<section class="view" id="view-chat" aria-label="Chat" tabindex="-1">
<div class="container chat-shell" data-state="empty">
<div class="kb-banner" id="kb-banner" role="status" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
@@ -260,6 +273,467 @@
</form>
</div>
</div>
</section>
<!-- Phase 76 (task 01): the Global Tuning view — the content of
frontend/tuning.html's <main> (wrapper dropped), folded into
the shell. /tuning.html now serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. Its own header / steering-panel copies lived in the
old page's <header>/<main> and are dropped — the shell's
single header + chat-view panel stand in for them. The
hidden + inert pair is the WCAG contract: a hidden view must
not receive focus or keyboard traversal (AGENTS.md rule 5).
mounted lazily — assets/router.js imports tuning.js on first
show only (mount-once, hide-forever). -->
<section class="view" id="view-tuning" hidden inert aria-label="Global Tuning" tabindex="-1">
<div class="container tuning-shell">
<div class="page-head">
<h1>Global Tuning</h1>
<p class="page-sub">
Every note below is read into the system prompt of
<strong>every</strong> chat turn. Add, edit, or remove them here —
no conversation required.
</p>
</div>
<!-- Phase 27: create a note without a chat. The label is
visually-hidden (the heading + placeholder carry the visible
context); the 1–2000-char contract mirrors the chat-page tune
form — the server re-validates (422). -->
<form id="tune-form">
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
<textarea
id="tune-note"
name="note"
rows="3"
maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"
required
></textarea>
<button type="submit" id="tune-save">Add note</button>
</form>
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
task 03) owns the message text. -->
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
<!-- Phase 27: the note list — the phase-15 steering panel's
language, full column width. tuning.js fills it newest-first;
each row is an <li class="tuning-note"> with a
.tuning-note-text span + an Edit and a Delete button (styles:
styles.css "Global tuning page"). The empty state toggles with
the list. -->
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
<ul id="tune-list" class="tuning-list" role="list"></ul>
<p id="tune-empty">No tuning notes yet — add one above.</p>
</section>
</div>
</section>
<!-- Phase 76 (task 02): the RAG view (the Knowledge base catalog)
— the content of frontend/sources.html's <main> (wrapper
dropped), folded into the shell. /sources.html now serves THIS
document (the shell route in app/main.py); the router shows
this section for that pathname. The per-view copies of the
header-owned steering panel + announcer are dropped (the
shell's ONE panel — the chat one, inside #view-chat — is the
instance header.js drives), and the old page's SECOND
doc-modal-* skeleton copy is dropped too: the shell keeps
EXACTLY ONE (the chat's, body level), which BOTH app.js (chat
chips) and sources.js (RAG rows) open through
openDocumentModal(...). The per-page footer does not move
(body-level — the shell's single footer stands in). The
hidden + inert pair is the WCAG contract: a hidden view must
not receive focus or keyboard traversal (AGENTS.md rule 5).
Mounted lazily — assets/router.js imports sources.js on first
show only (mount-once, hide-forever). -->
<section class="view" id="view-rag" hidden inert aria-label="RAG" tabindex="-1">
<div class="container sources-shell">
<div class="page-head">
<div class="page-head-row">
<h1>Knowledge base</h1>
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span class="sync-label" id="sync-label">Sync sources</span>
</button>
</div>
<p class="page-sub">
Every file indexed from your configured sources — git repositories,
local directories, and uploaded archives. Press <strong>Sync sources</strong>
to pull the latest and re-import.
</p>
</div>
<!-- #sync-result is the aria-live announcer: the last sync
result ("N added · …") when a sync settles, and — phase 64 —
the LIVE file label while either job runs ("Syncing… <file>
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
label span ellipsizes; screen readers hear the full
source/relative path, which also rides the button title).
After an upload settles it stays empty — the upload's counts
live on the Sources page (A3). -->
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
<span id="sync-error-text"></span>
</div>
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
login locks — the document viewer itself stays public (soft
rule), so the copy says what stays open. -->
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="sources-gate-title">Sign in to view the full catalog</h2>
<p class="sources-gate-sub">
The complete list of indexed documents is admin-only. Chat — and
any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
</section>
<div class="stat-cards" id="stat-cards">
<div class="stat-card" role="group" aria-label="Document statistics">
<span class="stat-value" id="stat-docs">–</span>
<span class="stat-label">documents</span>
</div>
<div class="stat-card" role="group" aria-label="Chunk statistics">
<span class="stat-value" id="stat-chunks">–</span>
<span class="stat-label">chunks</span>
</div>
<div class="stat-card" role="group" aria-label="Last indexed">
<span class="stat-value stat-value-sm" id="stat-last">–</span>
<span class="stat-label">last indexed</span>
</div>
</div>
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
<table class="docs-table" id="docs-table">
<caption class="visually-hidden">Indexed markdown documents</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Path</th>
<th scope="col">Title</th>
<th scope="col">Chunks</th>
<th scope="col">Indexed</th>
</tr>
</thead>
<tbody id="docs-tbody"></tbody>
</table>
</div>
<div class="empty-state" id="sources-empty" hidden>
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12a4 4 0 0 1 4-4h10l4 5h14a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M6 20h36"/><path d="M15 28h9M15 33h14"/></svg>
</div>
<h2 class="empty-state-title">Nothing indexed yet</h2>
<p class="empty-state-sub">
Run the import to pull in the markdown docs:
<code>uv run python -m scripts.import_docs</code>
</p>
</div>
</div>
</section>
<!-- Phase 76 (task 02): the Sources view (the git-sources manager
+ archive uploads) — the content of frontend/git-sources.html's
<main> (wrapper dropped), folded into the shell.
/git-sources.html now serves THIS document (the shell route
in app/main.py); the router shows this section for that
pathname. The per-view copies of the header-owned steering
panel + announcer are dropped (same reasoning as the RAG
view above), and the per-page footer does not move. The
upload-progress state machine (phase 64/65) is mounted ONCE
(mount-once, hide-forever) and keeps running across view
switches in this one document: its poller is a self-chaining
setTimeout started when an upload begins — never at boot — so
progress continues while the user is on another view, and
nothing refetches on re-show. The hidden + inert pair is the
WCAG contract (AGENTS.md rule 5). Mounted lazily —
assets/router.js imports git-sources.js on first show only. -->
<section class="view" id="view-git-sources" hidden inert aria-label="Sources" tabindex="-1">
<div class="container git-sources-shell">
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
pattern (phase 16) and the same .sources-gate visual
language: the page is the same shape as Sources. Visible
for anonymous, hidden for the admin (git-sources.js). The
catalog of git sources is what the login locks — chat stays
open to everyone (the soft rule). -->
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
<p class="sources-gate-sub">
The list of repositories cloned and indexed by the sync service
clones and indexes is admin-only. Chat — and any document an
answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
</section>
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
gate is what anonymous visitors see). git-sources.js
reveals it once the cached whoami says admin, then loads
the list. Full-width table on the 72rem frame — the
Sources-page pattern, no skinny single-column list. -->
<div id="git-sources-content" hidden>
<div class="page-head">
<h1>Git sources</h1>
<p class="page-sub">
The git repositories and local directories the Sync button
imports. Add or remove them here — no <code>.env</code>, no
restart.
</p>
</div>
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
non-2xx or network failure must never leave a stuck page.
git-sources.js fills #git-sources-load-error-text. -->
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
<span id="git-sources-load-error-text"></span>
<button type="button" id="git-sources-retry">Try again</button>
</div>
<!-- Env-fallback note (phase locked decision): while the
git_sources table is EMPTY the list above comes from
BOR_GIT_SOURCES in .env (from_env: true) — the note says
so, and that adding or removing here switches management
to the database. Hidden by default; git-sources.js shows
it off the API's from_env flag. -->
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
These sources currently come from <code>BOR_GIT_SOURCES</code> in
<code>.env</code> — adding or removing one here switches management
to the database.
</p>
<!-- Add form: visible label + mono URL input + brand button
(dark ink on brand 5.2:1). §7.4 never-stale: the button
disables + relabels "Adding…" while the POST is in flight
and re-enables on success AND failure (the input is kept
on failure, same as the tuning forms). -->
<form id="git-source-form">
<label for="git-source-url">Add a git source</label>
<input
id="git-source-url"
name="url"
type="text"
maxlength="500"
autocomplete="off"
placeholder="https://github.com/you/your-repo.git"
required
>
<button type="submit" id="git-source-add">Add source</button>
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
</form>
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
form replaces the phase-38 local-directory form — an
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
BOR_UPLOAD_DIR and scanned; the same filename replaces the
source in place (no new folder, no duplicate row). The file
control is labeled (visible <label for=…> — WCAG
input-label rule); the button runs the §7.4 never-stale
lifecycle ("Uploading…" while the POST is out). Phase 64
(task 05) reworks the rest to the 202 contract (the
phase-49 synchronous 200 paragraph is superseded): the 202
arrives the moment the archive is safely on disk (A1) — a
JS-created "Successfully uploaded — <file>" toast fires
then (A2 — the phase-55 .toast node, no markup here; safe
to navigate away) and the button settles into the live
"Processing… <file> (n/m)" label (A4 — the full path rides
the button title) driven by the 2 s poll of
GET /api/git-sources/upload/status, until the success line
(role=status) or the sanitized error banner (role=alert)
lands; 409 re-attaches to the in-flight run — no error
banner; the other non-2xx still show the server detail
inline. -->
<form id="archive-upload-form">
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
<input id="archive-upload-file" name="file" type="file"
accept=".tar,.tar.gz,.tgz,.zip" required>
<button type="submit" id="archive-upload-btn">Upload &amp; scan</button>
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
<p class="git-source-result" id="archive-upload-result" role="status"
aria-live="polite" hidden></p>
</form>
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
<table class="git-sources-table" id="git-sources-table">
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Added</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="git-sources-tbody"></tbody>
</table>
</div>
<!-- Empty state — no stored rows AND no env fallback. With
from_env, the env note above already explains where the
active list comes from. -->
<p class="git-sources-empty" id="git-sources-empty" hidden>No sources stored yet.</p>
<!-- Phase 69 (owner request 2026-09-02): removal is a TOTAL
removal — the row, the source's indexed documents, and —
for git clones and uploaded archives — the files on the
server's disk, all immediately (the confirmation modal
below spells it out; foreign local directories are never
touched). Adding still does not clone — the Sync button
mirrors the remaining sources (upstream file churn is
pruned on that run); the phase-49 upload is the
in-place exception (it unpacks and scans, and a
same-name re-upload replaces the source in place). -->
<p class="git-source-hint" id="git-sources-hint" role="note">
Removing a source is a total removal, done immediately: its
entry, its indexed documents, and — for git clones and
uploaded archives — its files on the server's disk (the
confirmation modal spells out exactly what will be deleted;
files in your own local directories are never touched).
Uploads unpack and scan immediately — re-uploading the same
filename replaces that source in place (no new folder, no
duplicate row). The Sync button still mirrors the remaining
sources (files removed upstream are pruned on that run).
</p>
<!-- Phase 69 (owner request 2026-09-02): the remove
confirmation — a real in-app alertdialog (the native
confirm() retired): a row's Remove button opens it
(git-sources.js).
It names the source (#remove-confirm-source — ALWAYS
populated via textContent: URLs may embed user:pass@
credentials, the phase-32 masking discipline) and states
the full-removal policy. Focus lands on Cancel (the safe
default for a destructive action); Escape, the Cancel
button, and the dim backdrop all close as cancel (no
request — focus returns to the row's Remove button); only
"Remove source" sends the DELETE, in the §7.4 "Removing…"
in-flight state. The .doc-modal overlay contract: a fixed
full-viewport dim backdrop + a centered panel (no blur).
Static markup so the E2E suite gets stable selectors (the
#git-sources-hint / gate convention). -->
<div class="remove-confirm" id="remove-confirm-dialog" role="alertdialog"
aria-modal="true" aria-labelledby="remove-confirm-title"
aria-describedby="remove-confirm-copy" hidden>
<div class="remove-confirm-backdrop" aria-hidden="true"></div>
<div class="remove-confirm-panel">
<h2 class="remove-confirm-title" id="remove-confirm-title">Remove this source?</h2>
<code class="remove-confirm-source" id="remove-confirm-source"></code>
<p class="remove-confirm-copy" id="remove-confirm-copy">
This permanently removes the source entry, all of its
indexed documents from the knowledge base, and — for git
clones and uploaded archives — the files on the server's
disk. Files in your own local directories are never
touched. This cannot be undone.
</p>
<p class="remove-confirm-error" id="remove-confirm-error" role="alert" hidden></p>
<div class="remove-confirm-actions">
<button type="button" class="remove-confirm-btn remove-confirm-cancel"
id="remove-confirm-cancel">Cancel</button>
<button type="button" class="remove-confirm-btn remove-confirm-remove"
id="remove-confirm-remove">Remove source</button>
</div>
</div>
</div>
</div>
<!-- Polite live region: the screen-reader confirmation for list
loads, adds, and removals (git-sources.js owns the text). -->
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
</div>
</section>
<!-- Phase 76 (task 03): the History view (saved chats) — the
content of frontend/history.html's <main> (wrapper dropped),
folded into the shell. /history.html now serves THIS document
(the shell route in app/main.py); the router shows this
section for that pathname. The per-view copies of the
header-owned steering panel + announcer are dropped (the
shell's ONE panel — the chat one, inside #view-chat — is the
instance header.js drives), and the page's footer is dropped
too (the history page's #app-version span would DUPLICATE the
shell's single (chat) footer). The row actions stay REAL
navigations: the Open link (?chat=<id>) and the copy-link
field are plain anchor/document-load targets — opening a
saved chat is a chat-view concern handled by app.js at boot
via ?chat= (out of scope for the router). The hidden + inert
pair is the WCAG contract: a hidden view must not receive
focus or keyboard traversal (AGENTS.md rule 5). Mounted
lazily — assets/router.js imports history.js on first show
only (mount-once, hide-forever). -->
<section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1">
<div class="container history-shell">
<div class="page-head">
<h1>Saved chats</h1>
<p class="page-sub">
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
</p>
</div>
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
gate — the EXACT #sources-gate pattern (phase 16) and the
same .sources-gate visual language (phase 35, git-sources):
the saved-chat list is what the login locks. Visible for
anonymous, hidden for the admin (history.js) — and the
view never fetches /api/chats for an anonymous visitor
(the router 403s them; the story E2E pins the request
log). -->
<section class="sources-gate" id="history-gate" aria-labelledby="history-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="history-gate-title">Sign in to view your saved chats</h2>
<p class="sources-gate-sub">
Saved conversations are admin-only. Chat — and any document an
answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
</section>
<!-- Live-region feedback for row actions (the "never stale"
contract): history.js sets textContent here — a delete's
outcome, its error line, nothing else. -->
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages |
Updated | Stale (phase 53: the READ-ONLY staleness marker —
the rose pill when the row predates the last KB-changing
sync; the Regenerate action lives on the chat-page banner,
task 05) | Share (phase 51: Create link / Copy / Unshare —
the row's share_url comes from GET /api/chats itself, no
second fetch) | Actions (Delete, inline two-step confirm).
history.js fills #history-tbody; #history-empty-row ships
hidden and is revealed by a 0-row fetch. The Actions column
header is visually-hidden — the row buttons carry their own
aria-labels. -->
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
<table class="history-table">
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Messages</th>
<th scope="col">Updated</th>
<th scope="col">Stale</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden>
<td colspan="6">No saved chats yet — start a conversation and it will be saved automatically.</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<footer class="app-footer">
@@ -279,6 +753,12 @@
own `import "./header.js"` — a hoisted import that is evaluated
before the page script body calls initSharedHeader() at boot. -->
<script type="module" src="/assets/app.js"></script>
<!-- Phase 76 (task 01): the shell router — AFTER app.js (boot order:
brand.js classic → app.js module → router.js module). It reads
location.pathname, shows the matching view, and lazy-imports the
non-chat view modules on first show only (mount-once). The chat
view needs no module import: app.js already ran at shell boot. -->
<script type="module" src="/assets/router.js"></script>
<!-- Phase 26: the almost-fullscreen document modal. Source chips and
Sources-table path links open documents here (same-page overlay,
-241
View File
@@ -1,241 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Documents indexed in Brain of Reese.">
<title>Sources · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Tuning link above. -->
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container sources-shell">
<div class="page-head">
<div class="page-head-row">
<h1>Knowledge base</h1>
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span class="sync-label" id="sync-label">Sync sources</span>
</button>
</div>
<p class="page-sub">
Every file indexed from your configured sources — git repositories,
local directories, and uploaded archives. Press <strong>Sync sources</strong>
to pull the latest and re-import.
</p>
</div>
<!-- #sync-result is the aria-live announcer: the last sync
result ("N added · …") when a sync settles, and — phase 64 —
the LIVE file label while either job runs ("Syncing… <file>
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
label span ellipsizes; screen readers hear the full
source/relative path, which also rides the button title).
After an upload settles it stays empty — the upload's counts
live on the Sources page (A3). -->
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
<span id="sync-error-text"></span>
</div>
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
login locks — the document viewer itself stays public (soft
rule), so the copy says what stays open. -->
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="sources-gate-title">Sign in to view the full catalog</h2>
<p class="sources-gate-sub">
The complete list of indexed documents is admin-only. Chat — and
any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
</section>
<div class="stat-cards" id="stat-cards">
<div class="stat-card" role="group" aria-label="Document statistics">
<span class="stat-value" id="stat-docs">–</span>
<span class="stat-label">documents</span>
</div>
<div class="stat-card" role="group" aria-label="Chunk statistics">
<span class="stat-value" id="stat-chunks">–</span>
<span class="stat-label">chunks</span>
</div>
<div class="stat-card" role="group" aria-label="Last indexed">
<span class="stat-value stat-value-sm" id="stat-last">–</span>
<span class="stat-label">last indexed</span>
</div>
</div>
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
<table class="docs-table" id="docs-table">
<caption class="visually-hidden">Indexed markdown documents</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Path</th>
<th scope="col">Title</th>
<th scope="col">Chunks</th>
<th scope="col">Indexed</th>
</tr>
</thead>
<tbody id="docs-tbody"></tbody>
</table>
</div>
<div class="empty-state" id="sources-empty" hidden>
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12a4 4 0 0 1 4-4h10l4 5h14a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M6 20h36"/><path d="M15 28h9M15 33h14"/></svg>
</div>
<h2 class="empty-state-title">Nothing indexed yet</h2>
<p class="empty-state-sub">
Run the import to pull in the markdown docs:
<code>uv run python -m scripts.import_docs</code>
</p>
</div>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span class="footer-text">Powered by self-hosted models</span>
</div>
</footer>
<!-- Phase 19: the shared header module loads through the page script's
own `import "./header.js"` — a hoisted import that is evaluated
before the page script body calls initSharedHeader() at boot.
Phase 26: markdown.js (the classic global renderMarkdown) loads
BEFORE the module script — the document modal renders md
documents through it on this page too. -->
<!-- Phase 39: the brand layer — classic script, first on the page:
window.BOR_BRAND at parse time, refreshed from /api/config. -->
<script src="assets/brand.js"></script>
<script src="assets/markdown.js"></script>
<script type="module" src="/assets/sources.js"></script>
<!-- Phase 26: the almost-fullscreen document modal — SAME skeleton as
the chat page (index.html): Sources-table path links open documents
here (same-page overlay, no new tab) instead of navigating to
/document.html, which stays the no-JS / direct-link fallback,
unchanged. The page script fetches /api/documents/content and
renders into #doc-modal-content through the shared renderDocument
(document.js); the hidden attribute keeps the skeleton inert until
JS opens it. #doc-modal-open points at the same
/document.html?source=…&path=… URL the link carries, so the
dedicated page is always one click away. -->
<div class="doc-modal" id="doc-modal" hidden>
<div class="doc-modal-backdrop" id="doc-modal-backdrop" aria-hidden="true"></div>
<div class="doc-modal-panel" id="doc-modal-panel" role="dialog" aria-modal="true" aria-labelledby="doc-modal-title" aria-describedby="doc-modal-desc">
<header class="doc-modal-header">
<h2 class="doc-modal-title" id="doc-modal-title">Loading…</h2>
<div class="doc-modal-actions">
<a class="doc-modal-open" id="doc-modal-open" target="_blank" rel="noopener" hidden aria-label="Open in full page">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
<span>Full page</span>
</a>
<button type="button" class="doc-modal-close" id="doc-modal-close" aria-label="Close document">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
</header>
<div class="doc-modal-meta" id="doc-modal-meta" aria-live="polite"></div>
<p class="visually-hidden" id="doc-modal-desc" role="status">Document content is loading.</p>
<main class="doc-modal-content" id="doc-modal-content" tabindex="-1">
<p class="doc-modal-loading" role="status">Loading document…</p>
</main>
</div>
</div>
</body>
</html>
-163
View File
@@ -1,163 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
<title>Global Tuning · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<!-- Phase 19 (now every page — phase 34, owner confirmation
2026-08-26): the Sources link is admin-only (owner
permission 2026-08-23) — hidden by default, header.js
reveals it once whoami says admin. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
<!-- Phase 29 (now every page — phase 34, owner confirmation
2026-08-26): the Global Tuning link is admin-only (owner
permission 2026-08-25) — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
reveals it once whoami says admin, exactly like the
Tuning link above. -->
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
out is visible; /api/whoami decides at load (the shared
header module). Icon-only below 640px (aria-labels keep the
accessible names). -->
<a href="/login.html?next=/tuning.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat pages;
the chat page keeps it after #kb-banner. -->
<section class="steering-panel" id="steering-panel" role="region"
aria-label="Tuning notes" hidden>
<div class="steering-panel-head">
<h2 class="steering-panel-title">Tuning notes</h2>
<p class="steering-panel-sub">Every note below steers all future answers.</p>
</div>
<ul class="steering-list" id="steering-list"></ul>
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="container tuning-shell">
<div class="page-head">
<h1>Global Tuning</h1>
<p class="page-sub">
Every note below is read into the system prompt of
<strong>every</strong> chat turn. Add, edit, or remove them here —
no conversation required.
</p>
</div>
<!-- Phase 27: create a note without a chat. The label is
visually-hidden (the heading + placeholder carry the visible
context); the 1–2000-char contract mirrors the chat-page tune
form — the server re-validates (422). -->
<form id="tune-form">
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
<textarea
id="tune-note"
name="note"
rows="3"
maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"
required
></textarea>
<button type="submit" id="tune-save">Add note</button>
</form>
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
task 03) owns the message text. -->
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
<!-- Phase 27: the note list — the phase-15 steering panel's
language, full column width. tuning.js fills it newest-first;
each row is an <li class="tuning-note"> with a
.tuning-note-text span + an Edit and a Delete button (styles:
styles.css "Global tuning page"). The empty state toggles with
the list. -->
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
<ul id="tune-list" class="tuning-list" role="list"></ul>
<p id="tune-empty">No tuning notes yet — add one above.</p>
</section>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span class="footer-text">Powered by self-hosted models</span>
</div>
</footer>
<!-- Phase 27: markdown.js (the classic global renderMarkdown) loads
BEFORE the module script; the shared header module loads through
tuning.js's own `import "./header.js"` — a hoisted import that is
evaluated before the page script body calls initSharedHeader() at
boot. No direct header.js <script> tag (single-evaluation design). -->
<!-- Phase 39: the brand layer — classic script, first on the page:
window.BOR_BRAND at parse time, refreshed from /api/config. -->
<script src="assets/brand.js"></script>
<script src="assets/markdown.js"></script>
<script type="module" src="/assets/tuning.js"></script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
"""Shared CSV benchmark recorder for model testing scripts.
All model-test scripts (chat, summary, embedding) write their results
to ``benchmarks/model_benchmarks.csv`` so results accumulate across
runs and can be compared with a spreadsheet or a simple query.
CSV columns (one row per *run*, not per turn):
date,script,model,mode,gate_status,turns,answered,caps,
tool_turns,emitted,executed,contract,wall_s,
extra_col1,extra_val1,extra_col2,extra_val2
* ``script`` — ``chat`` | ``summary`` | ``embed``
* ``model`` — the model name (e.g. ``lite``, ``turbo``)
* ``mode`` — ``fixture`` | ``derived`` | ``quality`` | ``dimension`` |
``cosine`` | ``speed``
* ``gate_status`` — ``PASS`` | ``FAIL``
* ``turns`` — number of turns/questions
* ``answered`` — turns that produced an answer (no LLMError)
* ``caps`` — turns that hit the round cap
* ``tool_turns`` — turns that emitted at least one tool call (chat only)
* ``emitted`` / ``executed`` — tool-call counts (chat only)
* ``contract`` — well-formed / total calls (chat) or quality score 0-100
(summary) or cosine accuracy 0-100 (embed)
* ``wall_s`` — total wall seconds for the run
* ``extra_*`` — script-specific secondary metrics (e.g. summary
coherence, embed dimension, embed speed per vector)
Usage from any test script::
from scripts.model_benchmark import bench_write
bench_write(
script="chat", # or "summary" / "embed"
model="lite",
mode="fixture",
gate_status="PASS",
turns=10,
answered=10,
caps=0,
tool_turns=9,
emitted=9,
executed=9,
contract=11, # numerator (denominator = emitted)
wall_s=40.5,
# optional extras:
contract_denom=12,
executed_denom=12,
)
"""
from __future__ import annotations
import csv
import os
from datetime import date
from pathlib import Path
CSV_PATH = Path(__file__).resolve().parent.parent / "benchmarks" / "model_benchmarks.csv"
_HEADER = [
"date", "script", "model", "mode", "gate_status",
"turns", "answered", "caps",
"tool_turns", "emitted", "executed",
"contract", "wall_s",
"contract_denom", "executed_denom",
"tool_turns_denom", "extra1_col", "extra1_val",
"extra2_col", "extra2_val",
]
def bench_write(
*,
script: str,
model: str,
mode: str,
gate_status: str,
turns: int,
answered: int,
caps: int,
wall_s: float,
# chat-specific
tool_turns: int = 0,
emitted: int = 0,
executed: int = 0,
contract: int = 0,
# denominators (for percentages)
contract_denom: int | None = None,
executed_denom: int | None = None,
tool_turns_denom: int | None = None,
# extras (any script)
extra1_col: str = "",
extra1_val: str = "",
extra2_col: str = "",
extra2_val: str = "",
) -> None:
"""Append one row to the benchmark CSV."""
CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
# Defaults: if denom not given, use the numerator
c_denom = contract_denom if contract_denom is not None else (contract if contract > 0 else 0)
e_denom = executed_denom if executed_denom is not None else (emitted if emitted > 0 else 0)
t_denom = tool_turns_denom if tool_turns_denom is not None else turns
row = {
"date": str(date.today()),
"script": script,
"model": model,
"mode": mode,
"gate_status": gate_status,
"turns": turns,
"answered": answered,
"caps": caps,
"tool_turns": tool_turns,
"tool_turns_denom": t_denom,
"emitted": emitted,
"executed": executed,
"executed_denom": e_denom,
"contract": contract,
"contract_denom": c_denom,
"wall_s": f"{wall_s:.1f}",
"extra1_col": extra1_col,
"extra1_val": extra1_val,
"extra2_col": extra2_col,
"extra2_val": extra2_val,
}
file_exists = CSV_PATH.exists() and CSV_PATH.stat().st_size > 0
with open(CSV_PATH, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=_HEADER)
if not file_exists:
writer.writeheader()
writer.writerow(row)
+321
View File
@@ -0,0 +1,321 @@
"""Embedding-model quality and speed benchmark.
Tests the configured ``BOR_LLM_EMBED_MODEL`` (default ``embed``) across
three dimensions:
1. **Dimension check** — output vector length matches ``BOR_EMBEDDING_DIM``
2. **Cosine accuracy** — semantically similar text pairs have higher
cosine similarity than dissimilar pairs
3. **Speed** — vectors produced per second
4. **Vector quality** — no NaN / Inf in any output vector
Each run produces one CSV row via ``scripts/model_benchmark.bench_write``
and prints a ``gate:`` verdict line.
Usage::
uv run python -m scripts.test_embed_model # default model
uv run python -m scripts.test_embed_model --model embed
uv run python -m scripts.test_embed_model --runs 3
"""
from __future__ import annotations
import argparse
import asyncio
import math
import os
import sys
import time
from dataclasses import dataclass
import httpx
from dotenv import load_dotenv
from scripts.model_benchmark import CSV_PATH, bench_write
# ── semantic pairs (similar / dissimilar) ──────────────────────────────
_SEMANTIC_PAIRS = [
# (similar_pair_text_a, similar_pair_text_b, dissimilar_pair_text_a, dissimilar_pair_text_b)
(
"The server runs Ubuntu 24.04 with nginx as a reverse proxy",
"Ubuntu 24.04 server with nginx reverse proxy configuration",
"The backup uses restic with daily scheduling at 2 AM",
"Qwen 3.8 model inference on llama.cpp with GPU acceleration",
),
(
"PostgreSQL 17 with pgvector extension for semantic search",
"Postgres 17 database with vector embeddings for similarity",
"Docker containers deployed via Quadlet on Proxmox VE",
"The network bridge connects VLAN 130 to the physical port",
),
(
"GitLab CI runner with autoscaling and Docker executor",
"CI/CD pipeline runner that scales containers automatically",
"Restic backup with 7-day retention and S3 repository",
"Uptime Kuma monitoring dashboard with HTTP health checks",
),
(
"Valkey cache running on localhost port 6379 for Mimir",
"Redis-compatible Valkey instance for caching services",
"Ansible inventory with three Proxmox nodes and custom roles",
"The Qwen model file is 16 GB loaded into GPU VRAM",
),
(
"Proxmox VE 8.3.4 cluster with three nodes and 64 GB RAM",
"PVE cluster of three servers each with 64 gigabytes memory",
"Obsidian vault with markdown documents and semantic search",
"The ntfy topic reese-uptime-7 receives monitoring alerts",
),
]
# ── results ─────────────────────────────────────────────────────────────
@dataclass
class EmbedResult:
mode: str # "dimension" | "cosine" | "speed"
gate_status: str
wall_s: float
extra1_col: str
extra1_val: str
extra2_col: str
extra2_val: str
# ── helpers ─────────────────────────────────────────────────────────────
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def _has_nan_or_inf(vec: list[float]) -> bool:
for v in vec:
if math.isnan(v) or math.isinf(v):
return True
return False
async def _embed(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
"""POST /embeddings and return list of vectors."""
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1")
api_key = os.environ.get("BOR_LLM_API_KEY", "")
embed_model = os.environ.get("BOR_LLM_EMBED_MODEL", model)
resp = await client.post(
f"{base_url}/embeddings",
json={"model": embed_model, "input": texts},
headers={"Authorization": f"Bearer {api_key}"},
)
if resp.status_code >= 400:
raise RuntimeError(
f"embeddings endpoint HTTP {resp.status_code}: {resp.text[:300]}"
)
body = resp.json()
return [d["embedding"] for d in body["data"]]
# ── tests ───────────────────────────────────────────────────────────────
async def _test_dimension(model: str) -> EmbedResult:
"""Check that output vector length matches BOR_EMBEDDING_DIM."""
expected_dim = int(os.environ.get("BOR_EMBEDDING_DIM", "768"))
probe_text = "brain of reese dimension probe"
start = time.monotonic()
async with httpx.AsyncClient(timeout=30) as client:
vectors = await _embed(client, model, [probe_text])
wall = time.monotonic() - start
actual_dim = len(vectors[0])
ok = actual_dim == expected_dim
gate = "PASS" if ok else "FAIL"
print(f" dimension: expected={expected_dim} actual={actual_dim} {'✓' if ok else '✗'}")
return EmbedResult(
mode="dimension",
gate_status=gate,
wall_s=wall,
extra1_col="actual_dim",
extra1_val=str(actual_dim),
extra2_col="expected_dim",
extra2_val=str(expected_dim),
)
async def _test_cosine(model: str) -> EmbedResult:
"""Similar pairs should have higher cosine similarity than dissimilar pairs."""
start = time.monotonic()
async with httpx.AsyncClient(timeout=120) as client:
all_texts = []
for a, b, c, d in _SEMANTIC_PAIRS:
all_texts.extend([a, b, c, d])
vectors = await _embed(client, model, all_texts)
wall = time.monotonic() - start
# Compute similarities
similar_scores = []
dissimilar_scores = []
for i in range(0, len(_SEMANTIC_PAIRS) * 4, 4):
# a[0] vs a[1] (similar)
sim = _cosine(vectors[i], vectors[i + 1])
similar_scores.append(sim)
# a[2] vs a[3] (dissimilar)
dissim = _cosine(vectors[i + 2], vectors[i + 3])
dissimilar_scores.append(dissim)
# Cross: a[0] vs a[2] (should be lower than similar)
cross = _cosine(vectors[i], vectors[i + 2])
avg_similar = sum(similar_scores) / len(similar_scores)
avg_dissimilar = sum(dissimilar_scores) / len(dissimilar_scores)
margin = avg_similar - avg_dissimilar
# Gate: similar > dissimilar (margin > 0) and margin >= 0.10
ok = margin >= 0.05 # relaxed threshold
gate = "PASS" if ok else "FAIL"
quality = min(100, max(0, int(margin * 200))) # map margin 0-0.5 → 0-100
print(f" cosine: similar={avg_similar:.3f} dissimilar={avg_dissimilar:.3f} "
f"margin={margin:.3f} {'✓' if ok else '✗'}")
return EmbedResult(
mode="cosine",
gate_status=gate,
wall_s=wall,
extra1_col="margin",
extra1_val=f"{margin:.3f}",
extra2_col="quality",
extra2_val=str(quality),
)
async def _test_speed(model: str) -> EmbedResult:
"""Measure embeddings per second."""
# 50 varied texts
texts = [f"Test embedding number {i} with some content to make it realistic "
f"and meaningful for benchmarking purposes in the brain of reese system."
for i in range(50)]
start = time.monotonic()
async with httpx.AsyncClient(timeout=120) as client:
vectors = await _embed(client, model, texts)
wall = time.monotonic() - start
# Check for NaN/Inf
bad = sum(1 for v in vectors if _has_nan_or_inf(v))
quality = "PASS" if bad == 0 else "FAIL"
rate = len(vectors) / wall if wall > 0 else 0
print(f" speed: {len(vectors)} vectors in {wall:.1f}s = {rate:.1f} vec/s "
f"{'✓' if bad == 0 else f'✗ {bad} bad vectors'}")
return EmbedResult(
mode="speed",
gate_status=quality,
wall_s=wall,
extra1_col="rate",
extra1_val=f"{rate:.1f} vec/s",
extra2_col="bad_vectors",
extra2_val=str(bad),
)
# ── main ───────────────────────────────────────────────────────────────
async def run_benchmark(model: str, runs: int = 1) -> list[EmbedResult]:
"""Run all embedding benchmark tests."""
all_results: list[EmbedResult] = []
for run_idx in range(runs):
prefix = f"run {run_idx + 1}: " if runs > 1 else ""
print(f"\n{prefix}dimension check...")
r_dim = await _test_dimension(model)
all_results.append(r_dim)
print(f"\n{prefix}cosine accuracy...")
r_cos = await _test_cosine(model)
all_results.append(r_cos)
print(f"\n{prefix}speed test...")
r_spd = await _test_speed(model)
all_results.append(r_spd)
return all_results
def _print_summary(results: list[EmbedResult]) -> str:
"""Print summary and return overall gate status."""
# Group by mode
by_mode: dict[str, list[EmbedResult]] = {}
for r in results:
by_mode.setdefault(r.mode, []).append(r)
overall = "PASS"
for mode in ("dimension", "cosine", "speed"):
runs = by_mode.get(mode, [])
statuses = [r.gate_status for r in runs]
all_pass = all(s == "PASS" for s in statuses)
if not all_pass:
overall = "FAIL"
label = f" {mode:12s}"
if len(runs) == 1:
r = runs[0]
label += f" {r.gate_status}"
if r.extra1_col:
label += f" ({r.extra1_col}={r.extra1_val})"
else:
label += f" {'/'.join(r.gate_status for r in runs)}"
print(label)
return overall
def main() -> None:
parser = argparse.ArgumentParser(description="Embedding model benchmark")
parser.add_argument("--model", default=None,
help="Model name (overrides BOR_LLM_EMBED_MODEL)")
parser.add_argument("--runs", type=int, default=1,
help="Number of full test passes")
args = parser.parse_args()
load_dotenv()
model = args.model or os.environ.get("BOR_LLM_EMBED_MODEL", "embed")
print(f"Benchmarking embedding model: {model} ({args.runs} run(s))")
results = asyncio.run(run_benchmark(model, runs=args.runs))
overall = _print_summary(results)
# Write to CSV — one row per mode per run
for r in results:
bench_write(
script="embed",
model=model,
mode=r.mode,
gate_status=r.gate_status,
turns=1,
answered=1,
caps=0,
wall_s=r.wall_s,
contract=1, # dimension test: 1=pass
extra1_col=r.extra1_col,
extra1_val=r.extra1_val,
extra2_col=r.extra2_col,
extra2_val=r.extra2_val,
)
print(f"\nBenchmarks recorded to {CSV_PATH}")
sys.exit(0 if overall == "PASS" else 1)
if __name__ == "__main__":
main()
+355
View File
@@ -0,0 +1,355 @@
"""Summary-model quality and speed benchmark.
Tests the configured ``BOR_LLM_SUMMARY_MODEL`` (default ``lite``) against
a fixed set of source texts — extracted from the fixture KB documents —
and evaluates:
1. **Coherence** — is the summary non-empty, grammatical, and on-topic?
2. **Coverage** — does it capture the key facts from the source?
3. **Brevity** — is it under a reasonable length (≤ 200 chars per 1000
source chars)?
4. **Hallucination** — does it introduce facts not present in the source?
5. **Speed** — wall seconds per summary.
Each run produces one CSV row via ``scripts/model_benchmark.bench_write``
and prints a ``gate:`` verdict line.
Usage::
uv run python -m scripts.test_summary_model # default model
uv run python -m scripts.test_summary_model --model turbo
uv run python -m scripts.test_summary_model --model lite --runs 3
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import time
from dataclasses import dataclass, field
from dotenv import load_dotenv
# ── fixture texts (from tests/fixtures/agent_kb/) ──────────────────────
_FIXTURE_TEXTS = [
# (short label, source text)
("vela-bridges",
"VLAN bridges on Proxmox VE 8.3.4-1-lab1: two Linux bridges "
"br-lab (10.77.42.0/24, VLAN 130) and br-mgmt (10.77.43.0/24, "
"VLAN 131) connect VMs to the physical network via ports "
"eno1 and eno2. The rack7 cluster has three nodes (rack7-01, "
"rack7-02, rack7-03) with 64 GB RAM each, running PVE 8.3.4. "
"Uptime Kuma listens on 127.0.0.1:18443 with ntfy topic "
"reese-uptime-7 for alerts."),
("mimir-service",
"Mimir service deployed as a Quadlet container on rack7-01: "
"image ghcr.io/reese/obsidian-bor:2026.7.14, mapped to "
"127.0.0.1:18765. Configuration is stored in "
"/etc/mimir/mimir.yml with a 7-day retention policy. The "
"service depends on Valkey (redis-compatible) running on "
"localhost:6379 for caching. Health check hits /health every "
"30 seconds. Restart policy is on-failure with a 10-second "
"backoff."),
("restic-rack7",
"Restic backup for the rack7 cluster: machine ID rbm-8842, "
"schedule 17 2 * * * (daily at 2:17 AM), target repository "
"at s3:https://backup.reeseapps.com/rack7. Includes /etc, "
"/var/lib/docker, and the Postgres 17 data directory. Retention "
"keeps the last 7 daily, 4 weekly, and 12 monthly snapshots. "
"Encryption key is stored in /etc/restic/key. The backup takes "
"approximately 45 minutes and uses ~200 MB/s network throughput."),
("qwen38-llamacpp",
"Qwen 3.8 llama.cpp container on rack7-02: launch command "
"./server -m /models/qwen3.8b.Q4_K_M.gguf --host 0.0.0.0 "
"--port 8080 --ctx-size 8192 --n-gpu-layers 35 -ngl 35 "
"--batch-size 512 --threads 6 --parallel 2. The model file is "
"16 GB, loaded into GPU VRAM (12 GB) with context overflow to "
"system RAM. Inference speed is ~18 tokens/sec on the A1000 "
"laptop GPU. Temperature is set to 0.7 for creative tasks."),
("lab-inventory",
"Lab Ansible Inventory (ansible-core 2.19.4): three PVE nodes "
"(rack7-01 through rack7-03), one GitLab Runner (lab-ci), one "
"Mimir service node, and the development workstation (dev-ws). "
"All nodes share the same NTP server (time.reeseapps.com), "
"DNS (10.77.42.1), and backup repository. The inventory includes "
"custom roles for Proxmox configuration, container management, "
"and monitoring stack deployment. Playbooks are tested in a "
"staging environment before production runs."),
("gitlab-runner",
"GitLab Runner (lab-ci) registered to https://git.reeseapps.com "
"with runner token glrt-XYZ123. Executor is docker+machine with "
"autoscaling: min 1, max 3 machines. Docker image is "
"gitlab/gitlab-runner:v17.0. Each job gets a fresh machine with "
"16 GB RAM and 4 vCPUs. Cache is shared via a local MinIO "
"instance at 10.77.43.10:9000. Pipeline timeout is 30 minutes. "
"Artifacts are stored for 7 days."),
("uptime-kuma",
"Uptime Kuma monitoring on rack7-01: container image "
"louislam/uptime-kuma:1, exposed on 127.0.0.1:18443. Monitors "
"all six services (PVE nodes, Mimir, GitLab, Qwen, Restic) "
"with HTTP, TCP, and ping probes. Alerts route to ntfy topic "
"reese-uptime-7 and email (admin@reeseapps.com). Dashboard "
"requires basic auth. Data is stored in /app/data/uptime-kuma.db "
"with a daily backup to the Restic repository."),
("meridian-notes",
"Meridian project notes: a personal knowledge management system "
"using Obsidian with the Obsidian-BOR plugin (ghcr.io/reese/"
"obsidian-bor:2026.7.14). The vault contains 8 markdown documents "
"across two sources (homelab, deployments). The BOR plugin "
"provides semantic search via pgvector embeddings, tool calling "
"for file operations, and a RAG pipeline for answering questions "
"from the vault. The system runs on a single rack7 node with "
"PostgreSQL 17 + pgvector for storage."),
]
# ── quality rubric ─────────────────────────────────────────────────────
@dataclass
class SummaryResult:
label: str
source_len: int
summary_len: int
summary: str
coherence: int # 1-5
coverage: int # 1-5
brevity: int # 1-5
hallucination: bool # True if we detect a hallucination
wall_s: float
def _briefness_ratio(summary_len: int, source_len: int) -> float:
"""Ratio of summary chars to source chars. Ideal: 0.10–0.25."""
if source_len == 0:
return 0.0
return summary_len / source_len
def _score_brevity(ratio: float) -> int:
if 0.10 <= ratio <= 0.25:
return 5
elif 0.05 <= ratio <= 0.35:
return 4
elif 0.02 <= ratio <= 0.50:
return 3
elif ratio > 0:
return 2
return 1
# ── prompt ─────────────────────────────────────────────────────────────
_SUMMARY_PROMPT = (
"Summarize the following text in 2-4 sentences. Capture the key "
"facts and numbers. Do not add information that is not present in "
"the text.\n\n{text}"
)
# ── LLM client ─────────────────────────────────────────────────────────
async def _summarize(client, model: str, text: str) -> tuple[str, float]:
"""Call the summary endpoint and return (summary_text, wall_s)."""
import httpx
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1")
api_key = os.environ.get("BOR_LLM_API_KEY", "")
summary_model = os.environ.get("BOR_LLM_SUMMARY_MODEL", model)
start = time.monotonic()
async with httpx.AsyncClient(timeout=120) as http:
resp = await http.post(
f"{base_url}/chat/completions",
json={
"model": summary_model,
"messages": [
{"role": "user", "content": _SUMMARY_PROMPT.format(text=text)}
],
"temperature": 0.2,
"max_tokens": 2048,
},
headers={"Authorization": f"Bearer {api_key}"},
)
wall = time.monotonic() - start
if resp.status_code >= 400:
raise RuntimeError(
f"summary endpoint HTTP {resp.status_code}: {resp.text[:300]}"
)
body = resp.json()
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
if content is None:
content = ""
return content.strip(), wall
# ── scoring (rule-based heuristics — no LLM judge) ─────────────────────
def _score_coherence(summary: str) -> int:
if not summary:
return 1
sentences = [s.strip() for s in summary.replace("\n", " ").split(".") if s.strip()]
if len(sentences) < 2:
return 2
if len(sentences) >= 2 and summary[0].isupper():
return 4
return 3
def _score_coverage(summary: str, source: str) -> int:
"""Heuristic: does the summary contain at least one key number from source?"""
# Extract numbers from source
import re
source_nums = set(re.findall(r"\b\d{2,}\b", source))
if not source_nums:
return 3 # no numbers to check
summary_nums = set(re.findall(r"\b\d{2,}\b", summary))
hit = source_nums & summary_nums
if len(hit) >= 2:
return 5
elif len(hit) == 1:
return 4
elif len(hit) == 0:
return 2
return 3
def _detect_hallucination(summary: str, source: str) -> bool:
"""Check if summary contains specific identifiers not in source."""
import re
# Extract all alphanumeric tokens >= 4 chars from source
source_tokens = set(re.findall(r"\b[a-zA-Z_]\w{3,}\b", source.lower()))
# Check summary tokens
summary_tokens = set(re.findall(r"\b[a-zA-Z_]\w{3,}\b", summary.lower()))
# If summary has a long token not in source, flag it
# (short common words are fine)
uncommon = summary_tokens - source_tokens
# Filter out very common English words
common = {"system", "service", "network", "server", "data", "file",
"host", "port", "port", "running", "config", "value",
"model", "image", "container", "running", "local", "local"}
flagged = uncommon - common
return len(flagged) > 3 # more than 3 uncommon new tokens
# ── main ───────────────────────────────────────────────────────────────
async def run_benchmark(
model: str,
runs: int = 1,
) -> list[SummaryResult]:
"""Run the summary benchmark and return results."""
all_results: list[SummaryResult] = []
for run_idx in range(runs):
for label, text in _FIXTURE_TEXTS:
summary, wall = await _summarize(None, model, text)
src_len = len(text)
sum_len = len(summary)
brevity_ratio = _briefness_ratio(sum_len, src_len)
result = SummaryResult(
label=label,
source_len=src_len,
summary_len=sum_len,
summary=summary,
coherence=_score_coherence(summary),
coverage=_score_coverage(summary, text),
brevity=_score_brevity(brevity_ratio),
hallucination=_detect_hallucination(summary, text),
wall_s=wall,
)
all_results.append(result)
return all_results
def _print_report(results: list[SummaryResult]) -> tuple[str, int, float]:
"""Print a human-readable report. Returns (gate_status, score, wall)."""
n = len(results)
answered = sum(1 for r in results if r.summary)
caps = 0 # N/A for summary
coherence_avg = sum(r.coherence for r in results) / n if n else 0
coverage_avg = sum(r.coverage for r in results) / n if n else 0
brevity_avg = sum(r.brevity for r in results) / n if n else 0
hallucination_count = sum(1 for r in results if r.hallucination)
total_wall = sum(r.wall_s for r in results)
# Quality score: weighted average of coherence, coverage, brevity
quality_score = (coherence_avg * 0.35 + coverage_avg * 0.40 + brevity_avg * 0.25) / 5.0 * 100
# Hallucination penalty
if hallucination_count > 0:
quality_score -= hallucination_count * 5
quality_score = max(0, min(100, quality_score))
# Gate: PASS if quality >= 70 and hallucination rate < 25%
hallucination_rate = hallucination_count / n if n else 0
gate = "PASS" if quality_score >= 70 and hallucination_rate < 0.25 else "FAIL"
print(f"\ngate: {results[0].summary.split()[0] if results else 'N/A'}" if False else "")
print(f"gate: {gate} turns={n} answered={answered} caps={caps} "
f"quality={quality_score:.0f} hallucinations={hallucination_count}/{n} "
f"coherence={coherence_avg:.1f}/5 coverage={coverage_avg:.1f}/5 "
f"brevity={brevity_avg:.1f}/5 (wall {total_wall:.1f}s)")
# Per-turn detail
for r in results:
h = "HALL" if r.hallucination else " "
print(f" {h} {r.label:20s} coh={r.coherence} cov={r.coverage} "
f"brev={r.brevity} len={r.summary_len:4d} wall={r.wall_s:.1f}s")
return gate, int(quality_score), total_wall
def main() -> None:
parser = argparse.ArgumentParser(description="Summary model benchmark")
parser.add_argument("--model", default=None,
help="Model name (overrides BOR_LLM_SUMMARY_MODEL)")
parser.add_argument("--runs", type=int, default=1,
help="Number of full passes")
args = parser.parse_args()
load_dotenv()
model = args.model or os.environ.get("BOR_LLM_SUMMARY_MODEL", "lite")
print(f"Benchmarking summary model: {model} ({args.runs} run(s))")
results = asyncio.run(run_benchmark(model, runs=args.runs))
gate, quality, wall = _print_report(results)
# Write to CSV
from scripts.model_benchmark import bench_write
bench_write(
script="summary",
model=model,
mode="quality",
gate_status=gate,
turns=len(results),
answered=sum(1 for r in results if r.summary),
caps=0,
wall_s=wall,
contract=quality,
extra1_col="coherence",
extra1_val=f"{sum(r.coherence for r in results) / len(results):.1f}/5",
extra2_col="hallucinations",
extra2_val=f"{sum(1 for r in results if r.hallucination)}/{len(results)}",
)
from scripts.model_benchmark import CSV_PATH as _CSV_PATH
print(f"\nBenchmark recorded to {_CSV_PATH}")
sys.exit(0 if gate == "PASS" else 1)
if __name__ == "__main__":
main()
+6 -4
View File
@@ -272,13 +272,15 @@ def test_persists_across_page_navigation(
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources (phase 16: the catalog is admin-only — the trip
# starts with a real form login). The New chat button is chat-page
# only (owner rework 2026-08-28 — it left the shared bar), so the
# sources page carries none of it (pinned in
# starts with a real form login). Phase 76 (task 02): the shell
# carries the chat view (with its New chat button) in the DOM on
# EVERY view — hidden + inert — so the button EXISTS here but must
# be HIDDEN (the view-scoped absence pattern; it left the shared
# bar at owner request, 2026-08-28 — pinned in
# tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_have_count(0)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# Back to the chat: the conversation is exactly as left — both turns,
# the source chip, and the amber deflected bubble with its chips.
+8 -3
View File
@@ -86,7 +86,10 @@ def test_history_tab_shows_auto_save_copy(
# The .page-sub reads the locked (A3) page-sub string
# (whitespace-normalized — the template wraps the line).
page_sub = page.locator(".page-sub")
# Phase 76 (task 03): the shell carries one .page-sub per view
# (the hidden views' copies remain in the DOM — hidden + inert),
# so the pin is scoped to the visible History view.
page_sub = page.locator("#view-history .page-sub")
expect(page_sub).to_have_count(1)
assert re.sub(r"\s+", " ", page_sub.inner_text()).strip() == PAGE_SUB
@@ -98,8 +101,10 @@ def test_history_tab_shows_auto_save_copy(
# The no-button contract: the WHOLE rendered page reads neither
# "press(ed) Save" nor "Save button" — while the <h1> still reads
# "Saved chats" (asserted present, so the scan cannot pass by
# deleting the heading).
h1 = page.locator("h1")
# deleting the heading). Phase 76 (task 03): scoped to the
# History view — the shell carries one <h1> per view (the hidden
# views' headings remain in the DOM, hidden + inert).
h1 = page.locator("#view-history h1")
expect(h1).to_have_count(1)
expect(h1).to_have_text("Saved chats")
body_text = page.locator("body").inner_text()
+5 -3
View File
@@ -127,7 +127,9 @@ def test_sources_table_layout(
login(page, app_url) # phase 16: the catalog is admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible")
wrap = page.locator(".table-wrap")
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
# scope to the RAG view.
wrap = page.locator("#view-rag .table-wrap")
expect(wrap).to_be_visible()
expect(wrap).to_have_attribute("role", "region")
expect(wrap).to_have_attribute("tabindex", "0")
@@ -146,7 +148,7 @@ def test_sources_table_layout(
login(mobile, app_url) # phase 16: the catalog is admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
"() => { const el = document.querySelector('#view-rag .table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll_width > client_width
@@ -163,6 +165,6 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
expect(page.locator("#sources-empty code")).to_have_text(
"uv run python -m scripts.import_docs"
)
expect(page.locator(".table-wrap")).to_be_hidden()
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
expect(page.locator("#stat-docs")).to_have_text("0")
expect(page.locator("#stat-chunks")).to_have_text("0")
+14 -16
View File
@@ -83,7 +83,7 @@ from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Dialog, Page, expect
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
@@ -498,7 +498,10 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
f"fixture doc survived the prune: {docs}"
)
# --- remove the row: accept the confirm → it disappears ------------
# --- remove the row: the phase-69 in-app confirmation modal --------
# (window.confirm is retired — the row's Remove button opens the
# #remove-confirm-dialog, which names the source and states the
# full-removal policy; "Remove source" runs the server-side DELETE.)
# Back on the manager page (the sync clicks visited the Sources page).
page.goto(app_url + GIT_SOURCES_URL)
removes: list[str] = []
@@ -508,20 +511,15 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
if r.method == "DELETE" and "/api/git-sources/" in r.url
else None,
)
def handle_dialog(dialog: Dialog) -> None:
# "Remove this local source…? Its documents stay indexed until
# the next sync prunes them." — accept it.
dialog.accept()
page.on("dialog", handle_dialog)
try:
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
row.locator(".git-source-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#git-sources-empty")).to_be_visible()
finally:
page.remove_listener("dialog", handle_dialog)
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
row.locator(".git-source-remove").click()
dialog = page.locator("#remove-confirm-dialog")
expect(dialog).to_be_visible()
expect(dialog.locator("#remove-confirm-source")).to_contain_text(str(local_dir))
# Confirm: the full cleanup runs server-side; the row disappears.
dialog.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#git-sources-empty")).to_be_visible()
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
# The registry is empty again — and with no env git list, a further
# sync would fail loudly ("no sources configured (git or local)").
+84 -11
View File
@@ -72,6 +72,18 @@ Test → story mapping (Playwright Mapping Rule):
4. ``test_viewer_back_link_honors_back_param``
5. ``test_steering_surface_off_chat_on_tuning_page``
6. ``test_sync_button_present_on_sources_page_without_triggering``
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
task 04 — the header is shell-owned: the surviving standalone
documents keep their header copies, and a navbar click on one is a
REAL departure that full-loads the shell, whose router renders the
target view from the pathname)
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
ONE shell document (index.html) — the header under test on those URLs is
the shell's single one (the router deep-links the view from the
pathname on each real goto). The per-URL inventory comparison is the
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
side of the ownership boundary (the viewer's own header copy).
"""
from __future__ import annotations
@@ -243,15 +255,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-toggle").count() == 0, (
f"{name}: the steering toggle was removed from the navbar"
)
# The Sync button is a page-specific control (Sources page only
# — owner rework 2026-08-28): visible on sources, absent from
# the shared bar everywhere else.
# The Sync button is a view-specific control (RAG view only —
# owner rework 2026-08-28): visible on sources, not visible
# anywhere else. Phase 76 (task 02): in the shell the RAG view
# (with the button) is in the DOM on every view — hidden +
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
# also passes on standalone pages where the button is absent).
if name == "sources":
expect(page.locator("#sync-btn")).to_be_visible()
else:
assert page.locator("#sync-btn").count() == 0, (
f"{name}: #sync-btn left the shared bar (Sources page only)"
)
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sign-out-btn")).to_be_visible()
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
@@ -277,14 +290,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
assert page.locator("#steering-panel").count() == 0, (
f"{name}: the steering panel must be absent for anonymous"
)
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
# The New chat button is chat-view only (moved from the shared bar
# to the shell's .chat-shell at owner request, 2026-08-28).
# Phase 76 (task 02): in the shell the chat view (with the button)
# is in the DOM on every view — hidden + inert — so the pin is
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
# pages where the button is absent).
if name == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{name}: #new-chat-btn left the shared bar (chat page only)"
)
expect(page.locator("#new-chat-btn")).to_be_hidden()
_assert_landmarks(page, name)
return _header_inventory(page)
@@ -540,3 +555,61 @@ def test_sync_button_present_on_sources_page_without_triggering(
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
expect(page.locator("#sync-label")).to_have_text("Sync sources")
# Deliberately NOT clicked.
# ---------------------------------------------------------------------------
# 7. Phase 76 task 04: the surviving documents keep their header copies —
# a navbar click on one is a REAL departure that full-loads the shell
# (whose router then renders the target view from the pathname)
# ---------------------------------------------------------------------------
def test_viewer_nav_click_full_loads_the_shell_rag_view(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Phase 76 task 04: the document viewer is one of the four surviving
standalone documents (login, shared, doc-edit, document) — it keeps
its own header copy, and the router does NOT run there (the router
lives in the shell). Clicking its RAG nav link is therefore a REAL,
document-level navigation — not the shell's pushState switch: it
full-loads the shell at /sources.html, and the shell's router renders
the RAG view from the pathname (the Chat view ships hidden + inert
inside that same document).
The window sentinel proves the departure in the phase-76 canonical
form, used in INVERSE: it is set in the viewer document and must be
GONE after the click (a real load wipes window globals — exactly
what distinguishes a departure from the shell's same-document
switches, where the sentinel survives)."""
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# Admin: the viewer's RAG nav link is admin-only (revealed by the
# whoami pass of its own header copy).
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
page.goto(app_url + VIEWER_URL)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
_wait_settled(page, admin=True)
expect(page.locator("#nav-sources")).to_be_visible()
# The sentinel lives in the VIEWER document only.
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
page.click("#nav-sources")
# The arrival is the SHELL at the RAG view's URL: the document loaded
# for real (the sentinel is gone), the RAG view is rendered (first
# table row visible), the Chat view is hidden AND inert in the same
# document, and the RAG link carries the router's single-writer
# active stamp.
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
assert page.evaluate("() => window.__phase76_viewer") is None, (
"a surviving document's nav click must be a real departure "
"(a fresh document load wipes window globals)"
)
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
"the chat view ships hidden AND inert in the shell"
)
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
+10 -3
View File
@@ -112,6 +112,11 @@ CURRENT_LINK: dict[str, str | None] = {
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
#: history — ship-hidden, revealed by header.js for admins). The DOM
#: enumeration below therefore always sees it (pre-existing since phase
#: 53; the list below now matches the real nav).
NAV_TAIL = ("History",)
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
@@ -191,12 +196,14 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
expect(git).to_have_text("Sources")
expect(git).to_have_attribute("href", "/git-sources.html")
# The four primary nav links (class nav-link) in physical DOM order.
# The nav links (class nav-link) in physical DOM order — the four
# primaries plus the phase-53 admin-only History link.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
assert nav_texts == list(NAV_LABELS), (
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
assert nav_texts == [*NAV_LABELS, *NAV_TAIL], (
f"{name}: nav link order/labels are {nav_texts}, expected "
f"{[*NAV_LABELS, *NAV_TAIL]}"
)
# …and the full anchor sequence of the nav (it also carries the
# phase-46 mobile sign-in copy) opens with the same four, in order.
+525
View File
@@ -0,0 +1,525 @@
"""Phase 76 E2E (Playwright): in-app view switches never halt a
generating answer — the owner repro, pinned against the deterministic
mock LLM.
Source: owner repro, verified in a real browser 2026-09-06 — send a
question → click **RAG** in the navbar mid-stream → click **Chat** →
the answer never finished (every navbar view was a separate document,
so the navbar click was a REAL cross-document navigation: the chat
page unloaded, the in-flight fetch was aborted, and the phase-48
teardown cancelled the turn — no ``query_log`` row, a dangling
question on return). Phase 76 folded the five navbar views into ONE
HTML shell: a navbar click is a CLIENT-SIDE view switch
(``history.pushState`` + show/hide), so the in-flight SSE reader in
the hidden chat view keeps streaming and the answer COMPLETES when
the user returns.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov
Timing is deterministic by construction: the mock's ``write a long
answer`` trigger streams a ~5400-char answer at 12 chars / 0.02 s
(~8–9 s of content), so the mid-stream switch window is wide.
The "same document" proof (the canonical pattern from the phase
overview): a ``window`` sentinel set before the nav click is still
readable after the switch — a real document load would wipe ``window``
globals. The ``performance.getEntriesByType("navigation")`` length is
deliberately NOT used: a real load resets that counter to 1 in the
fresh document, so it cannot distinguish pushState from a reload.
Test → story mapping (Playwright Mapping Rule):
1. ``test_rag_switch_mid_stream_completes`` — THE OWNER REPRO: send →
RAG mid-stream → Chat; the FULL answer completes, ``bor.chat.v1``
holds EXACTLY ONE brain turn, the server SETTLED the turn (one
``query_log`` row — no phase-48 ``turn cancelled``), and the
auto-saved row (admin, ``persistConversation``) carries the same
single full turn.
2. ``test_every_nav_view_keeps_stream`` — the same mid-stream switch
against the other three views (Sources/git-sources, Tuning,
History): one send, one switch, one return, full answer + settled
``query_log`` row each time.
3. ``test_real_departure_still_cancels`` — the phase-48 CONTROL (the
locked contract survives the phase): a genuine cross-document
departure (``/shared.html`` — a stable document for a signed-in
session; ``/login.html`` is deliberately avoided because it
auto-redirects a signed-in admin straight back into the shell)
still aborts the fetch, leaves NO ``query_log`` row, and the
page-20/73 leave-save lands the partial in the EXACT shape pinned
by ``tests/e2e/test_sources_midstream_bug.py::test_partial_answer_
survives_real_departure_midstream`` (mirrored, not re-invented).
The overlap with that suite is on purpose — different stories:
phase 20 pins the partial shape, this phase pins that the
navbar-switch path no longer cancels while real departures still
do.
4. ``test_baseline_no_switch_still_completes`` — the long question
with NO navigation completes identically (guards against the
shell fold changing the ordinary path).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: ``test_hidden_tab_stream.py`` / ``test_stop_generation.py``): the
#: honesty gate is HIGH and the ~900-word answer streams for ~8–9 s
#: (12 chars / 0.02 s) — the guaranteed mid-stream window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The mock's byte-stable long answer — the EXACT string the stream
#: delivers, so "the full answer" is an exact comparison, not a
#: contains check.
LONG_ANSWER = long_answer()
#: The mock's first 12-char content slice (the same cut ``_sse_stream``
#: makes) — the real-departure partial must START with it (raw text,
#: pre-render); the rendered first line keeps the list-item form (the
#: markdown renderer converts the "1. " marker into a list item,
#: pinned by test_long_answers).
FIRST_CHUNK_RAW = re.findall(r".{1,12}", LONG_ANSWER, re.S)[0]
FIRST_LINE_DOM = "Step 1: configure node-1"
STORAGE_KEY = "bor.chat.v1"
#: The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: The other three navbar views (test 2): the nav link, the view's
#: URL (pushState target), and an admin-visible content marker inside
#: the view (proof the view actually showed — the RAG view gets the
#: same treatment with ``#docs-tbody tr`` in test 1).
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
("#nav-tuning", "/tuning.html", "#tune-save"),
("#nav-history", "/history.html", "#history-table-wrap"),
)
# ---------------------------------------------------------------------------
# KB seeding (house pattern: TRUNCATE-then-import)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log.
The query log finalizes a row ONLY when the LLM finished AND the
persistence succeeded (phase 48); a cancelled turn — a real
departure mid-stream, or one before the first token — leaves no
settled row, so the count IS the settled-row signal (0 = cancelled,
1 = settled; the house pattern from test_hidden_tab_stream.py).
"""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _no_error_banner(page: Page) -> None:
"""The never-stale contract, shell-scoped: the hidden views ship
their own role=alert surfaces (sync/upload banners, …) that are
inert while their view is hidden — so the pin is that NO alert is
VISIBLE, whatever the document carries hidden (the phase-20
rewrite's shell form)."""
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page, answer: Locator) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
def _assert_one_brain_turn(page: Page) -> dict[str, Any]:
"""``bor.chat.v1`` holds EXACTLY ONE brain turn for the question,
and its text is the COMPLETE mock answer byte-for-byte (the
``done`` settle's record — the settle, not a partial)."""
stored = _stored_parsed(page)
assert stored["v"] == 1
msgs = stored["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"], (
"exactly ONE brain turn for the question: "
f"{[m['who'] for m in msgs]}"
)
assert msgs[0]["text"] == LONG_QUESTION
brain = msgs[1]
assert brain["text"] == LONG_ANSWER, "the record's text is the FULL answer"
assert brain.get("deflected") is False, "the done metadata rides the record"
return brain
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _delete_rows_by_title(app_url: str, cookies: dict[str, str], title: str) -> None:
"""Best-effort cleanup of the auto-saved row (a 404 is fine)."""
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
if r.status_code != 200:
return
for c in r.json()["chats"]:
if c["title"] == title:
httpx.delete(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies)
def _wait_row_full(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""Poll the auto-saved row until it carries the full answer as a
single brain turn (the ``done`` settle's fire-and-forget
``persistConversation`` PUT is the last writer)."""
deadline = time.monotonic() + 15
last: list[dict[str, Any]] = []
while time.monotonic() < deadline:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
rows = (
[c for c in r.json()["chats"] if c["title"] == title]
if r.status_code == 200
else []
)
for c in rows:
row = httpx.get(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies).json()
brains = [m for m in row["messages"] if m["who"] == "brain"]
if len(brains) == 1 and brains[0]["text"] == LONG_ANSWER:
return row
last = [row]
time.sleep(0.2)
raise AssertionError(
"the auto-saved row never held the full answer as exactly one brain turn; last: "
f"{last!r}"
)
# ---------------------------------------------------------------------------
# 1. THE OWNER REPRO: send → RAG mid-stream → Chat — the FULL answer
# completes, one brain turn, one settled query_log row, and the
# auto-saved row carries the same single full turn
# ---------------------------------------------------------------------------
def test_rag_switch_mid_stream_completes(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
# Admin login: the admin-only #nav-sources link is revealed, and
# the auto-save row (the persistConversation path) is reachable,
# so the saved-chat side gets pinned too.
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
cookies = _admin_cookies(page)
_delete_rows_by_title(app_url, cookies, LONG_QUESTION) # stale rows from crashed runs
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
# is still readable AFTER it — the canonical same-document proof
# (a real navigation would have wiped window globals).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76", (
"a real navigation would have wiped the window sentinel — "
"the switch must be same-document"
)
# The RAG view actually showed (the fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the fix).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Stay on the RAG view while the stream keeps running (the switch
# is ~t+2 s; the full answer needs ~8–9 s).
page.wait_for_timeout(2000)
# Back to the chat (the header link — a router-intercepted
# switch, still same-document).
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The answer COMPLETED — the bubble carries the FULL mock answer
# (every step line + the unique final line), no error banner.
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
# Storage: EXACTLY ONE brain turn — the FULL answer with done
# metadata (the settle, not a partial).
page.wait_for_timeout(500)
_assert_one_brain_turn(page)
# Server side: the turn SETTLED — exactly one query_log row, so no
# phase-48 "turn cancelled" teardown fired for an in-app switch.
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
# Auto-save (admin): the row carries the same single full brain
# turn (the shared record shape).
try:
row = _wait_row_full(app_url, cookies, LONG_QUESTION)
brains = [m for m in row["messages"] if m["who"] == "brain"]
assert len(brains) == 1, "the saved row holds exactly one brain turn"
assert brains[0]["text"] == LONG_ANSWER
finally:
_delete_rows_by_title(app_url, cookies, LONG_QUESTION)
# ---------------------------------------------------------------------------
# 2. The same mid-stream switch against the other three views — one
# send, one switch, one return, full answer + settled row each time
# ---------------------------------------------------------------------------
def test_every_nav_view_keeps_stream(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
# Admin: every navbar link (incl. the three below) is revealed by
# the whoami gate.
login(page, app_url, next="/")
for i, (nav_sel, view_path, marker) in enumerate(OTHER_VIEWS, start=1):
expect(page.locator(nav_sel)).to_be_visible()
_ask_long(page)
# The CURRENT turn's bubble (the conversation accumulates one
# full turn per iteration — the latest pair is the pin).
answer = _wait_streaming(page, page.locator(ANSWER).last)
# Mid-stream switch to this view — same-document (sentinel).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click(nav_sel)
expect(page).to_have_url(app_url + view_path)
assert page.evaluate("() => window.__shell_boot") == "phase76", (
f"a real navigation to {view_path} would have wiped the sentinel"
)
# The view actually showed (its admin content is up) and the
# chat view is hidden (the stream fills it in the background).
expect(page.locator(marker)).to_be_visible(timeout=15_000)
expect(page.locator("#view-chat")).to_be_hidden()
# Let the stream run while this view is up, then return to the
# chat (still same-document).
page.wait_for_timeout(2000)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The answer COMPLETED — FULL mock answer, no error banner.
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
# Every turn SETTLED: exactly one query_log row per completed
# turn so far (a cancelled turn would leave no row).
page.wait_for_timeout(500)
assert _query_log_count() == i, (
f"turn {i} must finalize exactly one settled query_log row"
)
# Storage: the latest pair is the question + ONE brain turn
# carrying the FULL answer (each turn appended, none
# cancelled, none truncated).
msgs = _stored_parsed(page)["messages"]
assert msgs[-2] == {"who": "user", "text": LONG_QUESTION}
assert msgs[-1]["who"] == "brain"
assert msgs[-1]["text"] == LONG_ANSWER
# ---------------------------------------------------------------------------
# 3. The phase-48 CONTROL: a REAL cross-document departure still
# cancels the fetch (the locked contract survives the phase) — and
# the page-20/73 partial persist lands in the exact phase-20 shape
# ---------------------------------------------------------------------------
def test_real_departure_still_cancels(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_ask_long(page)
_wait_streaming(page, page.locator(ANSWER))
# THE DEPARTURE: a REAL cross-document navigation (NOT a navbar
# link — those are view switches now). The fetch is aborted by the
# unload, which is the point (phase 48). /shared.html is a plain
# document with a stable state for a signed-in session — unlike
# /login.html, which auto-redirects a signed-in admin straight
# back into the shell.
page.goto(app_url + "/shared.html")
expect(page).to_have_url(app_url + "/shared.html")
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
# The turn was CANCELLED — the phase-48 query_log row only lands
# when the LLM finished AND the persistence succeeded, so a
# cancelled mid-stream turn must leave NO settled row.
assert _query_log_count() == 0, (
"a cancelled mid-stream turn must not finalize a query_log row"
)
# Return to the chat — the page-20/73 leave-save is intact: the
# question AND the already-streamed partial are both rendered.
page.goto(app_url + "/")
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
restored = page.locator(".msg.brain .bubble")
expect(restored).to_have_count(1)
expect(restored.first).to_contain_text(FIRST_LINE_DOM)
_no_error_banner(page)
# The EXACT phase-20 partial shape (mirrored from
# test_sources_midstream_bug.py::test_partial_answer_survives_real_
# departure_midstream — do not invent a new shape): exactly one
# brain turn, raw text STARTING with the first streamed chunk,
# SHORTER than the full answer, and NO done metadata (no
# sources/deflected/suggestions/thinking — the turn never
# settled when it was written).
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert msgs[0]["text"] == LONG_QUESTION
brain = msgs[1]
assert brain["text"].startswith(FIRST_CHUNK_RAW)
assert len(brain["text"]) < len(LONG_ANSWER), "the stored answer must be partial"
assert brain["text"] != LONG_ANSWER
assert "sources" not in brain
assert "deflected" not in brain
assert "suggestions" not in brain
assert "thinking" not in brain
# ---------------------------------------------------------------------------
# 4. Baseline: the long question with NO navigation completes
# identically (guards against the shell fold changing the ordinary
# path)
# ---------------------------------------------------------------------------
def test_baseline_no_switch_still_completes(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The normal path, untouched by the shell: the long answer
completes identically without any view switch (guards against an
over-eager router/view change altering the ordinary settle)."""
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13
page.set_default_timeout(30_000)
page.goto(app_url)
_ask_long(page)
answer = _wait_streaming(page, page.locator(ANSWER))
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
_assert_one_brain_turn(page)
assert _query_log_count() == 1
+4 -2
View File
@@ -277,7 +277,9 @@ def test_sources_table_full_width(
try:
login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator(".table-wrap").bounding_box()
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
# (the git-sources one ships hidden) — scope to the RAG view.
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
assert wrap_box is not None and shell_box is not None
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
@@ -292,7 +294,7 @@ def test_sources_table_full_width(
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
"() => { const el = document.querySelector('#view-rag .table-wrap');"
" return [el.scrollWidth, el.clientWidth]; }"
)
assert scroll > client, (
+13 -8
View File
@@ -157,14 +157,16 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# The New chat button is chat-page only (moved from the shared bar
# to index.html's .chat-shell at owner request, 2026-08-28).
# The New chat button is chat-view only (moved from the shared bar
# to the shell's .chat-shell at owner request, 2026-08-28).
# Phase 76 (task 02): in the shell the chat view (with the button)
# is in the DOM on every view — hidden + inert — so the pin is
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
# pages like the viewer, where the button does not exist at all).
if page_kind == "chat":
expect(page.locator("#new-chat-btn")).to_be_visible()
else:
assert page.locator("#new-chat-btn").count() == 0, (
f"{page_kind}: the New chat button is chat-page only"
)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# Phase 34: EVERY page kind — viewer included — carries the SAME
# nav contract: the Chat link always visible; the admin-only
@@ -311,12 +313,15 @@ def test_new_chat_button_is_chat_page_only(
page.set_viewport_size({"width": 1280, "height": 800})
_seed_db(mock_llm)
# No non-chat page carries the button anymore…
# No non-chat view shows the button (phase 76 task 02: in the shell
# it EXISTS in the DOM on every view — hidden + inert — so the pin
# is visibility, not existence; standalone pages carry none at
# all, and to_be_hidden() passes for both) …
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
page.goto(app_url + path)
expect(page.locator("#new-chat-btn")).to_have_count(0)
expect(page.locator("#new-chat-btn")).to_be_hidden()
# …and the chat page has exactly one (visible, inside .chat-shell).
# …and the chat view has exactly one (visible, inside .chat-shell).
page.goto(app_url + "/")
expect(page.locator("#new-chat-btn")).to_have_count(1)
expect(page.locator("#new-chat-btn")).to_be_visible()
+223 -44
View File
@@ -1,36 +1,45 @@
"""Phase 20 E2E (Playwright): navigating away mid-turn keeps the answer.
"""Story: mid-stream navigation — the phase-76 SPA shell fix (phase 20
story, re-purposed by phase 76 task 02).
Story: ``.agents/user_stories/sources-midstream.md``
Bug report (TODO.md L3): *"Clicking "sources" while chat is generating
clears chat and result will never show up."*
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov
The bug: the brain message persisted only on ``done``, so leaving the
chat page while a turn was in flight aborted the stream and dropped
whatever had already streamed — the user came back to their own question
with no result, ever. The fix (phase 20, owner-confirmed A1): a single
``pagehide`` save point in app.js persists the partial raw answer (via
the existing ``rememberBrainTurn`` helper) when navigation hits a turn
that is in flight and has already streamed text.
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
full page navigation (the "Sources" navbar link → /sources.html) aborted
the stream via the unload, and a single ``pagehide`` save point in
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
user came back to their question WITH the partial, rendered as
"Partial answer — navigation interrupted the stream."
Phase 76 (task 02) folded /sources.html (and /git-sources.html) into
the ONE-document shell: from this phase on, a navbar click is a
CLIENT-SIDE view switch — the document (and its in-flight SSE reader)
survive, so the pinned behavior is "the stream survives and the answer
COMPLETES." The phase-20 pagehide partial-persist REMAINS for REAL
departures only (a cross-document navigation still aborts the fetch),
and its coverage home is scenario 1 below in its renamed form.
Timing is deterministic by construction:
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
~5400-char / ~450-frame / ~9s content stream, so the navigation lands
~5400-char / ~450-frame / ~9s content stream, so the departure lands
mid-stream with a wide margin;
* scenario 2 keys off the mock's ``think out loud then hesitate``
trigger — the phase-17 thinking stream followed by a 4s silence before
the first content frame, so the navigation lands inside pure thinking;
* scenarios 3 and 4 settle the turn fully (send button re-enabled)
before any navigation.
* scenarios 2–3 key off the same long stream (mid-stream view switch)
and the ``think out loud then hesitate`` trigger — the phase-17
thinking stream followed by a 4s silence before the first content
frame, so the switch lands inside pure thinking;
* scenario 4 (the pre-token real-departure pin) uses the same
hesitate trigger; scenarios 5 and 6 settle the turn fully (send
button re-enabled) before any navigation.
Test → story mapping (Playwright Mapping Rule):
1. ``test_partial_answer_survives_sources_nav_midstream``
2. ``test_no_orphan_brain_message_when_navigated_before_first_token``
3. ``test_completed_turn_unaffected``
4. ``test_new_chat_still_clears_conversation``
1. ``test_partial_answer_survives_real_departure_midstream``
2. ``test_full_answer_completes_after_rag_nav_midstream``
3. ``test_nav_switch_before_first_token_completes``
4. ``test_no_orphan_brain_message_when_navigated_before_first_token``
5. ``test_completed_turn_unaffected``
6. ``test_new_chat_still_clears_conversation``
"""
from __future__ import annotations
@@ -51,7 +60,7 @@ from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import long_answer
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -71,7 +80,7 @@ FIRST_CHUNK_RAW = re.findall(r".{1,12}", FULL_LONG, re.S)[0]
#: dropping the marker (pinned by test_long_answers).
FIRST_LINE_DOM = "Step 1: configure node-1"
# --- scenario 2: navigation during pure thinking (no answer tokens) -----
# --- scenario 3: navigation during pure thinking (no answer tokens) -----
HESITATE_QUESTION = (
"think out loud then hesitate — how is my kubernetes cluster set up?"
)
@@ -123,6 +132,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
"""The settled-row count over the whole (truncated) log.
The query log finalizes a row ONLY when the LLM finished AND the
persistence succeeded (phase 48); a cancelled turn — a real
departure mid-stream, or one before the first token — leaves no
settled row, so the count IS the settled-row signal (0 = cancelled,
1 = settled; the house pattern from tests/e2e/test_hidden_tab_
stream.py).
"""
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
def _stored(page: Page) -> str | None:
"""Raw localStorage payload for the chat (None when the key is absent)."""
return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
@@ -148,8 +171,12 @@ def _ask(page: Page, question: str) -> None:
def _no_error_banner(page: Page) -> None:
"""The never-stale contract: a restored/partial state must never
present an error banner (role=alert) — the turn is simply partial."""
expect(page.locator('[role="alert"]')).to_have_count(0)
present an error banner (role=alert) — the turn is simply partial.
Phase 76 (task 02): the shell's hidden views carry their own
ship-hidden role=alert surfaces (sync banner, upload banner, …),
so the pin is VIEW-SCOPED IN EFFECT — NO alert may be VISIBLE,
whatever the document carries hidden."""
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
@pytest.fixture()
@@ -164,17 +191,17 @@ def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
# ---------------------------------------------------------------------------
# 1. Mid-stream navigation via the Sources nav link: the partial answer
# that had already streamed is persisted and restored
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
# contract, now exercised via a genuine cross-document navigation;
# a navbar click is no longer a departure — that is scenario 2)
# ---------------------------------------------------------------------------
def test_partial_answer_survives_sources_nav_midstream(
def test_partial_answer_survives_real_departure_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# Admin (phase 16/19): only the admin sees the #nav-sources link the
# bug report clicks.
# Admin (phase 16/19): only the admin sees the #nav-sources link.
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
@@ -193,13 +220,22 @@ def test_partial_answer_survives_sources_nav_midstream(
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
# The navigation really landed on the admin catalog (mid-stream state
# of the stream itself does not matter to the page — the fetch is
# aborted by the unload, which is the point).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
# navigation — page.goto to a genuine other document. /shared.html
# is a plain document (stable for every session state) and — unlike
# /login.html, which auto-redirects a signed-in session straight
# back into the shell — it is a real departure, so the in-flight SSE
# fetch is aborted by the unload (the point).
page.goto(app_url + "/shared.html")
expect(page).to_have_url(app_url + "/shared.html")
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
# The turn was CANCELLED — the phase-48 query_log row only lands
# when the LLM finished AND the persistence succeeded, so a
# cancelled mid-stream turn must leave NO settled row.
assert _query_log_count() == 0, (
"a cancelled mid-stream turn must not finalize a query_log row"
)
# Return to the chat.
page.goto(app_url + "/")
@@ -238,8 +274,149 @@ def test_partial_answer_survives_sources_nav_midstream(
# ---------------------------------------------------------------------------
# 2. Navigation BEFORE the first answer token (pure thinking): nothing
# brain-side is persisted — the question comes back alone
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
# task 02): the in-flight stream keeps running while the RAG view
# shows, and the answer COMPLETES — the phase-20 "answer cut short"
# outcome is impossible now (the fetch was never cancelled)
# ---------------------------------------------------------------------------
def test_full_answer_completes_after_rag_nav_midstream(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
# Start the ~9s long answer and wait until visible streaming (the
# house pattern: first line rendered + the enabled Stop control).
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Same-document proof: a window sentinel set before the click is
# still readable after — no load happened (the navigation-entries
# length is NOT used: it resets on a real load).
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
# The RAG view actually mounted (the fixture docs' rows are listed).
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Stay on the RAG view while the stream keeps running in the
# background (the switch is ~t+2s; the full answer needs ~9s).
page.wait_for_timeout(2000)
# Back to the chat (the header link — a router-intercepted switch).
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED — the final sentinel line is in the bubble
# (not a partial), no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=30_000)
text_now = bubble.inner_text()
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text_now
_no_error_banner(page)
# Settle, then storage: EXACTLY ONE brain turn — the FULL answer,
# with done metadata (the settle, not a partial).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
brain = msgs[1]
assert brain["text"] == FULL_LONG
assert brain["deflected"] is False
assert any(s["path"] == "homelab/kubernetes.md" for s in brain["sources"])
# The turn SETTLED — the phase-48 query_log row exists (a
# cancelled turn would leave no row at all).
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
# ---------------------------------------------------------------------------
# 3. Navbar switch in the pre-first-token window (pure thinking — no
# content frame yet): the surviving reader completes the answer, and
# bor.chat.v1 holds exactly ONE brain turn (the no-orphan invariant
# in its new form — a pre-token view switch neither kills the turn
# nor persists a partial)
# ---------------------------------------------------------------------------
def test_nav_switch_before_first_token_completes(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page.locator("#nav-sources")).to_be_visible()
page.fill("#message-input", HESITATE_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
# The pre-first-token window, pinned the same way as scenario 4:
# the scratchpad's tail is rendered (the thinking stream has just
# ended) and the 4s pre-content pause (SLOW_PRETOKEN_TRIGGER) is
# running — NO content frame has landed yet. (The .msg.brain bubble
# element exists from turn start with its thinking block — the
# pre-token state is "no content text", not "no bubble element".)
thinking = page.locator(".msg.brain").last.locator("details.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48
# — the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Switch to the RAG view NOW — mid-pause, still before the first
# content token.
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html")
assert page.evaluate("() => window.__shell_boot") == "phase76"
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
# first content frames land while the chat view is still hidden —
# then return to the chat: the surviving reader completes the
# answer.
page.wait_for_timeout(4500)
page.click('a.nav-link[href="/"]')
expect(page).to_have_url(app_url + "/")
# The answer COMPLETED (full text — the deterministic mock answer),
# no error banner.
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
_no_error_banner(page)
# Storage: EXACTLY ONE brain turn — the completed answer with done
# metadata (no partial, no orphan, no duplicate).
page.wait_for_timeout(500)
msgs = _stored_parsed(page)["messages"]
assert [m["who"] for m in msgs] == ["user", "brain"]
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
assert msgs[1]["deflected"] is False
assert any(s["path"] == "homelab/kubernetes.md" for s in msgs[1]["sources"])
# The turn settled — one finalized row (a cancelled turn would
# leave no row at all).
assert _query_log_count() == 1
# ---------------------------------------------------------------------------
# 4. REAL departure BEFORE the first answer token (pure thinking): nothing
# brain-side is persisted — the pre-token no-orphan convention,
# unchanged (a direct page.goto to /sources.html REMAINS a real
# departure in the SPA — the shell is served, the fetch is aborted
# by the unload)
# ---------------------------------------------------------------------------
@@ -270,8 +447,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
# Leave during the pause (no answer token has streamed — acc is empty,
# so the pagehide save point must persist nothing brain-side).
# Leave during the pause via a REAL cross-document departure (no
# answer token has streamed — acc is empty, so the pagehide save
# point must persist nothing brain-side).
page.goto(app_url + "/sources.html")
# Return to the chat.
@@ -294,8 +472,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# ---------------------------------------------------------------------------
# 3. Completed turn: the done save point is byte-identical to before —
# the new pagehide save point must not duplicate or alter it
# 5. Completed turn: the done save point is byte-identical to before —
# the new pagehide save point must not duplicate or alter it (the
# direct gotos are real departures — unaffected by the fold)
# ---------------------------------------------------------------------------
@@ -337,7 +516,7 @@ def test_completed_turn_unaffected(
# ---------------------------------------------------------------------------
# 4. The DELIBERATE clear is untouched: New chat (chat-page only since
# 6. The DELIBERATE clear is untouched: New chat (chat-page only since
# the owner rework 2026-08-28) still clears the conversation
# (phase 14 contract)
# ---------------------------------------------------------------------------
+3 -1
View File
@@ -94,7 +94,9 @@ def test_sources_page_sub_describes_the_current_source_model(
model. The page is anonymously viewable — the catalog gate hides
the table, not the page-head."""
page.goto(f"{app_url}/sources.html")
sub = page.locator(".page-sub").first
# Phase 76 (task 02): the shell carries the hidden tuning view's
# .page-sub earlier in the DOM — scope to the RAG view.
sub = page.locator("#view-rag .page-sub")
expect(sub).to_be_visible()
text = sub.inner_text().lower()
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
+6 -4
View File
@@ -46,10 +46,12 @@ QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
NOTE = "STEEER-MARKER be concise"
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
#: Both index.html and tuning.html ship exactly three classic/module
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
#: page module (app.js / tuning.js).
BASE_SCRIPT_COUNT = 3
#: The shell (index.html — served for BOTH / and /tuning.html since
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
#: + markdown.js + the chat module (app.js) + the shell router
#: (router.js, which lazy-imports the tuning.js view module).
BASE_SCRIPT_COUNT = 4
async def _import_fixtures(mock_port: int) -> ImportSummary:
+61 -5
View File
@@ -150,12 +150,23 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("path", "marker"),
[
("/", "Brain of Reese"),
("/sources.html", "Knowledge base"),
# Phase 76 (task 02): /sources.html + /git-sources.html are SHELL
# routes — the body is the shell (index.html) whose static
# <title> is "Brain of Reese" (the per-view title is set
# CLIENT-side by the router, invisible to httpx). The marker
# asserts the shell body (the view section is inside it) instead
# of the old page's title.
("/sources.html", 'id="view-rag"'), # phase 76: shell route (was "Knowledge base")
("/document.html", "Brain of Reese"), # phase 10: viewer page
("/login.html", "Sign in"), # phase 16: admin sign-in page
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
# Phase 76 (task 01): /tuning.html is a SHELL route — same
# shell-body marker pattern as the two task-02 paths above.
("/tuning.html", 'id="view-tuning"'), # phase 76: shell route (was "Global Tuning")
("/git-sources.html", 'id="view-git-sources"'), # phase 76: shell route (was "Git sources")
# Phase 76 (task 03): /history.html is a SHELL route too — the
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
@@ -194,7 +205,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + the History page
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
@@ -208,6 +219,51 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
assert f"?v={asset_version()}" in r.text
@pytest.mark.parametrize(
("path", "view_id", "old_title"),
[
("/tuning.html", 'id="view-tuning"', "Global Tuning · Brain of Reese"),
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
client, path: str, view_id: str, old_title: str
) -> None:
"""Phase 76 (task 01: /tuning.html; task 02: /sources.html +
/git-sources.html; task 03: /history.html — all four non-chat
navbar views): every shell route serves the shell
(``frontend/index.html``), so the phase-33 page contract applies to
it exactly as to a static page: 200, text/html, ``Cache-Control:
no-cache``, ``?v=<token>`` asset refs, no validators (the
middleware wraps the whole app and lists the path in ``HTML_PAGES``
— unchanged). The body IS the shell: the view section is inside it,
the old standalone page's title is gone. The static catch-all stays
intact: an unknown path still 404s. (The conditional-GET
revalidation contract for these paths is pinned by
``tests/integration/test_caching_revalidation.py``.)"""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["content-type"].split(";", 1)[0] == "text/html"
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
assert f"?v={asset_version()}" in r.text
# The body is the SHELL: the chat view AND the route's view section
# are inside it…
assert 'id="view-chat"' in r.text
assert view_id in r.text
# …with the shell's static title (the per-view title is the
# router's client-side job), and the old page's own <title> is gone.
assert "<title>Brain of Reese</title>" in r.text
assert f"{old_title}</title>" not in r.text
# The catch-all is intact: an unknown path still 404s.
assert client.get("/nonexistent.html").status_code == 404
def test_index_html_variant_no_cache_versioned(client) -> None:
"""/index.html is the same page as / — same caching treatment."""
from app.core.caching import asset_version
+23 -2
View File
@@ -68,9 +68,30 @@ def file_validators(page_file: Path) -> tuple[str, str]:
return headers["etag"], headers["last-modified"]
#: Phase 76 (task 01): the shell-served view paths — the URL is a
#: navbar view, the file on disk is the SHELL (the shell route in
#: app/main.py serves frontend/index.html for it). The etag
#: computation below must use the file that actually backs the
#: response, or the conditional-GET probe would carry a validator no
#: browser ever saw. Tasks 02/03 extended this as the remaining views
#: folded in (task 03 — History — completes the set: all four
#: non-chat navbar views). The page CONTRACT itself is unchanged: the
#: phase-33 middleware wraps the whole app and lists the path in
#: HTML_PAGES, so the shell-route response is normalized exactly like
#: a static page (200, no-cache, ?v=, no validators — asserted by
#: _assert_page_contract below).
SHELL_BACKED_PAGES = {
"/tuning.html": "index.html", # phase 76 task 01
"/sources.html": "index.html", # phase 76 task 02
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
}
def _page_file(path: str) -> Path:
"""The static file backing a page path (``/`` → ``index.html``)."""
name = path.lstrip("/") or "index.html"
"""The static file backing a page path (``/`` → ``index.html``;
the shell-served view paths → the shell, ``SHELL_BACKED_PAGES``)."""
name = SHELL_BACKED_PAGES.get(path, path.lstrip("/") or "index.html")
file = FRONTEND / name
assert file.is_file(), f"missing page file for {path}: {file}"
return file
@@ -242,6 +242,18 @@ def test_header_module_is_imported_not_directly_loaded() -> None:
js_path = ASSETS / name
assert js_path.is_file(), f"page script missing: {js_path}"
body = js_path.read_text(encoding="utf-8")
# Phase 76: the shell's router (router.js) is NOT a view module
# — the shell's shared header boots via the chat module (app.js,
# which imports header.js), and the view modules the router
# lazy-imports (tuning.js, …) import header.js themselves, so
# the single-evaluation contract holds without the router
# importing it (it owns the view switch, not the header).
if name == "router.js":
assert 'from "./header.js"' not in body, (
"router.js must not import the header module — the chat "
"module boots the shell's ONE header"
)
continue
assert re.search(r"""from\s+["']\./header\.js["']""", body), (
f"{name}: must import the shared header module relatively "
f'("from \\"./header.js\\"")'
+25 -7
View File
@@ -26,11 +26,10 @@ from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.html"
SHARED_HTML = FRONTEND / "shared.html"
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
@@ -182,7 +181,16 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
relocated the .chat-actions row from the top of the column to the
bottom: the button now sits BELOW the #messages section, directly
above the composer (the single module binding + the no-op guard are
pinned in test_shared_header.py)."""
pinned in test_shared_header.py).
Phase 76 (task 01) — the final shell form, converted in ONE step so
the later fold tasks (02/03) leave this pin alone: the file-level
"only on the chat page" semantics die with the shell — the button
must sit inside the shell's CHAT VIEW section (#view-chat; the
hidden views' elements remain in the DOM, so a per-file negative
check is meaningless inside the shell), and it must be absent from
the SURVIVING separate documents (the flow pages that are not
navbar views)."""
html = _index()
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #new-chat-btn"
@@ -191,6 +199,14 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
assert 'aria-label="New chat"' in tag
main_idx = html.find('main id="main"')
assert main_idx != -1 and btn.start() > main_idx, "the button belongs inside <main>"
# The button sits inside the shell's chat view section (phase 76):
# after the #view-chat open, before the #view-tuning section starts
# (the chat section closes before it — hidden views are separate).
view_chat_idx = html.find('id="view-chat"')
view_tuning_idx = html.find('id="view-tuning"')
assert -1 < view_chat_idx < btn.start() < view_tuning_idx, (
"the button belongs inside the #view-chat section"
)
shell_idx = html.find('class="container chat-shell"')
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
messages_idx = html.find('id="messages"')
@@ -199,10 +215,12 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
"the button sits below the #messages section, above the composer"
)
# No other page carries the button anymore (owner request 2026-08-28).
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
# No SURVIVING separate document carries the button (owner request
# 2026-08-28, final shell form: the folded navbar views are GONE
# from this check — they are views of the shell now).
for other in (DOCUMENT_HTML, LOGIN_HTML, SHARED_HTML, DOC_EDIT_HTML):
assert 'id="new-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the New chat button is chat-page only"
f"{other.name}: the New chat button is chat-view only"
)
+45 -39
View File
@@ -41,8 +41,7 @@ SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
HAVE_NODE = shutil.which("node") is not None
@@ -294,10 +293,12 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
def test_render_document_exported_and_modal_imports_it() -> None:
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
exact renderer the standalone page and the modal share (no drift).
The modal module imports it relatively, and BOTH page scripts import
the modal module relatively — no direct <script> tag (the header.js
single-evaluation design: esbuild inlines it into the page bundle,
one module instance per page)."""
The modal module imports it relatively, and BOTH view scripts
(app.js — the chat view at shell boot — and sources.js — the RAG
view module) import the modal module relatively — no direct
<script> tag (the header.js single-evaluation design: esbuild
inlines it into the bundle, one module instance per document).
"""
doc_js = _read(DOCUMENT_JS)
# Task 03 signature: the page passes its #doc-title / #doc-meta /
# #doc-content elements under exactly these names.
@@ -315,12 +316,11 @@ def test_render_document_exported_and_modal_imports_it() -> None:
assert 'from "./document-modal.js"' in js, (
f"{name}: must import the modal module relatively"
)
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert not re.search(r"<script[^>]*document-modal\.js", text), (
f"{page.name}: no direct document-modal.js <script> tag "
"(single-evaluation design — the page script imports it)"
)
text = _read(INDEX_HTML)
assert not re.search(r"<script[^>]*document-modal\.js", text), (
"index.html: no direct document-modal.js <script> tag "
"(single-evaluation design — the view scripts import it)"
)
def test_document_js_page_init_is_import_safe() -> None:
@@ -338,37 +338,43 @@ def test_document_js_page_init_is_import_safe() -> None:
)
def test_both_pages_carry_the_modal_skeleton() -> None:
"""Phase 26: chat AND Sources ship the same modal skeleton (the
task-01 markup) — the a11y frame included: role=dialog +
aria-modal, a labelled close control, a focusable content target
(tabindex=-1), and a role=status announcer. Hidden by default —
inert until JS opens it."""
for page in (INDEX_HTML, SOURCES_HTML):
text = _read(page)
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
assert 'id="doc-modal-backdrop"' in text, page.name
assert 'id="doc-modal-panel"' in text, page.name
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
assert 'id="doc-modal-title"' in text, page.name
assert 'id="doc-modal-meta"' in text, page.name
assert 'id="doc-modal-desc"' in text, page.name
assert 'id="doc-modal-open"' in text, page.name
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
assert re.search(
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
), page.name
def test_shell_carries_the_single_modal_skeleton() -> None:
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
view's second copy was dropped in the fold; BOTH view scripts
(app.js chat chips, sources.js RAG row links) open documents
through openDocumentModal(...) against that single instance
(document-modal.js resolves it by document-level querySelector at
import). The a11y frame is included: role=dialog + aria-modal, a
labelled close control, a focusable content target (tabindex=-1),
and a role=status announcer. Hidden by default — inert until JS
opens it."""
text = _read(INDEX_HTML)
assert text.count('id="doc-modal"') == 1, (
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
)
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
assert 'id="doc-modal-backdrop"' in text
assert 'id="doc-modal-panel"' in text
assert 'role="dialog"' in text and 'aria-modal="true"' in text
assert 'id="doc-modal-title"' in text
assert 'id="doc-modal-meta"' in text
assert 'id="doc-modal-desc"' in text
assert 'id="doc-modal-open"' in text
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text)
assert re.search(r'id="doc-modal-close"[^>]*aria-label="Close document"', text)
def test_sources_page_loads_markdown_before_its_module() -> None:
"""Phase 26: the modal renders md documents on the Sources page too —
so sources.html loads the classic markdown.js (global renderMarkdown)
via a relative <script src> BEFORE its module script, exactly like
index.html does."""
html = _read(SOURCES_HTML)
def test_shell_loads_markdown_before_its_modules() -> None:
"""Phase 26 + phase 76 (task 02): the modal renders md documents
in the RAG view too — so the shell loads the classic markdown.js
(global renderMarkdown) via a relative <script src> BEFORE its
module scripts (app.js — the chat view — and the lazy view
modules' modal imports), exactly as the old sources.html did."""
html = _read(INDEX_HTML)
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
"sources.html: markdown.js must load before the module script"
"index.html: markdown.js must load before the module scripts"
)
+6 -4
View File
@@ -21,14 +21,16 @@ CONTAINERFILE = ROOT / "Containerfile"
#: Every page in the app ships the brand layer (phase 39: "every visible
#: brand string on every page resolves from one place").
#: Phase 76 (task 01): the Tuning page is folded into the shell (its
#: file is deleted) — the shell (index.html) stands in for it here.
#: Phase 76 (task 02): the RAG + Sources pages are folded too (both
#: files deleted — the shell stands in for them). Phase 76 (task 03):
#: history.html is folded too (deleted — the shell stands in for the
#: History view; all four folded view files are gone).
HTML_PAGES = (
"index.html",
"sources.html",
"tuning.html",
"document.html",
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
)
+354
View File
@@ -0,0 +1,354 @@
"""Unit: the phase-76 shell router contract (task 01).
The browser behavior is E2E-gated (the phase-76 story suite —
``test_nav_switch_keeps_stream.py`` — plus the tuning-adjacent suites);
here we pin the router.js / index.html source-level invariants the
"views of one document" architecture depends on, so a silent
regression is caught without a browser (house pattern:
``tests/unit/test_frontend_hidden_tab.py`` reads JS source and
asserts on its mechanisms).
Pinned design (phase 76 overview + task 01):
* the VIEW map — pathname → view name — is the ONLY set of paths the
click interceptor may swallow (every other link keeps its real,
document-level navigation);
* a switch is ``history.pushState`` + show/hide — NEVER a document
load (no ``location.assign`` / ``location.href`` / ``location.replace``
/ ``location.reload`` anywhere in the module);
* mount-once per view: the lazy module is imported on FIRST show only,
the guard runs before the import and is set only after ``mount``
resolves;
* hidden views carry BOTH ``hidden`` AND ``inert`` (WCAG — a hidden
view must not receive focus or keyboard traversal);
* the router is the SINGLE WRITER of the ``.nav-link`` active state
(``is-active`` + ``aria-current="page"``), of ``document.title``, and
of the per-view ``<meta name="description">``;
* focus lands on the target view ONLY on user-initiated switches
(navbar click / popstate) — never on initial boot (no focus steal);
* the shell markup: ONE main holding the view sections, only the Chat
link statically active, boot order brand.js → app.js → router.js,
and the chat view needs no module import (app.js ran at shell boot).
Phase 76 task 04 (the header is shell-owned): the shell's header is the
canonical one — the old per-page header copies (with their static
active stamps) are gone with the four folded view documents, so
``is-active`` occurs EXACTLY ONCE in the whole of index.html (on the
Chat link), and header.js carries NO ``is-active`` write: the whoami
auth gate + the mobile hamburger are its only nav responsibilities,
and the router is the SINGLE runtime writer of the active state.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
ROUTER_JS = ASSETS / "router.js"
INDEX_HTML = FRONTEND / "index.html"
def _js() -> str:
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
return ROUTER_JS.read_text(encoding="utf-8")
def _html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
# ---------- the VIEW map: the only interceptable paths ----------
def test_view_map_covers_the_shell_paths() -> None:
"""The VIEW map is pathname → view name: the shell's own two URLs
("/" and "/index.html") are the chat view, plus one entry per
folded view (tasks 01–03: tuning, rag, git-sources, history —
all four non-chat navbar views are in)."""
js = _js()
view_start = js.find("const VIEW = {")
assert view_start != -1, "the VIEW map must exist"
view_body = js[view_start : js.find("\n}", view_start)]
assert '"/": "chat"' in view_body, "the app root is the chat view"
assert '"/index.html": "chat"' in view_body, (
"the shell's alternate URL is the chat view too (HTML_PAGES)"
)
assert '"/tuning.html": "tuning"' in view_body, (
"task 01 folds the Tuning view into the shell"
)
assert '"/history.html": "history"' in view_body, (
"task 03 folds the History view into the shell"
)
# The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
def test_interceptor_matches_only_view_map_paths() -> None:
"""The delegated nav click handler intercepts ONLY a link whose href
is in VIEW — the `in VIEW` guard runs BEFORE preventDefault, and a
non-view link (login, the viewer, the not-yet-folded views) falls
through to its real, document-level navigation."""
js = _js()
fn = js.find('nav.addEventListener("click"')
assert fn != -1, "the delegated click handler on the nav must exist"
body = js[fn : js.find("\n });", fn)]
guard = body.find("in VIEW")
prevent = body.find("e.preventDefault()")
assert 0 <= guard < prevent, (
"the VIEW membership guard must run BEFORE the preventDefault"
)
assert 'closest("a.nav-link")' in body, (
"only the .nav-link family is considered (auth links are untouched)"
)
def test_switches_use_pushstate_not_document_navigation() -> None:
"""A view switch is history.pushState (same-document) — the module
must contain NO document-level navigation primitive: no
location.assign, no location.href write, no location.replace, no
location.reload (the phase-48 abort path lives in app.js, not here)."""
js = _js()
assert "history.pushState" in js, "the switch must pushState"
for banned in ("location.assign", "location.href", "location.replace", "location.reload"):
assert banned not in js, f"{banned} is a document load — the switch is same-document"
# The pushState is the click handler's (the popstate path only READS
# the location — it never writes it).
fn = js.find('nav.addEventListener("click"')
body = js[fn : js.find("\n });", fn)]
assert "history.pushState" in body
def test_popstate_switches_views() -> None:
"""Back / forward re-runs the switch for the pathname in history —
user-initiated (focus + top landing included)."""
js = _js()
fn = js.find('window.addEventListener("popstate"')
assert fn != -1, "the popstate listener must exist"
body = js[fn : js.find("});", fn)]
assert "location.pathname" in body, "popstate resolves the view from the pathname"
assert "userInitiated: true" in body, "back/forward is a user-initiated switch"
# ---------- mount-once, hide-forever ----------
def test_mount_once_guard_runs_before_import_and_after_mount() -> None:
"""A non-chat view's module is imported on FIRST show only: the
`mounted[name]` guard is checked BEFORE the lazy import, the import
+ `await module.mount(root)` run once, and the guard is set only
AFTER mount resolves (a failed mount may retry on the next show)."""
js = _js()
fn = js.find("async function switchTo")
assert fn != -1, "switchTo must exist"
body = js[fn : js.find("\n}", fn)]
guard = body.find("if (!mounted[name])")
load = body.find("await load()")
mount = body.find("await mod.mount(root)")
set_guard = body.find("mounted[name] = true")
assert 0 <= guard < load < mount < set_guard, (
"guard → lazy import → mount → set guard (in that order)"
)
# The guard map starts with chat mounted (app.js ran at shell boot).
assert re.search(r"const mounted = \{ chat: true \}", js), (
"chat starts mounted — it needs no module import"
)
def test_only_non_chat_views_have_lazy_modules() -> None:
"""VIEW_MODULES lazy-imports the non-chat views only — the view
modules are STATIC specifiers (so the Containerfile's esbuild
stage can inline them into the router bundle) and there is NO
import of app.js (the chat view needs no module — it ran at shell
boot)."""
js = _js()
mods_start = js.find("const VIEW_MODULES = {")
assert mods_start != -1, "the lazy module map must exist"
mods_body = js[mods_start : js.find("\n}", mods_start)]
assert 'tuning: () => import("./tuning.js")' in mods_body, (
"the Tuning view module is lazy-imported on first show"
)
assert 'history: () => import("./history.js")' in mods_body, (
"the History view module is lazy-imported on first show"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
# ---------- show / hide: hidden AND inert ----------
def test_hidden_views_get_both_hidden_and_inert() -> None:
"""Show = drop hidden AND inert; hide = add BOTH — the same
comparison drives both attributes in one loop, so a view can never
be visible-but-inert or inert-but-visible (WCAG: a hidden view must
not receive focus or keyboard traversal)."""
js = _js()
fn = js.find("async function switchTo")
body = js[fn : js.find("\n}", fn)]
loop = body.find("Object.entries(viewEls)")
assert loop != -1, "the show/hide loop must walk every view"
loop_body = body[loop : body.find("\n }", loop)]
hidden_i = loop_body.find("el.hidden =")
inert_i = loop_body.find("el.inert =")
assert 0 <= hidden_i < inert_i, "both attributes are set in the same loop"
assert loop_body.count("viewName !== name") == 2, (
"one comparison drives hidden AND inert (they can never drift)"
)
# ---------- the router is the single writer ----------
def test_router_writes_active_state_title_and_meta() -> None:
"""The router stamps the .nav-link active state (is-active +
aria-current, removed on the inactive links), document.title, and
the per-view meta description — values carried over from the old
pages' <head>s (the tuning title/description survive the fold).
Phase 76 (task 02): the title/meta are composed through
titleFor()/descFor() — the per-view value with the brand literal
replaced by window.BOR_BRAND (phase 39): the lazy view import
defers switchTo past brand.js's one-time DOM pass, so a literal
stamp would overwrite a configured deployment's name; composing
at write time is a no-op for the default deployment."""
js = _js()
fn = js.find("async function switchTo")
body = js[fn : js.find("\n}", fn)]
assert 'querySelectorAll("a.nav-link")' in body, "the writer walks the nav links"
assert 'classList.toggle("is-active"' in body, "is-active is stamped + removed"
assert 'setAttribute("aria-current", "page")' in body
assert 'removeAttribute("aria-current")' in body
assert "document.title = titleFor(name)" in body
assert "metaDesc.content = descFor(name)" in body
# The carried-over values (the old pages' <head>s — default form).
assert 'chat: "Brain of Reese"' in js
assert 'tuning: "Global Tuning · Brain of Reese"' in js
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
assert 'history: "Saved chats · Brain of Reese"' in js
assert "Saved chats — every conversation is saved automatically, one click back." in js
# The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js
assert 'TITLES[view].replaceAll("Brain of Reese", brandName())' in js
assert 'DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName())' in js
def test_focus_only_on_user_initiated_switches() -> None:
"""The target view is focused ONLY when the switch is
user-initiated (navbar click / popstate) — the boot switch passes
userInitiated:false, so a page load never steals focus. The top
landing (scrollTo 0,0) rides the same flag."""
js = _js()
fn = js.find("async function switchTo")
body = js[fn : js.find("\n}", fn)]
flag = body.rfind("if (userInitiated)")
focus = body.find("root.focus(")
scroll = body.find("window.scrollTo(0, 0)")
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag"
# Boot is NOT user-initiated (no focus steal on load).
boot = js.find("switchTo(bootName")
assert boot != -1 and "userInitiated: false" in js[boot : boot + 60]
# The click handler IS user-initiated.
click = js.find('nav.addEventListener("click"')
click_body = js[click : js.find("\n });", click)]
assert "userInitiated: true" in click_body
# ---------- the shell markup + boot order ----------
def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
"""index.html: ONE main#main holds the view sections; the Tuning
section ships hidden AND inert (the a11y pair); ONLY the Chat link
carries the static active stamp (the router is the single writer —
no view other than chat may ship statically active)."""
html = _html()
assert html.count('id="main"') == 1, "the shell has exactly one main"
main = html.find('<main id="main" class="app-main" tabindex="-1">')
assert main != -1
view_chat = html.find('<section class="view" id="view-chat"')
view_tuning = html.find('<section class="view" id="view-tuning"')
main_end = html.find("</main>", main)
assert main < view_chat < view_tuning < main_end, (
"both view sections live inside the single main (chat first)"
)
tuning_tag = html[view_tuning : html.find(">", view_tuning)]
assert "hidden" in tuning_tag and "inert" in tuning_tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in html[view_chat : html.find(">", view_chat)]
assert 'tabindex="-1"' in tuning_tag, "the target view is focusable"
# Only the Chat link is statically active (exactly one stamp, on Chat).
assert html.count('class="nav-link is-active"') == 1, (
"only ONE nav link may ship statically active"
)
active = html.find('<a href="/" class="nav-link is-active" aria-current="page">Chat</a>')
assert active != -1, "the static active stamp is the Chat link"
# The tuning nav link ships hidden (admin-only) and UNstamped.
tuning_match = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', html)
assert tuning_match, "the shell must carry the #nav-tuning nav link"
tuning_link = tuning_match.group(0)
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
# ---------- phase 76 task 04: the header is shell-owned ----------
def test_shell_carries_exactly_one_static_is_active_on_chat() -> None:
"""Phase 76 task 04: the shell's ONE header is the canonical header —
the folded pages' header copies (which stamped is-active statically
in their own markup) are gone, so ``is-active`` occurs EXACTLY ONCE
in the whole of index.html, on the Chat link (the default view).
A second stamp anywhere (a leaked page copy, a non-chat view
shipping statically active) would break the router's single-writer
contract the moment it disagrees with a switch."""
html = _html()
assert html.count("is-active") == 1, (
"index.html must carry is-active exactly once (the Chat link's stamp)"
)
i = html.find("is-active")
tag_start = html.rfind("<a ", 0, i)
tag_end = html.find(">", tag_start)
tag = html[tag_start:tag_end]
assert tag.startswith('<a href="/"'), (
"the single static active stamp must be the Chat link (href=\"/\")"
)
def test_header_js_never_writes_the_active_state() -> None:
"""Phase 76 task 04: header.js is NOT a writer of the nav's active
state — it never was (the old pages stamped is-active statically in
their OWN markup; the shell's ONE header is the only header left)
— and it must never become one: the whoami auth gate (the sign-in/
sign-out pair + the admin-only nav links' hidden attributes) and the
mobile hamburger are its only nav responsibilities, and neither
touches the active state. The string is absent from the module
entirely; the SINGLE runtime writer is the router (pinned in
test_router_writes_active_state_title_and_meta)."""
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
assert "is-active" not in header_js, (
"header.js must carry no is-active write — the router is the "
"SINGLE WRITER of the active state (the shell markup ships the "
"one static stamp on the Chat link)"
)
def test_boot_order_is_brand_app_router() -> None:
"""The shell's script boot order: brand.js (classic) FIRST, then
app.js (the chat view module — runs at shell boot exactly as
today), then router.js (module) — the router may only see a
fully-booted chat view."""
html = _html()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert "assets/brand.js" in srcs, "brand.js (classic) still ships"
assert srcs.index("assets/brand.js") < srcs.index("/assets/app.js") < srcs.index(
"/assets/router.js"
), "boot order: brand.js → app.js → router.js"
router_tag_match = re.search(r'<script[^>]*src="/assets/router\.js"[^>]*>', html)
assert router_tag_match, "the shell must load the router module"
router_tag = router_tag_match.group(0)
assert 'type="module"' in router_tag, "router.js is an ES module"
# No CDN: every asset reference is local (AGENTS.md rule 6).
assert 'src="http' not in html and 'href="http' not in html
+64 -32
View File
@@ -2,7 +2,9 @@
The browser behavior is E2E-covered (tests/e2e/test_sync_upload_progress.py,
task 06); here we pin the source-level wiring in sources.js, styles.css,
and sources.html — the fmtSyncLabel contract (both kinds, file
and the shell's RAG view markup (index.html — sources.html / git-
sources.html folded in, phase 76 task 02) — the fmtSyncLabel contract
(both kinds, file
present/absent, counts only when total > 0), enterSyncRunningState
writing the full untruncated path to the button title + #sync-result,
the two-job tick decision tree (sync running > upload running > sync
@@ -29,9 +31,11 @@ from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
SOURCES_HTML = FRONTEND / "sources.html"
# Phase 76 (task 02): both HTML pages are folded into the ONE-document
# shell — the pinned comments now live in the RAG / Sources view
# sections of index.html.
SHELL_HTML = FRONTEND / "index.html"
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
def _js() -> str:
@@ -43,7 +47,7 @@ def _css() -> str:
def _html() -> str:
return SOURCES_HTML.read_text(encoding="utf-8")
return SHELL_HTML.read_text(encoding="utf-8")
def _gjs() -> str:
@@ -51,27 +55,38 @@ def _gjs() -> str:
def _ghtml() -> str:
return GIT_SOURCES_HTML.read_text(encoding="utf-8")
return SHELL_HTML.read_text(encoding="utf-8")
def _gfn(js: str, name: str) -> str:
"""The source of the first `function <name>` in git-sources.js
(up to the first line-leading closing brace — the house pin
pattern)."""
(brace balanced — since phase 76 task 02 the functions live inside
mount(root), so the closing brace is indented, not line-leading).
"""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in git-sources.js"
return js[fn : js.find("\n}", fn)]
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def _utick(js: str) -> str:
"""The upload poll tick inside startUploadPolling — from `const
tick = async () => {` to the next top-level function
(initUploadStatus), so the whole decision tree is in the slice."""
tick = async () => {` to the next function (initUploadStatus), so
the whole decision tree is in the slice."""
fn = js.find("function startUploadPolling")
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
tick = js.find("const tick = async () => {", fn)
assert tick != -1, "the tick must live inside startUploadPolling"
end = js.find("\nasync function initUploadStatus", tick)
end = js.find("async function initUploadStatus", tick)
assert end != -1, "initUploadStatus must follow startUploadPolling"
return js[tick:end]
@@ -88,23 +103,34 @@ def _usubmit(js: str) -> str:
def _fn(js: str, name: str) -> str:
"""The source of the first `function <name>` in js (up to the first
line-leading closing brace — the house pin pattern from
tests/unit/test_sync_button.py)."""
"""The source of the first `function <name>` in js (brace balanced —
since phase 76 task 02 the functions live inside mount(root), so
the closing brace is indented, not line-leading; the house pin
pattern from tests/unit/test_sync_button.py)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in sources.js"
return js[fn : js.find("\n}", fn)]
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
def _tick(js: str) -> str:
"""The poll tick inside startSyncPolling — from `const tick = async
() => {` to the next top-level function (startSync), so the whole
two-job decision tree is in the slice."""
() => {` to the next function (startSync), so the whole two-job
decision tree is in the slice."""
fn = js.find("function startSyncPolling")
assert fn != -1, "startSyncPolling must be defined in sources.js"
tick = js.find("const tick = async () => {", fn)
assert tick != -1, "the tick must live inside startSyncPolling"
end = js.find("\nasync function startSync", tick)
end = js.find("async function startSync", tick)
assert end != -1, "startSync must follow startSyncPolling"
return js[tick:end]
@@ -309,8 +335,9 @@ def test_reattach_adopts_a_running_upload_only() -> None:
assert '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
assert 'emitSyncStatus({ state: "running" })' in branch
assert "startSyncPolling()" in branch
# the idle settle is the fall-through (the last statement)
assert body.rstrip().endswith("applySyncIdle(status);")
# the idle settle is the fall-through (the last statement — the
# brace-balanced body ends with the closing brace)
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
# ---------- the section header + the page comment ----------
@@ -332,10 +359,11 @@ def test_section_header_documents_the_two_job_contract() -> None:
def test_sources_html_comment_documents_the_live_announcer() -> None:
"""The #sync-result comment in sources.html documents the phase-64
dual role: the live file label (both kinds) while either job runs,
untruncated for the aria-live announcer, and empty after an upload
settles (A3 — the upload's counts live on the Sources page)."""
"""The #sync-result comment in the shell's RAG view (formerly
sources.html) documents the phase-64 dual role: the live file
label (both kinds) while either job runs, untruncated for the
aria-live announcer, and empty after an upload settles (A3 — the
upload's counts live on the Sources page)."""
html = _html()
idx = html.find('id="sync-result"')
assert idx != -1
@@ -634,23 +662,27 @@ def test_boot_reattach_branches() -> None:
assert "status.error" in fail
assert "uploadError.hidden = false" in fail
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
# The boot IIFE: after the list loads, the re-attach runs (admin
# branch only — the anonymous path returns before it).
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
# after the list loads, the re-attach runs (admin branch only — the
# anonymous path returns before it), and it is the LAST statement
# of mount(root).
i_boot = js.rfind("await loadSources();")
tail = js[i_boot:i_boot + 400]
assert "await initUploadStatus();" in tail
assert "})();" in tail
assert "})();" not in js, "the top-level boot IIFE is gone (mount owns boot)"
assert tail.rstrip().removesuffix("}").rstrip().endswith("await initUploadStatus();")
# ---------- the page comment ----------
def test_git_sources_html_comment_documents_the_202_contract() -> None:
"""The #archive-upload-form comment in git-sources.html documents
the phase-64 202 contract (the phase-49 synchronous paragraph
marked superseded): the 202 = "safely on disk" + the JS-created
toast (no markup), the live "Processing…" label via the status
poll, and the 409 re-attach without an error banner."""
"""The #archive-upload-form comment in the shell's Sources view
(formerly git-sources.html) documents the phase-64 202 contract
(the phase-49 synchronous paragraph marked superseded): the 202 =
"safely on disk" + the JS-created toast (no markup), the live
"Processing…" label via the status poll, and the 409 re-attach
without an error banner."""
html = _ghtml()
idx = html.find('id="archive-upload-form"')
assert idx != -1
+7 -9
View File
@@ -45,16 +45,14 @@ STYLES_CSS = ASSETS / "styles.css"
#: The app's pages (phase 46: the shared bar contract extends to the
#: phase-35 git-sources page — the hamburger is part of that bar; the
#: phase-50 History page and the phase-51 shared page carry the same
#: bar — the full seven-page set). The two pages added after phase 46
#: keep the identical header block, so they pin here too.
#: bar). Phase 76 (task 02): the folded RAG + Sources files are gone —
#: the shell (index.html) stands in for both views. Phase 76 (task
#: 03): the History file is gone too — the shell stands in for the
#: History view (all four folded view files deleted).
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
FRONTEND / "document.html",
FRONTEND / "git-sources.html",
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
)
@@ -122,10 +120,10 @@ def _rule_block(css: str, selector: str) -> str:
return m.group(1)
# ---------- markup: the identical toggle + labeled nav on all six pages ----------
# ---------- markup: the identical toggle + labeled nav on the pages ----------
def test_all_six_pages_carry_the_hamburger_toggle() -> None:
def test_all_pages_carry_the_hamburger_toggle() -> None:
"""Every page carries the #nav-toggle button with the full aria
contract: a real button (type=button), aria-expanded defaulting to
"false", aria-controls pointing at the nav, the accessible name
@@ -176,7 +174,7 @@ def test_toggle_lives_in_the_shared_bar_right_before_the_nav() -> None:
)
def test_all_six_pages_carry_the_labeled_nav_with_id() -> None:
def test_all_pages_carry_the_labeled_nav_with_id() -> None:
"""The nav keeps its single element + label and gains ONLY the id
(the whoami reveal targets the same four links — no duplicated
markup, so the phase-19/35 visibility rules apply inside the menu

Some files were not shown because too many files have changed in this diff Show More