phase: 97_kb_tree_catalog
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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:
2026-09-11 22:48:02 -04:00
parent a49be80b8e
commit ad7585d474
81 changed files with 6299 additions and 211 deletions
+642 -63
View File
@@ -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();
}
+155
View File
@@ -1899,6 +1899,161 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.docs-table tbody tr:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- KB drill-down tree (phase 97, task 04) ----------
The RAG view lists the catalog the way the agent's `ls` sees it
(the phase-94 concept, one end to end): #kb-crumb (the location
breadcrumb — hidden at the top level), #kb-level (the current
directory's STORED description — the surface-panel language of the
phase-93 page heads), and #folders-table (the ONE folders/sources
table — the .docs-table language; .kb-folders-table only re-styles
the 2nd/3rd column cells). Phase-08 tokens only — NO new hue
(the phase-92 monochrome invariant): brand-ink on surface 9.0:1,
ink 13.8:1, ink-soft 5.1:1 (every pair AA). :focus-visible via the
global 3px outline rule; row targets keep the .docs-table cell
padding (>= 44px effective — the phase-08 touch floor). No CDN,
system fonts. */
.kb-crumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.1rem 0.3rem;
margin: 0.9rem 0 0;
font-size: 0.92rem;
}
.kb-crumb-link {
color: var(--brand-ink); /* 10.4:1 on --bg, 9.5:1 on the --brand-soft hover */
text-decoration: none;
padding: 0.25rem 0.35rem;
border-radius: var(--radius-sm);
}
.kb-crumb-link:hover { background: var(--brand-soft); text-decoration: underline; }
.kb-crumb-current {
color: var(--ink); /* 16.7:1 on --bg */
font-weight: 600;
padding: 0.25rem 0.35rem;
}
.kb-crumb-sep { color: var(--ink-soft); }
.kb-level {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 0.9rem 0 0.75rem;
}
.kb-level h2 {
margin: 0 0 0.35rem;
font-family: var(--mono);
font-size: 1rem;
color: var(--brand-ink); /* 9.0:1 on --surface — the path, in the path voice */
word-break: break-all;
}
.kb-level p { margin: 0; color: var(--ink-soft); } /* 5.1:1 on --surface */
.kb-folders-table { min-width: 480px; }
.kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; }
.kb-folders-table td:nth-child(3) {
white-space: normal;
min-width: 18rem;
max-width: 44rem;
font-size: 0.88rem;
}
.folder-link {
color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */
text-decoration: none;
border-radius: var(--radius-sm);
}
.folder-link:hover, .folder-link:focus-visible {
background: var(--brand-soft);
text-decoration: underline;
}
/* ---------- KB folder-description editor (phase 97, task 05) ----------
The phase-57 edit affordance on the RAG view's folder descriptions
(the .kb-summary-* family, mirroring the viewer's .doc-summary-*):
the Edit button (a 24px+ target; the [hidden] override beats its
display rule while the editor is open), the inline editor (the
8rem-min textarea + Save/Cancel + the role=status live region).
House dark-tech palette (phase-08 tokens), system fonts, no CDN;
:focus-visible via the global 3px outline rule. No new hue (the
phase-92 monochrome invariant) — every color is a var(). */
.kb-summary-edit {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.02em;
cursor: pointer;
}
.kb-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.kb-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
/* Context spacing: the row cell's button sits after the description
text; the level block's sits below the <p>. */
.kb-folders-table .kb-summary-edit { margin-left: 0.4rem; }
.kb-level-body .kb-summary-edit { margin-top: 0.5rem; }
.kb-summary-editor {
display: block;
width: 100%;
min-height: 8rem; /* the phase-57 spec */
padding: 0.6rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg); /* inset against the --surface panel / row */
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
line-height: 1.5;
resize: vertical;
}
.kb-summary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.kb-summary-save {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.kb-summary-save:hover { background: var(--brand-hover); } /* the house hover lightening */
.kb-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.kb-summary-cancel {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.kb-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.kb-summary-status {
margin: 0.6rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.kb-summary-status:empty { margin-top: 0; }
/* ---------- Git sources page (phase 35) ----------
/git-sources.html: the admin-only manager for the stored git source
list (add / remove, git-sources table). Same full-width table
+42
View File
@@ -424,6 +424,48 @@
</div>
</div>
<!-- Phase 97 (task 04): the drill-down catalog tree — the RAG view
lists the KB the way the agent's `ls` sees it (the phase-94
concept, one end to end): the SOURCES at the top (the ONE
#folders-table below lists them exactly like folders — name,
recursive count, the stored (source, "") description), then
per level the subfolders (count + stored description) and the
level's files (the existing #docs-table further below — its
columns and row links are UNCHANGED; it holds the current
level's direct files only, hidden at the top level). #kb-crumb
(the location breadcrumb) and #kb-level (the current
directory's stored description — the block the agent's level
line shows) ship HIDDEN: assets/sources.js fills all three with
createElement + textContent (never innerHTML with
document-derived data). -->
<nav id="kb-crumb" class="kb-crumb" aria-label="Catalog location" hidden></nav>
<section id="kb-level" class="kb-level" aria-labelledby="kb-level-title" hidden>
<h2 id="kb-level-title"></h2>
<!-- Phase 97 (task 05): the current directory's description +
its Edit button (the phase-57 affordance, task 05) live in
the .kb-level-body — the editor swaps INSIDE it, the <h2>
stays put. The button ships in the static HTML (the row
cells build theirs); it is visible whenever the block is
(the description can be edited or cleared from here). -->
<div class="kb-level-body">
<p id="kb-level-summary"></p>
<button type="button" class="kb-summary-edit" id="kb-level-edit">Edit</button>
</div>
</section>
<div id="folders-wrap" class="table-wrap" role="region" aria-label="Folders" tabindex="0" hidden>
<table class="docs-table kb-folders-table" id="folders-table">
<caption class="visually-hidden">Catalog sources and folders</caption>
<thead>
<tr>
<th scope="col">Folder</th>
<th scope="col">Documents</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="folders-tbody"></tbody>
</table>
</div>
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
<table class="docs-table" id="docs-table">
<caption class="visually-hidden">Indexed markdown documents</caption>