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/.
This commit is contained in:
2026-09-06 06:31:31 -04:00
parent 7e567bddf3
commit ffa919b8bf
78 changed files with 5548 additions and 3244 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,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

+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
+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)
+76 -49
View File
@@ -1,19 +1,41 @@
/* Brain of Reese — Git sources admin page (phase 35, task 04;
* archive uploads, phase 49 task 03).
/* Brain of Reese — Sources view (the git-sources manager; phase 35
* task 04; archive uploads, phase 49 task 03; phase 76 task 02: shell
* view module — formerly the standalone git-sources.html).
*
* The page module for /git-sources.html: the admin-only manager for the
* stored source list (git-sources table, phase 35 tasks 01/02) — git
* repo URLs (kind "git") and uploaded archives unpacked under
* The view module for /git-sources.html: the admin-only manager for
* the stored source list (git-sources table, phase 35 tasks 01/02) —
* git repo URLs (kind "git") and uploaded archives unpacked under
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
* local-directory form is gone — the kind=local API POST is
* unchanged, the page just no longer offers it).
* This module is the single owner of the page's behaviour:
* This module is the single owner of the view's behaviour:
*
* • boot — initSharedHeader() (one cached whoami, shared with the
* header toggling): anonymous → the sign-in gate shows and the
* manager stays hidden (the exact Sources page gate pattern, and
* NO /api/git-sources call is made); admin → gate hidden,
* #git-sources-content revealed, loadSources().
* Phase 76 (task 02) — shell view module (the "Sources" view of the
* ONE-document shell; /git-sources.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-git-sources">, 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 bindings and the upload-progress state machine survive
* every switch: the scan 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 single toast node/timer stay module
* scope (one per document life — toasts never stack).
* • 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 gate keeps fetchIsAdmin()
* — the SAME cached /api/whoami promise header.js exports (zero
* extra requests).
*
* • boot (the mount's tail) — fetchIsAdmin() (the cached whoami):
* anonymous → the sign-in gate shows and the manager stays hidden
* (the exact Sources page gate pattern, and NO /api/git-sources
* call is made); admin → gate hidden, #git-sources-content
* revealed, loadSources().
* • loadSources() — GET /api/git-sources → the table rows
* (#git-sources-tbody), the env-fallback note's visibility
* (from_env), and the empty state. Each row leads with its kind
@@ -122,44 +144,50 @@
*
* 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 — the shell
* loads the view module ONLY through the router's lazy import();
* esbuild inlines it into the router bundle in the image build).
*/
import { initSharedHeader } from "./header.js";
import { fetchIsAdmin } from "./header.js";
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
let uploadToastEl = null; // the single toast node — lazy-created, reused
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
export async function mount(root) {
/* ---------- page elements (git-sources.html, task 04) ---------- */
const gateEl = document.querySelector("#git-sources-gate");
const contentEl = document.querySelector("#git-sources-content");
const formEl = document.querySelector("#git-source-form");
const urlInput = document.querySelector("#git-source-url");
const addBtn = document.querySelector("#git-source-add");
const addError = document.querySelector("#git-source-error");
const gateEl = root.querySelector("#git-sources-gate");
const contentEl = root.querySelector("#git-sources-content");
const formEl = root.querySelector("#git-source-form");
const urlInput = root.querySelector("#git-source-url");
const addBtn = root.querySelector("#git-source-add");
const addError = root.querySelector("#git-source-error");
/* Phase 49: the archive upload form (replaces the phase-38 local
directory form — same card, a file input instead of a path input).
The response counts render in the role=status result line. */
const uploadFormEl = document.querySelector("#archive-upload-form");
const uploadFileInput = document.querySelector("#archive-upload-file");
const uploadBtn = document.querySelector("#archive-upload-btn");
const uploadError = document.querySelector("#archive-upload-error");
const uploadResult = document.querySelector("#archive-upload-result");
const loadErrorEl = document.querySelector("#git-sources-load-error");
const loadErrorText = document.querySelector("#git-sources-load-error-text");
const retryBtn = document.querySelector("#git-sources-retry");
const tableWrap = document.querySelector("#git-sources-table-wrap");
const tbody = document.querySelector("#git-sources-tbody");
const emptyEl = document.querySelector("#git-sources-empty");
const envNote = document.querySelector("#git-sources-env-note");
const announcer = document.querySelector("#git-sources-announcer");
const uploadFormEl = root.querySelector("#archive-upload-form");
const uploadFileInput = root.querySelector("#archive-upload-file");
const uploadBtn = root.querySelector("#archive-upload-btn");
const uploadError = root.querySelector("#archive-upload-error");
const uploadResult = root.querySelector("#archive-upload-result");
const loadErrorEl = root.querySelector("#git-sources-load-error");
const loadErrorText = root.querySelector("#git-sources-load-error-text");
const retryBtn = root.querySelector("#git-sources-retry");
const tableWrap = root.querySelector("#git-sources-table-wrap");
const tbody = root.querySelector("#git-sources-tbody");
const emptyEl = root.querySelector("#git-sources-empty");
const envNote = root.querySelector("#git-sources-env-note");
const announcer = root.querySelector("#git-sources-announcer");
/* Phase 69: the remove confirmation modal (the native confirm()
retired) — static markup in git-sources.html; this module owns the
open / cancel / confirm lifecycle. */
const removeDialog = document.querySelector("#remove-confirm-dialog");
const removeBackdrop = document.querySelector(".remove-confirm-backdrop");
const removeSourceEl = document.querySelector("#remove-confirm-source");
const removeError = document.querySelector("#remove-confirm-error");
const removeCancelBtn = document.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = document.querySelector("#remove-confirm-remove");
const removeDialog = root.querySelector("#remove-confirm-dialog");
const removeBackdrop = root.querySelector(".remove-confirm-backdrop");
const removeSourceEl = root.querySelector("#remove-confirm-source");
const removeError = root.querySelector("#remove-confirm-error");
const removeCancelBtn = root.querySelector("#remove-confirm-cancel");
const removeRemoveBtn = root.querySelector("#remove-confirm-remove");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
@@ -609,9 +637,6 @@ function fmtUploadResult(detail) {
* live-region line for it). SUCCESS-ONLY (A2): failures are the
* #archive-upload-error banner, never a toast. The .toast CSS ships
* as-is (styles.css, phase 55). */
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
let uploadToastEl = null; // the single toast node — lazy-created, reused
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
function showUploadToast(message) {
if (!uploadToastEl) {
@@ -868,12 +893,14 @@ function focusNewRow(createdId) {
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
(async () => {
// The shared header FIRST (Sign in/out + the admin-only nav links +
// the Sync button — one cached whoami), then the gate: anonymous
// visitors get the gate and NO /api/git-sources call (the Sources
// page gate pattern); the admin gets the manager.
const admin = await initSharedHeader();
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): anonymous visitors get
* the gate and NO /api/git-sources call (the Sources-page gate
* pattern); the admin gets the manager. */
const admin = await fetchIsAdmin();
if (!admin) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
@@ -886,4 +913,4 @@ if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
// resumes the Processing state) or re-render a terminal run's
// result line / error banner.
await initUploadStatus();
})();
}
+42 -21
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,19 +56,39 @@
* • 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,
@@ -444,19 +465,19 @@ async function loadChats() {
}
}
(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 });
+70 -61
View File
@@ -1,45 +1,60 @@
/* Brain of Reese — Sources page (knowledge base index view).
/* Brain of Reese — RAG view (the knowledge base catalog; phase 76
* task 02: shell view module — formerly the standalone sources.html).
*
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
* full-width document table, or the designed empty state when nothing is
* indexed yet. Cells are built with DOM APIs (textContent) — never
* full-width document table, or the designed empty state when nothing
* is indexed yet. Cells are built with DOM APIs (textContent) — never
* innerHTML with document-derived data (XSS-safe by construction).
*
* Phase 19: the page joins the shared header (assets/header.js) — the
* whoami gate below runs on the module's cached promise (one request per
* page, shared with the header toggling).
* Phase 76 (task 02) — shell view module (the "RAG" view of the
* ONE-document shell; /sources.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* Phase 34 task 02: the header's functional controls are module-owned
* (assets/header.js), including the New chat binding (on a non-chat
* page "new chat" means going to the chat, fresh — the module clears
* the phase-14 conversation key and navigates to "/").
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-rag">, 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 bindings,
* the sync state machine, and the in-flight poll 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 (sync button
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
* SAME cached /api/whoami promise header.js exports (zero extra
* requests).
* • the document modal needs NO wiring change: the shell keeps
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
* both app.js (chat chips) and this module (RAG row links) open
* documents through the shared openDocumentModal(...) against that
* single instance (assets/document-modal.js, resolved by
* document-level querySelector at import).
*
* Phase 64 (task 04): the sync button is also the live progress face
* of the BACKGROUND archive scan (task 03) — while an upload import
* is in flight the button animates with the upload's current file
* ("Importing <file>"), settles + refreshes the catalog when it
* finishes, and re-attaches to it on page load. See the sync-button
* block below for the full two-job contract.
*
* Phase 26: the table's path links open the document in the
* almost-fullscreen modal overlay (assets/document-modal.js) on the
* SAME page — no new tab, no navigation. The link keeps its
* /document.html href as the no-JS / context-menu escape hatch;
* left-clicks are intercepted (preventDefault) and routed to
* openDocumentModal. The module is loaded through the relative import
* below — the header.js single-evaluation design (no direct <script>
* tag; esbuild inlines it into the page bundle).
* The sync button (phase 32) + the live two-job progress contract
* (phase 64 task 04) and the table rows (phase 26: same-page modal
* links) are unchanged in content — only the boot shape moved.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
* query values are percent-encoded (paths contain slashes, sometimes
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
* context-menu escape hatch) — the left-click opens the same-page modal
* instead. */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
export async function mount(root) {
/* ---------- Sync sources button (Sources page only) ----------
*
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
* → running (2 s poll of GET /api/sync/status) → success | failed.
* Admin-only: the button SHIPS hidden and the page boot below reveals it
* for the admin on the SAME cached whoami initSharedHeader() used (no
* extra fetch); the 403 branches stay as defense in depth. A failed run
* Admin-only: the button SHIPS hidden and the view boot below reveals
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
* opens an error modal (same as the former header.js module — recreated
* here since the navbar button is gone).
*
@@ -74,12 +89,12 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js";
* #sync-icon (the spinner icon), #sync-result (aria-live result
* line), #sync-error-banner / #sync-error-text (error banner).
*/
const syncBtn = document.querySelector("#sync-btn");
const syncBtn = root.querySelector("#sync-btn");
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = document.querySelector("#sync-result");
const syncErrorBanner = document.querySelector("#sync-error-banner");
const syncErrorText = document.querySelector("#sync-error-text");
const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner");
const syncErrorText = root.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000;
let syncPollTimer = null;
@@ -429,27 +444,22 @@ if (syncBtn) {
initSyncButton();
}
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
const tbody = document.querySelector("#docs-tbody");
const emptyEl = document.querySelector("#sources-empty");
const tableWrap = document.querySelector(".table-wrap");
const statCards = document.querySelector("#stat-cards");
const gateEl = document.querySelector("#sources-gate");
const statDocs = document.querySelector("#stat-docs");
const statChunks = document.querySelector("#stat-chunks");
const statLast = document.querySelector("#stat-last");
const tbody = root.querySelector("#docs-tbody");
const emptyEl = root.querySelector("#sources-empty");
const tableWrap = root.querySelector(".table-wrap");
const statCards = root.querySelector("#stat-cards");
const gateEl = root.querySelector("#sources-gate");
const statDocs = root.querySelector("#stat-docs");
const statChunks = root.querySelector("#stat-chunks");
const statLast = root.querySelector("#stat-last");
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
* catalog is admin-only. The document viewer itself stays public (the
* soft rule), so the gate copy points at what keeps working.
* Phase 19: the whoami request is the shared header module's cached
* promise — the same single request initSharedHeader() awaited. */
function isAdmin() {
return fetchIsAdmin();
}
* Phase 76 (task 02): the whoami request is the shared header module's
* cached promise — fetchIsAdmin(), the same single request the shell's
* header boots (zero extra requests). */
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
@@ -458,14 +468,6 @@ function fmtDate(iso) {
}
}
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
* query values are percent-encoded (paths contain slashes, sometimes
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
* context-menu escape hatch) — the left-click opens the same-page modal
* instead. */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
async function loadDocs() {
let r;
@@ -543,10 +545,17 @@ function showEmpty() {
}
(async () => {
const admin = await initSharedHeader(); // phase 19: shared bar (cached whoami)
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): the sync button joins
* the admin reveal on that one whoami (no extra fetch), and the
* anonymous branch gates the catalog in / out with NO /api/docs
* request at all (the Sources-page soft rule, unchanged). */
const admin = await fetchIsAdmin();
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
if (!(await isAdmin())) {
if (!admin) {
// Anonymous: gate in, catalog out, and no /api/docs request at all.
if (statCards) statCards.hidden = true;
if (tableWrap) tableWrap.hidden = true;
@@ -556,4 +565,4 @@ function showEmpty() {
}
if (gateEl) gateEl.hidden = true;
loadDocs();
})();
}
+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
+56 -45
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,42 +26,54 @@
* 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. */
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");
@@ -193,15 +206,15 @@ if (tuneForm) {
/* ---------- 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
* 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 page-wide: close any other row's first.
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
// 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();
});
@@ -324,18 +337,16 @@ async function deleteNote(id, btn, li) {
}
}
/* ---------- 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.
/* ---------- 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 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
* 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>
+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")
+10 -12
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()
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()
finally:
page.remove_listener("dialog", handle_dialog)
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"
)
+43 -37
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,11 +316,10 @@ 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)
text = _read(INDEX_HTML)
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)"
"index.html: no direct document-modal.js <script> tag "
"(single-evaluation design — the view scripts import it)"
)
@@ -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
+49 -17
View File
@@ -1,11 +1,18 @@
"""Unit: phase-66 text pins — the History tab describes the auto-save
model, not the retired Save button.
House pattern (``test_stale_ui_copy.py``): read ``frontend/history.html``
as text and assert substrings — no browser. The browser-visible layer is
House pattern (``test_stale_ui_copy.py``): read the frontend files as
text and assert substrings — no browser. The browser-visible layer is
gated by the dedicated story suite (``tests/e2e/test_history_copy.py``);
these pins catch a silent regression in the template without it.
Phase 76 (task 03): the History view is a view of the shell —
``frontend/history.html`` is deleted, the view copy lives in the
shell's ``#view-history`` section, and the per-view meta description is
the router's (``frontend/assets/router.js`` — the single writer of the
client-side ``<meta name="description">``, value carried over from the
old page's ``<head>``). The pins follow the copy to its new home.
Locked decision (owner-locked A3, 2026-09-01): every conversation saves
itself automatically — there is NO Save button (retired phase 55, owner-
locked A2, pinned by ``test_save_share_ux.py::test_anonymous_auto_save``),
@@ -18,6 +25,9 @@ from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
ROUTER_JS = FRONTEND / "assets" / "router.js"
# --- the locked auto-save copy (owner-locked A3) -----------------------
META = "Saved chats — every conversation is saved automatically, one click back."
@@ -43,8 +53,24 @@ STATE_STRINGS = (
)
def _text() -> str:
return (FRONTEND / "history.html").read_text(encoding="utf-8")
def _shell() -> str:
assert SHELL_HTML.is_file(), f"missing {SHELL_HTML}"
return SHELL_HTML.read_text(encoding="utf-8")
def _router() -> str:
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
return ROUTER_JS.read_text(encoding="utf-8")
def _view(html: str) -> str:
"""The shell's History view section (the view is the shell's LAST
view section — the slice runs to the container main's close)."""
start = html.find('<section class="view" id="view-history"')
assert start != -1, "the #view-history section must be in the shell"
end = html.find("</main>", start)
assert end != -1
return html[start:end]
def _norm(text: str) -> str:
@@ -54,26 +80,32 @@ def _norm(text: str) -> str:
def test_manual_save_copy_is_gone() -> None:
"""history.html: the three retired manual-save strings are GONE —
the copy no longer tells the visitor to press a Save button."""
html = _text()
"""The shell (which carries the History view) and the router (which
carries the per-view meta): the three retired manual-save strings
are GONE — the copy no longer tells the visitor to press a Save
button."""
for text in (_view(_shell()), _router()):
for frag in (OLD_META, OLD_PAGE_SUB, OLD_EMPTY_ROW):
assert frag not in html, f"retired manual-save copy still present: {frag!r}"
assert frag not in text, f"retired manual-save copy still present: {frag!r}"
def test_locked_auto_save_copy_present_exactly_once() -> None:
"""The three locked (A3) auto-save strings, each exactly once in
history.html (a second copy could drift out of sync)."""
html = _norm(_text())
assert html.count(META) == 1, "the locked meta description"
assert html.count(PAGE_SUB) == 1, "the locked page-sub"
assert html.count(EMPTY_ROW) == 1, "the locked empty-row string"
"""The three locked (A3) auto-save strings, each exactly once (a
second copy could drift out of sync). Phase 76 (task 03): the meta
description lives in the router's DESCRIPTIONS table (the router is
the single writer of the client-side meta — the value is carried
over from the old page's <head>), and the page-sub + empty-row
strings live in the shell's History view."""
assert _router().count(META) == 1, "the locked meta description (router-owned)"
view = _norm(_view(_shell()))
assert view.count(PAGE_SUB) == 1, "the locked page-sub"
assert view.count(EMPTY_ROW) == 1, "the locked empty-row string"
def test_state_language_survivors_are_untouched() -> None:
"""Proof of NO over-deletion: the strings where "saved" is a state —
the <h1>, the anonymous gate title, and the gate sub — stay exactly
as phase 50 wrote them."""
html = _text()
as phase 50 wrote them (in the shell's History view)."""
view = _view(_shell())
for frag in STATE_STRINGS:
assert frag in html, f"state-language survivor deleted: {frag!r}"
assert frag in view, f"state-language survivor deleted: {frag!r}"
+166 -88
View File
@@ -1,8 +1,14 @@
"""Unit: the phase-50 task-04 History-page contract.
"""Unit: the phase-50 task-04 History contract.
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the History page depends on, so a silent
Phase 76 (task 03): the History view is a view of the shell —
``frontend/history.html`` is deleted, its content lives in the shell's
``#view-history`` section, and ``history.js`` is a ``mount(root)`` view
module the router lazy-imports. This module pins the JS/CSS/HTML
markers the view depends on accordingly (the JS pins against
``history.js`` — brace-balanced slices, since phase 76 task 03 the
functions live inside ``mount(root)`` — the HTML pins against the
shell, scoped to the view where view-scoped); the browser behavior
itself is E2E-gated by the story suite (task 05), and a silent
regression is caught without a browser:
* the anonymous no-fetch gate (the gate in, the table out, and the
@@ -14,19 +20,21 @@ regression is caught without a browser:
(owner-locked 2026-08-29: no native confirm dialog on this page);
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
that history with a click");
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* ``#nav-history`` on ALL surviving pages (the phase-34 one-bar
contract — phase 76: the four folded navbar-view files are gone
(tasks 01–03), so the page list is the shell + the surviving
documents) + ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in ``history.html``, and the ``.stale-pill``
rose-family CSS in ``styles.css``.
between Updated and Share in the shell's History view, and the
``.stale-pill`` rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
The Containerfile stage-1 coverage (the shell copied, the view modules
bundled into the router) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
missing from stage 1 fails there.
"""
@@ -39,27 +47,25 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the History page + the shared
#: page: EIGHT pages.
#: The phase-34 one-bar contract + the shared page.
#: Phase 76 (task 01): the post-shell set — TUNING_HTML dropped (the
#: Tuning view is folded into the shell; its file is deleted).
#: Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped (both
#: views folded into the shell; both files deleted).
#: Phase 76 (task 03): HISTORY_HTML dropped (the History view is
#: folded into the shell; its file is deleted) — the shell + the
#: surviving documents.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
TUNING_HTML,
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
SHARED_HTML,
)
@@ -77,11 +83,41 @@ def _css() -> str:
return _text(STYLES_CSS)
def _shell() -> str:
return _text(INDEX_HTML)
def _view(html: str) -> str:
"""The shell's History view section — from the #view-history open
tag to the container main's close (the view is the shell's LAST
view section, so the slice ends at the first ``</main>`` after
it)."""
start = html.find('<section class="view" id="view-history"')
assert start != -1, "the #view-history section must be in the shell"
end = html.find("</main>", start)
assert end != -1, "the container main must close after the view"
return html[start:end]
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in history.js"
return js[start : js.find("\n}\n", start) + 4]
"""The source of the first ``function <name>`` in history.js (brace
balanced — since phase 76 task 03 the functions live inside
mount(root), so the closing brace is indented, not line-leading;
the house pin pattern from tests/unit/test_frontend_sync_upload.py).
"""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in history.js"
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 _nav_history_tag(html: str) -> str:
@@ -90,14 +126,18 @@ def _nav_history_tag(html: str) -> str:
return tag.group(0)
# ---------- the #nav-history link: all seven pages ----------
# ---------- the #nav-history link: all surviving pages ----------
def test_nav_history_present_on_all_seven_pages() -> None:
def test_nav_history_present_on_all_surviving_pages() -> None:
"""The phase-34 one-bar contract extended by phase 50: the admin-only
History link SHIPS hidden (revealed by header.js for admin) on every
page, after the Tuning link, pointing at /history.html. The page's
own link is the active one (is-active + aria-current)."""
standalone page (the shell carries it once for its views — phase 76),
after the Tuning link, pointing at /history.html. NO page's link is
statically stamped active: in the shell the router is the SINGLE
WRITER of the active state (client-side, per view — the old
history.html page-level stamp is gone with the file), and the
surviving documents keep their pre-shell no-stamp state."""
for html in ALL_PAGES:
text = _text(html)
tag = _nav_history_tag(text)
@@ -107,29 +147,26 @@ def test_nav_history_present_on_all_seven_pages() -> None:
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
f"{html.name}: #nav-history must follow #nav-tuning"
)
# The history page is the only one whose link is active.
for html in ALL_PAGES:
tag = _nav_history_tag(_text(html))
if html.name == "history.html":
assert 'class="nav-link is-active"' in tag
assert 'aria-current="page"' in tag
else:
assert "is-active" not in tag, (
f"{html.name}: no nav link is current there"
f"{html.name}: no statically-current nav link (in the shell the "
"active state is the router's single-writer job)"
)
assert 'aria-current="page"' not in tag
def test_nav_history_count_is_exactly_eight_pages() -> None:
def test_nav_history_count_is_exactly_four_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, eight pages (phase 51: + the
shared page), no duplicates and no extra page that forgot (or
added twice)."""
one ``id="nav-history"`` per page, FOUR pages (phase 51: + the shared
page; phase 76: the four folded navbar-view files are gone — task 01
− Tuning, task 02 − RAG + Sources, task 03 − History — the shell's
ONE link covers all its views), no duplicates and no extra page
that forgot (or added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
assert total == 4, f"expected #nav-history on 4 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
@@ -145,41 +182,56 @@ def test_header_js_reveals_nav_history_for_admin() -> None:
assert "navHistory.hidden = !admin" in body
# ---------- history.html: the page scaffold ----------
# ---------- the shell's History view: the scaffold ----------
def test_history_page_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the page-head, the gate (ship-hidden), the
role="status" live region, and the table inside the
.table-wrap card. Footer with the version span (the index.html
shape)."""
html = _text(HISTORY_HTML)
def test_history_view_scaffold_and_landmarks() -> None:
"""The shell's History view (formerly history.html — phase 76 task
03): the shell's standard landmarks (skip link, the shared header,
the steering panel + announcer — the shell's ONE header-owned pair,
the view's copies dropped with the move) + the view section
(hidden AND inert + focusable — the WCAG pair, AGENTS.md rule 5),
the page-head, the gate (ship-hidden), the role="status" live
region, the table inside the .table-wrap card, and the shell's ONE
footer with the version span (the history page's footer copy is
dropped — no duplicate #app-version)."""
html = _shell()
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
assert '<h1>Saved chats</h1>' in html
assert html.count('id="steering-panel"') == 1, (
"the shell carries its ONE steering panel (the view's copy is dropped)"
)
assert html.count('id="steering-announcer"') == 1
assert html.count('<main id="main" class="app-main" tabindex="-1">') == 1
# The view section: hidden AND inert (the WCAG pair) + focusable.
view = re.search(r'<section[^>]*id="view-history"[^>]*>', html)
assert view, "the #view-history section must be in the shell"
view_tag = view.group(0)
assert "hidden" in view_tag and "inert" in view_tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in view_tag, "the target view is focusable"
body = _view(html)
assert '<h1>Saved chats</h1>' in body
# The anonymous gate — the #sources-gate pattern, ship-hidden.
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
assert 'href="/login.html?next=/history.html"' in html, (
"the gate's Sign in returns to the History page (no-JS fallback)"
assert 'href="/login.html?next=/history.html"' in body, (
"the gate's Sign in returns to the History view (no-JS fallback)"
)
# The action-feedback live region.
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', body)
# The table wrapper: the .table-wrap card (scrollable) with its
# own id, a labeled region, focusable.
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', body)
assert wrap, "the table must live in the .table-wrap card"
assert 'id="history-table-wrap"' in wrap.group(0)
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
# Footer with the version span.
# The shell keeps its ONE footer with the version span (the history
# page's footer copy is dropped — no duplicate #app-version).
assert 'class="footer-version" id="app-version"' in html
assert html.count('id="app-version"') == 1
def test_history_table_skeleton() -> None:
@@ -187,8 +239,9 @@ def test_history_table_skeleton() -> None:
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
Actions (the Actions header text is visually-hidden — the row
buttons carry their own aria-labels) — and the empty-state row
(ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
(ship-hidden, the exact copy). Phase 76 (task 03): scoped to the
shell's History view section."""
html = _view(_shell())
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
@@ -224,16 +277,22 @@ def test_history_table_skeleton() -> None:
) in html
def test_history_page_scripts_and_no_cdn() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the history.js module second, NO direct header.js <script> tag
(single-evaluation design — history.js imports it relatively).
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
html = _text(HISTORY_HTML)
def test_shell_scripts_and_no_cdn() -> None:
"""Shell script load order (the house pattern): brand.js classic
FIRST, the app.js (chat) + router.js modules, NO direct history.js
<script> tag (single-evaluation design — the router lazy-imports
the view module on first show; in the image the Containerfile's
esbuild stage inlines it into the router bundle). history.js keeps
its relative shared-header import (it uses the cached whoami
promise). No-CDN rule (AGENTS.md rule 6): no external
script/link tags."""
html = _shell()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["assets/brand.js", "/assets/history.js"], (
f"history.html must load brand.js (classic, first) + the history.js "
f"module, got {srcs}"
assert srcs[0] == "assets/brand.js", "brand.js (classic) must load first"
assert "/assets/app.js" in srcs, "the shell loads the chat module"
assert "/assets/router.js" in srcs, "the shell loads the router module"
assert [s for s in srcs if "history.js" in s] == [], (
"no direct history.js tag — the router lazy-imports the view module"
)
js = _js()
assert 'from "./header.js"' in js, (
@@ -249,12 +308,17 @@ def test_history_page_scripts_and_no_cdn() -> None:
def test_anonymous_boot_makes_no_chats_request() -> None:
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
(shared-header contract), then the anonymous branch hides the
table, shows the gate, and RETURNS — no ``/api/chats`` request on
the wire (the router 403s anonymous; the story E2E pins the
request log). Only the admin path reaches ``loadChats()``. The
single ``fetch("/api/chats")`` in the file lives in loadChats."""
"""The whoami gate in ``mount(root)`` (phase 76 task 03 — the shell
view module form): the view module does NOT boot the shared header
(the shell's header boots exactly once, via the chat module
(app.js) at shell boot — no call site, and the import carries ONLY
the cached whoami promise), so the anonymous branch gates on
``fetchIsAdmin()`` alone (the SAME cached /api/whoami request —
zero extra), hides the table, shows the gate, and RETURNS — no
``/api/chats`` request on the wire (the router 403s anonymous; the
story E2E pins the request log). Only the admin path reaches
``loadChats()``. The single ``fetch("/api/chats")`` in the file
lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
@@ -262,19 +326,33 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
boot = js[js.find("(async () => {"):]
assert boot, "the boot IIFE must exist"
assert "await initSharedHeader()" in boot
gate_i = boot.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1, "the whoami gate must run in boot"
# Phase 76 (task 03): the view module never boots the shell's
# header — no CALL to the header boot (a docstring may name it;
# a call may not) and no initSharedHeader in the import — the
# shell's header boots via the chat module (app.js) at shell boot.
assert "await initSharedHeader()" not in js, (
"the view must not re-boot the shell's header"
)
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("initSharedHeader" not in line for line in import_lines), (
"the view must not import the header boot — the shell's header boots "
"via the chat module (app.js) at shell boot"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
assert mount_i != -1, "mount(root) must be the module's entry"
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert gate_i > mount_i, "the whoami gate must run in mount"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = boot[gate_i : boot.find("return;", gate_i)]
branch = js[gate_i : js.find("return;", gate_i)]
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
after = boot[boot.find("return;", gate_i):]
after = js[js.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
@@ -346,9 +424,9 @@ def test_two_step_delete_confirm_pair() -> None:
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
# No (and the restore helper) bring the Delete button back, focused.
restore_start = fn.find("function restoreDelete")
restore_end = fn.find("\n }", restore_start)
restore = fn[restore_start:restore_end]
# (Brace-balanced — since phase 76 task 03 the helper lives inside
# mount(root), so a line-leading-brace slice would not find it.)
restore = _fn(js, "restoreDelete")
assert "cell.replaceChildren(del)" in restore
assert "del.focus()" in restore
assert 'no.addEventListener("click", restoreDelete)' in fn
+13 -9
View File
@@ -32,7 +32,8 @@ source-level contract a silent regression would break:
in-modal alert line + dialog stays open, network →
the fixed reachable? line, re-enable in the finally);
* the stale "prunes on the next sync" removal copy is GONE from
``git-sources.js`` + ``git-sources.html``; the new hint copy is
``git-sources.js`` + the shell (phase 76 task 02 folded git-
sources.html into index.html's Sources view); the new hint copy is
PRESENT (the README pins are task 03's);
* styles.css — the modal classes on the house dark-tech palette
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
@@ -44,7 +45,10 @@ import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
HTML = FRONTEND / "git-sources.html"
# Phase 76 (task 02): git-sources.html is folded into the ONE-document
# shell — the dialog markup now lives in the Sources view section of
# index.html (moved verbatim, ids unchanged).
SHELL_HTML = FRONTEND / "index.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
@@ -121,7 +125,7 @@ def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
a <p>)."""
marker = f'id="{id_attr}"'
i = html.find(marker)
assert i != -1, f"missing id={id_attr} in git-sources.html"
assert i != -1, f"missing id={id_attr} in the shell's Sources view"
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
assert opens, f"no <{tag}> owns id={id_attr}"
open_i = opens[-1]
@@ -161,7 +165,7 @@ def test_dialog_markup_is_the_locked_alertdialog() -> None:
selectors); all six child ids present; the error line is
role="alert"; both buttons are real type="button"; the title is
the locked h2; the modal copy is the locked paragraph verbatim."""
html = _text(HTML)
html = _text(SHELL_HTML)
frag = _element_block(html, "remove-confirm-dialog")
open_tag = frag[: frag.find(">") + 1]
assert 'role="alertdialog"' in open_tag
@@ -349,10 +353,10 @@ def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
def test_stale_next_sync_removal_copy_is_gone() -> None:
"""The phase-35 "prunes on the next sync" removal contract is
superseded: the retired confirm copy AND any 'prune(s/d) … next
sync' shape are absent from git-sources.js + git-sources.html
(code AND comments — the docstring copy moved with the flow).
The README pins are task 03's."""
for path in (JS, HTML):
sync' shape are absent from git-sources.js + the shell (code AND
comments — the docstring copy moved with the flow). The README
pins are task 03's."""
for path in (JS, SHELL_HTML):
raw = _text(path)
norm = _norm(raw)
assert OLD_STAY_INDEXED not in raw, (
@@ -368,7 +372,7 @@ def test_new_hint_copy_is_present_in_the_html() -> None:
local directories are never touched), and the Sync button still
mirrors the remaining sources (upstream churn is pruned on that
run — not 'on the next sync')."""
hint = _norm(_element_block(_text(HTML), "git-sources-hint", tag="p"))
hint = _norm(_element_block(_text(SHELL_HTML), "git-sources-hint", tag="p"))
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
assert frag in hint, f"the new hint copy is missing: {frag!r}"
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
+17 -9
View File
@@ -63,12 +63,11 @@ from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
# views are folded into the shell (index.html); both files deleted.
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"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
@@ -423,8 +422,11 @@ def test_share_button_ships_visible_beside_new_chat() -> None:
"#messages, above the composer (phase 65)"
)
assert messages_end < new_idx, ("the row moved below the #messages section")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the button — pinned
# above); the standalone pages carry none (task 03 dropped the
# last folded file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Share button is chat-page only"
)
@@ -729,8 +731,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
and "<section" not in after
and "<form" not in after
), ("nothing but the composer comment lands between the row and the composer")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the row — pinned above);
# the standalone pages carry none (task 03 dropped the last folded
# file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
f"{other.name}: the action row is chat-page only"
)
@@ -823,8 +828,11 @@ def test_stale_banner_html_after_kb_banner() -> None:
# the kb-banner warning triangle) — aria-hidden decoration.
lead = block[: btn.start()]
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the banner — pinned
# above); the standalone pages carry none (task 03 dropped the last
# folded file, history.html).
for other in (DOCUMENT_HTML, LOGIN_HTML):
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the stale banner is chat-page only"
)
+168 -53
View File
@@ -22,14 +22,14 @@ SOURCES_JS = ASSETS / "sources.js"
DOCUMENT_JS = ASSETS / "document.js"
LOGIN_JS = ASSETS / "login.js"
TUNING_JS = ASSETS / "tuning.js"
HISTORY_JS = ASSETS / "history.js"
STYLES_CSS = ASSETS / "styles.css"
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
# views are folded into the shell (index.html); both files are deleted.
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"
def _text(path: Path) -> str:
@@ -126,27 +126,37 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
the RAG nav link is hidden for anonymous — so it SHIPS with the
hidden attribute (anonymous-safe default) on every page (they all
carry the nav now, viewer included)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
carry the nav now, viewer included).
Phase 76 (task 01): the page list is the post-shell set — the
folded Tuning view's header copy is gone with tuning.html (the
shell's ONE header is INDEX_HTML's). Phase 76 (task 02): the RAG
+ Sources view files drop out too (the shell's ONE header covers
all its views); task 03 drops History."""
for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML):
text = _text(html)
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
f"{html.name}: #nav-sources must ship hidden"
)
def test_all_six_pages_share_the_header_control_order() -> None:
def test_shell_and_standalone_pages_share_the_header_control_order() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26) + phase 35/46
(the sixth page, git-sources): every page ships the IDENTICAL
header control inventory in the IDENTICAL order — brand, the mobile
hamburger, nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning],
Sign in, Sign out (the #steering-toggle was removed from the navbar
at owner request, 2026-08-28) — inside the shared .header-inner row
(the document viewer's row 1). The #sync-btn (Sources page only)
and the #new-chat-btn (chat page only, moved from the navbar at
(the document viewer's row 1). The #sync-btn (RAG view only)
and the #new-chat-btn (chat view only, moved from the navbar at
owner request 2026-08-28) are NOT part of the shared bar anymore.
Only the current-page is-active nav marker and the static ?next=
fallback may differ per page (task 05's story E2E pins the
rendered result)."""
rendered result).
Phase 76 (task 02): the post-shell set — the folded views' header
copies are gone (the shell's ONE header covers all its views); the
RAG/Sources files are deleted, task 03 drops History."""
markers = (
'class="brand"',
'<nav class="app-nav"',
@@ -159,12 +169,9 @@ def test_all_six_pages_share_the_header_control_order() -> None:
)
for html in (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
DOCUMENT_HTML,
TUNING_HTML,
LOGIN_HTML,
):
): # phase 76 (tasks 01/02): the folded view files are gone — the shell's ONE header stands in
text = _text(html)
start = text.find('<div class="container header-inner">')
assert start != -1, f"{html.name}: missing the shared .header-inner row"
@@ -185,8 +192,14 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
"""Phase 34 task 03: the #steering-panel section (+ the
#steering-announcer live region) ships on every page — after
#kb-banner in the chat shell, first child of <main> on the other
four pages — ship hidden, driven by assets/header.js."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
four pages — ship hidden, driven by assets/header.js.
Phase 76 (task 01): the folded Tuning view's panel COPY is dropped
(the shell keeps the chat's ONE instance — duplicate ids are not
legal). Phase 76 (task 02): the folded RAG + Sources view copies
are dropped with their files (the shell's ONE panel stands in);
task 03 drops History. The page list is the post-shell set."""
for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML):
text = _text(html)
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
assert tag, f"{html.name}: missing the #steering-panel section"
@@ -200,23 +213,32 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
assert text.find('id="steering-panel"') < text.find('id="steering-announcer"')
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
"""Phase 27: the Global Tuning page reuses the shared header — the
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
initSharedHeader once whoami says admin), is the page's active link
(is-active + aria-current), and the page loads markdown.js (classic)
+ the tuning.js module with NO direct header.js <script> tag
(single-evaluation design)."""
text = _text(TUNING_HTML)
def test_nav_tuning_ships_hidden_and_unstamped_on_the_shell() -> None:
"""Phase 27 + phase 76 (task 01) — the shell form of this pin: the
"Tuning" nav link is admin-only, so it SHIPS hidden in the shell's
ONE header (revealed by initSharedHeader once whoami says admin).
The old page-level active stamp (is-active + aria-current on
tuning.html's own link) is GONE — the router is the SINGLE WRITER
of the active state (client-side, per view); only the Chat link
may carry a static stamp. The shell loads markdown.js (classic) +
app.js + router.js (modules) with NO direct header.js <script> tag
(single-evaluation design), and no direct tuning.js tag (the router
lazy-imports it on first show — mount-once)."""
text = _text(INDEX_HTML)
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', text)
assert tag, "tuning.html must carry the #nav-tuning nav link"
assert 'class="nav-link is-active"' in tag.group(0), "the Tuning link is the active one"
assert 'aria-current="page"' in tag.group(0)
assert tag, "the shell must carry the #nav-tuning nav link"
assert "hidden" in tag.group(0), "#nav-tuning must ship hidden (admin-only)"
srcs = _script_srcs(TUNING_HTML)
assert "is-active" not in tag.group(0), (
"no static active stamp — the router is the single writer"
)
assert 'aria-current="page"' not in tag.group(0)
srcs = _script_srcs(INDEX_HTML)
assert [s for s in srcs if "header.js" in s] == [], "no direct header.js <script> tag"
assert [s for s in srcs if "markdown.js" in s]
assert [s for s in srcs if "tuning.js" in s]
assert [s for s in srcs if "router.js" in s], "the shell loads the router module"
assert [s for s in srcs if "tuning.js" in s] == [], (
"no direct tuning.js tag — the router lazy-imports the view module"
)
def test_viewer_carries_the_standard_nav() -> None:
@@ -253,27 +275,52 @@ def test_viewer_carries_the_standard_nav() -> None:
assert back, "the back link keeps its /sources.html no-JS fallback"
def test_sources_and_viewer_carry_the_shared_controls() -> None:
"""Sources AND the document viewer carry the Sign in / Sign out pair
(both starting hidden — initSharedHeader reveals exactly one after
whoami), and their page scripts load header.js (phase 23: via the
page script's relative import, not a direct script tag). The New
chat button is NOT on non-chat pages — it moved from the navbar to
the chat page at owner request (2026-08-28; the single binding is
pinned in test_new_chat_binding_is_single_and_module_owned)."""
for html in (SOURCES_HTML, DOCUMENT_HTML):
def test_sources_view_and_viewer_carry_the_shared_controls() -> None:
"""Sources (the shell's RAG view, phase 76 task 02) AND the document
viewer carry the Sign in / Sign out pair (both starting hidden —
initSharedHeader reveals exactly one after whoami), and their
scripts load header.js (phase 23: via the relative import, not a
direct script tag). The New chat button is chat-view only — in the
shell its ONE instance lives in the chat view (hidden + inert on
every other view); the viewer carries none (the single binding is
pinned in test_new_chat_binding_is_single_and_module_owned).
The RAG view module does NOT boot the shell's header (it boots once
via app.js) — it keeps fetchIsAdmin() for its admin gate (zero
extra requests)."""
for html in (INDEX_HTML, DOCUMENT_HTML):
text = _text(html)
assert 'id="new-chat-btn"' not in text, (
f"{html.name}: the New chat button is chat-page only (owner request)"
)
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
for js_file in (SOURCES_JS, DOCUMENT_JS):
assert 'from "./header.js"' in _text(js_file), (
f"{js_file.name}: page script must load the shared header module"
# The New chat button: exactly ONE instance in the shell (the chat
# view's) — none on the viewer.
assert _text(INDEX_HTML).count('id="new-chat-btn"') == 1
assert 'id="new-chat-btn"' not in _text(DOCUMENT_HTML), (
"the New chat button is chat-view only (owner request)"
)
assert 'from "./header.js"' in _text(DOCUMENT_JS), (
"document.js: page script must load the shared header module"
)
# The RAG view module (the phase-76 view-module branch):
# relative import, NO header boot, the fetchIsAdmin gate.
rag_js = _text(SOURCES_JS)
assert 'from "./header.js"' in rag_js
import_lines = [
line for line in rag_js.splitlines() if line.strip().startswith("import")
]
assert all("initSharedHeader" not in line for line in import_lines), (
"the view must not import the header boot — the shell's header boots "
"via the chat module (app.js) at shell boot"
)
assert "await initSharedHeader()" not in rag_js, (
"the view must not re-boot the shell's header"
)
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
assert "new-chat-btn" not in rag_js, (
"no #new-chat-btn binding (the module owns the single instance)"
)
assert 'fetch("/api/whoami")' not in rag_js
# Each page's Sign in link returns to ITS OWN page after login.
assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML)
assert 'href="/login.html?next=/sources.html"' in _text(INDEX_HTML)
assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML)
@@ -288,7 +335,6 @@ def test_header_module_loads_before_the_page_script() -> None:
would double-bind the sign-out listener)."""
cases = [
(INDEX_HTML, "app.js"),
(SOURCES_HTML, "sources.js"),
(DOCUMENT_HTML, "document.js"),
(LOGIN_HTML, "login.js"),
]
@@ -307,6 +353,20 @@ def test_header_module_loads_before_the_page_script() -> None:
assert 'from "/assets/header.js"' not in js, (
f"{page_script}: absolute header import would break the esbuild bundle"
)
# Phase 76 (task 02): the folded RAG view module is NOT directly
# loaded by the shell — the router lazy-imports it on first show
# (mount-once, like the Tuning view module); it still imports
# header.js relatively (single-evaluation design).
shell_srcs = _script_srcs(INDEX_HTML)
assert [s for s in shell_srcs if "sources.js" in s] == [], (
"no direct sources.js <script> tag — the router lazy-imports the view"
)
assert [s for s in shell_srcs if "router.js" in s], (
"the router is the loader of the folded view modules"
)
js = _text(SOURCES_JS)
assert 'from "./header.js"' in js
assert 'from "/assets/header.js"' not in js
def test_login_page_carries_the_full_header() -> None:
@@ -403,16 +463,71 @@ def test_new_chat_binding_is_single_and_module_owned() -> None:
assert 'window.location.href = "/"' not in js, (
"no navigate-to-chat branch left (the button left the navbar)"
)
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
page_js = _text(js_file)
assert 'from "./header.js"' in page_js
assert "initSharedHeader()" in page_js
assert "new-chat-btn" not in page_js, (
f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
doc_js = _text(DOCUMENT_JS)
assert 'from "./header.js"' in doc_js
assert "initSharedHeader()" in doc_js
assert "new-chat-btn" not in doc_js, (
"document.js: no #new-chat-btn binding (the module owns it)"
)
assert 'fetch("/api/whoami")' not in page_js, (
f"{js_file.name}: whoami goes through the shared cached promise"
assert 'fetch("/api/whoami")' not in doc_js, (
"document.js: whoami goes through the shared cached promise"
)
# Phase 76 (task 02): the RAG view module is the VIEW-MODULE branch
# (like the Tuning view module below) — it does not boot the shell's
# header, binds no #new-chat-btn, and gates on the shared cached
# promise.
rag_js = _text(SOURCES_JS)
assert 'from "./header.js"' in rag_js
assert "await initSharedHeader()" not in rag_js
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
assert "new-chat-btn" not in rag_js, (
"sources.js: no #new-chat-btn binding (the module owns it)"
)
assert 'fetch("/api/whoami")' not in rag_js, (
"sources.js: whoami goes through the shared cached promise"
)
# Phase 76 (task 01): the folded Tuning VIEW module no longer boots
# the header — in the shell it runs exactly once, via the chat
# module (app.js) at shell boot. The view keeps fetchIsAdmin() for
# its admin gate (the SAME cached whoami promise — zero extra
# requests) and imports the module relatively (single-evaluation).
tuning_js = _text(TUNING_JS)
assert 'from "./header.js"' in tuning_js
# No CALL to the header boot — the import line carries no
# initSharedHeader and no `await initSharedHeader()` call site
# exists (a docstring may name it; a call may not).
import_lines = [
line for line in tuning_js.splitlines() if line.strip().startswith("import")
]
assert all("initSharedHeader" not in line for line in import_lines), (
"the view must not import the header boot — the shell's header boots "
"via the chat module (app.js) at shell boot"
)
assert "await initSharedHeader()" not in tuning_js, (
"the view must not re-boot the shell's header"
)
assert "fetchIsAdmin()" in tuning_js, "the admin gate keeps the shared cached promise"
assert "new-chat-btn" not in tuning_js
assert 'fetch("/api/whoami")' not in tuning_js
# Phase 76 (task 03): the folded History VIEW module — the same
# view-module contract: no header boot, no #new-chat-btn binding,
# the admin gate on the shared cached whoami promise (zero extra
# requests), the relative import for single evaluation.
history_js = _text(HISTORY_JS)
assert 'from "./header.js"' in history_js
import_lines = [
line for line in history_js.splitlines() if line.strip().startswith("import")
]
assert all("initSharedHeader" not in line for line in import_lines), (
"the view must not import the header boot — the shell's header boots "
"via the chat module (app.js) at shell boot"
)
assert "await initSharedHeader()" not in history_js, (
"the view must not re-boot the shell's header"
)
assert "fetchIsAdmin()" in history_js, "the admin gate keeps the shared cached promise"
assert "new-chat-btn" not in history_js
assert 'fetch("/api/whoami")' not in history_js
app_js = _text(APP_JS)
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
assert "newChatBtn.addEventListener" not in app_js, (
+37 -20
View File
@@ -19,12 +19,15 @@ CONFIG_PY = ROOT / "app" / "config.py"
#: tuple, repeated here so this file stands alone).
HTML_PAGES = (
"index.html",
"sources.html",
"tuning.html",
# Phase 76 (task 01): tuning.html is folded into the shell (deleted)
# — the shell (index.html) stands in for it here.
# Phase 76 (task 02): sources.html + git-sources.html are folded
# too (both deleted — the shell stands in for both views).
# 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).
"document.html",
"login.html",
"git-sources.html",
"history.html",
"shared.html",
"doc-edit.html",
)
@@ -89,17 +92,21 @@ def test_chat_page_old_copy_is_gone() -> None:
def test_sources_page_old_copy_is_gone() -> None:
"""sources.html: the ~/Homelab + ~/Deployments page-sub citations and
the old footer are retired."""
html = _text(FRONTEND / "sources.html")
"""The shell's RAG view (formerly sources.html, phase 76 task 02):
the ~/Homelab + ~/Deployments page-sub citations and the old footer
are retired (the whole shell — which carries the view — must be
clean)."""
html = _text(FRONTEND / "index.html")
for frag in ("~/Homelab", "~/Deployments", OLD_FOOTER):
assert frag not in html, f"retired sources copy still present: {frag!r}"
def test_git_sources_page_old_copy_is_gone() -> None:
"""git-sources.html: the old example repo URL (A3) and the old
footer are retired."""
html = _text(FRONTEND / "git-sources.html")
"""The shell's Sources view (formerly git-sources.html, phase 76
task 02): the old example repo URL (A3) and the old footer are
retired (the whole shell — which carries the view — must be
clean)."""
html = _text(FRONTEND / "index.html")
for frag in (OLD_GIT_EXAMPLE, OLD_FOOTER):
assert frag not in html, f"retired git-sources copy still present: {frag!r}"
@@ -114,19 +121,29 @@ def test_chat_page_locked_copy_present_exactly_once() -> None:
def test_sources_page_sub_is_the_locked_copy() -> None:
"""The KB .page-sub (the string the TODO cited by name) reads the
locked (A1) copy, including the <strong> around Sync sources —
pinned inside the .page-sub element, not anywhere in the file."""
html = _text(FRONTEND / "sources.html")
m = re.search(r'<p class="page-sub">(.*?)</p>', html, re.DOTALL)
assert m, "sources.html must keep the .page-sub"
"""The RAG view's .page-sub (the string the TODO cited by name) reads
the locked (A1) copy, including the <strong> around Sync sources —
pinned inside the .page-sub element, not anywhere in the file.
Phase 76 (task 02): scoped to the RAG view — the shell carries one
.page-sub per view, and the earlier views' subs come first."""
html = _text(FRONTEND / "index.html")
i = html.find('id="view-rag"')
j = html.find('id="view-git-sources"', i)
assert -1 < i < j, "the RAG view section must be in the shell"
m = re.search(r'<p class="page-sub">(.*?)</p>', html[i:j], re.DOTALL)
assert m, "the RAG view must keep the .page-sub"
assert _norm(m.group(1)) == PAGE_SUB
def test_all_nine_footers_are_the_locked_neutral_default() -> None:
"""Every page carries the locked (A1) footer inside exactly one
``class="footer-text"`` span (the stable hook phase 62's
BOR_FOOTER_TEXT env var drives)."""
def test_all_five_footers_are_the_locked_neutral_default() -> None:
"""Every standalone page carries the locked (A1) footer inside
exactly one ``class="footer-text"`` span (the stable hook phase 62's
BOR_FOOTER_TEXT env var drives). Phase 76 (task 02): the folded RAG
+ Sources files are gone — the shell carries its footer once.
Phase 76 (task 03): the History file is gone too — the shell's
single (chat) footer stands in for the History view (its per-page
footer copy, with the duplicate #app-version span, is dropped with
the move)."""
span = f'<span class="footer-text">{FOOTER}</span>'
for page in HTML_PAGES:
html = _text(FRONTEND / page)
+13 -10
View File
@@ -6,7 +6,8 @@ the admin, removed from the DOM for anonymous. The owner asked for the
button to go away entirely: note management now lives on the
standalone Tuning page (``/tuning.html``, phase 27). This file pins the
removal at source level — the toggle + count badge are ABSENT from all
six pages, the ``#steering-panel`` section still ships hidden (kept
standalone pages (the shell's ONE header covers its folded views —
phase 76), the ``#steering-panel`` section still ships hidden (kept
fresh by the chat page's per-bubble Tune form through header.js), the
shared module owns no toggle wiring anymore, and the admin-only
``#nav-tuning`` link — the surviving path to the notes — still ships
@@ -22,17 +23,19 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: All eight pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page +
#: The standalone pages carry the shared header block (phase 34's five
#: pages + phase 35's git-sources page + phase 50's History page +
#: phase 51's shared page).
#: Phase 76 (task 01): tuning.html is folded into the shell (deleted)
#: — the shell (index.html) stands in for it here.
#: Phase 76 (task 02): sources.html + git-sources.html are folded too
#: (both deleted — the shell stands in for both views). 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).
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",
)
@@ -52,7 +55,7 @@ def _init_body(js: str) -> str:
# ---------- the removal: absent from every page's navbar ----------
def test_steering_toggle_removed_from_all_six_pages() -> None:
def test_steering_toggle_removed_from_all_pages() -> None:
"""The #steering-toggle button (and its #steering-count badge) is
gone from the navbar of EVERY page — absent, not hidden."""
for html in PAGES:
@@ -68,7 +71,7 @@ def test_steering_toggle_removed_from_all_six_pages() -> None:
)
def test_steering_panel_still_ships_hidden_on_all_six_pages() -> None:
def test_steering_panel_still_ships_hidden_on_all_pages() -> None:
"""The #steering-panel section survives the toggle removal (the chat
page's per-bubble Tune form keeps it fresh through header.js) and
still ships hidden, with its list / empty state / announcer."""
@@ -117,7 +120,7 @@ def test_header_js_keeps_the_panel_contract() -> None:
# ---------- the surviving path to the notes ----------
def test_nav_tuning_still_ships_hidden_on_all_six_pages() -> None:
def test_nav_tuning_still_ships_hidden_on_all_pages() -> None:
"""The admin-only Tuning NAV LINK (#nav-tuning) — the surviving path
to the steering notes now that the navbar toggle is gone — still
ships hidden on every page (the phase-19/29/35 contract)."""
+51 -26
View File
@@ -44,7 +44,9 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
SOURCES_JS = ASSETS / "sources.js"
STYLES_CSS = ASSETS / "styles.css"
SOURCES_HTML = FRONTEND / "sources.html"
# Phase 76 (task 02): sources.html is folded into the ONE-document shell
# — the sync markup now lives in the RAG view section of index.html.
SHELL_HTML = FRONTEND / "index.html"
def _text(path: Path) -> str:
@@ -52,23 +54,46 @@ def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _rag_view(html: str) -> str:
"""The RAG view section of the shell (view-scoped page-sub scope —
the shell carries one .page-sub per view, so a whole-file match
would hit the earlier views first)."""
i = html.find('<section class="view" id="view-rag"')
assert i != -1, "the RAG view section must be in the shell"
j = html.find('<section class="view" id="view-git-sources"', i)
assert j != -1, "the Sources view section must follow the RAG view"
return html[i:j]
def _body(js: str, fn_name: str) -> str:
"""The source of the first top-level `function <fn_name>` in js."""
"""The source of the first `function <fn_name>` in js (brace
balanced — since phase 76 task 02 the functions live inside
mount(root), so the closing brace is indented, not column 0)."""
fn = js.find(f"function {fn_name}")
assert fn != -1, f"{fn_name} must be defined"
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 {fn_name}")
# ---------- sources.html: anonymous-safe ship-hidden markup ----------
# ---------- the shell's RAG view: anonymous-safe ship-hidden markup ----------
def test_sync_button_ships_hidden_and_labeled() -> None:
"""#sync-btn SHIPS with the hidden attribute (anonymous-safe —
sources.js reveals it for the admin at page boot), is a real
sources.js reveals it for the admin at view boot), is a real
<button type="button">, and carries aria-label="Sync sources" so
the accessible name stays stable across the label states."""
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML))
assert tag, "sources.html must carry the #sync-btn button"
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SHELL_HTML))
assert tag, "the shell must carry the #sync-btn button (RAG view)"
attrs = tag.group(0)
assert 'class="sync-btn"' in attrs
assert 'type="button"' in attrs
@@ -80,7 +105,7 @@ def test_sync_button_has_icon_and_label_span() -> None:
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
the spin is the visible running state) + the .sync-label span with
the idle text, so the label can be swapped by sources.js."""
text = _text(SOURCES_HTML)
text = _text(SHELL_HTML)
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
# class for the module query + id for the E2E label assertions
@@ -95,9 +120,9 @@ def test_sync_result_is_the_aria_live_announcer() -> None:
"""#sync-result sits right after the button and is a polite live
region (role="status" + aria-live="polite") — the last-result /
counts announcement for screen readers."""
text = _text(SOURCES_HTML)
text = _text(SHELL_HTML)
tag = re.search(r'<span[^>]*id="sync-result"[^>]*>', text)
assert tag, "sources.html must carry the #sync-result announcer"
assert tag, "the shell must carry the #sync-result announcer (RAG view)"
attrs = tag.group(0)
assert 'role="status"' in attrs
assert 'aria-live="polite"' in attrs
@@ -108,9 +133,9 @@ def test_sync_error_banner_is_a_hidden_alert() -> None:
"""The failure banner uses the chat error-banner markup style
(kb-banner + is-error) and role="alert", shipping hidden —
sources.js un-hides it with the error text on a failed run."""
text = _text(SOURCES_HTML)
text = _text(SHELL_HTML)
tag = re.search(r'<div[^>]*id="sync-error-banner"[^>]*>', text)
assert tag, "sources.html must carry the #sync-error-banner"
assert tag, "the shell must carry the #sync-error-banner (RAG view)"
attrs = tag.group(0)
assert "kb-banner" in attrs and "is-error" in attrs
assert 'role="alert"' in attrs
@@ -125,9 +150,10 @@ def test_page_sub_copy_mentions_the_button() -> None:
the latest and re-import (the import CLI docs live elsewhere).
Phase 61: the copy describes the current source model (git repos +
local directories + uploaded archives), not the old ~/Homelab +
~/Deployments clone."""
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
assert sub, "sources.html must keep the .page-sub copy"
~/Deployments clone. Phase 76 (task 02): scoped to the RAG view —
the shell carries one .page-sub per view."""
sub = re.search(r'<p class="page-sub">(.*?)</p>', _rag_view(_text(SHELL_HTML)), re.DOTALL)
assert sub, "the RAG view must keep the .page-sub copy"
copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
assert "Press <strong>Sync sources</strong>" in copy
assert "pull the latest and re-import" in copy
@@ -139,22 +165,23 @@ def test_sources_page_stays_cdn_free() -> None:
"""No-CDN rule (PLAN §7.3, A11): the new button markup adds no
external references — same-origin assets only (the integration
test_index_html_served_locally re-checks this on the served page)."""
text = _text(SOURCES_HTML)
text = _text(SHELL_HTML)
assert 'src="https://' not in text
assert 'href="https://' not in text
# ---------- sources.js: the admin reveal (page boot) ----------
# ---------- sources.js: the admin reveal (view boot) ----------
def test_sources_js_reveals_sync_btn_on_the_admin_branch() -> None:
"""The page boot reveals #sync-btn for the admin on the SAME cached
whoami initSharedHeader() used (no extra fetch — header.js keeps
the single /api/whoami call site); anonymous users never leave the
ship-hidden default."""
"""The view boot (mount's tail, phase 76 task 02) reveals #sync-btn
for the admin on the SAME cached whoami fetchIsAdmin() reads (no
extra fetch — header.js keeps the single /api/whoami call site; the
header itself is booted exactly once, by the chat module at shell
boot); anonymous users never leave the ship-hidden default."""
js = _text(SOURCES_JS)
boot = js[js.rfind("(async () => {"):]
assert "const admin = await initSharedHeader()" in boot
boot = js[js.find("view boot (phase 76 task 02)"):]
assert "const admin = await fetchIsAdmin()" in boot
assert "syncBtn.hidden = !admin" in boot, "#sync-btn must join the admin reveal"
# The reveal must not introduce a second whoami call site.
header = _text(HEADER_JS)
@@ -337,9 +364,7 @@ def test_sources_js_reattaches_on_load_admin_only() -> None:
result, idle settles retry-ready; the click binding wires
startSync to the button."""
js = _text(SOURCES_JS)
fn = js.find("function initSyncButton")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
body = _body(js, "initSyncButton")
assert "await fetchIsAdmin()" in body, "admin-only boot (no extra fetch)"
assert 'fetch("/api/sync/status")' in body
assert 'status.state === "running"' in body