/* 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. * * Phase 77 (task 02) — the re-show refresh: the shell router * dispatches `bor:view-refresh` on the view's section when the user * RE-SHOWS an already-mounted view (a switch back onto it, a re-click * of the RAG nav link, or back/forward) — the first show (mount) and * boot never (the mount's own load is the first fetch). This module * listens on root and re-runs the catalog load, which is re-entrant: * a re-load clears the row containers' rows BEFORE filling them (the * History pattern from task 01 — phase 97 moved the clear from the * fetch top into the render, and from the one tbody to both), so a * refresh from a populated level into a sparser one replaces the rows * (no ghost rows). #sources-empty lives OUTSIDE the tbody (a * .empty-state div, not a row), so the clear is a bare * replaceChildren(). The listener is armed only in the ADMIN * branch, after the whoami gate passes: anonymous shows the gate and * never fetches the catalog (the phase-16 soft rule). * * Phase 97 (task 04) — the catalog becomes the DRILL-DOWN TREE the * agent's `ls` sees (the phase-94 concept, one end to end). The load * is now `loadTree()`: ONE fetch of GET /api/docs/tree returns the * FULL recursive tree (sources → folders → files, each folder carrying * its STORED description — the rows the agent reads), and the view * drills CLIENT-SIDE from that single fetch — zero per-level fetches, * no URL change (the navigation is a re-render of the already-fetched * tree). * * • the drill state is module-scoped: `current` — * { source: null, folder: null } = the top level (the rows ARE the * sources themselves — the ls() equivalence: name, recursive count, * the (source, "") description), `folder: ""` = the source root, * `folder: "one/two"` = the nested folder. The last fetched tree * lives in the module-scoped `kbTree`. * • `renderLevel()` from `current` + `kbTree`: #kb-crumb (hidden at * the top — one link per ancestor: the top level, the source, then * the folder chain, the last segment an aria-current span), #kb-level * (the current level's STORED description — title = the full * source-relative path; HIDDEN when none is stored — the ls rule: * count only, no placeholder), #folders-table = the level's direct * subfolders (top level: the sources), #docs-table = the level's * DIRECT files only — makeRow UNCHANGED (the path link still opens * the same-page modal; the no-JS href escape hatch intact), hidden * when the level has none (at the top level it is ALWAYS hidden — * files are seen per source, as with ls(source)). * • the stat cards walk the WHOLE tree (document count, chunks sum, * max indexed_at) — the values identical to the former flat walk. * • the empty state (#sources-empty) is now the "zero SOURCES" * semantic (nothing registered, nothing indexed) — the deliberate * phase-97 change: a registered 0-document source renders its row * (`0` documents) instead (the ls invariant — the agent lists it * too). A failed tree fetch renders that same no-data state (the * former flat-load failure behavior, unchanged in kind). * • Phase 77/79 carry over: the monotonic loadSeq race token (only * the newest load may touch the DOM after its await) and the * re-entrant render — renderLevel() clears BOTH row containers * BEFORE filling them, so a refresh from a populated level into a * sparser one leaves no ghost rows. NEVER-STALE (PLAN §7.4): after * a re-fetch (re-show, sync success, upload success), if the current * location no longer exists in the NEW tree (source unregistered/ * pruned, folder vanished), `current` RESETS to the top level BEFORE * rendering — no stale breadcrumb, no stale block. * • the refresh wirings move with the rename: `loadTree()` at the * boot load, the bor:view-refresh listener, applySyncSuccess, and * the upload-success branch of startSyncPolling — the anonymous * branch still NEVER fetches (no /api/docs/tree request at all). * • the sync button / poll / label / banner / error-modal machinery * and the document-modal wiring are UNTOUCHED (the sync section * above is byte-identical save its two catalog-refresh call * sites, which now call loadTree() — the phase-97 rename). * * Phase 97 (task 05) — the folder-description EDITOR (the phase-57 * affordance, mirrored). The owner edits (or clears) any directory's * stored description with the EXACT file-summary interaction: Edit → * inline textarea (prefilled via .value) → Save / Cancel → a * role=status live-region status. * * • ONE shared function, wireDescriptionEdit(), drives BOTH * surfaces: the static #kb-level Edit button (the current level's * description — the block ships the

+ a .kb-level-body holding * the

+ the Edit button) and the ALWAYS-present Edit button in * every source/folder row's Description cell (makeDescCell builds * it inside makeSourceRow / makeFolderRow — a description can be * CREATED where none is stored: a < 2-document folder, the * generator's fail-soft miss — the editor opens prefilled with the * empty string). NO whoami gate in the view: the RAG view is * admin-only already (the phase-16 gate) and the endpoint's * require_admin is the API-level gate. * • Save → PATCH /api/folders/summary with { source, folder_path, * summary } — folder_path "" for the source root, the * source-relative folder path otherwise (both known from the * target: the row's node, or `current` for the level). 200 → * re-render the description text (textContent ONLY — the XSS * contract) in the surface where the edit happened + status * "Description updated."; an empty save (the server echoes * summary null) → the text goes away (level block hidden / row * cell emptied) + "Description cleared."; the in-memory kbTree * node's summary is updated IN PLACE (no re-fetch — the tree state * stays coherent; the re-fetch is the safety net). Failure * (non-2xx / network) → neutral retry copy (the phase-55 * convention), the editor stays open with the user's text, the * stored text untouched. Cancel → restore the text node. * • the level block is PERSISTENT (reused across levels as you * drill), so wireDescriptionEdit takes a getTarget() getter (a * row's is a constant) and returns a handle whose reset() tears * down an open editor before every re-render (PLAN §7.4 — a * navigate-away or refresh never leaves a stale open editor). * • every editor part is static createElement; the description text * is a text node (textContent / .value) — this module never builds * HTML from document-derived data (the house rule, unchanged). */ 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); } /* ---------- folder-description editing (phase 97, task 05) ---------- * The phase-57 edit affordance, mirrored for the RAG view's folder * descriptions (the owner edits/clears any directory's stored * description exactly like a file summary): Edit → inline textarea * (prefilled via .value — the XSS contract) → Save / Cancel → a * role=status live-region status, wired to PATCH /api/folders/summary. * Every part is static createElement; the description text is a text * node (textContent / .value) — this module never builds HTML from * document-derived data (the house rule). */ function mkBtn(cls, label) { const b = document.createElement("button"); b.type = "button"; b.className = cls; b.textContent = label; return b; } /* The shared description editor. `container` holds ONLY the * description UI (the row's , or the level block's .kb-level-body) * — the swap is a bare replaceChildren on it, so any persistent * sibling (the level's

) is untouched. `getTarget()` returns the * current { node, source, folder } at open/save time: a row's is a * constant (the row's node), the level's reads `current` + * currentLevelNode() (the block is reused across levels). `node` is * the in-memory kbTree node: its .summary is read on open (prefill) * and updated IN PLACE on success (no re-fetch — the tree state stays * coherent; the re-fetch is the safety net). `folder` is "" for the * source root, the source-relative folder path otherwise. `onCleared` * (optional) runs after a successful clear — the level block hides * itself (the ls rule); a row cell just goes empty (the always-present * button stays). Returns a handle whose reset() tears down an OPEN * editor before a re-render (PLAN §7.4 — never a stale open editor). */ function wireDescriptionEdit({ editBtn, textEl, container, getTarget, onCleared }) { const actions = document.createElement("div"); actions.className = "kb-summary-actions"; const saveBtn = mkBtn("kb-summary-save", "Save"); const cancelBtn = mkBtn("kb-summary-cancel", "Cancel"); actions.append(saveBtn, cancelBtn); const status = document.createElement("p"); status.className = "kb-summary-status"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); let editor = null; let isOpen = false; /* Back to the display state: the text element re-rendered from the * node (the CURRENT stored description, textContent only), the Edit * button available again. A cleared node empties the text and, for * the level block, hides the whole block via onCleared. */ function closeEditor(message) { const target = getTarget(); const node = target ? target.node : null; const value = node && typeof node.summary === "string" ? node.summary : ""; textEl.textContent = value; // text node — the CURRENT stored description editBtn.hidden = false; status.textContent = message; container.replaceChildren(textEl, editBtn, status); isOpen = false; if (value.trim() === "" && onCleared) onCleared(); // level: hide the block else editBtn.focus(); // return focus to the opener } function openEditor() { if (isOpen) return; const target = getTarget(); if (!target) return; editor = document.createElement("textarea"); editor.className = "kb-summary-editor"; editor.value = typeof target.node.summary === "string" ? target.node.summary : ""; // .value, never innerHTML status.textContent = ""; editBtn.hidden = true; container.replaceChildren(editor, actions, status); editor.focus(); isOpen = true; } async function saveDescription() { const target = getTarget(); if (!target) return; const { node, source, folder } = target; const value = editor.value; saveBtn.disabled = true; // one PATCH at a time (never stale) status.textContent = ""; try { const res = await fetch("/api/folders/summary", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source, folder_path: folder, summary: value }), }); if (!res.ok) { // Neutral retry copy (phase-55) — the editor stays OPEN with the // user's text (no swap back, the stored text is untouched). status.textContent = "Couldn't save the description — try again."; return; } const data = await res.json(); node.summary = data.summary; // in-place kbTree update (no re-fetch) closeEditor(data.summary === null ? "Description cleared." : "Description updated."); } catch { // Network failure: the reachable? copy; the editor stays open. status.textContent = "Couldn't save the description — is the app reachable?"; } finally { saveBtn.disabled = false; } } editBtn.addEventListener("click", openEditor); saveBtn.addEventListener("click", () => { void saveDescription(); }); cancelBtn.addEventListener("click", () => closeEditor("")); return { /* Tear down an OPEN editor with NO message and WITHOUT re-rendering * the text from the (possibly stale) node — the surface is about to * re-render for a DIFFERENT level (navigation / refresh): a stale * open editor must never survive a re-render (PLAN §7.4). A closed * editor is a no-op. */ reset() { if (!isOpen) return; editor = null; saveBtn.disabled = false; status.textContent = ""; editBtn.hidden = false; container.replaceChildren(textEl, editBtn); isOpen = false; }, }; } /* A source/folder row's Description cell (task 05): the stored * description text (a text node, textContent only) + the ALWAYS-present * Edit button (`.kb-summary-edit`) — a description can be CREATED where * none is stored (a < 2-document folder, the generator's fail-soft * miss), so the button is added unconditionally (the view is * admin-only already — the endpoint's require_admin is the API gate). * The shared editor is wired with a CONSTANT target (the row's node). * `label` is the human name for the button's aria-label. */ function makeDescCell(node, source, folder, label) { const td = document.createElement("td"); const text = document.createElement("span"); text.textContent = (node && node.summary) || ""; // text node — never innerHTML const btn = document.createElement("button"); btn.type = "button"; btn.className = "kb-summary-edit"; btn.textContent = "Edit"; btn.setAttribute("aria-label", `Edit description: ${label}`); td.append(text, btn); wireDescriptionEdit({ editBtn: btn, textEl: text, container: td, getTarget: () => ({ node, source, folder }), }); return td; } 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 → BARE "Importing…" — the background upload * RUN (phase 90: unpack + register only, no * scan — its status never carries a file or * counts; 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 * (loadTree — phase 90: an upload no longer * changes the KB, the re-read is a no-op safety * net); the upload's result line lives 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 loadTree() 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 run'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), before any file * is indexed. Phase 90: the upload run is unpack + register only (no * scan), so its status never carries a file or counts — the * "Importing" label is always the bare one. 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. loadTree(); } 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 run (phase 90: unpack + register, * no 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 run (phase 90: unpack + // register only — no 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" }); loadTree(); 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 RUN (phase 90: unpack * + register — the bare "Importing…" label) adopts the button the * same way — the "user clicked upload, then opened sources" case; a * terminal upload is a no-op (the boot-time loadTree() 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 run // re-attaches (the bare "Importing…" label — phase 90: unpack + // register only). 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"); /* Phase 97 (task 04): the new #folders-wrap ALSO carries the shared * .table-wrap card class — the FILE table's wrap is therefore looked * up through its own table, not the class (a class lookup would hit * #folders-wrap first in document order). */ const tableWrap = root.querySelector("#docs-table").parentElement; 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"); const crumbEl = root.querySelector("#kb-crumb"); const levelEl = root.querySelector("#kb-level"); const levelTitleEl = root.querySelector("#kb-level-title"); const levelSummaryEl = root.querySelector("#kb-level-summary"); const foldersWrap = root.querySelector("#folders-wrap"); const foldersTbody = root.querySelector("#folders-tbody"); /* Phase 97 (task 05): the level block's static Edit button + the * .kb-level-body that holds the description UI (the

+ the * button) — the shared editor swaps INSIDE the body, leaving the * block's

