phase: 99_kb_tree_table_and_back_nav
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete and green. Final report:

**Phase 99 — final verification pass (all 3 tasks already in `complete/`)**

- Verified the one-line Description clamp: `.kb-desc-cell` flex wrapper + `.kb-desc-text` ellipsis triad + `nowrap` column + in-cell Edit button + hover `title` + untouched level block (source diffs match D1; unit pins present)
- Verified the back-button breadcrumb nav: `applyTarget(target, push)` state-only `pushState`, no-duplicate gate, popstate adopt/reset, `bor:view-refresh` alignment before `loadTree()`, anonymous gate listener-free, router.js untouched (D2)
- `uv run pytest --cov=app`: **2180 passed**, coverage **99%** (>90% gate)
- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**
- `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` (isolated, DB up): **4/4 passed** (back-walk, jump-then-back, fresh-nav/active-reclick, measured clamp incl. row-height parity ±4px, computed styles, full text in DOM/title/level block)
- Regression suites isolated: `test_kb_tree.py` 8✓, `test_ls_tree_drilldown.py` 3✓, `test_navbar_refresh.py` 7✓, plus `test_sync_summary_visibility.py` 3✓ (marker pin updated to `kb-desc-text kb-summary-pending` — the sanctioned class-pair change) and `test_edit_summaries.py` 4✓
- Criteria 1–4: **met** (each as above). Criterion 5 (atomic commit): left to the harness per executor rules — no `git add`/`commit` run; all changes left in the working tree
- No defects found in prior phases; no deviations. Stray `__pycache__/test_zz_smoke_tree_ui.*.pyc` is a leftover (no `.py` on disk) — inert, not touched
- Next pending phase: **none** — `todo/` will be empty once the harness moves this phase
This commit is contained in:
2026-09-13 01:57:40 -04:00
parent f665a83b1a
commit 8476dc1e07
22 changed files with 1755 additions and 53 deletions
+154 -19
View File
@@ -203,6 +203,65 @@
* surface passes its pending copy via pendingText — the row's
* "Summary pending" marker, the level's D4 note — and its
* tooltip via pendingTitle, the row only).
*
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
* owner's long stored descriptions no longer stretch the catalog
* rows. A row's Description cell is now ONE flex wrapper
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
* white-space: nowrap: the first line that fits, ellipsized — the row
* height is ONE line, independent of the description's length) and the
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
* never wraps to a second line; its spacing comes from the wrapper's
* gap, its old margin-left is gone). The clamp is VISUAL only: the
* full text stays in the DOM (the accessible name is unchanged) and
* rides the span's title (the hover escape hatch, the house pattern) —
* set for real description text whenever non-empty; the phase-98
* marker keeps its OWN D4 title (never overridden), the empty cell
* carries none. The editor's open/close swaps happen INSIDE the
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
* <td> always keeps it; the level block's container stays
* .kb-level-body), and closeEditor toggles ONLY the
* kb-summary-pending class (the base classes survive) while re-deriving
* the clamped span's title from the stored text (a stale pre-edit
* title cannot survive a save). The level block (`.kb-level p`) keeps
* the FULL unclamped description — the escape hatch at the top when
* you click in.
*
* Phase 99 (task 02, D2) — the browser back/forward walks the
* breadcrumb: the drill state IS the history state. Every user-
* initiated drill (source-row click, folder-link click, breadcrumb-
* segment click — all through goTo) goes through applyTarget(target,
* true): when the target DIFFERS from the current level, it FIRST
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
* target }, "") — then sets current + re-renders. The push is the
* STATE only: the second arg is "" and there is no third — the URL
* STAYS PUT (no new route, the phase-76 deep-link surface is
* untouched; the shell's pathname is the only route contract). A
* re-click of the current level pushes NOTHING (no duplicate history
* entries). Back/forward: the window popstate (armed ONCE at mount,
* admin branch only — the anonymous gate installs no listeners) ADOPTS
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
* false) — the browser owns its own entries, so adopt/reset never
* push); an entry WITHOUT a kb (the boot entry, the router's view
* entries, any foreign state) RESETS the drill to the TOP level
* (applyTarget({ source: null, folder: null }, false)). Back at the
* top level pops the router's view entry — the view leaves, exactly
* as today (the router's pathname-based switch, UNCHANGED). The
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
* (the browser's own entry is the base — the phase-76 boot contract),
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
* current WITHOUT pushing when a re-fetch finds the level gone — it
* follows whatever the history state says; no new entry). The router
* module is UNTOUCHED: its popstate reads the pathname only, and a
* state-only push never changes it (so rag-internal entries never
* trigger a view switch; for a foreign entry the router has already
* switched views, and this listener's render of the now-hidden view
* is harmless).
*/
import { fetchIsAdmin } from "./header.js";
@@ -235,7 +294,8 @@ function mkBtn(cls, label) {
}
/* The shared description editor. `container` holds ONLY the
* description UI (the row's <td>, or the level block's .kb-level-body)
* description UI (the row cell's .kb-desc-cell wrapper — Phase 99
* (task 01) — 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
@@ -282,16 +342,25 @@ function wireDescriptionEdit({
* the pre-edit state, including the marker); neither → the empty
* text. The Edit button is available again. A cleared node empties
* the text and, for the level block, hides the whole block via
* onCleared. */
* onCleared.
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
* (the base classes survive — the row's span keeps .kb-desc-text, the
* clamp; the level's <p> keeps none), and the CLAMPED row span's
* title is re-derived from the stored text (the D1 hover escape hatch
* — a stale pre-edit title cannot survive a save; the level's <p> is
* unclamped — it shows the full text and keeps its no-title
* behavior). */
function closeEditor(message) {
const target = getTarget();
const node = target ? target.node : null;
const stored = node && typeof node.summary === "string" ? node.summary : "";
const pending = node !== null && stored === "" && node.summary_pending;
const value = pending && pendingText ? pendingText : stored;
textEl.className = pending ? "kb-summary-pending" : "";
textEl.classList.toggle("kb-summary-pending", pending);
if (pending) {
if (pendingTitle) textEl.title = pendingTitle;
} else if (stored && textEl.classList.contains("kb-desc-text")) {
textEl.title = stored; // D1: full text on hover — the clamp is visual only
} else {
textEl.removeAttribute("title");
}
@@ -387,30 +456,48 @@ function wireDescriptionEdit({
* the "Summary pending" text + the D4 title; text + color, never color
* alone), and the empty cell (neither — the ls rule, unchanged). The
* Edit button is UNCHANGED in all three (a manual save creates the row
* and clears the marker in place — the editor's success path). */
* and clears the marker in place — the editor's success path).
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
* — flexes + ellipsizes, so the row height is ONE line, independent of
* the description's length) and the Edit button (`.kb-summary-edit` —
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
* text stays in the DOM and rides the span's title (the hover escape
* hatch, set for real description text whenever non-empty — the marker
* keeps its OWN D4 title, the empty cell carries none). The editor's
* open/close swaps happen INSIDE the wrapper (the container is the
* wrapper — the <td> always keeps it). */
function makeDescCell(node, source, folder, label) {
const td = document.createElement("td");
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
* ellipsizes, the button stays fixed (see the docstring above). */
const wrap = document.createElement("div");
wrap.className = "kb-desc-cell";
const text = document.createElement("span");
text.className = "kb-desc-text";
if (node && node.summary) {
text.textContent = node.summary; // stored description — text node, never innerHTML
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
} else if (node && node.summary_pending) {
// Phase 98 (D4): the summary is due but not stored yet — the marker.
text.className = "kb-summary-pending";
// Phase 98 (D4): the summary is due but not stored yet — the marker
// keeps its OWN title (the hover rule never overrides it).
text.classList.add("kb-summary-pending");
text.textContent = "Summary pending";
text.title = "No stored description yet — the next sync will generate one.";
} else {
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule)
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule, no title)
}
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);
wrap.append(text, btn);
td.append(wrap);
wireDescriptionEdit({
editBtn: btn,
textEl: text,
container: td,
container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
getTarget: () => ({ node, source, folder }),
// Phase 98 (D4): the row's pending display (closeEditor restores
// it on a cancel; a save clears the marker in place).
@@ -1097,12 +1184,32 @@ export async function mount(root) {
return span;
}
/* The drill navigation (client-side, no fetch, no URL change). */
function goTo(target) {
/* The drill navigation (phase 99, task 02, D2 — client-side, no
* fetch; the drill state IS the history state). `applyTarget(target,
* push)`: when `push` is true the target DIFFERS from the current
* level (a re-click of the current row/segment pushes NO duplicate
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
* { view: "rag", kb: target }, "") — the second arg "" + no third:
* the URL stays the shell's pathname (no new route, the phase-76
* deep-link surface is untouched) — then sets `current` +
* re-renders. Back/forward (popstate) and the re-show alignment
* (bor:view-refresh) adopt with push=false: they never push (the
* browser owns those entries). */
function applyTarget(target, push) {
if (push && (target.source !== current.source || target.folder !== current.folder)) {
history.pushState({ view: "rag", kb: target }, "");
}
current = { source: target.source, folder: target.folder };
renderLevel();
}
/* Every user-initiated drill (source row, folder link, breadcrumb
* segment) — push a history entry, then drill (D2, phase 99 task
* 02). */
function goTo(target) {
applyTarget(target, true);
}
/* 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. */
@@ -1358,13 +1465,41 @@ export async function mount(root) {
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());
/* Phase 77 (task 02) + phase 97 (task 04) + phase 99 (task 02,
D2): a user-initiated re-show of this already-mounted view
makes the router dispatch bor:view-refresh on the section —
FIRST align the drill state with the history state (a
kb-carrying entry KEEPS the drill — the active-link re-click
pushed nothing, the drilled entry is still on top; a kb-less
entry — a fresh nav visit — starts at the TOP level), THEN
re-load the catalog tree (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", () => {
const kb = history.state && history.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
loadTree();
});
/* Phase 99 (task 02, D2): the browser back/forward walks the
breadcrumb — a popstate ADOPTS the popped entry's kb via the
no-push variant (the browser owns its entries — adopt/reset
never push) or, for a kb-LESS entry (the boot entry, the
router's view entries, any foreign state), RESETS the drill to
the TOP level. The router's own popstate (registered earlier,
at shell boot) reads the pathname only: rag-internal entries
never change it, so its listener stays a no-op and THIS
listener's render is the visible one; for a foreign entry it
has already switched the visible view, so this render is a
harmless hidden one. Back at the top level pops the router's
view entry — the view leaves, exactly as today. Armed ONCE
here, admin branch only: the anonymous gate installs no
listeners. */
window.addEventListener("popstate", (event) => {
const kb = event.state && event.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
});
loadTree();
}
+29 -4
View File
@@ -1982,11 +1982,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.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;
white-space: nowrap; /* phase 99 (D1): one line — the clamp's budget is the column width */
min-width: 18rem;
max-width: 44rem;
font-size: 0.88rem;
}
/* Phase 99 (task 01, D1): the Description cell is ONE flex row — the
text span flexes + ellipsizes (the row height is one line, independent
of the description's length), the Edit button stays fixed (flex: 0 0
auto — it never wraps to a second line). The gap replaces the button's
old margin-left (the context-spacing rule below keeps only the level
block's margin-top). The clamp is visual only: the full text stays in
the DOM + on the span's title (hover). */
.kb-desc-cell {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.kb-desc-text {
flex: 1 1 auto;
min-width: 0; /* lets the flex item shrink — what engages the ellipsis (the .sync-label pattern) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-link {
color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */
text-decoration: none;
@@ -2024,9 +2044,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.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; }
/* Context spacing: the row cell's button spacing comes from the
.kb-desc-cell gap (phase 99, task 01 — the margin-left is gone);
the level block's sits below the <p>. */
.kb-level-body .kb-summary-edit { margin-top: 0.5rem; }
.kb-summary-editor {
display: block;
@@ -2041,6 +2061,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
line-height: 1.5;
resize: vertical;
}
/* Phase 99 (task 01): the open editor fills the .kb-desc-cell flex
wrapper — as a flex item it needs the explicit grow + min-width: 0
(the width: 100% base above still serves the level block's non-flex
.kb-level-body). */
.kb-desc-cell .kb-summary-editor { flex: 1 1 auto; min-width: 0; }
.kb-summary-actions {
display: flex;
align-items: center;