Files
brain-of-reese/frontend/assets/header.js
T
ducoterra 6cf1df9bf2 feat(sync): fail fast with a modal when a model is unavailable
TODO.md L4: with a dead model endpoint the sync discovered it only
mid-import, after slow clones — and a tooltip on the button is not a
readable error.

- app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny
  pre-sync probe (one short embedding + one 1-token-scale completion)
  that fails naming the unavailable model (embed first, then the
  summary model); the sync sanitizer still masks credentials.
- app/api/sync.py: the probe is step 1 of _run_sync — before source
  resolution and before any clone_or_pull; a model failure is just
  another 'failed' state (no new endpoint, A10/A12 untouched).
- frontend/assets/header.js: applySyncFailure now also opens the
  module-owned error modal (every page carrying #sync-btn, zero
  page-markup changes): lazily built backdrop + role=alertdialog
  panel, error text via textContent, close via button / Esc /
  backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn
  fallback — the run's disabled button drops focus to <body>).
- frontend/assets/styles.css: the modal on the phase-08 error palette
  (z-index above the header, .is-open open/close, reduced-motion
  stilling, 44px close target).
- Tests: probe unit tests (both up / embed down / summary down /
  custom model names), sync integration (fail-fast before any clone,
  probe-before-effective_sources ordering, credential masking,
  healthy regression), the phase-41 source pins, and the story E2E
  (two module apps on distinct ports — dead endpoint on a closed
  loopback port vs session mock: ≤10 s fail-fast + modal contract,
  all three dismissal paths with focus out to #sync-btn, button
  title/.is-error + Sources banner untouched, healthy phase-32
  lifecycle regression to 'Synced HH:MM').

E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py
3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py
3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright
clean.
2026-08-27 23:44:35 -04:00

694 lines
30 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 — 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.
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
* the admin-only nav links use: anonymous never sees it for a
* single frame) and initSharedHeader unhides it only when whoami
* says admin. 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; the button title/aria are the
* secondary failure surfaces — and a failed run ALSO opens the
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
* primary readable failure surface): lazily built by this module,
* appended to <body>, error text textContent-rendered, closable via
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
* every page carrying #sync-btn gets it with zero page-markup
* changes;
* • 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=<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 `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:
// unhide the toggle — it SHIPS hidden (phase 40, 2026-08-27, TODO.md
// L3, the same ship-hidden / reveal-for-admin contract as the
// admin-only nav links) — then 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 (steeringToggle) steeringToggle.hidden = false;
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 =
'<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;
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 + the module-owned error modal (phase 41,
* 2026-08-27, TODO.md L4 — the primary readable failure
* surface on every page; the button affordance and the
* Sources banner stay the secondary surfaces).
*
* 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);
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
// the primary readable failure surface (the button title/aria and the
// Sources banner above stay as the secondary surfaces).
showSyncModal(error);
}
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
*
* A tooltip on the button is not a readable error — a failed sync ALSO
* opens a modal dialog. It is built by THIS module (the owner of the
* sync state machine), so every page carrying #sync-btn gets it with
* zero page-markup changes: created lazily ONCE (module-level
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
* full-viewport dim) holding the .sync-modal panel
* (role="alertdialog", aria-modal, labelled + described). The error
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
* user data); a second failure while open updates the text IN PLACE
* (no stacking). Closes via the close button, Esc (ONE document
* keydown binding, acting only while open), or a click on the backdrop
* itself (never the panel); focus moves to the close button on open
* and back to the remembered element (#sync-btn — the control that
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
* no modal, exactly like the rest of this module.
*/
let syncModal = null; // the backdrop element — created once, lazily
let syncModalReturnFocus = null; // the element to refocus on close
function createSyncModal() {
const backdrop = document.createElement("div");
backdrop.className = "sync-modal-backdrop";
// Static skeleton — no user data anywhere in it; the error text is
// filled via textContent in showSyncModal, never interpolated here.
backdrop.innerHTML =
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
'<h2 id="sync-modal-title">Sync failed</h2>' +
'<p id="sync-modal-error"></p>' +
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
"</div>";
document.body.appendChild(backdrop);
// Close path 1: the close button (×).
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
// Close path 2: a click on the backdrop element itself — never one
// that bubbles up from the panel (event.target check).
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeSyncModal();
});
// Close path 3: Esc — ONE document-level keydown binding for the
// life of the page, acting only while the modal is open.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
});
return backdrop;
}
function showSyncModal(error) {
if (!syncBtn || !document.body) return; // null-safe: pages without the button
if (!syncModal) syncModal = createSyncModal();
// The sanitized error as TEXT (XSS-safe) — a second failure while
// open updates the text in place (no stacking, no focus jump).
syncModal.querySelector("#sync-modal-error").textContent = error;
if (syncModal.classList.contains("is-open")) return;
// First open: remember the focused element and move focus into the
// dialog (the close button). While the run was in flight the button
// was disabled (focus had fallen to <body>), so a body-level active
// element means "no meaningful focus target" — remember #sync-btn,
// the control that started the run, so the close returns focus there.
const active = document.activeElement;
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
syncModal.classList.add("is-open");
syncModal.querySelector(".sync-modal-close").focus();
}
function closeSyncModal() {
if (!syncModal || !syncModal.classList.contains("is-open")) return;
syncModal.classList.remove("is-open");
// Focus returns to the remembered element — #sync-btn when present.
const target = syncModalReturnFocus;
syncModalReturnFocus = null;
if (target && document.contains(target)) target.focus();
}
/* 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)
}