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
+300 -289
View File
@@ -1,8 +1,9 @@
/* Brain of Reese — Global Tuning page (phase 27, task 03).
/* 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 page's behaviour:
* 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
@@ -25,317 +26,327 @@
* 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.
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page). Phase 34 task 02: the New chat binding is module-owned
* (assets/header.js, the SINGLE one) — on a non-chat page "new
* chat" means going to the chat, fresh (the module clears the
* phase-14 conversation key and navigates to "/").
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
* page — loadNotes() only runs when the cached whoami says admin (the
* Sources page gate pattern), 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).
* 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; esbuild
* inlines it into the page bundle in the image build).
* 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, initSharedHeader } from "./header.js";
import { fetchIsAdmin } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
const tuneNote = document.querySelector("#tune-note");
const tuneSave = document.querySelector("#tune-save");
const tuneList = document.querySelector("#tune-list");
const tuneEmpty = document.querySelector("#tune-empty");
const tuneAnnouncer = document.querySelector("#tune-announcer");
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);
/* 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 */
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* 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>';
/* 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;
/* 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 r = await fetch("/api/steering", {
method: "POST",
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: tuneNote ? tuneNote.value : "" }),
body: JSON.stringify({ note: textarea.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
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 {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.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 page-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 page-wide: close any other row's first.
document.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 */
/* ---------- 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;
}
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
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("That note was already removed.");
await loadNotes();
return;
}
if (!r.ok) {
announce("Could not delete the note — try again.");
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
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;
}
}
/* ---------- header boot (task 02) ---------- */
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state.
*
* Boot: the shared header FIRST (Sign in/out + the admin-only nav
* links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */
(async () => {
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
/* ---------- 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
})();
}