"""Unit: the RAG view's drill-down catalog tree (phase 97, task 04). The RAG (Knowledge base) view lists the catalog the way the agent's ``ls`` sees it (the phase-94 concept, ONE end to end): sources at the top, then per level the subfolders (recursive count + the STORED description) and the level's files (the ``#docs-table`` — phase 106, task 08, D8, added the Created column BEFORE Indexed, making it the 6-column contract). The view's catalog load is now ``loadTree()``: ONE fetch of ``GET /api/docs/tree`` (the full recursive tree in a single payload — task 02), then client-side drill navigation (no per-level fetch, no URL change). This module pins the source-level contract (the house pattern of ``tests/unit/test_sync_button.py`` — read the frontend files as text, no browser; the behavior is E2E-covered by the phase's dedicated suite, task 06): * the shell's RAG view ships the static tree skeleton (``#kb-crumb``, ``#kb-level`` + ``#kb-level-title``/``#kb-level-summary``, ``#folders-wrap`` + ``#folders-table`` — ONE table for every level, the Folder | Documents | Updated | Description head — phase 106 (task 08, D8) added the Updated column (the subtree's MAX document created_at, D9) BETWEEN Documents and Description) in order after ``#stat-cards`` and BEFORE the file table (now the 6-column Source | Path | Title | Chunks | Created | Indexed head — phase 106 (task 08, D8) added the Created column BEFORE Indexed); * the exact ``/api/docs/tree`` fetch (and the flat ``/api/docs`` fetch is gone from the view module); * the drill state (``current`` / ``kbTree``), the state transitions (source row click, folder row click, breadcrumb up, top reset), and the aria-current last segment; * the textContent contract (no ``innerHTML`` with document-derived data — the module's standing rule); * the top-level semantics (the rows ARE the sources — the ``ls()`` equivalence — and the file table is hidden at the top); * the level block's ls rule (hidden when no description is stored); * the stat cards' whole-tree walk; * the empty-state semantic (``#sources-empty`` ONLY on zero sources — the deliberate phase-97 change: a registered 0-document source renders its row instead); * the never-stale reset-to-top fallback (PLAN §7.4 — a vanished location resets ``current`` to the top BEFORE rendering); * ``loadTree`` wired at exactly the three refresh points (view-refresh, sync success, upload success) plus the boot load — and the anonymous branch still never fetches the catalog. Phase 97 (task 05) adds the FOLDER-DESCRIPTION EDITOR (the phase-57 affordance, mirrored) — the source pins this module gains for it: * the static ``#kb-level-edit`` button in the level block (type=button, the .kb-summary-edit class, the "Edit" label, after the description ``

`` — in the .kb-level-body the editor swaps inside); * the Description cell of EVERY source/folder row (``makeDescCell``, called from both row builders) — the stored description as a text node + the ALWAYS-present Edit button (a description can be CREATED where none is stored: no gate on the stored value); * the shared editor (``wireDescriptionEdit``): the swap builds a textarea prefilled via ``.value`` (never innerHTML — the XSS contract), Save / Cancel, and the ``role="status"`` ``aria-live="polite"`` live region; * the exact PATCH — ``/api/folders/summary``, method PATCH, body ``{ source, folder_path, summary }`` with ``folder_path ""`` for the source root (exactly one call site in the module); * the outcomes — success updates the in-memory ``kbTree`` node IN PLACE (no re-fetch) + re-renders the text via textContent + " Description updated."; a cleared echo (summary null) empties the text (row cell) / hides the level block (the phase-57 announcement beat, guarded) + "Description cleared."; a failure (non-ok OR network) keeps the editor open with neutral retry copy (phase-55); Cancel restores the text node without a fetch; * the double-click guard (disabled before the fetch, re-enabled in the ``finally``); the level editor's ``getTarget()`` getter + the ``reset()`` handle called on every re-render (PLAN §7.4); * the five ``.kb-summary-*`` classes in styles.css (house palette, no new hue in the phase-97 block). Phase 98 (task 04) adds the "SUMMARY PENDING" markers — the pins this module gains for them: * the row Description cell's THREE text states (``makeDescCell``): stored → the stored text (NEVER the marker); no stored + ``summary_pending`` → the ``kb-summary-pending`` class on the existing text span + the exact ``Summary pending`` copy + the exact D4 title (textContent/title only — the house rule); neither → the empty cell (the ls rule, unchanged) — with the Edit button UNCONDITIONAL in all three (a manual save creates the row); * the in-place clear: the editor's success path sets ``node.summary_pending = false`` immediately after ``node.summary = data.summary`` (no re-fetch — the marker clears in the surface where the edit happened); * the level block's OR condition (a stored description OR ``summary_pending``) + the EXACT pending note — neither stored nor pending stays hidden (the ls rule, unchanged) — and the level's pending text takes the muted ``kb-summary-pending`` class on render (the block is REUSED across levels — a stored level clears it); * the editor's close re-derives the display state from the node (task 05 defect fix): the pending marker's muted class + D4 tooltip CANNOT survive a save (the flag is cleared first — the in-place clear, with the stale "next sync" tooltip gone), and a cancel restores the surface's pending display (each 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); * ``.kb-summary-pending`` in styles.css (the ink-soft muted AA pair — text + color, never color alone; no font/white-space/italic overrides, so the row height is unchanged; no new hue in the phase-97 block). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" SOURCES_JS = ASSETS / "sources.js" STYLES_CSS = ASSETS / "styles.css" SHELL_HTML = FRONTEND / "index.html" def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _js() -> str: return _text(SOURCES_JS) def _rag_view(html: str) -> str: """The RAG view section of the shell (view-scoped scope — the shell carries many views, so whole-file matches hit the wrong view).""" i = html.find('

str: """The CSS between two phase markers (comments included — the no-new hue check wants to see that none was added in prose either).""" i = css_text.find(start_marker) assert i != -1, f"missing CSS marker: {start_marker!r}" j = css_text.find(end_marker, i) assert j != -1, f"missing CSS end marker: {end_marker!r}" return css_text[i:j] def _fn(js: str, name: str) -> str: """The source of a (possibly async, possibly nested) function via balanced-brace counting (module-level and mount-scoped alike). The brace count starts AFTER the parameter list — a destructured parameter (wireDescriptionEdit's target object) may carry braces of its own.""" for prefix in ("async function ", "function "): start = js.find(f"{prefix}{name}(") if start != -1: # Skip the parameter list (balanced parens). depth = 0 i = js.find("(", start) close = i while i < len(js): if js[i] == "(": depth += 1 elif js[i] == ")": depth -= 1 if depth == 0: close = i break i += 1 # Then brace-count the body. brace = js.find("{", close) depth = 0 for j in range(brace, len(js)): if js[j] == "{": depth += 1 elif js[j] == "}": depth -= 1 if depth == 0: return js[start : j + 1] raise AssertionError(f"unbalanced braces in {name}()") raise AssertionError(f"{name}() must exist in sources.js") # ---------- the shell's RAG view: the static tree skeleton ---------- def test_rag_view_ships_the_tree_skeleton_in_order() -> None: """The house no-JS-safe skeleton convention: the tree surfaces ship in the static HTML (after the stat cards, before the file table) and ship HIDDEN — assets/sources.js fills them with createElement. ``#kb-crumb`` is the location nav; ``#kb-level`` is the level block (title + description); ``#folders-wrap`` hosts the ONE folders/sources table (``.table-wrap`` card, Folder | Documents | Updated | Description — phase 106 (task 08, D8), visually-hidden caption, the ``.docs-table`` language + ``.kb-folders-table``). The file table's head is the 6-column contract (Source | Path | Title | Chunks | Created | Indexed — phase 106 (task 08, D8)).""" view = _rag_view(_text(SHELL_HTML)) for fragment in ( '', '