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).
This commit is contained in:
+642
-63
@@ -39,14 +39,108 @@
|
||||
* 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 `loadDocs()`, which is now re-entrant:
|
||||
* a re-load drops the tbody's rows BEFORE the fetch, so a refresh
|
||||
* from a populated list into an empty result replaces the list
|
||||
* (no ghost rows) — the History pattern (task 01). #sources-empty
|
||||
* lives OUTSIDE the tbody (a .empty-state div), so the top clear is
|
||||
* a bare replaceChildren(). The listener is armed only in the ADMIN
|
||||
* 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 /api/docs (the phase-16 soft rule).
|
||||
* 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";
|
||||
@@ -61,6 +155,163 @@ 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) ----------
|
||||
*
|
||||
@@ -87,7 +338,7 @@ export async function mount(root) {
|
||||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||||
* (loadDocs — phase 90: an upload no longer
|
||||
* (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);
|
||||
@@ -99,7 +350,7 @@ export async function mount(root) {
|
||||
* rides the button title (hover) and #sync-result (the aria-live
|
||||
* announcer — screen readers hear it). The load-time re-attach
|
||||
* (initSyncButton) re-enters a RUNNING upload the same way; a terminal
|
||||
* upload is a no-op there (the boot-time loadDocs() already shows the
|
||||
* upload is a no-op there (the boot-time loadTree() already shows the
|
||||
* current catalog).
|
||||
*
|
||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||
@@ -265,7 +516,7 @@ export async function mount(root) {
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
// Refresh the catalog live — the KB just changed.
|
||||
loadDocs();
|
||||
loadTree();
|
||||
}
|
||||
|
||||
function applySyncFailure(status) {
|
||||
@@ -356,7 +607,7 @@ export async function mount(root) {
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
emitSyncStatus({ state: "idle" });
|
||||
loadDocs();
|
||||
loadTree();
|
||||
return;
|
||||
}
|
||||
// 6. upload failed: settle only — the failure is the Sources page's
|
||||
@@ -416,7 +667,7 @@ export async function mount(root) {
|
||||
* 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 loadDocs() already shows
|
||||
* terminal upload is a no-op (the boot-time loadTree() already shows
|
||||
* the current catalog). */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
@@ -470,12 +721,28 @@ export async function mount(root) {
|
||||
|
||||
const tbody = root.querySelector("#docs-tbody");
|
||||
const emptyEl = root.querySelector("#sources-empty");
|
||||
const tableWrap = root.querySelector(".table-wrap");
|
||||
/* 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
|
||||
@@ -493,52 +760,336 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
|
||||
/* Phase 77 (task 02): re-entrant — the top clear (the History
|
||||
pattern from task 01) drops the tbody's rows BEFORE the fetch,
|
||||
so a re-show refresh from a populated list into an empty result
|
||||
replaces the list instead of leaving ghost rows. #sources-empty
|
||||
lives OUTSIDE the tbody (a .empty-state div, not a row), so the
|
||||
clear is a bare replaceChildren(). Phase 79 (task 04): the clear
|
||||
alone is NOT enough when two loads interleave — the boot re-attach
|
||||
(applySyncSuccess → loadDocs) and the boot-time loadDocs both clear
|
||||
first, then the SLOWER fetch appends after the newer load's clear,
|
||||
duplicating every row (2×). The monotonic seq token invalidates an
|
||||
in-flight load the moment a newer one starts: only the newest load
|
||||
may touch the DOM after its await. */
|
||||
/* 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;
|
||||
async function loadDocs() {
|
||||
let levelEditor = null; // phase 97 (task 05): the level editor handle
|
||||
|
||||
async function loadTree() {
|
||||
const my = ++loadSeq;
|
||||
tbody.replaceChildren();
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/docs");
|
||||
r = await fetch("/api/docs/tree");
|
||||
} catch {
|
||||
if (my === loadSeq) showEmpty();
|
||||
if (my === loadSeq) renderEmpty();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
if (my === loadSeq) showEmpty();
|
||||
if (my === loadSeq) renderEmpty();
|
||||
return;
|
||||
}
|
||||
const { documents } = await r.json();
|
||||
if (my !== loadSeq) return; // a newer load owns the tbody now
|
||||
if (!documents.length) {
|
||||
showEmpty();
|
||||
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;
|
||||
}
|
||||
|
||||
let totalChunks = 0;
|
||||
let last = "";
|
||||
for (const d of documents) {
|
||||
totalChunks += d.chunks;
|
||||
if (d.indexed_at > last) last = d.indexed_at;
|
||||
tbody.appendChild(makeRow(d));
|
||||
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;
|
||||
}
|
||||
statDocs.textContent = String(documents.length);
|
||||
statChunks.textContent = String(totalChunks);
|
||||
statLast.textContent = last ? fmtDate(last) : "–";
|
||||
emptyEl.hidden = true;
|
||||
tableWrap.hidden = false;
|
||||
|
||||
// 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) {
|
||||
@@ -575,40 +1126,68 @@ export async function mount(root) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
statDocs.textContent = "0";
|
||||
statChunks.textContent = "0";
|
||||
statLast.textContent = "–";
|
||||
emptyEl.hidden = false;
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
}
|
||||
|
||||
|
||||
/* 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
|
||||
* request at all (the Sources-page soft rule, unchanged). */
|
||||
* 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 request at all.
|
||||
// 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): a user-initiated re-show of this already-
|
||||
mounted view makes the router dispatch bor:view-refresh on the
|
||||
section — re-load the catalog then (loadDocs is re-entrant).
|
||||
Armed ONLY here, after the whoami gate passed: anonymous shows
|
||||
the gate and must never fetch /api/docs (the phase-16 soft
|
||||
rule the story E2E pins). */
|
||||
root.addEventListener("bor:view-refresh", () => loadDocs());
|
||||
loadDocs();
|
||||
/* 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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user