From 8476dc1e072830543a11b69d426451d749e17cd4 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 13 Sep 2026 01:57:40 -0400 Subject: [PATCH] phase: 99_kb_tree_table_and_back_nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../99_kb_tree_table_and_back_nav/00_phase.md | 0 .../01_description_line_clamp.md | 0 .../02_breadcrumb_history_nav.md | 0 .../03_e2e_tree_nav.md | 0 ...b_tree_table_and_back_nav__00_phase.a1.err | 0 ...kb_tree_table_and_back_nav__00_phase.a1.md | 13 + ...e_table_and_back_nav__00_phase.a1.validate | 98 +++ ...back_nav__01_description_line_clamp.a1.err | 0 ..._back_nav__01_description_line_clamp.a1.md | 17 + ...nav__01_description_line_clamp.a1.validate | 98 +++ ...back_nav__02_breadcrumb_history_nav.a1.err | 0 ..._back_nav__02_breadcrumb_history_nav.a1.md | 20 + ...nav__02_breadcrumb_history_nav.a1.validate | 98 +++ ...table_and_back_nav__03_e2e_tree_nav.a1.err | 0 ..._table_and_back_nav__03_e2e_tree_nav.a1.md | 21 + ..._and_back_nav__03_e2e_tree_nav.a1.validate | 98 +++ frontend/assets/sources.js | 173 +++- frontend/assets/styles.css | 33 +- tests/e2e/test_kb_tree_nav.py | 759 ++++++++++++++++++ tests/e2e/test_sync_summary_visibility.py | 9 +- tests/unit/test_frontend_router.py | 25 +- tests/unit/test_kb_tree_ui.py | 346 +++++++- 22 files changed, 1755 insertions(+), 53 deletions(-) rename .agents/phases/{todo => complete}/99_kb_tree_table_and_back_nav/00_phase.md (100%) rename .agents/phases/{todo => complete}/99_kb_tree_table_and_back_nav/01_description_line_clamp.md (100%) rename .agents/phases/{todo => complete}/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md (100%) rename .agents/phases/{todo => complete}/99_kb_tree_table_and_back_nav/03_e2e_tree_nav.md (100%) create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.err create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.md create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.validate create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.err create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.md create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.validate create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.err create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.md create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.validate create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.err create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.md create mode 100644 .agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.validate create mode 100644 tests/e2e/test_kb_tree_nav.py diff --git a/.agents/phases/todo/99_kb_tree_table_and_back_nav/00_phase.md b/.agents/phases/complete/99_kb_tree_table_and_back_nav/00_phase.md similarity index 100% rename from .agents/phases/todo/99_kb_tree_table_and_back_nav/00_phase.md rename to .agents/phases/complete/99_kb_tree_table_and_back_nav/00_phase.md diff --git a/.agents/phases/todo/99_kb_tree_table_and_back_nav/01_description_line_clamp.md b/.agents/phases/complete/99_kb_tree_table_and_back_nav/01_description_line_clamp.md similarity index 100% rename from .agents/phases/todo/99_kb_tree_table_and_back_nav/01_description_line_clamp.md rename to .agents/phases/complete/99_kb_tree_table_and_back_nav/01_description_line_clamp.md diff --git a/.agents/phases/todo/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md b/.agents/phases/complete/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md similarity index 100% rename from .agents/phases/todo/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md rename to .agents/phases/complete/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md diff --git a/.agents/phases/todo/99_kb_tree_table_and_back_nav/03_e2e_tree_nav.md b/.agents/phases/complete/99_kb_tree_table_and_back_nav/03_e2e_tree_nav.md similarity index 100% rename from .agents/phases/todo/99_kb_tree_table_and_back_nav/03_e2e_tree_nav.md rename to .agents/phases/complete/99_kb_tree_table_and_back_nav/03_e2e_tree_nav.md diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.err b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.md b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.md new file mode 100644 index 0000000..9584edf --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.md @@ -0,0 +1,13 @@ +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 diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.validate b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.validate new file mode 100644 index 0000000..f057f37 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__00_phase.a1.validate @@ -0,0 +1,98 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 99%] +.................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 134 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 137 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 126 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 200 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 313 0 100% +-------------------------------------------------- +TOTAL 3903 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.err b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.md b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.md new file mode 100644 index 0000000..4994966 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.md @@ -0,0 +1,17 @@ +Task 01 complete — all gates green. + +**Implemented** +- `sources.js` `makeDescCell`: the Description `` now holds ONE `div.kb-desc-cell` flex wrapper — text `` (flexes + ellipsizes) + the always-present Edit button; stored text always carries `title` = full text (D1 hover escape hatch); the phase-98 pending marker keeps its own D4 title (`classList.add` — base class survives); the editor's row `container` is now the wrapper (level block's `.kb-level-body` untouched) +- `closeEditor`: toggles only `kb-summary-pending` (base classes survive the swap) and re-derives the clamped span's `title` from the stored text — a stale pre-edit title cannot survive a save; the unclamped level `