untouched. */ const levelEditBtn = root.querySelector("#kb-level-edit"); const levelBody = levelEl ? levelEl.querySelector(".kb-level-body") : null; /* 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; } } /* Phase 97 (task 04): the drill-down tree (the module docstring carries the full contract). `kbTree` = the last fetched tree; `current` = the drill state ({ null, null } = top, `folder: ""` = the source root). Phase 79 carries over: the monotonic loadSeq race token invalidates an in-flight load the moment a newer one starts — only the newest load may touch the DOM after its await (the boot re-attach (applySyncSuccess → loadTree) and the boot-time loadTree interleave exactly as the flat load once did). Re-entrancy moves from the fetch top into the render: renderLevel() clears BOTH row containers BEFORE filling them (the phase-77 History pattern, extended to the second container), so a refresh from a populated level into a sparser one leaves no ghost rows. */ let kbTree = { sources: [] }; let current = { source: null, folder: null }; let loadSeq = 0; let levelEditor = null; // phase 97 (task 05): the level editor handle async function loadTree() { const my = ++loadSeq; let r; try { r = await fetch("/api/docs/tree"); } catch { if (my === loadSeq) renderEmpty(); return; } if (!r.ok) { if (my === loadSeq) renderEmpty(); return; } const tree = await r.json(); if (my !== loadSeq) return; // a newer load owns the DOM now kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] }; resetVanishedLocation(); renderLevel(); } /* Never-stale (PLAN §7.4, phase 97): after a re-fetch, the drilled * location may no longer exist in the NEW tree (the source was * unregistered/pruned, the folder vanished) — reset to the top level * BEFORE rendering: no stale breadcrumb, no stale block. */ function resetVanishedLocation() { if (current.source === null) return; const src = kbTree.sources.find((s) => s.name === current.source); if (!src) { current = { source: null, folder: null }; return; } if (current.folder !== null && !folderExistsIn(src, current.folder)) { current = { source: null, folder: null }; } } /* The phase-94 existence rule, mirrored client-side over the tree's * file paths: a folder exists under the source iff some of the * source's indexed paths starts with `folder + "/"` (the source root * — `""` — always exists). */ function folderExistsIn(src, folderPath) { if (folderPath === "") return true; const paths = []; const collect = (node) => { for (const child of node.children || []) { if (child.kind === "file") paths.push(child.path); else collect(child); } }; collect(src); return paths.some((p) => p.startsWith(folderPath + "/")); } /* The node for `current`: the source node at the root, or the folder * node found by walking the tree with the folder's cumulative * source-relative path (the builder's folder paths are * source-relative, so `one/two` resolves one level at a time). */ function currentLevelNode() { const src = kbTree.sources.find((s) => s.name === current.source); if (!src) return null; if (current.folder === null || current.folder === "") return src; let node = src; let acc = ""; for (const part of current.folder.split("/")) { acc = acc ? acc + "/" + part : part; const next = (node.children || []).find( (c) => c.kind === "folder" && c.path === acc ); if (!next) return null; node = next; } return node; } /* The KB-wide stat cards (phase 97): walk the WHOLE tree — document * count, chunks sum, max indexed_at — the values identical to the * former flat /api/docs walk (the same documents, one level deeper). * fmtDate reuse for the max. */ function treeStats() { let docs = 0; let totalChunks = 0; let last = ""; const walk = (node) => { for (const child of node.children || []) { if (child.kind === "file") { docs += 1; totalChunks += child.chunks; if (child.indexed_at > last) last = child.indexed_at; } else { walk(child); } } }; for (const s of kbTree.sources) walk(s); return { docs, totalChunks, last }; } /* The breadcrumb (phase 97): hidden at the top level; when drilled * in, one link per ancestor — the top level (back to the sources * list), the source, then the folder chain — the LAST segment a * span with aria-current="page". Client-side only: no fetch, no URL * change (the navigation is a re-render of the fetched tree). */ function renderCrumb() { if (!crumbEl) return; crumbEl.replaceChildren(); if (current.source === null) { crumbEl.hidden = true; return; } crumbEl.hidden = false; let appended = false; const append = (el) => { if (appended) { const sep = document.createElement("span"); sep.className = "kb-crumb-sep"; sep.setAttribute("aria-hidden", "true"); sep.textContent = "/"; crumbEl.appendChild(sep); } crumbEl.appendChild(el); appended = true; }; append(crumbSegment("Knowledge base", { source: null, folder: null })); if (current.folder === null || current.folder === "") { append(crumbCurrent(current.source)); } else { append(crumbSegment(current.source, { source: current.source, folder: "" })); let acc = ""; const parts = current.folder.split("/"); parts.forEach((part, i) => { acc = acc ? acc + "/" + part : part; if (i === parts.length - 1) append(crumbCurrent(part)); else append(crumbSegment(part, { source: current.source, folder: acc })); }); } } function crumbSegment(label, target) { const a = document.createElement("a"); a.className = "kb-crumb-link"; a.href = "#"; // client-side navigation only — no URL change a.textContent = label; // document-derived text — never innerHTML a.addEventListener("click", (e) => { e.preventDefault(); goTo(target); }); return a; } function crumbCurrent(label) { const span = document.createElement("span"); span.className = "kb-crumb-current"; span.setAttribute("aria-current", "page"); span.textContent = label; // document-derived text — never innerHTML return span; } /* The drill navigation (client-side, no fetch, no URL change). */ function goTo(target) { current = { source: target.source, folder: target.folder }; renderLevel(); } /* ONE table for every level (phase 97): at the TOP level the rows * are the SOURCES themselves (the ls() equivalence — name, recursive * count, the stored (source, "") description). textContent only. */ function makeSourceRow(s) { const tr = document.createElement("tr"); const nameTd = document.createElement("td"); const link = document.createElement("a"); link.className = "folder-link"; link.href = "#"; // client-side drill — no URL change link.title = s.name; // hover name (the cell may ellipsize) link.textContent = s.name; // document-derived text — never innerHTML link.addEventListener("click", (e) => { e.preventDefault(); goTo({ source: s.name, folder: "" }); }); nameTd.appendChild(link); tr.appendChild(nameTd); const countTd = document.createElement("td"); countTd.textContent = String(s.documents); tr.appendChild(countTd); tr.appendChild(makeDescCell(s, s.name, "", s.name)); // Description + ALWAYS-present Edit (task 05) return tr; } /* A level's subfolder row: the folder's LAST path segment as the * label (the full source-relative path rides the title — the cell * ellipsizes), the recursive count, the stored description (AI or * manual — any row) or an empty cell. textContent only. */ function makeFolderRow(f) { const tr = document.createElement("tr"); const nameTd = document.createElement("td"); const link = document.createElement("a"); link.className = "folder-link"; link.href = "#"; // client-side drill — no URL change link.title = current.source + "/" + f.path; // full path on hover link.textContent = f.path.split("/").pop(); // never innerHTML link.addEventListener("click", (e) => { e.preventDefault(); goTo({ source: current.source, folder: f.path }); }); nameTd.appendChild(link); tr.appendChild(nameTd); const countTd = document.createElement("td"); countTd.textContent = String(f.documents); tr.appendChild(countTd); tr.appendChild( makeDescCell(f, current.source, f.path, current.source + "/" + f.path) ); // Description + ALWAYS-present Edit (task 05) return tr; } /* Render the CURRENT level from `current` + `kbTree` (phase 97). * Clears BOTH row containers FIRST (re-entrancy — no ghost rows), * then: zero sources → the no-data state; top level → the source * rows (level block + file table hidden); inside a source/folder → * the breadcrumb, the level block (the level's stored description — * hidden when none is stored, the ls rule), the direct subfolders, * and the direct files (makeRow, unchanged — the file node carries * no source, the row object restores the flat shape makeRow reads). */ function renderLevel() { if (foldersTbody) foldersTbody.replaceChildren(); if (tbody) tbody.replaceChildren(); if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4) if (!kbTree.sources.length) { renderEmpty(); return; } emptyEl.hidden = true; const st = treeStats(); statDocs.textContent = String(st.docs); statChunks.textContent = String(st.totalChunks); statLast.textContent = st.last ? fmtDate(st.last) : "–"; renderCrumb(); if (current.source === null) { // Top level: the level block is hidden (nothing above the // sources); the folders table lists the SOURCES themselves and // the file table is ALWAYS hidden (files are seen per source, // as with ls(source)). levelEl.hidden = true; for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s)); foldersWrap.hidden = false; if (tableWrap) tableWrap.hidden = true; return; } const node = currentLevelNode(); if (node === null) { // Defensive: the reset above guarantees the level exists in the // tree this render reads — a null means the tree changed under // us, in which case the top level is the honest view. current = { source: null, folder: null }; renderLevel(); return; } // The level block: the current level's STORED description (source: // the (source, "") row; folder: its row) — hidden when none is // stored (the ls rule: count only, no placeholder). Title = the // full source-relative path (e.g. `alpha/two`). if (node.summary) { levelTitleEl.textContent = current.folder ? current.source + "/" + current.folder : current.source; levelSummaryEl.textContent = node.summary; levelEl.hidden = false; } else { levelEl.hidden = true; } const children = node.children || []; const subfolders = children.filter((c) => c.kind === "folder"); const files = children.filter((c) => c.kind === "file"); for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f)); foldersWrap.hidden = subfolders.length === 0; for (const f of files) { tbody.appendChild( makeRow({ source: current.source, path: f.path, title: f.title, chunks: f.chunks, indexed_at: f.indexed_at, }) ); } if (tableWrap) tableWrap.hidden = files.length === 0; } /* The no-data state (phase 97): zero sources (nothing registered, * nothing indexed) OR a failed tree fetch (the former showEmpty * failure behavior, unchanged in kind) — every catalog surface * hidden, the stat cards read zero. A registered 0-document source * does NOT land here: it renders its `0 documents` row (the ls * invariant — the deliberate semantic change, module docstring). */ function renderEmpty() { if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4) statDocs.textContent = "0"; statChunks.textContent = "0"; statLast.textContent = "–"; if (crumbEl) crumbEl.hidden = true; levelEl.hidden = true; if (foldersTbody) foldersTbody.replaceChildren(); if (tbody) tbody.replaceChildren(); if (foldersWrap) foldersWrap.hidden = true; if (tableWrap) tableWrap.hidden = true; emptyEl.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; } /* Phase 97 (task 05): wire the LEVEL block's editor (the static * #kb-level-edit button). The level block is PERSISTENT — reused for * whichever level is current — so the target is a GETTER (a row's is * a constant): it reads `current` + currentLevelNode() at open/save * time. onCleared hides the whole block (the ls rule: no description * → no block). The handle's reset() runs on every re-render so a * navigate-away / refresh never leaves a stale open editor (§7.4). */ if (levelEditBtn && levelBody) { levelEditor = wireDescriptionEdit({ editBtn: levelEditBtn, textEl: levelSummaryEl, container: levelBody, getTarget: () => { const node = currentLevelNode(); return node ? { node, source: current.source, folder: current.folder } : null; }, onCleared: () => { // The phase-57 announcement beat: keep the block visible a short // beat so the role=status "Description cleared." is still // readable, THEN hide it (the ls rule: no description → no // block) — GUARDED: if a newer level is current by then (a // navigate-away first) or its node has a description again (a // re-create within the beat), the hide is a no-op. setTimeout(() => { const n = currentLevelNode(); if (n && !n.summary) levelEl.hidden = true; }, 2000); }, }); } /* ---------- 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/tree request at all (the phase-16 soft rule — the * tree IS the catalog; phase 97). */ 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/tree request // at all — the tree surfaces ship hidden and never fill. if (statCards) statCards.hidden = true; if (foldersWrap) foldersWrap.hidden = true; if (tableWrap) tableWrap.hidden = true; if (emptyEl) emptyEl.hidden = true; if (gateEl) gateEl.hidden = false; return; } if (gateEl) gateEl.hidden = true; /* Phase 77 (task 02) + phase 97 (task 04): a user-initiated re-show of this already-mounted view makes the router dispatch bor:view-refresh on the section — re-load the catalog tree then (loadTree is race-tokened: only the newest load touches the DOM). Armed ONLY here, after the whoami gate passed: anonymous shows the gate and must never fetch /api/docs/tree (the phase-16 soft rule the story E2E pins). */ root.addEventListener("bor:view-refresh", () => loadTree()); loadTree(); }