Files
brain-of-reese/frontend/assets/sources.js
T
ducoterra ffa919b8bf fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the
five navbar views (Chat, RAG, Sources, Tuning, History) were separate
HTML documents, so a navbar click was a REAL cross-document navigation
— the chat page unloaded, the in-flight SSE fetch was aborted, and the
phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled")
stopped the model. Observed: send question -> click RAG mid-stream ->
click Chat -> the answer never finished: no `query_log` row, and on
return a dangling question with no brain record (the pre-token pagehide
partial persist skips because `acc` is empty).

Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06,
flagged per AGENTS.md rule 3, not silently deviated): "real navigation
cancels the fetch" now means LEAVING THE APP — tab close,
external/other-document navigation, the Stop button. In-app navbar
switches are client-side view switches and no longer cancel.

Fix — Option A (SPA shell), chosen over B (Service Worker owns the
stream) and C (server-side turn registry + resume):
- frontend/index.html is the shell: ONE `<main id="main">` holds the
  five `<section class="view">` blocks; hidden views carry BOTH
  `hidden` and `inert` (WCAG — no focus/keyboard traversal). The
  shared header, the single `doc-modal-*` skeleton, and the
  `#app-version` footer each exist exactly once; the per-view copies
  from the four folded pages are dropped.
- New frontend/assets/router.js (vanilla module — no framework, no
  bundler, No-CDN rule intact): lazy-imports a view module on FIRST
  show only (mount-once, hide-forever — the chat view's in-flight SSE
  reader persists across switches; that persistence IS the fix);
  intercepts same-shell navbar links with preventDefault +
  history.pushState (never a document load); handles popstate; single
  writer of `.nav-link` active state (is-active + aria-current),
  document.title, and the per-view meta description (values carried
  over from the old pages' heads, brand-resolved at write time).
- Each folded page's JS becomes `export async function mount(root)` —
  root-scoped queries; `initSharedHeader()` dropped (the header boots
  once in the shell via the chat module; the admin flag comes from the
  same cached `fetchIsAdmin()` promise — zero extra requests).
- app/main.py: a small list-driven route factory serves the shell for
  /tuning.html, /sources.html, /git-sources.html, /history.html —
  registered AFTER the API routers and BEFORE the static catch-all
  (routes-first). The phase-33 caching middleware applies no-cache +
  `?v=` rewriting unchanged; app/core/caching.py needed NO change
  (the view paths did not change — pinned by the integration tests).
- The four old view .html files are DELETED (one source of truth);
  deep links to the old URLs keep working (the router picks the view
  from the pathname); `/?chat=<id>` is unaffected; the Containerfile
  bundles router.js (inlining the lazy view modules) and drops the
  folded page files.
- app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell
  keeps long saved answers in the chat, and the old cap (stricter than
  the 24_000-char total history budget) 422-rejected any second turn
  in such a chat (found by the phase-42 E2E suite on the shell).

Boundaries: login.html, shared.html, doc-edit.html, document.html
REMAIN separate documents (flow pages, not navbar tabs); a mid-stream
navigation to doc-edit/document.html still cancels per phase 48
(follow-up candidate, out of scope). The SSE API is unchanged. Real
departures still cancel the turn — phase 48 intact (pinned by
tests/e2e/test_stop_generation.py, unchanged, and by the new suite's
real-departure control).

Tests:
- Phase-20 suite REWRITTEN to the new semantics
  (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer
  cancels — the stream survives the switch and the FULL answer
  settles; the pagehide partial persist REMAINS for real departures
  (the partial's exact shape — first streamed chunk prefix, no done
  metadata — is still pinned there).
- NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock
  LLM): the owner repro (send -> RAG mid-stream -> Chat: window
  sentinel survives = same document, FULL answer, exactly one brain
  turn in bor.chat.v1, exactly one settled query_log row, auto-saved
  row matches) + the same mid-stream switch against the other three
  views + the real-departure-still-cancels control + the no-switch
  baseline.
- tests/unit/test_frontend_router.py: source-level pins of the router
  invariants (click interceptor targets ONLY same-shell view paths,
  pushState-only switches, mount-once guard, hidden+inert pair,
  single-writer active state/title); shell-route integration tests
  (each folded path serves the shell with no-cache + `?v=` body; a
  non-view path still 404s); the file-reading unit pins re-pointed at
  the shell (the four view files are gone — the shell is the source
  of truth).

Verification (this commit): full suite green — 1565 unit+integration
tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the
phase's E2E suites green in isolation (house protocol, AGENTS.md rule
9). Owner repro verified in a real browser against the real LLM
(dev server :8010, headful Chromium): "tell me about everquest" ->
RAG mid-stream -> Chat — the answer completed with one brain bubble
and no error banner, `query_log` gained exactly one settled row
(deflected=True: the dev KB holds no EverQuest docs — the settle, not
the topic, is the proof), zero "chat: turn cancelled" lines for that
turn; the control (real navigation to /shared.html mid-stream) still
cancelled (no settled row, the cancel line logged, the partial
persisted on return). Screenshots: .agents/screenshots/76_manual_*.

