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
+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