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:
@@ -0,0 +1,241 @@
|
||||
/* 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 });
|
||||
Reference in New Issue
Block a user