` keeps its no-title behavior +- `styles.css`: `td:nth-child(3)` → `white-space: nowrap` (budget/widths kept), new `.kb-desc-cell` (flex + 0.4rem gap) and `.kb-desc-text` (ellipsis triad + `min-width: 0`) rules, button `margin-left` removed (gap replaces it; level rule kept), `.kb-desc-cell .kb-summary-editor` flex-fill for the open editor; `.kb-level p` and no-new-hue invariant untouched; module docstring records the D1 contract +- Unit pins: updated the restructured-cell pins + 4 new tests (wrapper structure, hover-title rules, closeEditor title re-derivation, CSS declarations) +- Fixed two exact `to_have_class` pins in `test_sync_summary_visibility.py` broken by the additive base class (the phase-03 "fix-only-broken-pins" case, surfaced early) + +**Results** +- `uv run pytest --cov=app --cov-report=term` → 2176 passed, `app/` TOTAL **99%** (>90%) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings +- E2E in isolation (`--no-cov`): `test_kb_tree.py` 8 passed, `test_sync_summary_visibility.py` 3 passed, `test_ls_tree_drilldown.py` 3 passed, `test_navbar_refresh.py` 7 passed + +**Decisions**: hover title is re-derived in `closeEditor` (not just `makeDescCell`) to prevent stale titles after save/cancel; no E2E file added here (task 03's mandate); no commit made (harness commits per phase). + +**Next pending task**: `.agents/phases/todo/99_kb_tree_table_and_back_nav/02_breadcrumb_history_nav.md` diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.validate b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.validate new file mode 100644 index 0000000..b52be95 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__01_description_line_clamp.a1.validate @@ -0,0 +1,98 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 99%] +................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 134 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 137 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 126 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 200 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 313 0 100% +-------------------------------------------------- +TOTAL 3903 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.err b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.md b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.md new file mode 100644 index 0000000..4d3f770 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.md @@ -0,0 +1,20 @@ +All green. Final report: + +## Task 02 complete — back button walks the breadcrumb (drill state = history state) + +**Implemented** (per D2/D3 in `00_phase.md`): +- `frontend/assets/sources.js`: `goTo` → `applyTarget(target, push)` — every drill (source row, folder link, breadcrumb segment) first pushes a **state-only** entry `history.pushState({ view: "rag", kb: target }, "")` (URL stays put, no third arg), skipping the push when the target equals `current` (no duplicate entries) +- `window popstate` listener (armed once, admin branch only): adopts `event.state.kb` via the no-push variant; a `kb`-less entry (boot/router/foreign) resets the drill to the top level +- `bor:view-refresh` listener: aligns `current` with `history.state` (adopt kb / reset to top) **before** `loadTree()` — fresh nav visit → top, active-link re-click → drill survives +- No pushState on boot; `resetVanishedLocation` and `router.js` untouched; module docstring gained the D2 contract section +- `tests/unit/test_kb_tree_ui.py`: replaced the stale client-side-only pin with the state-only push/duplicate-skip pin; 4 new tests (popstate adopt/reset, refresh alignment, anonymous installs no listeners, docstring) +- `tests/unit/test_frontend_router.py`: fixed the shared `_pin_refresh_listener` helper (slices to the listener's `});` close — the RAG listener became a block arrow) and the RAG pin (broken only by this additive change; asserted behavior unchanged) + +**Gates (all green, final state):** +- `uv run pytest --cov=app --cov-report=term` → 2180 passed, TOTAL 99% (>90%) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings +- Regression E2E in isolation (`--no-cov`): `test_navbar_refresh.py` 7 passed, `test_kb_tree.py` 8 passed, `test_ls_tree_drilldown.py` 3 passed + +**Notes:** no deviations from D2; no E2E file added (task 03 owns the browser proof + atomic commit). No defects found in prior work — the one broken pin was this task's own additive change. + +**Next pending task:** `03_e2e_tree_nav.md` (`tests/e2e/test_kb_tree_nav.py` + regression sweep + atomic commit). diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.validate b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.validate new file mode 100644 index 0000000..f057f37 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__02_breadcrumb_history_nav.a1.validate @@ -0,0 +1,98 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 99%] +.................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 134 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 137 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 126 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 200 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 313 0 100% +-------------------------------------------------- +TOTAL 3903 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.err b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.md b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.md new file mode 100644 index 0000000..1410c13 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.md @@ -0,0 +1,21 @@ +Task 03 complete. All gates green. + +**Implemented** +- `tests/e2e/test_kb_tree_nav.py` — phase-99's dedicated A16 E2E suite (4 tests, mock-only, per-module app on port 8144, nested `alpha/one/two` + `alpha/three` fixture KB seeded via API + real sync): + - `test_back_button_walks_the_breadcrumb` — full drill chain → Back pops one level/press (two→one→root→top→CHAT view), Forward replays the whole chain; URL stays `/sources.html` on all state-only entries + - `test_breadcrumb_jump_then_back` — `Knowledge base` crumb jump pushes an entry; Back returns to the jump's origin + - `test_fresh_nav_visit_starts_at_the_top` — fresh RAG nav visit resets to top (D2 alignment); active-link re-click (no pushState, `history.length` pinned) keeps the drill + - `test_description_cell_clamps_to_one_line` — 470-char manual description via `PATCH /api/folders/summary`: row `td` height = short row's (±4 px), computed `ellipsis`+`nowrap`, full text in `title`/DOM, Edit button measured in-cell, `#kb-level-summary` shows full unclamped text +- Module-scoped autouse cleanup removes the temp source's documents/chunks/folder_summaries/git_sources rows (verified: shared DB empty after the run) + +**Results (exact commands)** +- `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` → 4 passed (twice, incl. after the sweep) +- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → 8 passed · `test_ls_tree_drilldown.py` → 3 passed · `test_navbar_refresh.py` → 7 passed (all in isolation; no pin fixes needed) +- `uv run pytest --cov=app --cov-report=term-missing` → 2180 passed, `app/` coverage 99% (>90%) +- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings + +**Decisions/deviations** +- No commit and no phase-dir move made — per harness rules the harness makes the one atomic `--no-gpg-sign` commit and moves task files (overrides the task file's commit step) +- No defects found in tasks 01/02 (baseline was fully green) + +**Next pending task:** none — 03 was the final task of phase 99. diff --git a/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.validate b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.validate new file mode 100644 index 0000000..f057f37 --- /dev/null +++ b/.agents/reports/99_kb_tree_table_and_back_nav/99_kb_tree_table_and_back_nav__03_e2e_tree_nav.a1.validate @@ -0,0 +1,98 @@ +........................................................................ [ 3%] +........................................................................ [ 6%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 99%] +.................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 181 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 94 0 100% +app/api/docs.py 134 1 99% +app/api/git_sources.py 232 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 137 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 140 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 21 0 100% +app/main.py 66 0 100% +app/models.py 126 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 316 1 99% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 200 3 98% +app/rag/llm.py 243 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 88 0 100% +app/rag/retriever.py 150 3 98% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 24 0 100% +app/schemas.py 313 0 100% +-------------------------------------------------- +TOTAL 3903 15 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index 3ebe7bd..6a5b6ec 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -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 , 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 + * 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 , 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

) 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

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

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 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 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 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(); } diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index bf42db5..d57bf02 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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

. */ -.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

