diff --git a/.agent/phases/todo/64_sync_upload_progress/00_phase.md b/.agent/phases/complete/64_sync_upload_progress/00_phase.md similarity index 100% rename from .agent/phases/todo/64_sync_upload_progress/00_phase.md rename to .agent/phases/complete/64_sync_upload_progress/00_phase.md diff --git a/.agent/phases/todo/64_sync_upload_progress/06_e2e_sync_upload_progress.md b/.agent/phases/complete/64_sync_upload_progress/06_e2e_sync_upload_progress.md similarity index 100% rename from .agent/phases/todo/64_sync_upload_progress/06_e2e_sync_upload_progress.md rename to .agent/phases/complete/64_sync_upload_progress/06_e2e_sync_upload_progress.md diff --git a/.agent/phases/todo/65_bottom_chat_actions/01_move_chat_actions_to_bottom.md b/.agent/phases/complete/65_bottom_chat_actions/01_move_chat_actions_to_bottom.md similarity index 100% rename from .agent/phases/todo/65_bottom_chat_actions/01_move_chat_actions_to_bottom.md rename to .agent/phases/complete/65_bottom_chat_actions/01_move_chat_actions_to_bottom.md diff --git a/.agent/phases/todo/65_bottom_chat_actions/02_pin_bottom_cluster.md b/.agent/phases/complete/65_bottom_chat_actions/02_pin_bottom_cluster.md similarity index 100% rename from .agent/phases/todo/65_bottom_chat_actions/02_pin_bottom_cluster.md rename to .agent/phases/complete/65_bottom_chat_actions/02_pin_bottom_cluster.md diff --git a/.agent/phases/todo/65_bottom_chat_actions/03_align_button_clusters.md b/.agent/phases/complete/65_bottom_chat_actions/03_align_button_clusters.md similarity index 100% rename from .agent/phases/todo/65_bottom_chat_actions/03_align_button_clusters.md rename to .agent/phases/complete/65_bottom_chat_actions/03_align_button_clusters.md diff --git a/.agent/phases/todo/65_bottom_chat_actions/04_e2e_bottom_chat_actions.md b/.agent/phases/complete/65_bottom_chat_actions/04_e2e_bottom_chat_actions.md similarity index 100% rename from .agent/phases/todo/65_bottom_chat_actions/04_e2e_bottom_chat_actions.md rename to .agent/phases/complete/65_bottom_chat_actions/04_e2e_bottom_chat_actions.md diff --git a/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.err b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.md b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.md new file mode 100644 index 0000000..b5dfa28 --- /dev/null +++ b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.md @@ -0,0 +1,20 @@ +All verification complete — nothing to fix; the phase commit `4677d86` contains the full implementation. Final report: + +**Phase 64 final verification pass** (tasks 01–06 already complete; verified all criteria, no defects found) + +- **Test/lint/coverage** + - `uv run pytest --cov=app --cov-report=term -q` → 1251 passed, 0 failed; `app/` coverage **99%** (gate >90% OK) + - `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations" + - `uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov` → **4 passed** (isolation, DB up) + - Regressions, each in isolation: `test_archive_upload_sources` **5 passed**, `test_sync_button` **3 passed**, `test_git_sources_admin` **6 passed**, `test_sync_model_down` **4 passed** +- **Completion criteria** + 1. `current_file`/`files_done`/`files_total` on both status endpoints — ✅ idle live-checked via running app (both return `null`/`0`/`0`); running states asserted by unit + E2E + 2. `POST /upload` → 202 + background scan + 409 in-flight — ✅ (unit contract + E2E green) + 3. Toast at 202, navigate-away mid-scan shows "Importing \", settle + catalog refresh — ✅ `test_upload_toast_then_navigate_away` + 4. Live labels "Syncing… \" / "Importing \" / "Processing… \" — ✅ E2E green + 5. Full pytest green, coverage >90% — ✅ (99%) + 6. All 5 E2E suites green in isolation — ✅ + 7. ruff + pyright clean — ✅ + 8. Commit `4677d86` (`feat(sources): real-time file progress…`, `--no-gpg-sign`) exists; overview move to `complete/` left to the harness — ✅ +- **Decisions/deviations:** none — no code changes needed. +- **Next pending phase:** `65_bottom_chat_actions` diff --git a/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.validate b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__00_phase.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.md b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.md new file mode 100644 index 0000000..891a306 --- /dev/null +++ b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.md @@ -0,0 +1,17 @@ +**Task 06 complete — Phase 64 (sync + upload real-time progress) is done and committed.** + +**Implemented** +- `tests/e2e/test_sync_upload_progress.py` (new story suite, 4 tests): toast-on-202 → navigate away → sync button "Importing " + settle + catalog refresh; live `current_file` at both layers (100 ms endpoint recorder + UI label poll); sync "Syncing… " both layers with preserved pre-64 settle; mid-scan reload re-attach (no second upload, pinned on `started_at`). +- `tests/e2e/test_archive_upload_sources.py` adapted to the 202 contract: toast while running, "Uploading… → Processing… → restore", result/row from status `success`, 2nd-run status `detail` prune/refresh counts, 422 gates unchanged, anonymous 403 extended to `GET /upload/status`. +- `tests/e2e/slow_llm.py` (new helper): delay-injecting proxy in front of the mock LLM — the mock indexes 25 files in ≈0.4 s (benchmark), so the scan could never outlive the 2 s UI poll; the proxy makes scan duration deterministic (0.15 s/req → ≈4.2 s; 0.6 s in the archive suite). + +**Results (all green)** +- `uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov` → 4 passed (×3 runs); `test_archive_upload_sources.py` → 5 passed (×2); `test_sync_button.py` → 3 passed; `test_git_sources_admin.py` → 6 passed; `test_sync_model_down.py` → 4 passed (all isolated, DB up). +- `uv run pytest --cov=app` → all passed, TOTAL 99% (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors. +- Commit `4677d86` `--no-gpg-sign`: "feat(sources): real-time file progress for sync and upload — background upload with success toast"; `TODO.md` contains only `# TODO`. + +**Decisions/deviations** +- Per harness rules I did **not** move the phase dir — the todo→complete move of task 06 is left to the harness; the commit (per the task's exact `git add` scope) also swept phases 61–63 pending `.agent/` bookkeeping, matching the repo's established pattern. +- Recorder ignores residual terminal states (in-memory run state survives across module tests) and the autouse fixture waits for no in-flight job before each truncate — without these, a prior test's run leaked into the next. + +**Next pending task:** `.agent/phases/todo/65_bottom_chat_actions/01_move_chat_actions_to_bottom.md` diff --git a/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.validate b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/64_sync_upload_progress/64_sync_upload_progress__06_e2e_sync_upload_progress.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.err b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.md b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.md new file mode 100644 index 0000000..0e6d068 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.md @@ -0,0 +1,16 @@ +All gates green. The `.agent/phases/todo/64_*` deletions in git status are harness bookkeeping from phase 64's completion (not mine); the commit happens in task 05. + +**Task 01 report — `65_bottom_chat_actions/01_move_chat_actions_to_bottom`** + +- Moved the entire `.chat-actions` block (Phase 55/14/51 comment + row div) from between `#steering-announcer` and `#messages` to after the messages ``, directly above the composer — buttons verified byte-identical (git diff line-set comparison: only the appended note differs); DOM order New chat (L179) → Share (L211); exactly one `class="chat-actions"` in `frontend/index.html`. +- Appended the Phase 65 relocation note to the row's moved comment block (top of column now banner → steering → announcer → messages). +- No CSS/JS changes; untouched `#kb-banner`, `#stale-banner`, `#steering-panel`, `#steering-announcer`, `#messages`, `#composer`. +- Updated 3 position-pinning unit tests to the new contract: `test_chat_persistence.py::test_new_chat_button_lives_only_on_the_chat_page`, `test_save_chat_ui.py::test_share_button_ships_visible_beside_new_chat` + `test_chat_actions_wrapper_holds_both_pills_in_order` (now pin row below `#messages`, above composer, with nothing else between). + +Results (all green): +- `uv run pytest --cov=app --cov-report=term -q` → exit 0, TOTAL coverage **99%** (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` → **5 passed** (isolation, DB up) +- `uv run pytest tests/e2e/test_pinned_composer.py --no-cov` → exit 0 (isolation); `uv run pytest tests/e2e/test_chat_persistence.py --no-cov` → exit 0 (isolation) + +No deviations. Next pending task: `02_pin_bottom_cluster.md` (same phase). diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.validate b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__01_move_chat_actions_to_bottom.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.err b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.md b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.md new file mode 100644 index 0000000..cf48ab6 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.md @@ -0,0 +1,16 @@ +All gates green. Task 02 complete — no commit (that's task 05's job), task file untouched. + +**Report** + +- **Implemented (locked A1 pinned bottom cluster):** + - `frontend/index.html`: wrapped the `.chat-actions` row + `#composer` form (with comments, re-indented) in one new `
` (no id) — the last child of `.chat-shell`, containing exactly `.chat-actions` then `#composer`; appended the Phase 65 A1 note to the row's comment. + - `frontend/assets/styles.css`: new `.chat-bottom` rule directly above `.composer` — `display:flex; flex-direction:column; gap:0.5rem; position:sticky; bottom:env(safe-area-inset-bottom,0)`, no z-index; `#composer` rule body untouched, phase-52 comment gained a one-line Phase 65 note. + - `tests/unit/test_pinned_composer.py`: re-pinned the structure test (renamed `test_chat_bottom_unit_is_last_child_of_the_chat_shell`) — wrapper is last shell child, no id, exactly row→form; updated module docstring (also removed a duplicated bullet block found there). +- **Test/lint results:** + - `uv run pytest --cov=app` → all pass, TOTAL 99% (>90%) + - `uv run ruff check . && uv run pyright` → clean (0 errors) + - `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` → 4 passed (pin at every scroll position, empty-chat rest, mobile under header) + - `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` → 5 passed + - Extra: `test_chat_persistence.py` (4), `test_smoke.py` (3) passed in isolation; `bash .agent/validate.sh` → validation OK +- **Deviations:** none — wrapper per spec; only pre-existing stale unit assertion updated to the owner-locked A1 structure. +- **Next pending task:** `03_align_button_clusters.md` (right-align the bottom row, verify five-pill geometry). diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.validate b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__02_pin_bottom_cluster.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.err b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.md b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.md new file mode 100644 index 0000000..836f9c7 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.md @@ -0,0 +1,19 @@ +Task 03 complete. All gates green. + +**Implemented** +- `frontend/assets/styles.css` — added the single `justify-content: flex-end;` declaration to `.chat-actions` (pills keep intrinsic widths; the row now hugs the column's right edge per owner-locked A2) + updated the rule's comment (top member of the pinned `.chat-bottom` cluster, A2 rationale, 0.5rem wrapper gap, ≤640px stack unchanged). +- Five-pill geometry pass: no deviations found → no declaration changes (solid family: 44px/999px/16px icons; ghost family: 44px/999px/14px icons/0.82rem; global `:focus-visible` covers all five). +- No HTML/JS/`.msg-meta` changes; ≤640px stack rule byte-identical. + +**Verification** +- Temporary Playwright measurement (file deleted after use): all five pills exactly 44.0px rendered at 1280/390/360; Share's right edge flush with the composer's at 1280 (A2); stacked full-width at 390; no horizontal overflow at 360/390. (Save-as-doc measured via a same-markup probe in the real `.msg-meta` — it's gated off in the e2e app since `BOR_DOCS_REPO` is forced empty; CSS under test is identical.) +- `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` → 5 passed (DB up, isolated) +- `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` → 4 passed (sanity, isolated) +- `uv run pytest --cov=app --cov-report=term-missing` → 1251 passed, TOTAL **99%** (>90%) +- `uv run ruff check . && uv run pyright` → All checks passed / 0 errors, 0 warnings + +**Notes** +- Repo carries uncommitted task 01/02 work — expected; the phase's single atomic commit is task 05's job (not committed here). +- No defects found in prior work. + +**Next pending task:** `.agent/phases/todo/65_bottom_chat_actions/04_e2e_bottom_chat_actions.md` diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.validate b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__03_align_button_clusters.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.err b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.md b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.md new file mode 100644 index 0000000..ae30147 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.md @@ -0,0 +1,17 @@ +All gates are green. Final report: + +**Implemented** +- `tests/e2e/test_bottom_chat_actions.py` (NEW, house patterns; no other file changes): + - `test_empty_chat_row_rests_at_screen_bottom` — one `.chat-bottom` (row + composer), not scrollable, row rests ≥75% viewport height directly above composer, A2 right-edge alignment (row + Share flush ±2px vs column content edge), composer not clipped. + - `test_bottom_cluster_pinned_at_every_scroll_position` — seeded KB, 6 grounded turns (overflow precondition), sticky range from `.chat-shell` document-bottom (pin_limit > 200, max_scroll > pin_limit); cluster fully in viewport + flush at 0/25/50/75%/pin_limit−1; at document bottom settles above `.app-footer`, composer unglued, row still above composer. + - `test_row_geometry_and_alignment` — one `.chat-actions` in `.chat-bottom`, two pills, DOM order New→Share (compareDocumentPosition); desktop horizontal + intrinsic widths + A2 edge; 390×844 full-width stack (pills == `.chat-bottom` width ±2px); 360px `scrollWidth ≤ 360`; ≥44px targets at 1280 and 390 incl. `.retry-btn` after one seeded turn. + - `test_buttons_still_work_from_the_bottom` — New chat click: `.msg` 0, `#empty-state`, `bor.chat.v1` null, "New chat started"; Share on empty chat: "Nothing to share yet.", no toast, URL unchanged. + +**Results** (DB up) +- Each test green in isolation; `uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov` → **4 passed** (13.5s). +- Baseline `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` → 4 passed (prior tasks 01–03 defect-free). +- `.agent/validate.sh` → **validation OK**: unit+integration green, coverage TOTAL **99%** (>90%), `ruff check .` + `pyright` clean. + +**Decisions** — A2 "shell right edge" measured against the column's *content* edge (`.chat-shell` is a `.container` with padding; computed live), with the rightmost pill asserted flush as the real A2 pin; `_reset_db` truncates all 5 tables per task spec. + +**Next pending task:** `.agent/phases/todo/65_bottom_chat_actions/05_regressions_and_commit.md` diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.validate b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.validate new file mode 100644 index 0000000..6239700 --- /dev/null +++ b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__04_e2e_bottom_chat_actions.a1.validate @@ -0,0 +1,72 @@ +........................................................................ [ 5%] +........................................................................ [ 11%] +........................................................................ [ 17%] +........................................................................ [ 23%] +........................................................................ [ 28%] +........................................................................ [ 34%] +........................................................................ [ 40%] +........................................................................ [ 46%] +........................................................................ [ 51%] +........................................................................ [ 57%] +........................................................................ [ 63%] +........................................................................ [ 69%] +........................................................................ [ 74%] +........................................................................ [ 80%] +........................................................................ [ 86%] +........................................................................ [ 92%] +........................................................................ [ 97%] +........................... [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 22 0 100% +app/api/chat.py 131 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 7 0 100% +app/api/doc_drafts.py 93 0 100% +app/api/docs.py 50 0 100% +app/api/git_sources.py 181 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 8 0 100% +app/api/sync.py 101 0 100% +app/config.py 112 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 20 0 100% +app/core/caching.py 108 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/logging.py 13 0 100% +app/db.py 21 0 100% +app/main.py 52 0 100% +app/models.py 86 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 78 0 100% +app/rag/archive_upload.py 128 0 100% +app/rag/chunker.py 206 4 98% +app/rag/git_sources.py 14 0 100% +app/rag/importer.py 180 3 98% +app/rag/llm.py 189 0 100% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 65 0 100% +app/rag/retriever.py 94 3 97% +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 201 0 100% +----------------------------------------------- +TOTAL 2529 12 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +validation OK diff --git a/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__05_regressions_and_commit.a1.err b/.agent/reports/65_bottom_chat_actions/65_bottom_chat_actions__05_regressions_and_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 39bacd4..f5aabd0 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -423,17 +423,31 @@ html::after { (a normal .chat-shell column child). As a flex ITEM of the column it spans the column width, but as a flex ROW with align-items: center (not the column default stretch) each pill keeps its INTRINSIC - content width — two pills side by side, left-aligned in the column, - on desktop. The ≤640px block flips this to a vertical stack - (flex-direction: column + align-items: stretch — full-width pills, - New chat above Share; the existing ≤640px pill rules apply to the - stacked pills unchanged). The reading-column contract is untouched - (--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage). - */ + content width — two pills side by side on desktop. The ≤640px block + flips this to a vertical stack (flex-direction: column + + align-items: stretch — full-width pills, New chat above Share; the + existing ≤640px pill rules apply to the stacked pills unchanged). + The reading-column contract is untouched (--chat-column: 46rem base, + 92rem at >=1500px — PLAN §7 lineage). + + Phase 65 (task 03, 2026-09-01, `TODO.md` L3, owner-locked A2): the + row is the TOP member of the pinned .chat-bottom cluster (task + 01/02) and hugs the column's RIGHT edge on desktop + (justify-content: flex-end) — mirroring the right-aligned + "Save as doc" action of every brain bubble above it, so the + bottom-right of the chat reads as ONE aligned action column. The + pills keep their intrinsic widths (align-items: center already + beats the column default stretch; justify-content only shifts the + group). The vertical row↔composer spacing is the .chat-bottom + wrapper's 0.5rem gap (task 02). The ≤640px stack rule below is + UNCHANGED — with stretched full-width pills the horizontal + alignment is moot (the stacked pills already edge-to-edge the + column). */ .chat-actions { display: flex; flex-direction: row; align-items: center; + justify-content: flex-end; gap: 0.6rem; } @@ -1222,7 +1236,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } so messages scrolling behind the pinned box never show through it, and no z-index is added: DOM order already paints the composer over `.messages`, it never overlaps the sticky header (z 20) and stays - under the z-1000 document modal. */ + under the z-1000 document modal. + Phase 65 (2026-09-01, `TODO.md` L3, owner-locked A1): the pin now + belongs to the .chat-bottom wrapper (rule below) — the row + this + form are ONE sticky unit; this composer's own sticky declaration is + kept, redundant inside the wrapper but pinned as a computed style by + tests/e2e/test_pinned_composer.py. */ +/* Phase 65 (owner-locked A1, 2026-09-01, `TODO.md` L3): the pinned + bottom cluster — the .chat-actions row + the #composer form as ONE + sticky unit (A1): the pills are literally always at the bottom of + the screen, even while the reader is scrolled up through a long + conversation (the rejected in-flow alternative would scroll away). + The sticky range is the .chat-shell containing block (the wrapper's + parent) — the same range the composer used to have alone — and the + .messages flex-grow still rests the WHOLE unit at the screen bottom + on short chats (the wrapper is the last shell child, so the grow + pushes it down). The shell's 1rem gap applies only ABOVE the unit, + from .messages — the 0.5rem gap here is the row↔composer spacing + INSIDE it. No z-index: the phase-12 sticky header (z 20) and the + phase-46 dropdown always stay on top, and at the document bottom the + unit settles back into flow above the footer (the composer's opaque + surface keeps messages hidden where the box overlaps them). The + composer's own sticky rule below stays — redundant here (its + containing block is the wrapper, so it cannot shift within the + unit), pinned by tests/e2e/test_pinned_composer.py. */ +.chat-bottom { + display: flex; + flex-direction: column; + gap: 0.5rem; + position: sticky; + bottom: env(safe-area-inset-bottom, 0); +} .composer { display: flex; align-items: flex-end; diff --git a/frontend/index.html b/frontend/index.html index 19537e2..dd06c23 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -128,63 +128,6 @@

