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