Files
brain-of-reese/frontend/assets/sources.js
T
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
2026-09-11 22:48:02 -04:00

1194 lines
51 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.
*
* 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 <h2> + a .kb-level-body holding
* the <p> + 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 <td>, or the level block's .kb-level-body)
* — the swap is a bare replaceChildren on it, so any persistent
* sibling (the level's <h2>) 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… <file> (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 =
'<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.
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 <p> + the
* button) — the shared editor swaps INSIDE the body, leaving the
* block's <h2> 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();
}