Files
brain-of-reese/frontend/assets/router.js
T
ducoterra ffa919b8bf 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/.
2026-09-06 06:31:31 -04:00

242 lines
11 KiB
JavaScript

/* Brain of Reese — shell router (phase 76, task 01).
*
* The five navbar views are views of ONE HTML shell (index.html), not
* five documents: this module makes a navbar click a CLIENT-SIDE view
* switch — history.pushState + show/hide — never a document load, so
* the in-flight chat stream in the hidden view keeps streaming
* through any switch and completes when the user returns to Chat.
* Real departures (tab close, leaving the app, the Stop button) still
* cancel the fetch and stop the model — the phase-48 contract, owned
* by app.js and untouched here. The phase-48 LOCKED refinement
* (owner-confirmed 2026-09-06): "real navigation cancels the fetch"
* now means LEAVING THE APP — in-app navbar switches no longer cancel.
*
* The contract (pinned at source level in
* tests/unit/test_frontend_router.py):
*
* • VIEW — the pathname → view name map for the folded views
* ("/" → chat, "/index.html" → chat — the shell's own two URLs,
* "/tuning.html" → tuning, "/sources.html" → rag, "/git-sources.html"
* → git-sources, "/history.html" → history). Only a link whose href
* is IN this map is intercepted; every other link (login, document
* viewer, a /?chat=<id> deep link — its query string keeps it out
* of the map) still performs its real, document-level navigation.
* • boot from location.pathname: the matching view is shown WITHOUT
* focus (no focus steal on load) — a direct load of /tuning.html
* deep-links to the Tuning view (the shell route in app/main.py
* serves this shell for that path).
* • mount-once, hide-forever: a non-chat view's module is
* lazy-imported on FIRST show only, and `await module.mount(root)`
* runs once (the `mounted` guard) — the view's DOM and JS state
* (for chat, the in-flight SSE reader; for the Sources view, the
* upload-progress poller) persist across every switch; that
* persistence IS the phase-76 fix. The chat view needs no module:
* app.js already ran at shell boot.
* • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view
* must not receive focus or keyboard traversal — the inert pair
* pins the [hidden] contract in the a11y tree, AGENTS.md rule 5).
* • SINGLE WRITER of the .nav-link active state (is-active +
* aria-current="page"), of document.title, and of the per-view
* <meta name="description"> (values carried over from the old
* pages' <head>s) — no page script stamps any of these.
* • focus the target view (its tabindex="-1") ONLY on
* user-initiated switches (navbar click / popstate back-forward);
* a switch also lands the viewport at the top of the document,
* the same way the old per-view page loads did (user intent — the
* no-reply-autoscroll contract is about streaming frames, not
* navigation the user performs).
*
* Boot order (index.html): brand.js (classic) → app.js (module — the
* chat view, runs at shell boot exactly as before) → router.js
* (module — this file). No CDN, no framework, no bundler dependency:
* a plain ES module whose dynamic imports (./tuning.js, task 01;
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03)
* resolve relatively in dev and are inlined by the Containerfile's
* esbuild stage in the image.
*/
/* ---------- the view map (pathname → view name) ----------
* The shell's own two URLs are the chat view (the shell IS the chat
* page — app.js boots it); every folded view adds one entry. The
* values are the <section class="view" id="view-<name>"> slugs in
* index.html. */
const VIEW = {
"/": "chat",
"/index.html": "chat", // the shell's alternate URL (HTML_PAGES)
"/tuning.html": "tuning", // phase 76 task 01: the first folded view
"/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base)
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
};
/* The nav-link href the router stamps active for each view (the
Chat link is href="/", the RAG link href="/sources.html", …). */
const VIEW_PATH = {
chat: "/",
tuning: "/tuning.html",
rag: "/sources.html",
"git-sources": "/git-sources.html",
history: "/history.html",
};
/* The lazy view modules — ONLY the non-chat views (chat needs no
import: app.js already ran at shell boot). Static specifiers so the
Containerfile's esbuild stage can inline each module into the
router bundle (the browser still defers its code until the first
import() — mount-once semantics are preserved in the image). */
const VIEW_MODULES = {
tuning: () => import("./tuning.js"),
rag: () => import("./sources.js"), // phase 76 task 02
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
history: () => import("./history.js"), // phase 76 task 03
};
/* Per-view document.head values, carried over from the old pages'
<head>s (the router is the single writer of both). The values are
the DEFAULT-deployment form: at write time they are composed through
brandName() (below) so a configured deployment keeps its name. */
const TITLES = {
chat: "Brain of Reese",
tuning: "Global Tuning · Brain of Reese",
rag: "Sources · Brain of Reese", // old sources.html <title>
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
history: "Saved chats · Brain of Reese", // old history.html <title>
};
const DESCRIPTIONS = {
chat:
"Ask anything about your indexed documents — every answer cites the exact doc.",
tuning:
"Manage the global tuning notes that steer every Brain of Reese answer.",
rag: "Documents indexed in Brain of Reese.", // old sources.html meta
"git-sources":
"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).",
history:
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
};
/* The brand-resolved display name (phase 39 — brand.js is the single
owner: window.BOR_BRAND is "Brain of Reese" from parse time and is
updated once /api/config settles). The router composes the per-view
title/meta from it instead of stamping the hardcoded literal: the
lazy view import defers switchTo PAST brand.js's one-time
DOMContentLoaded pass, so a literal stamp would overwrite a
configured deployment's name (e.g. "Brain of Testy") in the
client-side head. Composing at write time keeps the name correct
for every config/switch ordering (an unset deployment — the name IS
the literal — stays byte-identical: replaceAll is a no-op). */
const brandName = () => window.BOR_BRAND || "Brain of Reese";
const titleFor = (view) => TITLES[view].replaceAll("Brain of Reese", brandName());
const descFor = (view) => DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName());
/* The view sections — one per view name (index.html: #view-chat is
visible at boot, the folded views ship hidden + inert). */
const viewEls = {};
for (const name of new Set(Object.values(VIEW))) {
viewEls[name] = document.getElementById(`view-${name}`);
}
/* The mount-once guard: a view is imported + mounted at most ONCE per
document life — re-shows are show/hide only (no refetch, no
re-mount; the view's state persists). Chat starts mounted: app.js
owns it and ran at shell boot. */
const mounted = { chat: true };
const nav = document.getElementById("app-nav");
const metaDesc = document.querySelector('meta[name="description"]');
let current = null; // the visible view name (null until boot resolves)
/* ---------- show / hide (the single writer of the view state) ---------- */
/* Show `name`, hide every other view, and write the single-writer
head/nav state. `userInitiated` marks navbar-click / popstate
switches: only those focus the target view (its tabindex="-1") and
land the viewport at the top — a boot switch never steals focus. */
async function switchTo(name, { userInitiated }) {
const root = viewEls[name];
if (!root) return;
/* Mount-once: the lazy module is imported on FIRST show only, then
mounted into the view's section. The guard runs BEFORE the import
(a re-show never re-imports) and is set only after mount resolves
(a failed mount may retry on the next show). */
if (!mounted[name]) {
const load = VIEW_MODULES[name];
if (load) {
const mod = await load();
await mod.mount(root);
}
mounted[name] = true;
}
/* Show = drop hidden AND inert; hide = add BOTH (a hidden view must
not receive focus or keyboard traversal — the inert pair makes the
[hidden] contract hold in the a11y tree, not just the layout). */
for (const [viewName, el] of Object.entries(viewEls)) {
el.hidden = viewName !== name;
el.inert = viewName !== name;
}
/* SINGLE WRITER: the active nav link (is-active + aria-current),
the document title, and the per-view meta description. */
const path = VIEW_PATH[name];
if (nav) {
for (const a of nav.querySelectorAll("a.nav-link")) {
const active = (a.getAttribute("href") || "") === path;
a.classList.toggle("is-active", active);
if (active) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
}
document.title = titleFor(name);
if (metaDesc) metaDesc.content = descFor(name);
current = name;
/* Focus the target view ONLY on user-initiated switches (navbar
click / popstate) — never on initial boot (no focus steal on
load). The top landing mirrors what the old per-view page loads
did (user intent, not a streaming-frame autoscroll). */
if (userInitiated) {
window.scrollTo(0, 0);
root.focus({ preventScroll: true });
}
}
/* ---------- navbar click: same-shell links become view switches ----------
* Delegated on the nav (covers the mobile dropdown too — it is the
* same #app-nav element): a same-shell a.nav-link (href in VIEW) is
* intercepted — preventDefault + history.pushState + switch, so the
* click is a view switch, NEVER a document load. Every other link
* (login, the document viewer, the not-yet-folded views in tasks
* 02/03) keeps its real navigation untouched. */
if (nav) {
nav.addEventListener("click", (e) => {
const a = e.target instanceof Element ? e.target.closest("a.nav-link") : null;
if (!a) return;
const href = a.getAttribute("href") || "";
if (!(href in VIEW)) return; // not a same-shell view — real navigation
e.preventDefault();
const name = VIEW[href];
if (name === current) return; // already visible (the menu still closes)
history.pushState({ view: name }, "", href);
switchTo(name, { userInitiated: true });
});
/* Back / forward: popstate switches views (the history entries were
written by the pushState above — same-document, no page load). */
window.addEventListener("popstate", () => {
const name = VIEW[window.location.pathname];
if (name && name !== current) switchTo(name, { userInitiated: true });
});
}
/* ---------- boot: deep-link from the pathname, no focus steal ---------- */
/* A direct load of any shell path shows its view (chat for "/" and
"/index.html", tuning for "/tuning.html"); an unexpected pathname
falls back to chat (the shell's default view). userInitiated:false
— boot never focuses (no focus steal on load). */
const bootName = VIEW[window.location.pathname] ?? "chat";
switchTo(bootName, { userInitiated: false });