/* 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
, 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… (n/m)" — bare "Syncing…" until * the import's first file (clone/pull, A4); * 2. upload running → "Importing (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 = '
' + '

Sync failed

' + '

' + '' + "
"; 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(); }