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.