- -
- - - - -
- -
- - - -
+ +
+
+ + + + +
+ + +
+ + + +
+
diff --git a/tests/e2e/test_bottom_chat_actions.py b/tests/e2e/test_bottom_chat_actions.py new file mode 100644 index 0000000..29bcc74 --- /dev/null +++ b/tests/e2e/test_bottom_chat_actions.py @@ -0,0 +1,576 @@ +"""Phase 65 E2E (Playwright): the chat action cluster is the pinned bottom. + +Source: ``TODO.md`` L3 — "Move the new chat and share button to the +tune/retry/save doc cluster area so it's always at the bottom of the +screen and easily accessble. Make the button clusters look better, +neater, more aligned" (no user story file — TODO-derived phase). +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov + +Mock-only, no admin login (chat is public). The owner-locked contracts +(2026-09-01, roadmap confirmation) under test — observed in a real +browser: + +* **A1 — the pinned bottom cluster:** the ``.chat-bottom`` wrapper + (the ``.chat-actions`` row + the ``#composer``, ONE sticky unit — + the LAST child of ``.chat-shell``; ``position: sticky; bottom: + env(safe-area-inset-bottom, 0)``, NO z-index) is fully inside the + viewport with its bottom flush with the viewport bottom at EVERY + scroll position inside the sticky range — the New chat + Share + pills are literally always at the bottom of the screen, even while + the reader is scrolled up through a long conversation — and at the + document bottom the whole unit settles back into normal flow above + the ``.app-footer`` (never floating over it); +* **A2 — the right-aligned bottom row:** on desktop the row hugs the + column's RIGHT edge (mirroring the right-aligned "Save as doc" + corner of every brain bubble above — the bottom-right of the chat + reads as one aligned action column); at ≤640px the row is a + full-width vertical stack (alignment is moot when stretched); +* **the click-through survived the move:** ``#new-chat-btn`` + (header.js → the ``bor:new-chat`` window event → the app.js reset) + clears the conversation (``.msg`` count 0, ``#empty-state`` back, + ``bor.chat.v1`` gone from localStorage, "New chat started" in + ``#send-status``); ``#share-chat-btn`` on the now-empty chat is the + guarded no-op (app.js ``shareCurrentChat``: "Nothing to share + yet.", no toast, no navigation). + +Determinism: the mock's grounded answers quote the question and end in +the ``Deterministic mock answer for E2E`` marker; overflow is built +from real UI turns (6 short grounded turns — the house +``SHORT_QUESTIONS`` phrasing, the same overflow recipe as +``test_pinned_composer.py``); all scrolling is done by the tests via +``window.scrollTo`` (the phase-42 never-auto-scroll contract — the +app, and the phase-65 sticky wrapper, add no scroll call site). + +Test → contract mapping (Playwright Mapping Rule): +1. ``test_empty_chat_row_rests_at_screen_bottom`` — the resting + A1/A2 geometry on a fresh page (static markup, no KB seed) +2. ``test_bottom_cluster_pinned_at_every_scroll_position`` — A1 + across the whole sticky range + the settle into flow +3. ``test_row_geometry_and_alignment`` — A2 geometry + the A5 DOM + order + the mobile stack + the 360px overflow bound + the 44px + touch targets +4. ``test_buttons_still_work_from_the_bottom`` — the click-through + contracts (New chat reset, Share no-op) +""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from pathlib import Path +from threading import Thread +from typing import Any + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +STORAGE_KEY = "bor.chat.v1" + +#: Six short grounded questions (the house phrasing + on-topic variants +#: — the honesty gate is HIGH for all of them, so every turn renders an +#: answer bubble + source chips). Six turns overflow the 800px viewport +#: by a wide margin; the tests assert the overflow, so the count can +#: never silently stop being enough. +SHORT_QUESTIONS = ( + "How is my Kubernetes cluster set up?", + "How do my backups work?", + "How did I install gitlab?", + "How does my homelab networking work?", + "What is in the new-service deployment?", + "What scripts do I have in the homelab?", +) + +#: Tolerance for "flush with the viewport bottom": `env(safe-area-inset- +#: bottom)` resolves to 0 on a desktop/notched-free viewport, so the only +#: slack is sub-pixel rounding (the same headroom `test_pinned_composer.py` +#: allows the composer). +FLUSH_PX = 4 +#: The resting row must sit in the LOWER part of the screen — the pre-phase +#: 65 geometry had it at the very top of the column (the exact defect this +#: phase removes). +LOWER_PART = 0.75 + +# The typing indicator is itself a .msg.brain — exclude its bubble. +ANSWER = ".msg.brain .bubble:not(.typing)" + + +# --------------------------------------------------------------------------- +# KB seeding (same pattern as the phase 42/52/55 story suites) +# --------------------------------------------------------------------------- + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test + thread, so ``asyncio.run`` cannot be called directly from a test + body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log, steering notes, saved chats — + fully deterministic per test), then optionally re-import fixtures. + ``saved_chats`` IS truncated here (unlike the phase-55 suite, which + keeps rows across its tests): the click-through tests below touch + the auto-save path, so each test starts from an empty table.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes, saved_chats")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +@pytest.fixture() +def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]: + """A fresh KB seeded from ``tests/fixtures/docs`` (13 docs, A9 + formats), truncated again on teardown. ``db_ready`` (conftest) + skips with clear instructions when Postgres is down.""" + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 13 + yield + _reset_db(mock_llm, seed=False) + + +# --------------------------------------------------------------------------- +# Measurement + flow helpers +# --------------------------------------------------------------------------- + + +def scroll_state(page: Page) -> dict[str, float]: + """The document scroller's state (there is no inner scroll container).""" + return page.evaluate( + "() => ({ y: window.scrollY, " + "sh: document.documentElement.scrollHeight, " + "ch: window.innerHeight })" + ) + + +def wait_settled(page: Page, timeout: int = 30_000) -> None: + """The turn is over: the label is back to "Send" (phase 48 — the + in-flight state is the enabled Stop control, so the label carries + the wait).""" + expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout) + expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout) + + +def submit(page: Page, question: str) -> None: + """Submit through the composer (the real-user flow).""" + page.fill("#message-input", question) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def build_conversation(page: Page, n: int = 6) -> None: + """Settle ``n`` short grounded turns through the UI (the overflow is + real conversation content, not injected DOM).""" + for question in SHORT_QUESTIONS[:n]: + submit(page, question) + wait_settled(page) + + +def box_of(page: Page, selector: str) -> dict[str, float]: + """The selector's viewport-relative box as a plain dict. + + Measured WITHOUT scrolling (``bounding_box`` never scrolls the page), + so the answer is "where the box sits where the user left it" — + exactly the question this phase asks (always at the bottom). + """ + rect = page.locator(selector).bounding_box() + assert rect is not None, f"{selector} must be rendered (no bounding box)" + return { + "x": rect["x"], + "y": rect["y"], + "width": rect["width"], + "height": rect["height"], + } + + +def column_right_edge(page: Page) -> float: + """The right edge of the chat column's CONTENT (A2's reference edge). + + ``.chat-shell`` is a ``.container`` (``padding-inline`` 1.25rem + desktop / 0.9rem ≤640px), so its bounding box includes the padding — + but the column's visible content (every bubble, the meta rows, the + bottom row) ends at the padding edge. Measured live so the + breakpoint's padding value is never hard-coded.""" + return page.evaluate( + "() => {" + " const s = document.querySelector('.chat-shell');" + " const r = s.getBoundingClientRect();" + " return r.right - parseFloat(getComputedStyle(s).paddingRight);" + " }" + ) + + +def assert_cluster_flush_with_viewport_bottom(page: Page) -> dict[str, float]: + """The ``.chat-bottom`` unit is FULLY inside the viewport with its + bottom edge at the viewport bottom (± ``FLUSH_PX``), and the row + AND the composer are each fully visible — the pills are literally + always at the bottom of the screen (A1) at every reading + position. This is the whole story: nothing in the cluster hides + behind the fold while the reader is scrolled up.""" + box = box_of(page, ".chat-bottom") + ch = scroll_state(page)["ch"] + assert box["y"] >= -FLUSH_PX, ( + f"the pinned cluster is clipped at the TOP of the viewport " + f"(y={box['y']:.1f}, viewport={ch:.0f})" + ) + bottom = box["y"] + box["height"] + assert bottom <= ch + FLUSH_PX, ( + f"the cluster runs BELOW the viewport bottom " + f"(bottom={bottom:.1f}, viewport={ch:.0f})" + ) + assert bottom >= ch - FLUSH_PX, ( + f"the cluster is not pinned to the bottom edge " + f"(bottom={bottom:.1f}, viewport={ch:.0f})" + ) + # The unit's two members are each fully visible too — the row is + # the contract (the pills), the composer rides along with it. + row = box_of(page, ".chat-actions") + composer = box_of(page, "#composer") + for name, b in ((".chat-actions", row), ("#composer", composer)): + assert b["y"] >= -FLUSH_PX and b["y"] + b["height"] <= ch + FLUSH_PX, ( + f"{name} is not fully inside the viewport at a reading position " + f"(box={b}, viewport={ch:.0f})" + ) + return box + + +# --------------------------------------------------------------------------- +# 1. Resting geometry: a fresh chat is not scrollable and the row rests +# in the lower part of the screen, directly above the composer, +# hugging the column's right edge (A2) +# --------------------------------------------------------------------------- + + +def test_empty_chat_row_rests_at_screen_bottom( + page: Page, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + # Exactly ONE pinned unit, holding exactly the row + the composer — + # the static markup of the new bottom cluster (no KB seed needed). + expect(page.locator(".chat-bottom")).to_have_count(1) + cluster = page.locator(".chat-bottom") + expect(cluster.locator(".chat-actions")).to_have_count(1) + expect(cluster.locator("#composer")).to_have_count(1) + + # A fresh visitor: the empty state, nothing to scroll — the wrapper + # must not invent scrollable space (the phase-52 contract, kept: + # the wrapper adds no height the row did not already carry). + expect(page.locator("#empty-state")).to_be_visible() + state = scroll_state(page) + assert state["sh"] <= state["ch"] + 1, ( + f"an empty chat must not be scrollable (sh={state['sh']:.0f}, " + f"ch={state['ch']:.0f}) — the wrapper must not invent scrollable space" + ) + ch = state["ch"] + + row = box_of(page, ".chat-actions") + composer = box_of(page, "#composer") + share = box_of(page, "#share-chat-btn") + + # The row rests in the LOWER part of the screen ... (pre-phase-65 it + # sat at the top of the column and scrolled off-screen immediately). + assert row["y"] + row["height"] >= ch * LOWER_PART, ( + f"the resting row is at {row['y'] + row['height']:.0f}px of a " + f"{ch:.0f}px viewport — it has to sit at the bottom of the screen" + ) + # ... directly ABOVE the composer (the unit's 0.5rem gap sits + # between them — no overlap, no dead band). + assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, ( + f"the row must sit directly above the composer (row={row}, " + f"composer={composer})" + ) + # A2: the row hugs the column's right edge — the row box spans the + # column's content width, and the rightmost pill (Share) is flush + # with that edge (justify-content: flex-end — a left-aligned group + # would leave a dead band on the right). + right = column_right_edge(page) + assert abs(row["x"] + row["width"] - right) <= 2, ( + f"the row's right edge ({row['x'] + row['width']:.1f}) does not align " + f"with the column's right edge ({right:.1f})" + ) + assert abs(share["x"] + share["width"] - right) <= 2, ( + f"the rightmost pill is not flush with the column's right edge " + f"(share right={share['x'] + share['width']:.1f}, column={right:.1f})" + ) + # The composer is not clipped below the viewport (it rests in flow, + # above the footer — never hanging off the bottom of the screen). + assert composer["y"] + composer["height"] <= ch + FLUSH_PX, ( + f"the resting composer hangs below the viewport bottom " + f"(bottom={composer['y'] + composer['height']:.1f}, viewport={ch:.0f})" + ) + + +# --------------------------------------------------------------------------- +# 2. The A1 pin: an over-viewport conversation keeps the whole cluster +# (row + composer) flush with the viewport bottom at every scroll +# position, settling into flow above the footer at the document bottom +# --------------------------------------------------------------------------- + + +def test_bottom_cluster_pinned_at_every_scroll_position( + page: Page, app_url: str, seeded_kb: None +) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + build_conversation(page, n=len(SHORT_QUESTIONS)) + + # The phase's precondition: a conversation longer than the viewport + # (the reader has content to scroll through — the moment the old + # top-of-column row used to leave the screen). + state = scroll_state(page) + assert state["sh"] > state["ch"] + 200, ( + f"the conversation must overflow the viewport (sh={state['sh']:.0f}, " + f"ch={state['ch']:.0f}) — more turns are needed" + ) + + # Scrolled to the very top (the "reading the beginning" position): + # the WHOLE cluster is inside the viewport, flush with its bottom — + # the pills are reachable without any scrolling. + page.evaluate("() => window.scrollTo(0, 0)") + assert scroll_state(page)["y"] <= 1 + assert_cluster_flush_with_viewport_bottom(page) + + # And at every stop through the sticky range. The range is bounded by + # the cluster's CONTAINING BLOCK (`.chat-shell` — the wrapper's + # parent): the pin holds while that column's bottom edge sits below + # the viewport bottom (the same math as + # `test_pinned_composer.py::test_composer_pinned_at_every_scroll_position`). + limits = page.evaluate( + "() => ({ shellBottom: document.querySelector('.chat-shell')" + ".getBoundingClientRect().bottom + window.scrollY, " + "sh: document.documentElement.scrollHeight, ch: window.innerHeight })" + ) + max_scroll = limits["sh"] - limits["ch"] + pin_limit = limits["shellBottom"] - limits["ch"] + assert pin_limit > 200, ( + f"the conversation must keep the cluster inside its sticky range " + f"for at least a screen of scrolling (limit={pin_limit:.0f})" + ) + assert max_scroll > pin_limit, ( + "the document bottom must leave the sticky range, so the settle " + "back into flow is covered too" + ) + for y in (0, pin_limit * 0.25, pin_limit * 0.5, pin_limit * 0.75, pin_limit - 1): + page.evaluate("y => window.scrollTo(0, y)", y) + assert abs(scroll_state(page)["y"] - y) <= 1, f"the test scroll to {y} must land" + assert_cluster_flush_with_viewport_bottom(page) + + # Settled at the document bottom: the unit has returned to its NORMAL + # FLOW position — above the footer, not hovering over it, the composer + # no longer glued to the viewport edge, the row still directly above it. + page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)") + assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom" + ch = scroll_state(page)["ch"] + cluster = box_of(page, ".chat-bottom") + row = box_of(page, ".chat-actions") + composer = box_of(page, "#composer") + footer = box_of(page, ".app-footer") # the footer must be rendered + assert cluster["y"] + cluster["height"] <= footer["y"] + FLUSH_PX, ( + f"the settled cluster overlaps the footer (cluster={cluster}, " + f"footer={footer}) — the pin must not float the unit over chrome" + ) + assert composer["y"] + composer["height"] < ch - FLUSH_PX, ( + "at the document bottom the composer settles into flow — it is no " + "longer glued to the viewport edge" + ) + assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, ( + "in its settled slot the row must still sit directly above the " + f"composer (row={row}, composer={composer})" + ) + + # The conversation itself is intact (6 turns, all answers landed) — + # the pin changed the geometry, not the content. + expect(page.locator(".msg.user .bubble")).to_have_count(len(SHORT_QUESTIONS)) + expect(page.locator(".msg.brain .bubble")).to_have_count(len(SHORT_QUESTIONS)) + expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) + + +# --------------------------------------------------------------------------- +# 3. Row geometry + alignment (A2): ONE row with the two pills in the A5 +# DOM order — horizontal + right-hugging at desktop, full-width stack +# at ≤640px, no horizontal overflow at 360px, 44px touch targets +# --------------------------------------------------------------------------- + + +def test_row_geometry_and_alignment( + page: Page, app_url: str, seeded_kb: None +) -> None: + page.set_default_timeout(30_000) + + # No conversation needed for the row — static markup, always present + # (the page fixture's viewport is the 1280×800 desktop). + page.goto(app_url) + + # Exactly ONE .chat-actions, inside the pinned unit, holding exactly + # the two pills — and the A5 DOM order: New chat, then Share (the + # same compareDocumentPosition check as test_save_share_ux.py). + expect(page.locator(".chat-actions")).to_have_count(1) + expect(page.locator(".chat-bottom .chat-actions")).to_have_count(1) + row_el = page.locator(".chat-actions") + assert row_el.locator("#new-chat-btn").count() == 1 + assert row_el.locator("#share-chat-btn").count() == 1 + expect(page.locator("#new-chat-btn")).to_have_count(1) + expect(page.locator("#share-chat-btn")).to_have_count(1) + assert page.evaluate( + "() => {" + " const n = document.getElementById('new-chat-btn');" + " const s = document.getElementById('share-chat-btn');" + " return !!(n && s && (n.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING));" + " }" + ), "the DOM order must be New chat, then Share" + + new_btn = page.locator("#new-chat-btn") + share_btn = page.locator("#share-chat-btn") + + # Desktop (1280×800): one horizontal row — overlapping y bands, + # Share to the right of New chat, each pill at its INTRINSIC width + # (never stretched), and the row hugging the column's RIGHT edge (A2). + nb = new_btn.bounding_box() + sb = share_btn.bounding_box() + assert nb is not None and sb is not None + assert nb["y"] < sb["y"] + sb["height"] and sb["y"] < nb["y"] + nb["height"], ( + f"the pills must share one horizontal row (new={nb}, share={sb})" + ) + assert sb["x"] > nb["x"] + nb["width"], "Share must sit right of New chat" + column_w = page.evaluate( + "() => document.querySelector('.chat-shell').getBoundingClientRect().width" + ) + assert nb["width"] < column_w / 2 and sb["width"] < column_w / 2, ( + "each pill must keep its intrinsic width on desktop, not stretch the column" + ) + row_box = page.locator(".chat-actions").bounding_box() + assert row_box is not None + right = column_right_edge(page) + assert abs(row_box["x"] + row_box["width"] - right) <= 2, ( + f"the row's right edge ({row_box['x'] + row_box['width']:.1f}) does not " + f"align with the column's right edge ({right:.1f}) — A2" + ) + assert abs(sb["x"] + sb["width"] - right) <= 2, ( + f"the rightmost pill is not flush with the column's right edge " + f"(share right={sb['x'] + sb['width']:.1f}, column={right:.1f}) — A2" + ) + + # Mobile (390×844): a vertical stack — Share BELOW New chat, both + # pills the SAME width, each stretched to the unit's full content + # width (the ≤640px stack rule — alignment is moot when stretched). + page.set_viewport_size({"width": 390, "height": 844}) + nb = new_btn.bounding_box() + sb = share_btn.bounding_box() + assert nb is not None and sb is not None + assert sb["y"] > nb["y"] + nb["height"], ( + f"the pills must stack at 390px, Share below New chat (new={nb}, share={sb})" + ) + cluster_box = page.locator(".chat-bottom").bounding_box() + assert cluster_box is not None + assert abs(nb["width"] - sb["width"]) <= 2, "the stacked pills share one full width" + # .chat-bottom carries no padding/border — its box width IS the + # unit's content width the pills must fill. + assert abs(nb["width"] - cluster_box["width"]) <= 2, ( + "the stacked New chat pill must stretch the unit's full width" + ) + assert abs(sb["width"] - cluster_box["width"]) <= 2, ( + "the stacked Share pill must stretch the unit's full width" + ) + + # 360px wide: no horizontal page overflow (the two stacked pills + + # the container padding must fit). + page.set_viewport_size({"width": 360, "height": 800}) + scroll_w = page.evaluate("() => document.documentElement.scrollWidth") + assert scroll_w <= 360, f"horizontal overflow at 360px: scrollWidth={scroll_w}" + + # Touch targets: the bottom cluster's pills render ≥44px tall at BOTH + # widths — and so does the .retry-btn the app injects on the last + # brain bubble (one seeded turn makes it exist; the ghost family + # shares the 44px floor with the solid family). + page.set_viewport_size({"width": 1280, "height": 800}) + submit(page, SHORT_QUESTIONS[0]) + wait_settled(page) + expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER) + expect(page.locator(".retry-btn")).to_have_count(1) + for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"): + assert box_of(page, selector)["height"] >= 44, ( + f"{selector} must keep the 44px touch floor at 1280px" + ) + page.set_viewport_size({"width": 390, "height": 844}) + for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"): + assert box_of(page, selector)["height"] >= 44, ( + f"{selector} must keep the 44px touch floor at 390px" + ) + + +# --------------------------------------------------------------------------- +# 4. The click-through survived the move: New chat resets the +# conversation from the bottom cluster; Share on the empty chat is +# the guarded no-op +# --------------------------------------------------------------------------- + + +def test_buttons_still_work_from_the_bottom( + page: Page, app_url: str, seeded_kb: None +) -> None: + page.set_default_timeout(30_000) + page.goto(app_url) + + # One real conversation: the buttons have something to act on (and + # the auto-save path fires — the row is cleaned up by _reset_db). + submit(page, SHORT_QUESTIONS[0]) + wait_settled(page) + expect(page.locator(".msg")).to_have_count(2) + expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER) + + # New chat (header.js → the `bor:new-chat` window event → the app.js + # reset): the conversation is cleared from the DOM, from localStorage, + # and the a11y announcer reports the reset — from its new home in the + # bottom cluster (the binding is id-based and position-independent). + page.click("#new-chat-btn") + expect(page.locator(".msg")).to_have_count(0) + expect(page.locator("#empty-state")).to_be_visible() + assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None, ( + "the cleared conversation must be gone from localStorage (bor.chat.v1)" + ) + expect(page.locator("#send-status")).to_contain_text("New chat started") + + # Share on the now-empty chat: the guarded no-op (app.js + # shareCurrentChat) — the a11y status line, no toast, no navigation. + url_before = page.url + page.click("#share-chat-btn") + expect(page.locator("#send-status")).to_contain_text("Nothing to share yet.") + expect(page.locator(".toast")).to_have_count(0) + assert page.url == url_before, "the empty-chat share no-op must not navigate" diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index 882e96f..fb75ac7 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -178,8 +178,11 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None: """#new-chat-btn is a real type=button with an accessible name. Moved from the shared header bar to the chat page at owner request (2026-08-28): it lives ONLY in index.html — inside
, in - .chat-shell, above the #messages section (the single module binding - + the no-op guard are pinned in test_shared_header.py).""" + .chat-shell. Phase 65 (2026-09-01, `TODO.md` L3, owner confirmation) + relocated the .chat-actions row from the top of the column to the + bottom: the button now sits BELOW the #messages section, directly + above the composer (the single module binding + the no-op guard are + pinned in test_shared_header.py).""" html = _index() btn = re.search(r']*id="new-chat-btn"[^>]*>', html) assert btn, "index.html must contain #new-chat-btn" @@ -191,8 +194,10 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None: shell_idx = html.find('class="container chat-shell"') assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell" messages_idx = html.find('id="messages"') - assert messages_idx != -1 and btn.start() < messages_idx, ( - "the button sits above the #messages section" + messages_end = html.find("", messages_idx) + composer_idx = html.find('
None: # ---------- index.html: no DOM change needed ---------- -def test_composer_is_last_child_of_the_chat_shell() -> None: - """The composer must remain the LAST element child of `.chat-shell` - (the sticky containing block): the sticky shift range is that - column's box, so a sibling after the form would carve the range away - and re-break the pin. The `#message-input` / `#send-btn` / - `#send-status` markup is untouched (the pin ships no DOM change).""" +def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None: + """Phase 65 (task 02, owner-locked A1): the LAST element child of + `.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS + binds it; the bindings live on the inner elements, the move is pure + HTML/CSS) — holding exactly the `.chat-actions` row and then the + `#composer` form, in that order: the row + composer are ONE sticky + unit, and the wrapper owns the shell's bottom slot, so the sticky + shift range is still that column's box (a sibling after it would + carve the range away and re-break the pin). The composer form keeps + `novalidate` and its contract ids.""" shell = _tree().find("chat-shell") last = shell["children"][-1] - assert last["tag"] == "form", ( - "the composer must stay the last child of .chat-shell" + assert last["tag"] == "div" and ( + last["attrs"].get("class") or "" + ).split() == ["chat-bottom"], ( + "the .chat-bottom wrapper must be the last child of .chat-shell" ) - assert last["attrs"].get("id") == "composer" - assert "novalidate" in last["attrs"], ( + assert last["attrs"].get("id") is None, ( + "the wrapper carries no id — the JS bindings live on the inner " + "elements" + ) + kids = last["children"] + assert len(kids) == 2, ( + "the unit holds exactly two element children: .chat-actions, then " + "#composer" + ) + row, form = kids + assert row["tag"] == "div" and ( + row["attrs"].get("class") or "" + ).split() == ["chat-actions"], ( + "the first child is the .chat-actions row (the New chat → Share " + "DOM order is pinned by test_save_chat_ui.py)" + ) + assert form["tag"] == "form" and form["attrs"].get("id") == "composer" + assert "novalidate" in form["attrs"], ( "phase 48: the composer form stays `novalidate` (a `required` " "input would swallow the Stop click)" ) ids = { node["attrs"].get("id") - for node in _walk(last) + for node in _walk(form) if node["attrs"].get("id") } assert {"composer", "message-input", "send-btn", "send-status"} <= ids, ( diff --git a/tests/unit/test_save_chat_ui.py b/tests/unit/test_save_chat_ui.py index 360ca57..a07954f 100644 --- a/tests/unit/test_save_chat_ui.py +++ b/tests/unit/test_save_chat_ui.py @@ -393,9 +393,12 @@ def test_share_button_ships_visible_beside_new_chat() -> None: "Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 — NO ``hidden`` attribute, no reveal step; the phase-51 admin-only ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell - inside
, above #messages — the chat-shell actions read as a - pair (New chat | Share; the Save pill is gone, phase 55). No other - page carries it (chat-page only, like New chat).""" + inside
— the chat-shell actions read as a pair (New chat | + Share; the Save pill is gone, phase 55). Phase 65 (2026-09-01, + ``TODO.md`` L3, owner confirmation) moved the row from the top of + the column to the bottom: below #messages, directly above the + composer. No other page carries it (chat-page only, like New + chat).""" html = _index() btn = re.search(r']*id="share-chat-btn"[^>]*>', html) assert btn, "index.html must contain #share-chat-btn" @@ -408,13 +411,18 @@ def test_share_button_ships_visible_beside_new_chat() -> None: btn_block = html[btn.start() : html.find("", btn.start())] assert '>Share' in btn_block # Beside New chat (the Save pill is gone): after it, still inside - # .chat-shell, above #messages. + # .chat-shell — below #messages, above the composer (phase 65 + # moved the row to the bottom of the column). shell_idx = html.find('class="container chat-shell"') new_idx = html.find('id="new-chat-btn"') messages_idx = html.find('id="messages"') - assert -1 < shell_idx < new_idx < btn.start() < messages_idx, ( - "the button must sit beside #new-chat-btn in .chat-shell, above #messages" + messages_end = html.find("", messages_idx) + composer_idx = html.find(' None: def test_chat_actions_wrapper_holds_both_pills_in_order() -> None: - """index.html (task 05, A5): ONE ``
`` - wraps BOTH pills — its element children are exactly the two - buttons, in the A5 order New chat → Share. The wrapper replaces - the two pills as direct children of ``.chat-shell`` (a normal - column child): inside the shell, above ``#messages``; nothing else - lands between the steering announcer and the row, and nothing but - the phase-49 comment lands between the row and ``#messages``. No + """index.html (task 05, A5; phase 65 task 01): ONE + ``
`` wraps BOTH pills — its element + children are exactly the two buttons, in the A5 order New chat → + Share. The wrapper is a normal ``.chat-shell`` column child, but + phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it + from the top of the column to the bottom: below the ``#messages`` + section, directly above the composer; nothing but the row's own + comment lands between ``#messages`` and the row, and nothing but + the composer comment lands between the row and the composer. No other page carries ``.chat-actions`` (chat-page only, like the pills).""" html = _index() @@ -694,23 +704,31 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None: new_i = wrap.find('id="new-chat-btn"') share_i = wrap.find('id="share-chat-btn"') assert -1 < new_i < share_i, "A5 order: New chat first, then Share" - # Position: a .chat-shell column child, above #messages — the - # kb-banner / stale-banner / steering / announcer structure is - # untouched (nothing else with an id around the row). + # Position: a .chat-shell column child, BELOW #messages and directly + # above the composer (phase 65 — the top of the column is now + # banner → steering → announcer → messages; nothing else with an id + # around the row). shell_idx = html.find('class="container chat-shell"') messages_idx = html.find('id="messages"') - assert -1 < shell_idx < start < end < messages_idx, ( - "the row is a .chat-shell column child, above #messages" - ) - ann_idx = html.find('id="steering-announcer"') - between = html[html.find("

", ann_idx):start] - assert "id=" not in between and "", messages_idx) + composer_idx = html.find('