. */ .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; diff --git a/tests/e2e/test_kb_tree_nav.py b/tests/e2e/test_kb_tree_nav.py new file mode 100644 index 0000000..e6c065d --- /dev/null +++ b/tests/e2e/test_kb_tree_nav.py @@ -0,0 +1,759 @@ +"""Phase 99 task 03 E2E (Playwright, mock-only): the browser Back/Forward +buttons walk the directory breadcrumb — and a long description renders +exactly one catalog row-line. + +The dedicated story suite for ``99_kb_tree_table_and_back_nav`` (owner +request, 2026-09-12): both owner-visible contracts, pinned in a browser +against the real app + the deterministic mock: + +* **the back button walks the breadcrumb** (D2) — every drill + (source-row click, folder-link click, breadcrumb-segment click) is a + STATE-ONLY history entry (``history.pushState({ view: "rag", + kb: target }, "")`` — the URL stays the shell's pathname, the + phase-76 deep-link surface untouched), so ``page.go_back()`` pops + exactly one level at a time (``one/two`` → ``one`` → the source root + → the top level), ``page.go_forward()`` re-descends the whole chain, + and Back at the top level pops the router's own view entry — the + CHAT view becomes visible (the phase-76 router contract, unchanged). + A breadcrumb jump (clicking ``Knowledge base`` from ``one/two``) + pushes a top-level entry, and Back returns to the jump's ORIGIN + (standard history semantics). A fresh nav visit to RAG (a router + entry carrying no ``kb``) starts at the top level (the D2 refresh + alignment), while a re-click of the ACTIVE RAG nav link (no + pushState — the drilled entry is still on top) KEEPS the drill. +* **the one-line clamp** (D1) — a long stored description renders ONE + line in the catalog row: the Description ````'s bounding-box + height equals a short-description row's height (± 4 px), the + ``.kb-desc-text`` span's computed style is ``text-overflow: + ellipsis`` + ``white-space: nowrap``, the span's ``title`` carries + the FULL text (hover escape hatch) and the full text stays in the + DOM, the Edit button is visible IN the same cell (its bounding box + shares the cell's row — not wrapped below), and clicking the folder + shows the FULL unclamped text in the level block (``#kb-level- + summary`` — the owner's escape hatch at the top). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the clamp test's +level-block assertion keys on a MANUAL description (LLM-free), but the +seeded KB's stored descriptions at the other levels are the mock's +canned ``FOLDER_SUMMARY_MODE`` one-liner ``Fixture folder summary for +.`` (the phase-94 convention — byte-exact only against the +mock). + +KB fixture — a host temp dir (``tmp_path_factory``; the app runs on the +same host) with ONE registered local source (the +``test_ls_tree_drilldown.py`` / phase-94 API-registration + real +``POST /api/sync`` pattern; no git anywhere): ``alpha/`` with the +NESTED folder chain ``one/`` → ``two/`` (2 docs each, so the mock +stores a description at every level) plus the sibling folder +``three/`` (2 docs — the short-description row the clamp height is +measured against). Total: 6 documents; the mock stores exactly four +rows (the recursive-subtree ≥ 2 rule): the source root ``""``, +``one``, the nested ``one/two``, and ``three``. + +The long manual description (test 4) is set through +``PATCH /api/folders/summary`` with the admin cookie (the phase-97 +endpoint — pure DB write, no LLM call) BEFORE the browser visit, so +the view's first tree fetch already carries it. + +Autouse cleanup (the phase-96 pattern, module-scoped — the KB is +module-scoped: all four tests drill the same tree): after the module's +tests, the suite removes its OWN rows — this temp source's KB rows +(documents + their chunks), its ``folder_summaries`` rows, and its +``git_sources`` registration — leaving the shared E2E Postgres clean +(the deterministic start is the fresh TRUNCATE in ``seeded_kb``). + +Test → observable mapping (Playwright Mapping Rule): +1. ``test_back_button_walks_the_breadcrumb`` — the full chain: chat + (``/``) → RAG nav link (the router's pushState entry) → source row + (push #1) → ``one`` (push #2) → ``two`` (push #3); the breadcrumb + renders ``Knowledge base / alpha / one / two`` (last segment + ``aria-current="page"``). Then one level per press: Back → ``one`` + (title ``alpha/one``, ``two`` listed as a subfolder, current + segment ``one``), Back → the source root (title ``alpha``, current + segment ``alpha``), Back → the TOP level (the source row, + breadcrumb hidden — the URL is STILL ``/sources.html``: state-only + entries never change it), Back → the CHAT view visible + the RAG + view hidden (Back at the top leaves the view — the router + contract). Forward replays the whole chain: top → ``alpha`` → + ``one`` → ``two``, the current segment correct at each step. +2. ``test_breadcrumb_jump_then_back`` — at ``one/two``: clicking the + ``Knowledge base`` breadcrumb segment is a JUMP (it pushes a + top-level entry) → the top level renders; Back → ``one/two`` again + (the jump's origin — standard history semantics, pinned). +3. ``test_fresh_nav_visit_starts_at_the_top`` — drill to ``one/two``; + leave via the Tuning nav link; click the RAG nav link again (a + FRESH router entry, no ``kb`` state) → the RAG view shows the TOP + level after the refresh re-render (the D2 alignment). Then drill to + ``one`` and re-click the ACTIVE RAG link (no pushState — + ``history.length`` unchanged) → the refresh re-render KEEPS the + drill (still at ``one`` — the active re-click contract). +4. ``test_description_cell_clamps_to_one_line`` — a 400+ char manual + description on ``one`` (via the admin API): at the source level the + ``one`` row's Description ```` bounding-box height equals the + ``three`` row's (± 4 px — both one line, the row height is + independent of the description's length), the ``.kb-desc-text`` + span computes to ``text-overflow: ellipsis`` + ``white-space: + nowrap``, its ``title`` attribute carries the FULL text, the full + text is in the DOM, and the Edit button is visible IN the same cell + (its box shares the cell's row — vertically centered with the text, + not wrapped below). Clicking ``one`` → the level block's + ``#kb-level-summary`` textContent is the FULL long text (unclamped + at the top — the owner's escape hatch). +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import select, text + +from app.config import Settings as _Settings +from app.db import SessionLocal +from app.models import FolderSummary +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) + +REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_KBTRENAV", "8144")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +# -------------------------------------------------------------------------- +# Fixture constants (deterministic, token-controlled) +# -------------------------------------------------------------------------- + +ALPHA = "alpha" + +ONE_A = "one/one-a.md" +ONE_B = "one/one-b.md" +TWO_A = "one/two/two-a.md" +TWO_B = "one/two/two-b.md" +THREE_A = "three/three-a.md" +THREE_B = "three/three-b.md" + +TOTAL_DOCS = 6 # 2 one/ + 2 one/two/ + 2 three/ (no root-level files) + +#: The sync-time folder descriptions the mock's canned +#: ``FOLDER_SUMMARY_MODE`` branch stores (the phase-94 byte-stable +#: template — the one-liner names the folder), in ``(source, +#: folder_path)`` order: one row per ≥ 2-doc folder (the +#: recursive-subtree rule — ``one/two`` is a distinct nested folder) — +#: the ``""`` row is the source root. +SUMMARY_FOR = "Fixture folder summary for {}." +ALPHA_ROOT_SUM = SUMMARY_FOR.format(ALPHA) +ONE_SUM = SUMMARY_FOR.format(f"{ALPHA}/one") +TWO_SUM = SUMMARY_FOR.format(f"{ALPHA}/one/two") +THREE_SUM = SUMMARY_FOR.format(f"{ALPHA}/three") + +EXPECTED_SUMMARIES: list[tuple[str, str, str]] = [ + (ALPHA, "", ALPHA_ROOT_SUM), + (ALPHA, "one", ONE_SUM), + (ALPHA, "one/two", TWO_SUM), + (ALPHA, "three", THREE_SUM), +] +assert [ + (source, folder) for source, folder, _s in EXPECTED_SUMMARIES +] == sorted((source, folder) for source, folder, _s in EXPECTED_SUMMARIES) + +#: The LONG manual description test 4 sets on ``one`` (via +#: ``PATCH /api/folders/summary``): ≥ 300 chars — at the column's +#: clamped width (0.88rem text, ≤ 44rem) that is comfortably ≥ 4 +#: WRAPPED lines' worth without the clamp (the owner's "rows grow way +#: too much in height" — the pre-fix failure mode) — single line (no +#: newlines: the API stores it as one text node) and distinctive +#: (no part of it occurs in the fixture or the canned template). +LONG_DESC = ( + "Alpha one is the deep fixture folder of this navigation suite: it " + "holds the one-a and one-b fixture notes plus the nested two folder " + "with its two-a and two-b notes, and every one of those documents " + "exists to prove the point of this very cell, namely that no matter " + "how many words the owner writes into a catalog description, the " + "row stays a single clamped line with an ellipsis instead of " + "growing to four wrapped lines and stretching the whole table. " + "(RESE-KBTRENAV-01)" +) +assert len(LONG_DESC) >= 300 and "\n" not in LONG_DESC + + +def _md(title: str, body: str) -> str: + return f"# {title}\n\n{body}\n" + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def nav_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The ONE-source temp tree (see the module docstring): the app + server runs on the same host, so the path is visible to it. The + directory NAME is the source name (``kind=local`` → the + directory's basename, phase 38).""" + root = tmp_path_factory.mktemp("bor_kb_tree_nav") + alpha = root / ALPHA + (alpha / "one" / "two").mkdir(parents=True) + (alpha / "three").mkdir(parents=True) + + (alpha / ONE_A).write_text( + _md("Alpha One A", "Alpha one fixture note A: covers topic A of " + "the alpha source tree."), + encoding="utf-8", + ) + (alpha / ONE_B).write_text( + _md("Alpha One B", "Alpha one fixture note B: covers topic B of " + "the alpha source tree."), + encoding="utf-8", + ) + (alpha / TWO_A).write_text( + _md("Alpha Two A", "Alpha one/two fixture note A: covers topic A " + "of the nested folder."), + encoding="utf-8", + ) + (alpha / TWO_B).write_text( + _md("Alpha Two B", "Alpha one/two fixture note B: covers topic B " + "of the nested folder."), + encoding="utf-8", + ) + (alpha / THREE_A).write_text( + _md("Alpha Three A", "Alpha three fixture note A: covers topic A " + "of the alpha source tree."), + encoding="utf-8", + ) + (alpha / THREE_B).write_text( + _md("Alpha Three B", "Alpha three fixture note B: covers topic B " + "of the alpha source tree."), + encoding="utf-8", + ) + assert (alpha / TWO_B).is_file() and (alpha / THREE_B).is_file() + return alpha + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, nav_dirs: Path) -> Iterator[str]: + """The real app under test — per-module app (the conftest pattern, + cf. ``test_ls_tree_drilldown.py`` / ``test_kb_tree.py``): NO + ``BOR_GIT_SOURCES`` (the env fallback is git-only — the source here + is a DB-registered local directory), the mock LLM, the + mock-calibrated threshold, and the leak-guarded code defaults. The + session app is never started in this isolated run, so no port + clash. ``nav_dirs`` is a dependency only for the fixture ordering + (the temp tree exists before the app boots — the sync reads it).""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated threshold (conftest pattern): this suite never + # asks the chat model anything — the gate is never on a path. + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + # Phase 67: instant retry waits + the code-default budget (the + # conftest leak-guard pattern). + env["BOR_LLM_RETRY_DELAY"] = "0" + env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default) + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + # The repo's .env file carries the owner's BOR_GIT_SOURCES (the app + # reads it from cwd) — override it with an EMPTY value (the env var + # beats the .env file): the registry must hold EXACTLY the one + # local directory this suite registers (a leftover env git list + # would pollute the top-level rows the whole story asserts on). + env["BOR_GIT_SOURCES"] = "" + # Leak guards (conftest pattern): an operator's local (gitignored) + # .env cannot leak corpus-specific settings into the app under test. + env["BOR_DOCS_REPO"] = "" + env["BOR_SUGGESTIONS"] = json.dumps( + _Settings.model_fields["suggestions"].default + ) + env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default + env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +def _truncate_all() -> None: + """Fresh registry + KB (the E2E isolation pattern): the E2E suites + share one Postgres, so a leftover git_sources row would pollute the + top-level rows and a leftover document would show up in the level + file tables and the stat cards the suite asserts on exactly.""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries" + ) + ) + db.commit() # without the commit the TRUNCATE rolls back (the house pattern) + + +def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]: + """Poll the (cookie-authenticated) status endpoint until the run + reaches a terminal state (the test_local_directory_sources pattern, + over plain httpx — this fixture has no browser page yet).""" + deadline = time.monotonic() + timeout_s + body: dict[str, Any] = {} + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + body = r.json() + if body["state"] in ("success", "failed"): + return body + time.sleep(0.5) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +@pytest.fixture(scope="module") +def seeded_kb(app_server: str, nav_dirs: Path) -> None: + """The story's precondition: the nested-folder KB synced under the + deterministic mock. + + Registers the temp directory through the authenticated API (the + ``test_local_directory_sources.py`` / phase-94 pattern), runs the + REAL in-process sync (``POST /api/sync`` — walk → chunk → embed → + overview → folder summaries → version bump), and pins the stored + folder descriptions: the mock's canned ``FOLDER_SUMMARY_MODE`` + branch (phase 94) makes the sync store one deterministic row per + ≥ 2-doc folder — the level-block assertions below key on that + exact text at every level of the chain. + """ + alpha = nav_dirs + _truncate_all() + with httpx.Client(base_url=app_server, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + r = client.post( + "/api/git-sources", json={"kind": "local", "path": str(alpha)} + ) + assert r.status_code == 201, r.text + r = client.post("/api/sync") + assert r.status_code == 202, r.text + body = _wait_sync_done_http(client) + assert body["state"] == "success", body + detail = body["detail"] + assert detail["added"] == TOTAL_DOCS, detail + assert detail["pruned"] == 0, detail + assert detail["overview"] is True, detail + # The change-gated folder descriptions (phase 94) landed: one row + # per ≥ 2-doc folder (the nested ``one/two`` included), the mock's + # byte-stable text — and every row AI-written (the phase-97 + # ``manually_edited`` flag starts false). + with SessionLocal() as db: + rows = db.execute( + select( + FolderSummary.source, + FolderSummary.folder_path, + FolderSummary.summary, + FolderSummary.manually_edited, + ).order_by(FolderSummary.source, FolderSummary.folder_path) + ).all() + assert [ + (source, folder, summary, False) + for source, folder, summary in EXPECTED_SUMMARIES + ] == [(s, f, t, m) for s, f, t, m in rows], rows + + +@pytest.fixture(autouse=True, scope="module") +def _clean(nav_dirs: Path, seeded_kb: None) -> Iterator[None]: + """Autouse cleanup (the phase-96 pattern, module-scoped — the KB is + module-scoped: all four tests drill the same tree): AFTER the + module's tests, remove this suite's OWN rows — the temp source's + KB rows (documents + their chunks), its ``folder_summaries`` rows + (test 4's manual description included), and its ``git_sources`` + registration (the local row's ``url`` column holds the expanded + path) — leaving the shared E2E Postgres the way the house + isolation pattern found it. The deterministic START is the fresh + TRUNCATE in ``seeded_kb``.""" + yield + with SessionLocal() as db: + db.execute( + text( + "DELETE FROM chunks WHERE document_id IN " + "(SELECT id FROM documents WHERE source = :s)" + ), + {"s": ALPHA}, + ) + db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": ALPHA}) + db.execute(text("DELETE FROM folder_summaries WHERE source = :s"), {"s": ALPHA}) + db.execute(text("DELETE FROM git_sources WHERE url = :p"), {"p": str(nav_dirs)}) + db.execute(text("TRUNCATE query_log")) + db.commit() + + +# -------------------------------------------------------------------------- +# Page helpers +# -------------------------------------------------------------------------- + + +def _wait_top_level(page: Page) -> None: + """The TOP level has rendered: the source rows (this suite: exactly + one — ``alpha``), the breadcrumb + the level block hidden, the file + table empty (files are per-source — always hidden at the top), and + the stat cards' document total (the tree walk).""" + expect(page.locator("#folders-tbody tr")).to_have_count(1, timeout=30_000) + expect(page.locator("#kb-crumb")).to_be_hidden() + expect(page.locator("#kb-level")).to_be_hidden() + expect(page.locator("#docs-tbody tr")).to_have_count(0) + expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS)) + + +def _drill(page: Page, *names: str) -> None: + """Drill one level at a time (client-side — no fetch, NO URL + change): each name is the EXACT text of the source/folder link at + the current level (the row builders' link text: the source name, or + the folder's last path segment). Phase 99 (D2): every drill click + is a STATE-ONLY history entry (``goTo`` → ``applyTarget(target, + true)``).""" + for name in names: + page.click(f'#folders-tbody a.folder-link:text-is("{name}")') + + +def _assert_crumb(page: Page, links: list[str], current: str) -> None: + """The breadcrumb (``#kb-crumb``) renders the drilled chain: one + ``.kb-crumb-link`` per ancestor (in order — the first is always the + ``Knowledge base`` jump-to-top segment), then the last segment as + the ``.kb-crumb-current`` span with ``aria-current="page"``.""" + expect(page.locator("#kb-crumb")).to_be_visible() + texts = page.eval_on_selector_all( + "#kb-crumb :is(.kb-crumb-link, .kb-crumb-current)", + "(els) => els.map((el) => el.textContent)", + ) + assert texts == [*links, current], texts + cur = page.locator("#kb-crumb .kb-crumb-current") + expect(cur).to_have_count(1) + expect(cur).to_have_attribute("aria-current", "page") + + +def _assert_level(page: Page, title: str, summary: str) -> None: + """The level block (``#kb-level``) shows the current directory's + stored description: the full source-relative path as the title, the + description as the text (UNCLAMPED at the top — the D1 escape + hatch).""" + expect(page.locator("#kb-level")).to_be_visible() + expect(page.locator("#kb-level-title")).to_have_text(title) + expect(page.locator("#kb-level-summary")).to_have_text(summary) + + +def _open_rag_from_chat(page: Page, app_url: str) -> None: + """The phase-76 router entry: from the chat view (``/``) click the + RAG nav link — a client-side view switch (pushState ``{ view: + "rag" }`` — NO ``kb``) that mounts the RAG view on its first show + and lands on the top level.""" + page.click("#nav-sources") + expect(page.locator("#view-rag")).to_be_visible() + expect(page).to_have_url(app_url + "/sources.html") + _wait_top_level(page) + + +def _drill_to_two(page: Page) -> None: + """The full drill chain, asserted at every level (the chain every + test in this suite starts with): source row → ``one`` → ``two`` — + the level block + the breadcrumb's exact segment chain at each + step (the last segment ``aria-current``).""" + page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') + _assert_level(page, ALPHA, ALPHA_ROOT_SUM) + _assert_crumb(page, ["Knowledge base"], ALPHA) + expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2) + + page.click('#folders-tbody a.folder-link:text-is("one")') + _assert_level(page, f"{ALPHA}/one", ONE_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA], "one") + expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible() + + page.click('#folders-tbody a.folder-link:text-is("two")') + _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") + expect(page.locator("#folders-wrap")).to_be_hidden() # a leaf folder: no subfolders + + +# -------------------------------------------------------------------------- +# 1. The back button walks the breadcrumb — one level per press, the +# router contract at the top, the whole chain replays on Forward +# -------------------------------------------------------------------------- + + +def test_back_button_walks_the_breadcrumb( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _open_rag_from_chat(page, app_url) + + # The full drill chain (three STATE-ONLY pushes — the URL is + # /sources.html at every level; the phase-76 deep-link surface is + # untouched). + _drill_to_two(page) + expect(page).to_have_url(app_url + "/sources.html") + + # --- Back: one level per press -------------------------------------- + # popstate adopts the popped entry's kb (no push) — the level is + # `one`: the title, `two` listed as a subfolder, the breadcrumb's + # current segment = `one`. + page.go_back() + expect(page.locator("#view-rag")).to_be_visible() + expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change + _assert_level(page, f"{ALPHA}/one", ONE_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA], "one") + expect(page.locator('#folders-tbody a.folder-link:text-is("two")')).to_be_visible() + + # Back: the source root — title `alpha`, current segment the source. + page.go_back() + _assert_level(page, ALPHA, ALPHA_ROOT_SUM) + _assert_crumb(page, ["Knowledge base"], ALPHA) + expect(page.locator("#folders-tbody a.folder-link")).to_have_count(2) + + # Back: the TOP level — the source row, the breadcrumb + level + # block hidden. The URL is STILL the RAG path (the entry popped is + # the router's view entry — same-document, state-only pushes never + # changed the pathname). + page.go_back() + expect(page).to_have_url(app_url + "/sources.html") + _wait_top_level(page) + + # Back at the top level: the router's OWN popstate (pathname `/`) + # switches the view — the CHAT view is visible, the RAG view hidden + # (Back at the top leaves the view, exactly as pre-phase-99). + page.go_back() + expect(page.locator("#view-chat")).to_be_visible() + expect(page.locator("#view-rag")).to_be_hidden() + expect(page).to_have_url(app_url + "/") + + # --- Forward: the whole chain replays ------------------------------- + # Forward: the router's view entry — the RAG view re-shows on the + # top level (the kb-less entry — the D2 alignment, via the + # refresh re-render). + page.go_forward() + expect(page.locator("#view-rag")).to_be_visible() + _wait_top_level(page) + + # Forward: the source root. + page.go_forward() + _assert_level(page, ALPHA, ALPHA_ROOT_SUM) + _assert_crumb(page, ["Knowledge base"], ALPHA) + + # Forward: `one`. + page.go_forward() + _assert_level(page, f"{ALPHA}/one", ONE_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA], "one") + + # Forward: `two` — the chain's end again, current segment correct. + page.go_forward() + _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") + + +# -------------------------------------------------------------------------- +# 2. A breadcrumb jump pushes an entry — Back returns to the jump's +# origin (standard history semantics) +# -------------------------------------------------------------------------- + + +def test_breadcrumb_jump_then_back( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _open_rag_from_chat(page, app_url) + _drill_to_two(page) + + # The jump: the `Knowledge base` breadcrumb segment (a real drill + # target — goTo pushes a top-level entry). + page.click('#kb-crumb a.kb-crumb-link:text-is("Knowledge base")') + expect(page).to_have_url(app_url + "/sources.html") # state-only: no URL change + _wait_top_level(page) + + # Back: the jump's ORIGIN — `one/two` again (standard history + # semantics: the jump was an entry, not a reset). + page.go_back() + _assert_level(page, f"{ALPHA}/one/two", TWO_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA, "one"], "two") + + +# -------------------------------------------------------------------------- +# 3. The D2 alignment: a fresh nav visit starts at the top; an +# active-link re-click keeps the drill +# -------------------------------------------------------------------------- + + +def test_fresh_nav_visit_starts_at_the_top( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _open_rag_from_chat(page, app_url) + _drill_to_two(page) + + # Leave via the Tuning nav link (the router's view entry). + page.click("#nav-tuning") + expect(page.locator("#view-tuning")).to_be_visible() + expect(page.locator("#view-rag")).to_be_hidden() + expect(page).to_have_url(app_url + "/tuning.html") + + # A FRESH nav visit to RAG: the router's entry carries no `kb` + # state — the D2 alignment resets the drill to the TOP level (after + # the refresh re-render). + page.click("#nav-sources") + expect(page.locator("#view-rag")).to_be_visible() + expect(page).to_have_url(app_url + "/sources.html") + _wait_top_level(page) + + # Drill to `one`… + page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') + page.click('#folders-tbody a.folder-link:text-is("one")') + _assert_level(page, f"{ALPHA}/one", ONE_SUM) + + # …and re-click the ACTIVE RAG link: NO pushState (the router's + # re-click contract — `history.length` unchanged), the drilled + # entry is still on top, and the refresh alignment KEEPS the drill. + length_before = page.evaluate("() => history.length") + page.click("#nav-sources") + expect(page.locator("#view-rag")).to_be_visible() + assert page.evaluate("() => history.length") == length_before # no pushState + _assert_level(page, f"{ALPHA}/one", ONE_SUM) + _assert_crumb(page, ["Knowledge base", ALPHA], "one") + expect(page).to_have_url(app_url + "/sources.html") + + +# -------------------------------------------------------------------------- +# 4. The measured one-line clamp: row height independent of the +# description's length, ellipsis + hover title, Edit in-cell, the +# full text at the top +# -------------------------------------------------------------------------- + + +def test_description_cell_clamps_to_one_line( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + page.set_default_timeout(30_000) + + # The long manual description on `one` (the phase-97 endpoint — a + # pure DB write, the admin cookie via the real /api/login), set + # BEFORE the browser visit: the view's first tree fetch already + # carries it (manually_edited — the sync's generator would have + # skipped it). + with httpx.Client(base_url=app_url, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + r = client.patch( + "/api/folders/summary", + json={"source": ALPHA, "folder_path": "one", "summary": LONG_DESC}, + ) + assert r.status_code == 200, r.text + assert r.json()["summary"] == LONG_DESC, r.json() + + login(page, app_url, next="/sources.html") + _wait_top_level(page) + + # The source level: the folder rows `one` (the long manual + # description) + `three` (the short canned one). + page.click(f'#folders-tbody a.folder-link:text-is("{ALPHA}")') + _assert_level(page, ALPHA, ALPHA_ROOT_SUM) + + one_row = page.locator( + "#folders-tbody tr", has=page.locator('a.folder-link:text-is("one")') + ) + three_row = page.locator( + "#folders-tbody tr", has=page.locator('a.folder-link:text-is("three")') + ) + expect(one_row).to_have_count(1) + expect(three_row).to_have_count(1) + + # The measured clamp: the long-description row's Description + # is ONE line — its bounding-box height equals the short row's (± + # 4 px). Without the clamp, ≥ 4 wrapped lines would stretch it. + one_desc = one_row.locator("td:nth-child(3)") + three_desc = three_row.locator("td:nth-child(3)") + one_box = one_desc.bounding_box() + three_box = three_desc.bounding_box() + assert one_box is not None and three_box is not None + assert abs(one_box["height"] - three_box["height"]) <= 4, (one_box, three_box) + + # The span: the full text stays in the DOM (the accessible name is + # unchanged — the ellipsis is CSS-only) and rides the `title` (the + # hover escape hatch, D1); the computed style is the ellipsis + # triad's visible half. + span = one_row.locator("span.kb-desc-text") + expect(span).to_have_text(LONG_DESC) + expect(span).to_have_attribute("title", LONG_DESC) + style = span.evaluate( + """(el) => { + const s = getComputedStyle(el); + return { textOverflow: s.textOverflow, whiteSpace: s.whiteSpace }; + }""" + ) + assert style["textOverflow"] == "ellipsis", style + assert style["whiteSpace"] == "nowrap", style + + # The Edit button stays IN the same cell (D1 — the owner's "nice + # touch"): visible, its bounding box inside the cell's, sharing the + # cell's row (vertically centered with the text — NOT wrapped + # below). + btn = one_row.locator("button.kb-summary-edit") + expect(btn).to_be_visible() + btn_box = btn.bounding_box() + span_box = span.bounding_box() + assert btn_box is not None and span_box is not None + assert btn_box["y"] >= one_box["y"] - 1, (btn_box, one_box) + assert btn_box["y"] + btn_box["height"] <= one_box["y"] + one_box["height"] + 1 + assert btn_box["x"] >= one_box["x"] - 1, (btn_box, one_box) + assert btn_box["x"] + btn_box["width"] <= one_box["x"] + one_box["width"] + 1 + assert ( + abs( + (span_box["y"] + span_box["height"] / 2) + - (btn_box["y"] + btn_box["height"] / 2) + ) + <= 4 + ), (span_box, btn_box) + + # The escape hatch at the top: clicking `one` shows the FULL long + # text in the level block (unclamped — `.kb-level p` is untouched). + one_row.locator("a.folder-link").click() + _assert_level(page, f"{ALPHA}/one", LONG_DESC) diff --git a/tests/e2e/test_sync_summary_visibility.py b/tests/e2e/test_sync_summary_visibility.py index f8e0ccb..cadf851 100644 --- a/tests/e2e/test_sync_summary_visibility.py +++ b/tests/e2e/test_sync_summary_visibility.py @@ -865,7 +865,9 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal( src_row = page.locator("#folders-tbody tr") s_span = src_row.locator("td:nth-child(3) span") expect(s_span).to_have_text(PENDING_COPY) - expect(s_span).to_have_class("kb-summary-pending") + # Phase 99 (task 01): the marker toggles onto the cell's base + # .kb-desc-text span (the one-line clamp) — the class pair, exact. + expect(s_span).to_have_class("kb-desc-text kb-summary-pending") expect(s_span).to_have_attribute("title", PENDING_TITLE) # The Edit button is KEPT with the marker (a manual save creates # the row and clears the marker in place). @@ -888,10 +890,11 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal( expect(alpha_row.locator("td:nth-child(3) span")).not_to_have_class( "kb-summary-pending" ) - # The affected folder: the marker (copy + class + title). + # The affected folder: the marker (copy + class + title) — the + # class pair with the phase-99 .kb-desc-text base span, exact. b_span = bravo_row.locator("td:nth-child(3) span") expect(b_span).to_have_text(PENDING_COPY) - expect(b_span).to_have_class("kb-summary-pending") + expect(b_span).to_have_class("kb-desc-text kb-summary-pending") expect(b_span).to_have_attribute("title", PENDING_TITLE) # Drill into the affected folder: ITS level block shows the D4 diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py index e19c89c..b14388d 100644 --- a/tests/unit/test_frontend_router.py +++ b/tests/unit/test_frontend_router.py @@ -548,23 +548,34 @@ def _pin_refresh_listener(js: str, gate: str, listener_call: str, name: str) -> """Shared shape of the task-02 pin: the view module listens for ``bor:view-refresh`` on its own root, the listener re-runs the view's existing load, and the listener is armed ONLY in the ADMIN - branch — after the whoami gate (anonymous never fetches).""" + branch — after the whoami gate (anonymous never fetches). The + body is sliced to the listener's close: the block-arrow forms + (phase 99 task 02: the rag view's history-alignment step runs + before the load) close with ``});``, the inline-arrow forms close + with ``());`` (the slice then runs to the NEXT ``});`` in the file + — or to its end — harmless, the inline call sits at the slice's + start).""" listener = js.find('addEventListener("bor:view-refresh"') assert listener != -1, f"{name} must listen for the refresh event on the view root" gate_i = js.find(gate) assert 0 <= gate_i < listener, ( f"{name}: the listener must be armed in the ADMIN branch (after {gate!r})" ) - assert listener_call in js[listener : listener + 120], ( + end = js.find("});", listener) + body = js[listener:] if end == -1 else js[listener : end + 3] + assert listener_call in body, ( f"{name}: the listener must re-run the view's load ({listener_call!r})" ) def test_rag_view_refetches_on_reshow() -> None: - """Phase 77 task 02 (+ phase 97 task 04): the RAG (knowledge base) - view re-fetches on a user-initiated re-show — sources.js listens - and re-runs ``loadTree()`` (the phase-97 catalog load: ONE fetch of - ``GET /api/docs/tree``). The load is race-tokened (phase 79) and + """Phase 77 task 02 (+ phase 97 task 04, + phase 99 task 02): + the RAG (knowledge base) view re-fetches on a user-initiated + re-show — sources.js listens and re-runs ``loadTree()`` (the + phase-97 catalog load: ONE fetch of ``GET /api/docs/tree``), now + AFTER the phase-99 history alignment (adopt the entry's kb / reset + to the top — a block-arrow listener, hence the ``});`` close in + the pin helper below). The load is race-tokened (phase 79) and the RE-ENTRANT render — ``renderLevel`` clears BOTH row containers at the TOP before filling them (the History pattern from task 01, extended to the folders table) — so a refresh from a populated @@ -572,7 +583,7 @@ def test_rag_view_refetches_on_reshow() -> None: of leaving ghost rows.""" js = _asset("sources.js") _pin_refresh_listener( - js, "const admin = await fetchIsAdmin();", "() => loadTree()", "sources.js" + js, "const admin = await fetchIsAdmin();", "loadTree()", "sources.js" ) load = js.find("async function loadTree()") assert load != -1, "loadTree must exist" diff --git a/tests/unit/test_kb_tree_ui.py b/tests/unit/test_kb_tree_ui.py index b892030..1f9d916 100644 --- a/tests/unit/test_kb_tree_ui.py +++ b/tests/unit/test_kb_tree_ui.py @@ -357,16 +357,40 @@ def test_breadcrumb_links_go_up_and_the_top_reset() -> None: assert "span.textContent = label" in cur -def test_navigation_is_client_side_only() -> None: - """goTo() is the ONLY navigation: set `current`, re-render. No - fetch (the tree is already in memory), no URL change (history is - untouched — the deep-link contract of the shell is unchanged).""" +def test_drill_navigation_pushes_state_only_entries() -> None: + """Phase 99 (task 02, D2): every user-initiated drill is a history + entry — ``goTo`` is the push variant of ``applyTarget``: when 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 is ``""`` and there is 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. The + drill still never fetches (the tree is in memory) and the module + never ``replaceState``s.""" js = _js() - body = js[js.find("function goTo(") : js.find("function makeSourceRow(")] - assert "current = { source: target.source, folder: target.folder };" in body - assert "renderLevel();" in body + start = js.find("function applyTarget(") + assert start != -1, "applyTarget must exist (the two-step drill)" + body = js[start : js.find("function makeSourceRow(")] + gate_i = body.find( + "if (push && (target.source !== current.source || target.folder !== current.folder)) {" + ) + push_i = body.find('history.pushState({ view: "rag", kb: target }, "")') + set_i = body.find("current = { source: target.source, folder: target.folder };") + render_i = body.find("renderLevel();") + assert -1 < gate_i < push_i < set_i < render_i, ( + "push gate (target differs from current) → pushState → set current → render" + ) + # STATE-ONLY: exactly ONE pushState call site — the "" second arg, + # no third (no URL change). + assert _code(body).count("pushState") == 1, ( + "ONE pushState call site — the state-only drill entry" + ) + assert "replaceState" not in _code(js), "no replaceState anywhere in the module" + # goTo is the push variant — every drill call site keeps calling it. + go = body[body.find("function goTo(") :] + assert "applyTarget(target, true)" in go, "goTo pushes (the user-initiated drill)" assert "fetch(" not in body, "the drill never fetches (client-side only)" - assert "pushState" not in body and "replaceState" not in body, "no URL change" # ---------- the level rendering ---------- @@ -577,16 +601,30 @@ def test_load_tree_wired_at_exactly_the_three_refresh_points_plus_boot() -> None assert _code(js).count("loadTree()") == 5, ( "the definition + the 4 call sites, no others (code, not comments)" ) - # 1. the view-refresh listener, armed in the admin branch. + # 1. the view-refresh listener, armed in the admin branch — since + # Phase 99 (task 02, D2) its body is the ALIGNMENT step (a + # kb-carrying history.state entry keeps the drill — the + # active-link re-click; a kb-less entry starts at the top — a + # fresh nav visit) BEFORE the re-fetch. listener = js.find('addEventListener("bor:view-refresh"') assert listener != -1 gate = js.find("const admin = await fetchIsAdmin();") assert -1 < gate < listener, "armed after the whoami gate (the phase-77 pin)" - assert "() => loadTree()" in js[listener : listener + 120] + listener_body = js[listener : js.find('window.addEventListener("popstate"')] + state_i = listener_body.find("history.state && history.state.kb") + adopt_i = listener_body.find("applyTarget(kb, false)") + reset_i = listener_body.find("applyTarget({ source: null, folder: null }, false)") + load_i = listener_body.find("loadTree();") + assert -1 < state_i < adopt_i < reset_i < load_i, ( + "align with history.state (adopt kb / reset to top) BEFORE the re-fetch" + ) + assert "pushState" not in _code(listener_body), ("the alignment adopts only — no push") # 2. the boot load — the last statement of mount, right after the - # listener is armed (the mount's own load is the first fetch). - assert 'root.addEventListener("bor:view-refresh", () => loadTree());\n loadTree();' in js, ( - "the boot load follows the listener" + # listeners are armed (the mount's own load is the first fetch). + pop = js.find('window.addEventListener("popstate"') + boot = js[js.find("});", pop) + 3 :] # past the popstate listener's close + assert boot.strip().startswith("loadTree();"), ( + "the boot load follows the listeners (no pushState on boot)" ) # 3. applySyncSuccess (the sync's terminal — the KB just changed). success = js[js.find("function applySyncSuccess(") : js.find("function applySyncFailure(")] @@ -746,13 +784,16 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None: the text gates on the node state, the button never does). The button is a real type=button with a human aria-label, and the shared editor is wired with a CONSTANT target { node, source, - folder }.""" + folder }. Phase 99 (task 01, D1): text + button live in ONE + `div.kb-desc-cell` flex wrapper (the holds only the wrapper), + and the editor's container IS the wrapper (the open/close swaps + fill it — the cell layout survives the editor swap).""" body = _fn(_js(), "makeDescCell") # The Edit button is built unconditionally — the whole button # block (from its creation to the append) carries no `if` gate on # the stored description or the pending flag. btn_block = body[ - body.find("const btn = document.createElement") : body.find("td.append(text, btn)") + body.find("const btn = document.createElement") : body.find("wrap.append(text, btn)") ] assert "if (" not in btn_block, ( "always present: no gate on the stored description" @@ -761,10 +802,20 @@ def test_row_description_cell_builds_text_and_always_present_edit() -> None: assert 'btn.className = "kb-summary-edit"' in body assert 'btn.textContent = "Edit"' in body assert 'btn.setAttribute("aria-label", `Edit description: ${label}`)' in body - assert "td.append(text, btn)" in body + assert 'wrap.className = "kb-desc-cell"' in body, "the ONE flex wrapper" + assert "wrap.append(text, btn)" in body, "text + button, in order, in the wrapper" + assert "td.append(wrap)" in body, "the holds the wrapper (no new td class)" + assert "container: wrap" in body, ( + "the editor's container is the WRAPPER (Phase 99 — the swap fills it)" + ) assert "getTarget: () => ({ node, source, folder })" in body, ( "a row's target is a constant (its own node)" ) + # The level block's editor is UNTOUCHED — its container is still + # .kb-level-body (the D1 escape hatch keeps its full text). + js = _js() + wire = js[js.find("levelEditor = wireDescriptionEdit({") : js.find("/* ---------- view boot")] + assert "container: levelBody" in wire def test_desc_cell_pending_marker_branch() -> None: @@ -786,7 +837,12 @@ def test_desc_cell_pending_marker_branch() -> None: assert "kb-summary-pending" not in stored, "a stored summary is NEVER the marker" assert "Summary pending" not in stored pending = body[pending_i:empty_i] - assert 'text.className = "kb-summary-pending"' in pending + assert 'text.classList.add("kb-summary-pending")' in pending, ( + "Phase 99: the marker TOGGLES onto the .kb-desc-text base class" + ) + assert 'text.className = "kb-summary-pending"' not in body, ( + "a bare className re-assignment would drop the clamp's base class" + ) assert 'text.textContent = "Summary pending"' in pending, "the D4 marker copy" assert ( 'text.title = "No stored description yet — the next sync will generate one."' @@ -841,13 +897,23 @@ def test_close_editor_rederives_the_pending_display() -> None: close = body[body.find("function closeEditor(") : body.find("function openEditor()")] flag_i = close.find('const pending = node !== null && stored === "" && node.summary_pending;') value_i = close.find("const value = pending && pendingText ? pendingText : stored;") - class_i = close.find('textEl.className = pending ? "kb-summary-pending" : "";') + class_i = close.find('textEl.classList.toggle("kb-summary-pending", pending);') title_i = close.find("if (pendingTitle) textEl.title = pendingTitle;") + hover_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {') + full_i = close.find("textEl.title = stored;") clear_title_i = close.find('textEl.removeAttribute("title")') render_i = close.find("textEl.textContent = value") - assert -1 < flag_i < value_i < class_i < title_i < clear_title_i < render_i, ( - "flag → value → class → title (set/clear) → text, in order" + assert -1 < flag_i < value_i < class_i < title_i < hover_i < full_i, ( + "flag → value → class toggle → title (pending / hover-full), in order" ) + assert full_i < clear_title_i < render_i, "title-set → title-clear → text, in order" + # Phase 99 (task 01): the class line is a TOGGLE (the base classes + # survive — the row's span keeps .kb-desc-text, the clamp; the + # level's

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 unclamped + # level's

is gated out by the kb-desc-text check). + assert 'textEl.className' not in close, "no bare className re-assignment" # The row passes its marker copy + the EXACT D4 tooltip… cell = _fn(_js(), "makeDescCell") assert 'pendingText: "Summary pending"' in cell @@ -1212,3 +1278,243 @@ def test_module_docstring_documents_the_pending_markers() -> None: in header ), "the level block's pending note" assert "node.summary_pending = false" in header, "the in-place clear" + + +# ---------- the one-line Description clamp (phase 99, task 01) ---------- +# D1: the clamp is VISUAL only — the row's Description cell is ONE flex +# row (text flexes + ellipsizes, the Edit button stays fixed), the full +# text stays in the DOM + on the span's hover title, and the level +# block keeps the full unclamped description. The measured-height proof +# lands in the phase's E2E (task 03); this module pins the structure +# and the CSS. + + +def _css_rule(css_text: str, selector: str) -> str: + """The declarations of the rule with exactly this selector (the + task-named classes own their base rule — no compound matches).""" + m = re.search(r"(? None: + """Phase 99 (task 01, D1): the text span (`.kb-desc-text` — the + base class, set UNCONDITIONALLY before the three-state branch) AL- + WAYS carries `title` = the FULL description text for real + (non-empty) stored text — the hover escape hatch of the visual + clamp; the phase-98 marker keeps its OWN D4 title (never over- + ridden by the hover rule); the empty cell carries no title at all. + textContent/title only — the house rule (no innerHTML).""" + body = _fn(_js(), "makeDescCell") + base_i = body.find('text.className = "kb-desc-text"') + stored_i = body.find("if (node && node.summary) {") + pending_i = body.find("else if (node && node.summary_pending) {") + empty_i = body.find("} else {") + assert -1 < base_i < stored_i, "the base clamp class is set before the state branch" + stored = body[stored_i:pending_i] + assert "text.title = node.summary;" in stored, "stored → title = the full text (hover)" + assert "Summary pending" not in stored, "the stored branch never touches the marker" + pending = body[pending_i:empty_i] + assert ( + 'text.title = "No stored description yet — the next sync will generate one."' + in pending + ), "the marker keeps its OWN D4 title" + empty = body[empty_i : empty_i + 200] + assert "text.title" not in empty, "the empty cell carries no title" + code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M) + assert "innerHTML" not in code, "the house rule: no innerHTML in the cell builder" + + +def test_close_editor_rederives_the_hover_title_from_the_stored_text() -> None: + """Phase 99 (task 01, D1): the hover title is the escape hatch for + the CLAMPED text, so a Save/Cancel must re-derive it — a stale + pre-edit title cannot survive a save (the span is re-inserted + into the wrapper by the swap, attributes included). The re- + derivation is gated on the kb-desc-text class: only the clamped + row span gets the full-text title; the unclamped level's

+ (full text visible) keeps its no-title behavior, and an emptied + (cleared) cell loses its title.""" + body = _fn(_js(), "wireDescriptionEdit") + close = body[body.find("function closeEditor(") : body.find("function openEditor()")] + gate_i = close.find('} else if (stored && textEl.classList.contains("kb-desc-text")) {') + set_i = close.find("textEl.title = stored;") + clear_i = close.find('textEl.removeAttribute("title")') + assert -1 < gate_i < set_i < clear_i, "gated set (stored + clamped span) → clear, in order" + assert close.count("textEl.title =") == 2, ( + "exactly: pendingTitle (the marker's own) + stored (the hover full text) — nothing else" + ) + + +def test_styles_carry_the_one_line_clamp() -> None: + """Phase 99 (task 01, D1): the Description cell is one flex row — + .kb-desc-cell (flex + center + the 0.4rem gap that replaces the + button's old margin-left + min-width: 0) and .kb-desc-text (flex: + 1 1 auto + min-width: 0 — the flex item may shrink — + the + ellipsis triad). The column itself is white-space: nowrap (was + normal — the column width is the clamp's budget; the min/max width + + font-size stay). The button's margin-left rule is GONE, the + level block's margin-top rule is untouched, and the open editor + fills the flex wrapper (flex: 1 1 auto + min-width: 0). .kb-level + p is UNTOUCHED (the full unclamped description — D1), and the + phase-97 block carries no new hue (the phase-92 invariant).""" + css = _text(STYLES_CSS) + cell = _css_rule(css, ".kb-desc-cell") + for prop in ("display: flex", "align-items: center", "gap: 0.4rem", "min-width: 0"): + assert prop in cell, f".kb-desc-cell must carry {prop!r}" + text = _css_rule(css, ".kb-desc-text") + for prop in ( + "flex: 1 1 auto", + "min-width: 0", + "overflow: hidden", + "text-overflow: ellipsis", + "white-space: nowrap", + ): + assert prop in text, f".kb-desc-text must carry {prop!r}" + col_i = css.find(".kb-folders-table td:nth-child(3) {") + assert col_i != -1 + col = css[col_i : col_i + 400] + col = col[: col.find("\n}")] + assert "white-space: nowrap" in col, "the column is one line (was: normal)" + assert "white-space: normal" not in col + for prop in ("min-width: 18rem", "max-width: 44rem", "font-size: 0.88rem"): + assert prop in col, f"the column keeps {prop!r} (the clamp's budget)" + assert ".kb-folders-table .kb-summary-edit" not in css, ( + "the button's margin-left is gone — the wrapper's gap replaces it" + ) + lvl_i = css.find(".kb-level-body .kb-summary-edit") + assert lvl_i != -1 + assert "margin-top: 0.5rem" in css[lvl_i : lvl_i + 80], ( + "the level block's spacing rule is untouched" + ) + fill_i = css.find(".kb-desc-cell .kb-summary-editor") + assert fill_i != -1 + fill = css[fill_i : fill_i + 80] + assert "flex: 1 1 auto" in fill and "min-width: 0" in fill, ( + "the open editor fills the flex wrapper" + ) + # The level block keeps the FULL unclamped description (D1) — its + # rule is unchanged (no white-space/ellipsis additions). + m = re.search(r"(? rule must exist" + assert "white-space" not in m.group(1) and "text-overflow" not in m.group(1) + block = _css_block(css, "KB drill-down tree (phase 97", "Git sources page (phase 35)") + assert ".kb-desc-cell" in block and ".kb-desc-text" in block, ( + "the clamp lives in the phase-97 tree region" + ) + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no new hue (phase-92 invariant)" + assert not re.search(r"rgba?\(", block), "no new hue (phase-92 invariant)" + + +def test_module_docstring_documents_the_one_line_clamp() -> None: + """The house per-phase module-note convention: the phase-99 + task-01 section records the flex wrapper structure (text flexes + + ellipsizes, button fixed), the VISUAL-only clamp (full text in the + DOM + on the hover title), the marker's own title, and the wrapper + as the editor's container (the level block's container stays + .kb-level-body).""" + js = _js() + header = js[: js.find("import { fetchIsAdmin }")] + assert "Phase 99 (task 01, D1)" in header + assert "kb-desc-cell" in header and "kb-desc-text" in header + assert "VISUAL only" in header, "the clamp is documented as visual only" + assert "title" in header, "the hover escape hatch is documented" + assert "kb-level-body" in header, "the level block's container is documented as kept" + + +# ---------- the history integration (phase 99, task 02, D2) ---------- +# The drill state IS the history state: every drill pushes a state- +# only entry (no URL change), Back/Forward adopt the entry's kb (or +# reset to the top), the re-show aligns before the re-fetch, and the +# anonymous gate installs no listeners. The browser proof lands in +# the phase's E2E (task 03); this module pins the wiring. + + +def test_popstate_adopts_the_entry_kb_or_resets_to_top() -> None: + """Phase 99 (task 02, D2): the window popstate — armed exactly + ONCE, in the ADMIN branch (after the whoami gate, next to the + refresh listener) — ADOPTS the popped entry's ``event.state.kb`` + via the no-push variant (the browser owns its entries — + adopt/reset never push), and an entry WITHOUT a kb (the boot + entry, the router's view entries, any foreign state) RESETS the + drill to the TOP level. Rendering a currently-hidden view is + harmless: the router's own popstate (registered earlier, at shell + boot) owns the view switch for foreign entries; rag-internal + entries never change the pathname, so this listener's render is + the visible one.""" + js = _js() + assert js.count('window.addEventListener("popstate"') == 1, ( + "armed exactly once (mount-once — the router mounts a view ONCE)" + ) + pop = js.find('window.addEventListener("popstate"') + gate = js.find("const admin = await fetchIsAdmin();") + refresh = js.find('root.addEventListener("bor:view-refresh"') + assert -1 < gate < refresh < pop, ( + "armed in the admin branch, after the gate, next to the refresh listener" + ) + body = js[pop : js.find("});", pop)] + kb_i = body.find("event.state && event.state.kb") + adopt_i = body.find("applyTarget(kb, false)") + reset_i = body.find("applyTarget({ source: null, folder: null }, false)") + assert -1 < kb_i < adopt_i < reset_i, ( + "read event.state.kb → adopt (no push) / reset to the top" + ) + assert "pushState" not in _code(body), ("adopt/reset never push (the browser owns its entries)") + + +def test_refresh_alignment_adopts_history_state_before_the_refetch() -> None: + """Phase 99 (task 02, D2): the bor:view-refresh listener (the + phase-77 re-show) aligns ``current`` with ``history.state`` + BEFORE ``loadTree()`` — a kb-carrying top entry (the active-link + re-click pushed NOTHING — the drilled entry is still on top) + KEEPS the drill (``applyTarget(kb, false)``); a kb-less entry (a + fresh nav visit) RESETS to the top level. The alignment adopts — + it never pushes.""" + js = _js() + listener = js.find('root.addEventListener("bor:view-refresh"') + assert listener != -1 + body = js[listener : js.find("});", listener)] + state_i = body.find("history.state && history.state.kb") + adopt_i = body.find("applyTarget(kb, false)") + reset_i = body.find("applyTarget({ source: null, folder: null }, false)") + load_i = body.find("loadTree();") + assert -1 < state_i < adopt_i < reset_i < load_i, ( + "align (adopt kb / reset to top) BEFORE the re-fetch" + ) + assert "pushState" not in _code(body), "the alignment never pushes" + + +def test_anonymous_branch_installs_no_navigation_listeners() -> None: + """Phase 99 (task 02): the anonymous gate installs NO listeners at + all — no popstate, no refresh (the phase-16 rule: anonymous shows + the gate, fetches nothing, and owns no drill state — both + listeners are armed only in the admin branch).""" + js = _js() + start = js.find("if (!admin) {") + end = js.find("if (gateEl) gateEl.hidden = true;") + assert -1 < start < end, "the anonymous branch must exist" + branch = js[start:end] + assert "addEventListener" not in branch, ( + "the anonymous branch arms no listeners (no popstate, no refresh)" + ) + + +def test_module_docstring_documents_the_history_integration() -> None: + """The house per-phase module-note convention: the phase-99 + task-02 section records the push/adopt/reset rules — the + STATE-ONLY push (the URL STAYS PUT, no new route — the phase-76 + deep-link surface untouched), the duplicate-push skip, the + popstate adopt/reset, the refresh alignment before the re-fetch, + the no-boot-push contract, the UNCHANGED (no-push) + resetVanishedLocation, and the UNTOUCHED router module.""" + js = _js() + header = js[: js.find("import { fetchIsAdmin }")] + assert "Phase 99 (task 02, D2)" in header + assert "STATE-ONLY" in header, "the state-only push is documented" + assert "STAYS PUT" in header, "the URL-stays-put note" + assert "no duplicate history" in header, "the duplicate-push skip" + assert "ADOPTS" in header and "popstate" in header, "the popstate adopt" + assert "RESETS the drill to the TOP level" in header, "the reset branch" + assert "BEFORE the re-fetch" in header, "the refresh alignment" + assert "Boot pushes NOTHING" in header, "no pushState on boot" + assert "resetVanishedLocation is UNCHANGED" in header + assert "module is UNTOUCHED" in header, "the router module is untouched"