Files
brain-of-reese/frontend/assets/header.js
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

422 lines
20 KiB
JavaScript

/* 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 — "RAG" (#nav-sources, phase 19),
* "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 sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation.
* Phase 79 (task 05): it ALSO drops the cached token
* (localStorage["bor.token"], try/catch — the fail-silence storage
* contract) BEFORE the reload, so a signing-out token user meets
* the gate again on the next load (the server session is wiped by
* the logout; the localStorage key must go with it);
* • the mobile hamburger binding (phase 46, owner permission
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
* elsewhere) the #nav-toggle button opens the nav as an animated
* dropdown (#app-nav .is-open — the 180ms slide+fade state from
* task 01's CSS): a click toggles it with aria-expanded kept in
* sync, a nav link click shuts it (the navigation happens anyway),
* Esc shuts it and returns focus to the toggle, and resizing back
* to >640px drops the open state (matchMedia change) so
* aria-expanded stays honest. One binding for all six pages; a
* page without either element is a no-op. The binding toggles
* ONLY the container — the nav links keep their ship-hidden
* whoami contract (hidden links stay hidden inside the menu);
* • the steering-notes panel (phase 15, moved here from app.js in
* phase 34) — the #steering-panel list (newest-first,
* textContent-rendered, per-note delete) + the #steering-announcer
* live region — so every page that ships the panel markup gets
* exactly this behavior with zero page-script duplication. The
* navbar #steering-toggle was REMOVED at owner request
* (2026-08-28) — note management now lives on /tuning.html — so
* the panel ships hidden on every page and is only kept fresh,
* never opened from the header. 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 (panel removed from the
* DOM, /api/steering never fetched);
* • the SINGLE New chat binding (phase 34 task 02 — it was
* duplicated across app.js / sources.js / tuning.js / document.js;
* moved from the navbar to the chat page at owner request):
* the button now lives ONLY on the chat page (inside .chat-shell,
* above #messages). When clicked it dispatches window
* "bor:new-chat" and app.js acts (it owns the in-flight-turn
* guard + the list reset).
* • clearChatStorage() — the phase-14 conversation key, for clearing
* the conversation when navigating away from the chat page.
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
* points #sign-in-link at /login.html?next=<current pathname>
* (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 `whoamiPromise`, so app.js's tuning gate, the sources
* page's catalog gate, and the header toggling all share one request.
* Phase 79 (task 05): the cache stores the FULL response —
* `{ authenticated, role }` (role: "admin" | "user" | "anonymous") —
* not just the admin flag: the token-gate module (assets/token-gate.js)
* reuses it for its role check, and `resetWhoami()` invalidates it
* right after a mid-page auth (a silent re-auth or an interactive
* login) so the next fetchWhoami() is a fresh post-auth request.
* Anonymous-safe: any network failure or non-2xx resolves to
* `{ authenticated: false, role: "anonymous" }` (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.
*/
/* The anonymous fallback (phase 79): non-2xx, a network failure, or a
malformed body all resolve to the anonymous role — the UI degrades
to the guest surface, never to an error (the phase-19 anonymous-safe
contract, unchanged in spirit). */
const ANONYMOUS_WHOAMI = Object.freeze({ authenticated: false, role: "anonymous" });
let whoamiPromise = null;
/* The SINGLE /api/whoami call site for the whole frontend (phase 79,
task 05: the cache now stores the FULL response — { authenticated,
role } — not just the admin flag). First call stores the promise in
`whoamiPromise`; 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 the anonymous role. */
export function fetchWhoami() {
if (!whoamiPromise) {
whoamiPromise = fetch("/api/whoami")
.then(async (r) => {
if (!r.ok) return ANONYMOUS_WHOAMI;
const data = await r.json();
return {
authenticated: data.authenticated === true,
role:
data.role === "admin" || data.role === "user"
? data.role
: "anonymous",
};
})
.catch(() => ANONYMOUS_WHOAMI);
}
return whoamiPromise;
}
/* The phase-16/19 contract every existing admin gate consumes: the
role check is `role === "admin"` — a token user (role "user") is
authenticated but NOT an admin, so every admin-only surface keys off
this (never off `authenticated`). SAME single request: it delegates
to fetchWhoami(), so all existing callers keep working with zero
changes. */
export function fetchIsAdmin() {
return fetchWhoami().then((w) => w.role === "admin");
}
/* Phase 79 (task 05): the token gate changes the session MID-PAGE (a
silent re-auth of a cached token, or an interactive login) — a
whoami cached BEFORE that auth (fired at boot) is stale. The gate
clears the cache right after a successful auth, so the NEXT
fetchWhoami() is a fresh request carrying the post-auth role — and
every consumer that awaits it afterwards (the header re-boot, the
view gates) reuses that one fresh promise. */
export function resetWhoami() {
whoamiPromise = null;
}
/* 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 fetchWhoami() again (the
cached promise makes both awaits the same single request). */
export async function initSharedHeader() {
const whoami = await fetchWhoami();
const admin = whoami.role === "admin";
// Phase 79 (task 05): the auth PAIR keys off the authenticated role —
// admin OR token user: both get Sign out (the binding below drops the
// cached token too) and neither sees Sign in. The admin-ONLY surfaces
// (the nav links, the steering refresh) still key off role ===
// "admin": a token user gets the anonymous branch — the links stay
// hidden and the steering panel is REMOVED from the DOM (/api/steering
// 403s a user, so it must never be fetched). Admin and anonymous
// behavior is byte-identical to phase 16/19.
const signedIn = whoami.authenticated;
// The Sign in link: hidden for any authenticated role (admin or
// token user — phase 79), visible for anonymous — 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).
//
// Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
// on the NESTED /shared/<token> page "where you were" is a public
// link, not a place in the app: the rewrite stays at the APP ROOT
// ("/"), so a guest signing in from a shared page lands in the chat
// (the static ?next=/ fallback in shared.html matches — the rewrite
// only ever KEEPS it there). A signed-in admin on the shared page
// never sees the link (hidden = admin), so this only shapes the
// guest experience.
const nextPath = window.location.pathname || "/";
const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath;
document.querySelectorAll(".sign-in-link").forEach(link => {
link.hidden = signedIn;
link.href = "/login.html?next=" + signInNext;
});
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !signedIn; });
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the RAG 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 50 (owner permission 2026-08-29, `TODO.md` L5): the History
// nav link (the phase-34 one-bar contract — it ships on every page)
// — admin-only, the same ship-hidden / reveal-for-admin contract as
// the Tuning link above.
const navHistory = document.querySelector("#nav-history");
if (navHistory) navHistory.hidden = !admin;
// Phase 79 (task 06): the Tokens nav link (the shell's sixth view —
// it ships ONLY in the shell's header) — admin-only, the same
// ship-hidden / reveal-for-admin contract as the History link above.
// Null-safe: a page without the link (the viewer / login / shared
// pages) is a no-op. A token user (role "user") never sees it.
const navTokens = document.querySelector("#nav-tokens");
if (navTokens) navTokens.hidden = !admin;
// Phase 34: the steering panel (phase 15) is module-owned. The
// navbar #steering-toggle was removed at owner request (2026-08-28)
// — the panel ships hidden and is only kept fresh. Admin: refresh
// the list (fire-and-forget). Anonymous AND token user (phase 79):
// the panel is REMOVED from the DOM entirely — the phase-16 contract
// says "absent", not just hidden — and /api/steering is never fetched
// (it 403s a user; only the admin's notes steer the prompt).
if (admin) {
if (steeringPanel) refreshSteering();
} else {
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.
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
dropdown copy for ≤640px). Disable during the call, POST /api/logout
(the result is ignored — the reload resets the UI either way), drop
the cached token (phase 79: one logout clears BOTH the server
session and the localStorage key — a signing-out token user meets
the gate again), then reload so the header re-resolves to the
anonymous state (Sign in back, Sources gone, the gate back for the
not-yet-token holder). */
document.querySelectorAll(".sign-out-btn").forEach(btn => {
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
await fetch("/api/logout", { method: "POST" });
} catch {
/* the reload resets the UI either way */
}
try {
localStorage.removeItem("bor.token");
} catch {
/* private mode / storage error — the server logout already signed
out; the next load re-gates either way */
}
window.location.reload();
});
});
/* ---------- mobile hamburger (phase 46; module-owned) ----------
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
* nav as a dropdown (#app-nav .is-open — the animated state, task 01
* CSS). One binding for all six pages; a page without either element
* is a no-op, like the rest of this module. The nav LINKS keep their
* ship-hidden whoami contract (hidden links stay hidden inside the
* menu) — this binding only toggles the container. */
const navToggle = document.querySelector("#nav-toggle");
const appNav = document.querySelector("#app-nav");
function setNavMenu(open) {
if (!appNav || !navToggle) return;
appNav.classList.toggle("is-open", open);
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
if (navToggle && appNav) {
navToggle.addEventListener("click", () =>
setNavMenu(!appNav.classList.contains("is-open")));
// A link click navigates (or closes same-page) — shut the menu.
appNav.addEventListener("click", (e) => {
if (e.target.closest("a")) setNavMenu(false);
});
// Esc closes while open (document-level — no other modal to fight
// for a key).
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
setNavMenu(false);
navToggle.focus(); // focus returns to the opener
}
});
// Resize back to desktop: the inline nav reappears — no stale open
// state (the .is-open class is scoped by the ≤640px CSS anyway, but
// dropping it keeps aria-expanded honest).
const mq = window.matchMedia("(max-width: 640px)");
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
if (mq.addEventListener) mq.addEventListener("change", onMqChange);
else mq.addListener(onMqChange); // older engines, defensive
}
/* ---------- 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 — list, per-note delete, announcer —
* is owned by THIS module: every page that ships the panel markup gets
* exactly this behavior, with zero page-script duplication. The navbar
* #steering-toggle was removed at owner request (2026-08-28) — note
* management now lives on /tuning.html — so the panel ships hidden and
* is only kept fresh. 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 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 updates without owning the
fetch itself). Non-2xx (the anonymous 403) or an unreachable API
render the empty state: 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, and the empty text toggled on notes.length. */
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 =
'<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>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
}
/* 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;
}
}
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
* task 02; moved from navbar to chat page at owner request) ----------
*
* The binding used to be duplicated across app.js / sources.js /
* tuning.js / document.js. It lives here exactly once (module import,
* like the sign-out binding). The button now lives ONLY on the chat
* page (inside .chat-shell, above #messages), so the click always
* dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn
* guard and the rendered-list reset).
*/
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
window.dispatchEvent(new CustomEvent("bor:new-chat"));
});
}