Phase 76 (76_spa_nav_shell) complete — moved to
.agents/phases/complete/.
2026-09-06 06:31:31 -04:00

569 lines
22 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 — RAG view (the knowledge base catalog; phase 76
* task 02: shell view module — formerly the standalone sources.html).
*
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
* full-width document table, or the designed empty state when nothing
* is indexed yet. Cells are built with DOM APIs (textContent) — never
* innerHTML with document-derived data (XSS-safe by construction).
*
* Phase 76 (task 02) — shell view module (the "RAG" view of the
* ONE-document shell; /sources.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-rag">, 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 bindings,
* the sync state machine, and the in-flight poll 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 (sync button
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
* SAME cached /api/whoami promise header.js exports (zero extra
* requests).
* • the document modal needs NO wiring change: the shell keeps
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
* both app.js (chat chips) and this module (RAG row links) open
* documents through the shared openDocumentModal(...) against that
* single instance (assets/document-modal.js, resolved by
* document-level querySelector at import).
*
* The sync button (phase 32) + the live two-job progress contract
* (phase 64 task 04) and the table rows (phase 26: same-page modal
* links) are unchanged in content — only the boot shape moved.
*/
import { fetchIsAdmin } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
* query values are percent-encoded (paths contain slashes, sometimes
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
* context-menu escape hatch) — the left-click opens the same-page modal
* instead. */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
export async function mount(root) {
/* ---------- Sync sources button (Sources page only) ----------
*
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
* → running (2 s poll of GET /api/sync/status) → success | failed.
* Admin-only: the button SHIPS hidden and the view boot below reveals
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
* opens an error modal (same as the former header.js module — recreated
* here since the navbar button is gone).
*
* Phase 64 (task 04): the button reports the FILE being processed, not
* just "Syncing…" — TWO jobs drive it. Each poll tick fetches BOTH
* status endpoints — GET /api/sync/status +
* GET /api/git-sources/upload/status — and applies this decision
* tree, in order (startSyncPolling):
* 1. sync running → "Syncing… <file> (n/m)" — bare "Syncing…" until
* the import's first file (clone/pull, A4);
* 2. upload running → "Importing <file> (n/m)" — the background
* archive scan (the "clicked upload, then opened
* sources" contract, A3);
* 3. sync success → the phase-32 settle (counts + catalog refresh);
* 4. sync failed → the phase-32 failure (banner + modal);
* 5. upload success → settle "Sync sources" + catalog refresh
* (loadDocs — the new documents must appear); the
* upload's counts live on the Sources page, never
* in #sync-result (A3);
* 6. upload failed → settle "Sync sources" — the failure is the
* Sources page's error banner, never this page's (A3);
* 7. both idle → retry-ready idle.
* The live label is the status endpoint's full source/relative/path
* (A4): CSS ellipsizes #sync-label; the full untruncated path also
* rides the button title (hover) and #sync-result (the aria-live
* announcer — screen readers hear it). The load-time re-attach
* (initSyncButton) re-enters a RUNNING upload the same way; a terminal
* upload is a no-op there (the boot-time loadDocs() already shows the
* current catalog).
*
* Elements: #sync-btn (the button), #sync-label (the text),
* #sync-icon (the spinner icon), #sync-result (aria-live result
* line), #sync-error-banner / #sync-error-text (error banner).
*/
const syncBtn = root.querySelector("#sync-btn");
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner");
const syncErrorText = root.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000;
let syncPollTimer = null;
let lastSyncState = null;
function emitSyncStatus(status) {
lastSyncState = status.state;
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
}
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
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())}`;
}
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
* word, A3). The current file — the status endpoint's full
* source/relative/path (A4) — is appended while one is being processed;
* the BARE prefix shows during the clone/pull (sync) or unpack (upload)
* phase, before any file is indexed. The counts appear only once the
* import has started (total > 0). CSS ellipsizes the button label; the
* same untruncated text goes to the button title + #sync-result (the
* aria-live announcer). */
function fmtSyncLabel(kind, currentFile, done, total) {
const prefix = kind === "upload" ? "Importing" : "Syncing…";
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
if (total > 0) label += ` (${done}/${total})`;
return label;
}
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(" · ");
}
function sanitizeSyncError(message) {
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/* The single running-state entry point (phase 64 task 04: the label
* carries the live file — `kind` "sync" | "upload", the status
* endpoint's current_file + done/total). Same mechanics as before
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
* untruncated path on the button title (removed when null — no file
* yet) and in #sync-result (the aria-live announcer reads the full
* live path; CSS ellipsizes the button's label span only). */
function enterSyncRunningState(kind, currentFile, done, total) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
if (currentFile) syncBtn.title = currentFile;
else syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
const label = fmtSyncLabel(kind, currentFile, done, total);
if (syncLabel) syncLabel.textContent = label;
if (syncResult) syncResult.textContent = label;
}
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 showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false;
}
function hideSyncError() {
if (syncErrorText) syncErrorText.textContent = "";
if (syncErrorBanner) syncErrorBanner.hidden = true;
}
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
let syncModal = null;
let syncModalReturnFocus = null;
function createSyncModal() {
const backdrop = document.createElement("div");
backdrop.className = "sync-modal-backdrop";
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);
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeSyncModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
});
return backdrop;
}
function showSyncModal(error) {
if (!syncBtn || !document.body) return;
if (!syncModal) syncModal = createSyncModal();
syncModal.querySelector("#sync-modal-error").textContent = error;
if (syncModal.classList.contains("is-open")) return;
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");
const target = syncModalReturnFocus;
syncModalReturnFocus = null;
if (target && document.contains(target)) target.focus();
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError();
emitSyncStatus(status);
// Refresh the catalog live — the KB just changed.
loadDocs();
}
function applySyncFailure(status) {
const error = sanitizeSyncError(status.error);
settleSyncButton("Sync sources");
if (syncBtn) {
syncBtn.title = error;
syncBtn.setAttribute("aria-label", error);
syncBtn.classList.add("is-error");
}
if (syncResult) syncResult.textContent = "";
showSyncError(error);
emitSyncStatus(status);
showSyncModal(error);
}
function applySyncIdle(status) {
settleSyncButton("Sync sources");
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
* sync AND the background upload scan — and applies the two-job
* decision tree in order (see the section header). The 403 on the SYNC
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
* fetch is simply "no upload" (never a hide), and a network blip on
* either fetch retries next tick. */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let syncStatus = null;
let uploadStatus = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) syncStatus = await r.json();
} catch { /* network blip — retry next tick */ }
if (notAdmin) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
// The SECOND job: the background upload scan (admin-only surface).
try {
const ur = await fetch("/api/git-sources/upload/status");
if (ur.ok) uploadStatus = await ur.json();
} catch { /* network blip — retry next tick */ }
if (!syncStatus) {
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
// 1. sync running: the live sync file (bare "Syncing…" until the
// import's first file — A4).
if (syncStatus.state === "running") {
enterSyncRunningState(
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total
);
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
// 2. upload running: the same animation, the upload's file (A3).
if (uploadStatus && uploadStatus.state === "running") {
enterSyncRunningState(
"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total
);
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (syncStatus.state === "success") {
stopSyncPolling();
applySyncSuccess(syncStatus);
return;
}
if (syncStatus.state === "failed") {
stopSyncPolling();
applySyncFailure(syncStatus);
return;
}
// 5. upload success: settle + catalog refresh (A3 — the upload's
// counts live on the Sources page; #sync-result stays empty).
if (uploadStatus && uploadStatus.state === "success") {
stopSyncPolling();
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
emitSyncStatus({ state: "idle" });
loadDocs();
return;
}
// 6. upload failed: settle only — the failure is the Sources page's
// error banner, never this page's (A3).
if (uploadStatus && uploadStatus.state === "failed") {
stopSyncPolling();
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
emitSyncStatus({ state: "idle" });
return;
}
// 7. both idle: settle retry-ready.
stopSyncPolling();
applySyncIdle(syncStatus);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
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) {
// Phase 64: the run is just starting (model check / clone-pull) —
// bare "Syncing…" until the first polled file (A4); entering the
// running state also clears #sync-result with the same label.
enterSyncRunningState("sync", null, 0, 0);
hideSyncError();
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
let detail = "";
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
applySyncFailure({
state: "failed",
error: detail || `The server refused to start the sync (${r.status}).`,
});
}
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
* state, a terminal run renders its last result. Phase 64 (A3): with
* the sync IDLE, an in-flight background upload scan adopts the button
* the same way — the "user clicked upload, then opened sources" case;
* a terminal upload is a no-op (the boot-time loadDocs() already shows
* the current catalog). */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return;
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) { syncBtn.hidden = true; return; }
if (!r.ok) return;
status = await r.json();
} catch { return; }
if (status.state === "running") {
enterSyncRunningState(
"sync", status.current_file, status.files_done, status.files_total
);
emitSyncStatus(status);
startSyncPolling();
return;
}
if (status.state === "success") {
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
applySyncFailure(status);
return;
}
// Sync idle: check the SECOND job — an in-flight upload scan re-attaches.
let upload;
try {
const ur = await fetch("/api/git-sources/upload/status");
if (ur.ok) upload = await ur.json();
} catch { /* network blip — the idle settle below is still honest */ }
if (upload && upload.state === "running") {
enterSyncRunningState(
"upload", upload.current_file, upload.files_done, upload.files_total
);
emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
applySyncIdle(status);
}
if (syncBtn) {
syncBtn.addEventListener("click", startSync);
initSyncButton();
}
const tbody = root.querySelector("#docs-tbody");
const emptyEl = root.querySelector("#sources-empty");
const tableWrap = root.querySelector(".table-wrap");
const statCards = root.querySelector("#stat-cards");
const gateEl = root.querySelector("#sources-gate");
const statDocs = root.querySelector("#stat-docs");
const statChunks = root.querySelector("#stat-chunks");
const statLast = root.querySelector("#stat-last");
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
* catalog is admin-only. The document viewer itself stays public (the
* soft rule), so the gate copy points at what keeps working.
* Phase 76 (task 02): the whoami request is the shared header module's
* cached promise — fetchIsAdmin(), the same single request the shell's
* header boots (zero extra requests). */
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function loadDocs() {
let r;
try {
r = await fetch("/api/docs");
} catch {
showEmpty();
return;
}
if (!r.ok) {
showEmpty();
return;
}
const { documents } = await r.json();
if (!documents.length) {
showEmpty();
return;
}
tbody.replaceChildren();
let totalChunks = 0;
let last = "";
for (const d of documents) {
totalChunks += d.chunks;
if (d.indexed_at > last) last = d.indexed_at;
tbody.appendChild(makeRow(d));
}
statDocs.textContent = String(documents.length);
statChunks.textContent = String(totalChunks);
statLast.textContent = last ? fmtDate(last) : "–";
emptyEl.hidden = true;
tableWrap.hidden = false;
}
function makeRow(d) {
const tr = document.createElement("tr");
const sourceTd = document.createElement("td");
sourceTd.textContent = d.source; // document-derived text — never innerHTML
tr.appendChild(sourceTd);
// Path cell: a link to the document (phase 10), full path as the
// accessible/hover name (the column is ellipsized). Phase 26: the
// left-click opens the same-page modal — no new tab (document-modal.js);
// the href stays as the no-JS / context-menu escape hatch.
const pathTd = document.createElement("td");
pathTd.title = d.path; // full path on hover (column is ellipsized)
const link = document.createElement("a");
link.className = "doc-link";
link.href = documentUrl(d.source, d.path);
link.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(d.source, d.path, link);
});
link.title = d.path; // full path as the link's hover/accessible name
link.textContent = d.path;
pathTd.appendChild(link);
tr.appendChild(pathTd);
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
const td = document.createElement("td");
td.textContent = value;
tr.appendChild(td);
}
return tr;
}
function showEmpty() {
statDocs.textContent = "0";
statChunks.textContent = "0";
statLast.textContent = "–";
emptyEl.hidden = false;
if (tableWrap) tableWrap.hidden = true;
}
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): the sync button joins
* the admin reveal on that one whoami (no extra fetch), and the
* anonymous branch gates the catalog in / out with NO /api/docs
* request at all (the Sources-page soft rule, unchanged). */
const admin = await fetchIsAdmin();
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
if (!admin) {
// Anonymous: gate in, catalog out, and no /api/docs request at all.
if (statCards) statCards.hidden = true;
if (tableWrap) tableWrap.hidden = true;
if (emptyEl) emptyEl.hidden = true;
if (gateEl) gateEl.hidden = false;
return;
}
if (gateEl) gateEl.hidden = true;
loadDocs();
}