/* Brain of Reese — shared header module (phase 19). * * Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in * vanish — the user expects ONE consistent bar on every page. This module * is the single owner of the shared header controls: * * • the Sign in / Sign out auth pair (phase 16, exactly one visible — * decided by /api/whoami at load); * • the admin-only nav links — "Sources" (#nav-sources, phase 19), * "Git sources" (#nav-git-sources, phase 35) and "Tuning" * (#nav-tuning, phase 29) — phase 19 UX revision * (owner permission 2026-08-23): hidden for anonymous on EVERY * page, revealed for admin. Phase 34 task 03 (owner confirmation * 2026-08-26): the SAME nav ships on all five pages (chat, * sources, document viewer, tuning, login) — the viewer's * "no nav" bar is gone. The links SHIP hidden in the HTML * (anonymous-safe default — the phase-16 "absent, not hidden" * spirit), so no anonymous user ever sees one for a frame; and * the "Sync sources" button (#sync-btn, phase 32 — every page * from phase 34 task 03) — the same ship-hidden / reveal-for-admin * contract on the SAME cached whoami (one fetch, no extra request); * • the sign-out click binding (POST /api/logout → reload) — moved * here from app.js so there is exactly one implementation; * • the steering-notes controls (phase 15, moved here from app.js in * phase 34) — the #steering-toggle open/close + the #steering-panel * list (newest-first, textContent-rendered, per-note delete, count * badge, the #steering-announcer live region) — so the toggle can * sit in every page's header with zero page-script duplication. * refreshSteering() / announceSteering() are exported for the chat * page's per-bubble Tune form (which stays in app.js); anonymous * visitors get the phase-16 "absent, not hidden" treatment (toggle * + panel removed from the DOM, /api/steering never fetched); * • the Sync sources state machine (phase 32, moved here from * sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle * for #sync-btn (idle → running → success | failed): admin-only * boot re-attach on the SAME cached whoami (non-admins never poll), * POST /api/sync (202 start / 409 adopt), the 2 s * GET /api/sync/status poll (one live timer, NO client-side hard * timeout — the server state is authoritative). Every state change * dispatches window "bor:sync-status" (detail = the status object) * so the Sources page renders its #sync-result line + * #sync-error-banner off the event; on non-Sources pages the * failed state is visible in the button's title + aria-label; * • the SINGLE New chat binding (phase 34 task 02 — it was * duplicated across app.js / sources.js / tuning.js / document.js): * on the chat page (#messages exists) the module dispatches * window "bor:new-chat" and app.js acts (it owns the in-flight-turn * guard + the list reset); on every other page it means "go to the * chat, fresh" — clearChatStorage() + navigate to "/"; * • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader * points #sign-in-link at /login.html?next= * (default "/"), so the admin lands back on the page they signed in * from; the page markup keeps its own href as the no-JS fallback; * • clearChatStorage() — the phase-14 conversation key, for the * New chat action on the NON-CHAT pages (sources / document viewer / * tuning / login): a new chat means going to the chat, fresh. * * Every page loads this module (type="module", before its page script) * and its page script calls initSharedHeader() once at boot. init… * toggles ONLY the controls that exist on the page — a missing element * is a no-op. Phase 34 task 03 ships the SAME full header block on all * five pages (the login page included), so every control resolves on * every page; a page that lacks one simply skips it. * * whoami is fetched at most ONCE per page load: the promise is cached in * the module-level `adminPromise`, so app.js's tuning gate, the sources * page's catalog gate, and the header toggling all share one request. * Anonymous-safe: any network failure resolves to false (the anonymous * UI), mirroring the per-page catch the pages used before phase 19. * * A10/A11 untouched: no API change, no CDN, no state beyond the cached * promise; the soft gate page and the A10 API split are unchanged — * this is UI visibility only. */ let adminPromise = null; /* The SINGLE /api/whoami call site for the whole frontend. First call stores the promise in `adminPromise`; every later call — on this page — returns the same promise, i.e. exactly one request per page load. Anonymous-safe: non-2xx or a network failure resolves to false. */ export function fetchIsAdmin() { if (!adminPromise) { adminPromise = fetch("/api/whoami") .then(async (r) => (r.ok ? (await r.json()).authenticated === true : false)) .catch(() => false); } return adminPromise; } /* Toggle the shared header controls, only the ones present on this page (querySelector, null-safe — missing → no-op). Returns the admin flag so callers can reuse it instead of awaiting fetchIsAdmin() again (the cached promise makes both awaits the same single request). */ export async function initSharedHeader() { const admin = await fetchIsAdmin(); // The Sign in link: hidden for the admin, visible otherwise — and its // href is rewritten to return the admin to THIS page after login // (phase 34 task 02: "return to where you were"). The markup keeps its // own static ?next= as the no-JS fallback. location.pathname is always // a query-safe "/…" string (never "//"; ? # and spaces stay // percent-encoded in it), so it rides in next= as-is — the same shape // the static fallbacks use (login.js safeNext re-validates it). const signIn = document.querySelector("#sign-in-link"); if (signIn) { signIn.hidden = admin; signIn.href = "/login.html?next=" + (window.location.pathname || "/"); } const signOut = document.querySelector("#sign-out-btn"); if (signOut) signOut.hidden = !admin; const navSources = document.querySelector("#nav-sources"); if (navSources) navSources.hidden = !admin; // Phase 35 (owner permission 2026-08-26): the Git sources nav link — // admin-only, the same ship-hidden / reveal-for-admin contract as // the Sources link above. const navGitSources = document.querySelector("#nav-git-sources"); if (navGitSources) navGitSources.hidden = !admin; // Phase 29: the Global Tuning nav link (every page from phase 34 // task 03) — admin-only, the same ship-hidden / reveal-for-admin // contract as the Sources link. const navTuning = document.querySelector("#nav-tuning"); if (navTuning) navTuning.hidden = !admin; // Phase 32: the "Sync sources" button — admin-only, revealed on this // same cached whoami (anonymous users never see it). if (syncBtn) syncBtn.hidden = !admin; // Phase 34: the steering controls (phase 15) are module-owned. Admin: // refresh the list so the count badge is right before the panel is // ever opened (fire-and-forget, as the chat page did before the move). // Anonymous: the toggle + panel are REMOVED from the DOM entirely — // the phase-16 contract says "absent", not just hidden — and // /api/steering is never fetched. if (admin) { if (steeringPanel) refreshSteering(); } else { steeringToggle?.remove(); steeringPanel?.remove(); } return admin; } /* Remove the phase-14 conversation key — same key + fail-silence contract as app.js's clearStoredConversation: private mode or a storage error is swallowed, the navigation still happens. */ export function clearChatStorage() { try { localStorage.removeItem("bor.chat.v1"); } catch { /* nothing was stored */ } } /* Sign-out binding (phase 16 behavior, now module-owned): runs at module import, so every page that loads header.js gets it exactly once. Disable during the call, POST /api/logout (the result is ignored — the reload resets the UI either way), then reload so the header re-resolves to the anonymous state (Sign in back, Sources gone). */ const signOutBtn = document.querySelector("#sign-out-btn"); if (signOutBtn) { signOutBtn.addEventListener("click", async () => { signOutBtn.disabled = true; try { await fetch("/api/logout", { method: "POST" }); } catch { /* the reload resets the UI either way */ } window.location.reload(); }); } /* ---------- steering notes (phase 15; module-owned from phase 34) ---------- * * The owner's tuning notes steer every future answer: they live in * Postgres (stateless API, A10) and the chat turn reads them into the * system prompt. The header panel — toggle, list, per-note delete, count * badge, announcer — is owned by THIS module: every page that ships the * panel markup gets exactly this behavior, with zero page-script * duplication. The chat page keeps only its per-bubble Tune form * (app.js), which refreshes the panel through refreshSteering() and * announces through announceSteering(). * * All elements are looked up null-safe (querySelector + guard): a page * that lacks the panel markup is a no-op — the same contract as * initSharedHeader(). */ const steeringToggle = document.querySelector("#steering-toggle"); const steeringCount = document.querySelector("#steering-count"); const steeringPanel = document.querySelector("#steering-panel"); const steeringList = document.querySelector("#steering-list"); const steeringEmpty = document.querySelector("#steering-empty"); const steeringAnnouncer = document.querySelector("#steering-announcer"); /* Announce a steering change through the polite live region (role="status", aria-live="polite") — exported so the chat page's per-bubble Tune form (app.js) announces on the exact same channel. */ export function announceSteering(message) { if (steeringAnnouncer) steeringAnnouncer.textContent = message; } /* Fetch + render the note list (exported — the chat page's per-bubble Tune form calls it on save, so the panel + count badge update without owning the fetch itself). Non-2xx (the anonymous 403) or an unreachable API render the empty state: count badge 0, the "no notes yet" text visible — the safe fallback in either case. */ export async function refreshSteering() { let notes = []; try { const r = await fetch("/api/steering"); if (r.ok) notes = (await r.json()).notes || []; } catch { /* API unreachable: the empty list state is the safe fallback */ } renderSteeringPanel(notes); return notes; } /* Newest-first list — the note is ALWAYS rendered with textContent (XSS-safe, never innerHTML), a per-note Remove button with a labeled aria-label, the empty text toggled on notes.length, and the header count badge. */ function renderSteeringPanel(notes) { if (!steeringList) return; steeringList.textContent = ""; for (const n of notes) { const li = document.createElement("li"); li.className = "steering-note"; const text = document.createElement("span"); text.className = "steering-note-text"; text.textContent = n.note; // rendered as text, never as HTML li.appendChild(text); const del = document.createElement("button"); del.type = "button"; del.className = "steering-delete"; del.setAttribute("aria-label", `Delete tuning note: ${n.note}`); del.innerHTML = ''; del.addEventListener("click", () => deleteSteeringNote(n.id, del)); li.appendChild(del); steeringList.appendChild(li); } if (steeringEmpty) steeringEmpty.hidden = notes.length > 0; if (steeringCount) steeringCount.textContent = String(notes.length); } /* Per-note delete: disable the row button (no double-fire), DELETE /api/steering/{id}, re-load the list, announce through #steering-announcer. A 404 means the note was already gone — say so and still refresh; any other failure re-enables the button so the user can retry. */ async function deleteSteeringNote(id, btn) { btn.disabled = true; try { const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" }); if (r.status === 404) { announceSteering("That note was already removed."); await refreshSteering(); return; } if (!r.ok) { announceSteering("Could not delete the note — try again."); btn.disabled = false; return; } await refreshSteering(); announceSteering("Tuning note deleted."); } catch { announceSteering("Could not delete the note — is the app reachable?"); btn.disabled = false; } } /* Open/close the panel, keeping the toggle's aria-expanded in sync — the exact phase-15 chat-page contract (open on click, close on click; the panel itself is a plain region — no Esc / outside-click close in the original, so none here). Re-opening refreshes the list, so notes changed elsewhere (the Tuning page, another tab) show up. */ function setSteeringPanel(open) { if (!steeringPanel || !steeringToggle) return; steeringPanel.hidden = !open; steeringToggle.setAttribute("aria-expanded", open ? "true" : "false"); } /* Toggle binding (module-owned, like the sign-out binding): runs at module import, so a page with the toggle markup gets exactly one implementation. */ if (steeringToggle && steeringPanel) { steeringToggle.addEventListener("click", () => { setSteeringPanel(steeringPanel.hidden); if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened }); } /* ---------- New chat (the SINGLE binding — module-owned from phase 34 * task 02) ---------- * * The binding used to be duplicated across app.js / sources.js / * tuning.js / document.js with the same page-kind branch. It lives here * exactly once (module import, like the sign-out binding): on the chat * page (#messages exists) the module dispatches window "bor:new-chat" * and app.js acts — the chat script owns the in-flight-turn guard and * the rendered-list reset; on every other page "new chat" means go to * the chat, fresh: clear the phase-14 conversation key, then navigate * to "/" (its empty state, since the conversation is gone from storage). */ const newChatBtn = document.querySelector("#new-chat-btn"); if (newChatBtn) { newChatBtn.addEventListener("click", () => { if (document.querySelector("#messages")) { window.dispatchEvent(new CustomEvent("bor:new-chat")); return; } clearChatStorage(); window.location.href = "/"; }); } /* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ---------- * * The "never stale" lifecycle for the long background sync job, moved * here from sources.js so the SAME #sync-btn markup on ANY page (phase * 34 task 03) behaves identically. The button is the module's; the * Sources page's #sync-result line + #sync-error-banner render off the * "bor:sync-status" event this machine dispatches (sources.js * subscribes): * * idle → click → POST /api/sync * 202 → running (disabled, aria-busy, spinning icon, "Syncing…") * + a 2 s poll of GET /api/sync/status; * 409 → the in-flight run is ADOPTED the same way (one sync * at a time, one poll loop at a time); * success → "Synced HH:MM"; failed → retry-ready "Sync sources" * + the sanitized error in the button's title + * aria-label (on non-Sources pages that IS where the * failure is visible; the Sources banner is the event). * * Boot (admin only — non-admins never poll, the status endpoint is * admin-only): one GET /api/sync/status on the SAME cached whoami — * running re-enters the running state (reload mid-sync), a terminal * state renders its last result. NO client-side hard timeout (phase 32 * locked decision): a sync can legitimately outlive the page, so the * 2 s poll is the feedback loop and the server state is authoritative. * * All elements are looked up null-safe: a page that doesn't (yet) carry * the #sync-btn markup is a complete no-op, exactly like the rest of * this module. */ const syncBtn = document.querySelector("#sync-btn"); const syncLabel = document.querySelector("#sync-label"); const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null; const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract) let syncPollTimer = null; // at most ONE live poll loop let lastSyncState = null; // the last state emitted on bor:sync-status /* The module → page channel: detail is the GET /api/sync/status object (or the synthetic { state: "running" } frame the click path emits before the first poll tick — the Sources handlers only need the state, the next real object carries the full fields). */ function emitSyncStatus(status) { lastSyncState = status.state; window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status })); } function stopSyncPolling() { if (syncPollTimer !== null) { clearTimeout(syncPollTimer); syncPollTimer = null; } } /* The local HH:MM of finished_at — 24-hour, locale-independent, so the * "Synced 14:32" last-result label is deterministic. */ function fmtSyncTime(iso) { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const pad = (n) => String(n).padStart(2, "0"); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } /* The last-result line for the Sources page's #sync-result (aria-live). * "added" is ALWAYS announced (the run's headline term); "updated" / * "pruned" only when they happened (zero terms omitted); "unchanged" * whenever it is non-zero — or whenever nothing was added or updated, * so a no-op re-sync reads "0 added · 1 unchanged" instead of an empty * live region (the story gate's idempotency check). Exported so the * Sources page renders the counts from ONE implementation. */ export function fmtSyncResult(detail) { const d = detail || {}; const added = d.added || 0; const updated = d.updated || 0; const parts = [`${added} added`]; if (updated > 0) parts.push(`${updated} updated`); if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) { parts.push(`${d.unchanged || 0} unchanged`); } if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`); return parts.join(" · "); } /* The failed-state affordance text for the button's title + aria-label * (non-Sources pages: that is where the failure is visible). The server * already masks any embedded credentials (sync.py _sanitize_error); * here the string is collapsed to a single line and capped so a chatty * git stderr can't bloat the attributes. */ function sanitizeSyncError(message) { const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim(); return text.length > 200 ? `${text.slice(0, 200)}…` : text; } /* §7.4 running state: disabled + aria-busy + spinning icon + the * "Syncing…" label — and a fresh run starts clean: the previous * failure's affordances (title / aria-label / .is-error) come off NOW, * not when the run settles. The button only — the Sources page's * result line / banner clear off the matching "running" event (no * 2 s lag). */ function enterSyncRunningState() { if (!syncBtn) return; syncBtn.disabled = true; syncBtn.setAttribute("aria-busy", "true"); syncBtn.removeAttribute("title"); syncBtn.setAttribute("aria-label", "Sync sources"); syncBtn.classList.remove("is-error"); if (syncIcon) syncIcon.classList.add("is-spinning"); if (syncLabel) syncLabel.textContent = "Syncing…"; } /* Settle the button back to clickable + un-spun with the given label, * dropping the failed-state affordances (a fresh run starts clean). */ function settleSyncButton(label) { if (!syncBtn) return; syncBtn.disabled = false; syncBtn.removeAttribute("aria-busy"); syncBtn.removeAttribute("title"); syncBtn.setAttribute("aria-label", "Sync sources"); syncBtn.classList.remove("is-error"); if (syncIcon) syncIcon.classList.remove("is-spinning"); if (syncLabel) syncLabel.textContent = label; } function applySyncSuccess(status) { const time = fmtSyncTime(status.finished_at); settleSyncButton(time ? `Synced ${time}` : "Synced"); emitSyncStatus(status); } function applySyncFailure(status) { const error = sanitizeSyncError(status.error); settleSyncButton("Sync sources"); // retry-ready if (syncBtn) { // The failed look: error text in title + aria-label (and the // .is-error class for the non-Sources pages' visible error state). syncBtn.title = error; syncBtn.setAttribute("aria-label", error); syncBtn.classList.add("is-error"); } emitSyncStatus(status); } /* A run can only vanish with a server restart mid-sync (status resets * to idle — the phase-accepted behavior): retry-ready, no error to * name. Also the post-403 cleanup. */ function applySyncIdle(status) { settleSyncButton("Sync sources"); emitSyncStatus(status || { state: "idle" }); } /* The 2 s poll loop — the ONLY feedback timer (no client-side hard * timeout, phase 32 locked decision). One tick at a time (re-scheduled * only while the run is still live, so an in-flight fetch can never * overlap the next tick), and startSyncPolling refuses to run a second * loop (a 409 adoption or a reload never doubles the polling). */ function startSyncPolling() { if (syncPollTimer !== null) return; const tick = async () => { let status = null; let notAdmin = false; try { const r = await fetch("/api/sync/status"); if (r.status === 403) notAdmin = true; else if (r.ok) status = await r.json(); } catch { /* network blip — the next tick retries (no client timeout to trip) */ } if (notAdmin) { // Session lost mid-sync: defense in depth — hide the button. stopSyncPolling(); if (syncBtn) syncBtn.hidden = true; applySyncIdle(); return; } if (!status) { syncPollTimer = setTimeout(tick, SYNC_POLL_MS); return; } if (status.state === "success") { stopSyncPolling(); applySyncSuccess(status); return; } if (status.state === "failed") { stopSyncPolling(); applySyncFailure(status); return; } if (status.state === "idle") { // The run died with a server restart — retry-ready, no banner. stopSyncPolling(); applySyncIdle(status); return; } // Still running: keep the button state honest (idempotent) and // re-schedule. No event — the running frame was already emitted // when the state entered (click / boot), and the Sources handlers // are no-ops for repeated running frames anyway. enterSyncRunningState(); syncPollTimer = setTimeout(tick, SYNC_POLL_MS); }; syncPollTimer = setTimeout(tick, SYNC_POLL_MS); } /* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight * one (started elsewhere — e.g. a second tab); 403 hides the button * (defense in depth); anything else names the failure (banner on * Sources via the event, button affordance everywhere). */ async function startSync() { let r; try { r = await fetch("/api/sync", { method: "POST" }); } catch { applySyncFailure({ state: "failed", error: "Could not reach the server to start the sync — try again.", }); return; } if (r.status === 403) { stopSyncPolling(); if (syncBtn) syncBtn.hidden = true; applySyncIdle(); return; } if (r.status === 202 || r.status === 409) { enterSyncRunningState(); // The synthetic running frame clears the Sources result line / // banner IMMEDIATELY (before the first poll tick, 2 s away) — the // exact sources.js enterRunningState behavior, now event-driven. if (lastSyncState !== "running") emitSyncStatus({ state: "running" }); startSyncPolling(); return; } let detail = ""; try { detail = (await r.json()).detail || ""; } catch { /* non-JSON error body */ } applySyncFailure({ state: "failed", error: detail || `The server refused to start the sync (${r.status}).`, }); } /* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status * endpoint is admin-only): a running run re-enters the running state * (the user may have reloaded mid-sync), a terminal run renders its * last result, idle settles nothing visible. Awaits the SAME cached * whoami promise — exactly one /api/whoami per page load, unchanged. */ async function initSyncButton() { if (!syncBtn) return; if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden let status; try { const r = await fetch("/api/sync/status"); if (r.status === 403) { syncBtn.hidden = true; // defense in depth return; } if (!r.ok) return; status = await r.json(); } catch { return; // network blip — the button stays idle and clickable } if (status.state === "running") { enterSyncRunningState(); emitSyncStatus(status); startSyncPolling(); } else if (status.state === "success") { applySyncSuccess(status); } else if (status.state === "failed") { applySyncFailure(status); } else { applySyncIdle(status); // idle: settle + the idle frame } } if (syncBtn) { syncBtn.addEventListener("click", startSync); initSyncButton(); // re-attach to a running / last sync run (admin only) }