Files
brain-of-reese/frontend/assets/tuning.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

353 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — Global Tuning view (phase 27; phase 76 task 01:
* shell view module).
*
* The standalone manager for steering notes: create / list / edit /
* delete WITHOUT a chat conversation. This module is the single owner
* of the view's behaviour:
*
* • loadNotes() — GET /api/steering → the newest-first note list
* (#tune-list) + the empty state. A failed fetch (API down, or the
* anonymous direct-URL 403) keeps the LAST RENDERED list —
* progressive enhancement, never a blanked panel.
* • create — #tune-form submit → POST /api/steering. 201 clears the
* textarea, announces through the live region, and reloads the
* list; any failure keeps the form (the instruction survives) and
* shows the API detail inline under the button (role=alert).
* #tune-save is disabled while the request is out.
* • edit — a row's Edit button swaps the text for an inline
* .tuning-edit-form: a prefilled textarea (maxlength 2000) +
* Save / Cancel. Save → PUT /api/steering/{id}; 200 replaces the
* form with the .tuning-saved status (role=status) and announces;
* a failure keeps the form + an inline error; Cancel reverts to
* the text span. The note id rides on the form (data attribute).
* • delete — DELETE /api/steering/{id}. 204 removes the row
* immediately (optimistic) and announces; 404 also drops the row
* and reloads to resync; other failures re-enable the button and
* announce a retry. The empty state is re-checked on every removal.
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
* the screen-reader confirmation for create / edit / delete.
*
* Phase 76 (task 01) — shell view module (the "Global Tuning" view of
* the ONE-document shell; /tuning.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-tuning">, and every DOM
* lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the binding + state survive every switch.
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The admin gate keeps
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
* exports (zero extra requests; the flag decides whether the note
* list loads at all, the Sources-page gate pattern).
*
* Anonymous-safe (phase 27 task 03, unchanged in the shell): the
* header hides the "Tuning" nav link for anonymous visitors; a DIRECT
* anonymous URL still gets a safe view — loadNotes() only runs when
* the cached whoami says admin, the list stays on its empty state,
* and the create form 403s gracefully on submit (the inline error
* carries the API detail). Note text is always rendered with
* textContent — never innerHTML (XSS-safe, like app.js's steering
* panel).
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; in the
* image the Containerfile's esbuild stage inlines it — today into the
* router bundle, phase 76 task 01).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- page elements (the view's section, scoped to root) ---------- */
const tuneForm = root.querySelector("#tune-form");
const tuneNote = root.querySelector("#tune-note");
const tuneSave = root.querySelector("#tune-save");
const tuneList = root.querySelector("#tune-list");
const tuneEmpty = root.querySelector("#tune-empty");
const tuneAnnouncer = root.querySelector("#tune-announcer");
/* The create form's inline error (role=alert) — created once, hidden
by default, and kept between attempts: a failed POST keeps the
form AND its message until the next submit. */
const createError = document.createElement("p");
createError.className = "tuning-error";
createError.setAttribute("role", "alert");
createError.hidden = true;
if (tuneForm) tuneForm.appendChild(createError);
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
carry their own labels), the same marks as app.js's steering panel. */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
const DELETE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as app.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
an anonymous direct-URL visit) keeps the last rendered list —
progressive enhancement, never a blanked panel. */
async function loadNotes() {
let r;
try {
r = await fetch("/api/steering");
} catch {
return; // API unreachable: keep the last rendered list
}
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
let notes;
try {
notes = (await r.json()).notes || [];
} catch {
return; // corrupt body: keep the last rendered list
}
renderNotes(notes);
}
function renderNotes(notes) {
if (!tuneList) return;
tuneList.textContent = "";
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
syncEmptyState(notes.length);
}
/* The empty state tracks the list's rendered rows (the HTML ships on
the "No tuning notes yet" text; it hides as soon as one row shows). */
function syncEmptyState(count) {
if (!tuneEmpty || !tuneList) return;
const rows = typeof count === "number" ? count : tuneList.children.length;
tuneEmpty.hidden = rows > 0;
}
/* One list row: the note text (textContent — XSS-safe, never
innerHTML) + the Edit and Delete buttons. */
function makeNoteRow(n) {
const li = document.createElement("li");
li.className = "tuning-note";
const text = document.createElement("span");
text.className = "tuning-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "tuning-edit";
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
editBtn.addEventListener("click", () => openEditForm(li, n));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "tuning-delete";
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
li.append(editBtn, delBtn);
return li;
}
/* ---------- create (POST /api/steering) ---------- */
if (tuneForm) {
tuneForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (tuneSave) tuneSave.disabled = true; // one note per click
createError.hidden = true;
try {
const r = await fetch("/api/steering", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
});
if (r.ok) {
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
announce("Tuning note added. Future answers will follow it.");
await loadNotes(); // the new note lands in the list, newest first
} else {
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
createError.hidden = false; // form kept — the instruction survives
}
} catch {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
}
});
}
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
* The row swaps to the inline form — the is-editing class does the
* visual swap (styles.css hides the text + the row buttons). One open
* form view-wide: opening a new one reverts the others. Cancel reverts
* to the text span; a failed save keeps the form + the inline error.
*/
let editSeq = 0; // unique ids for the edit forms' labeled textareas
function openEditForm(li, n) {
if (li.classList.contains("is-editing")) return; // one per row
// One open form view-wide: close any other row's first.
root.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
other.classList.remove("is-editing");
other.querySelector(".tuning-edit-form")?.remove();
});
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
li.classList.add("is-editing");
editSeq += 1;
const inputId = `tuning-edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "tuning-edit-form";
form.dataset.noteId = n.id; // the note id rides on the form
const label = document.createElement("label");
label.className = "visually-hidden";
label.htmlFor = inputId;
label.textContent = `Edit tuning note: ${n.note}`;
const textarea = document.createElement("textarea");
textarea.id = inputId;
textarea.className = "tuning-edit-input";
textarea.rows = 2;
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
textarea.required = true;
textarea.value = n.note; // prefilled with the current text
const actions = document.createElement("div");
actions.className = "tuning-edit-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const error = document.createElement("p");
error.className = "tuning-error";
error.setAttribute("role", "alert");
error.hidden = true;
form.append(label, textarea, actions, error);
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
cancelBtn.addEventListener("click", () => {
li.classList.remove("is-editing"); // revert to the text span
form.remove();
li.querySelector(".tuning-edit")?.focus();
});
li.insertBefore(form, li.querySelector(".tuning-edit"));
textarea.focus();
}
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
e.preventDefault();
saveBtn.disabled = true;
error.hidden = true;
const id = form.dataset.noteId;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: textarea.value }),
});
if (r.ok) {
let saved = textarea.value.trim();
try {
saved = (await r.json()).note ?? saved;
} catch {
/* keep the trimmed local text */
}
const textEl = li.querySelector(".tuning-note-text");
if (textEl) textEl.textContent = saved; // the list shows the stored text
const savedPill = document.createElement("p");
savedPill.className = "tuning-saved";
savedPill.setAttribute("role", "status");
savedPill.textContent = "Saved";
form.replaceWith(savedPill);
li.classList.remove("is-editing"); // updated text + row buttons come back
announce("Tuning note updated.");
return;
}
error.textContent = await apiDetail(r, "Could not update the note — try again.");
error.hidden = false; // form kept — the edit survives the failure
saveBtn.disabled = false;
} catch {
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.disabled = false;
}
}
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
* The row leaves the DOM the moment the server agrees (204); a 404
* (already gone) also drops the row and reloads to resync; any other
* failure re-enables the button and says to retry. */
async function deleteNote(id, btn, li) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
li.remove(); // already gone on the server — drop it and resync
syncEmptyState();
announce("That note was already removed.");
await loadNotes();
return;
}
if (!r.ok) {
announce("Could not delete the note — try again.");
btn.disabled = false;
return;
}
li.remove(); // 204: the server confirmed — the row goes now
syncEmptyState();
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* ---------- view boot (phase 76 task 01) ----------
* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding) and lives in the chat view only.
*
* Boot: the shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The note
* list is admin data (the Sources page gate pattern): the gate reads
* fetchIsAdmin() — the SAME cached whoami promise the header uses
* (zero extra requests). An anonymous visitor gets the view frame
* with the empty state, and the create form 403s gracefully on
* submit if one tries. */
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
}