# 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` = ``), 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 `
` 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 `
` container (skip-link target unchanged) holds the five `
` blocks; each folded page's own `
` 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 `
` — 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 `` (values carried over from the old pages' ``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=` (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=` 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/`.