feat(web): move the chat action cluster to the pinned bottom and align the button sets

- task 01: relocate the .chat-actions row (New chat + Share, comments byte-identical with a Phase 65 note) from the top of the column to the bottom of .chat-shell, directly above the composer
- task 02 (owner-locked A1): wrap the row + #composer in ONE sticky .chat-bottom unit (position: sticky; bottom: env(safe-area-inset-bottom, 0), no z-index) — the pills stay at the bottom of the screen at every scroll position and settle into flow above the footer
- task 03 (owner-locked A2): right-align the bottom row to the column's right edge (justify-content: flex-end), mirroring the right-aligned Save-as-doc corner; the five action pills share one 44px / 999px-pill geometry
- task 04: dedicated Playwright suite tests/e2e/test_bottom_chat_actions.py (resting geometry, the A1 pin across the sticky range, A2 alignment + DOM order + mobile stack + 360px overflow bound + 44px touch targets, New chat / Share click-through) — green in isolation
- task 05: regression matrix green in isolation (pinned_composer 4, save_share_ux 5, chat_persistence 4, share_chat 4, chat_history 5, smoke 3); full gate green — unit + integration pass, app/ coverage 99% (>90%), ruff + pyright clean
This commit is contained in:
2026-09-02 01:08:18 -04:00
parent 4677d86f49
commit 8a1f99cb38
30 changed files with 1376 additions and 147 deletions
@@ -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 \<file\>", settle + catalog refresh — ✅ `test_upload_toast_then_navigate_away`
4. Live labels "Syncing… \<file\>" / "Importing \<file\>" / "Processing… \<file\>" — ✅ 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`
@@ -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
@@ -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 <file>" + settle + catalog refresh; live `current_file` at both layers (100 ms endpoint recorder + UI label poll); sync "Syncing… <file>" 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`
@@ -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
@@ -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 `</section>`, 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).
@@ -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
@@ -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 `<div class="chat-bottom">` (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).
@@ -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
@@ -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`
@@ -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
@@ -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`
@@ -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
+52 -8
View File
@@ -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;
+44 -22
View File
@@ -128,6 +128,27 @@
</section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin
only, phase 15) and Retry (every visitor; the LAST brain
bubble only, redo-in-place). No static markup: both are
injected like the source chips. -->
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
<div class="empty-state" id="empty-state">
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M10 8h28a4 4 0 0 1 4 4v18a4 4 0 0 1-4 4H24l-9 8v-8h-5a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z"/><path d="m15 17 5 4-5 4"/><path d="M24 25h8"/></svg>
</div>
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
<p class="empty-state-sub">
I've read through your documents — ask me anything,
and I'll point you at the exact doc. You've got this.
</p>
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
<!-- suggestion chips rendered by app.js from /api/suggestions -->
</div>
</div>
</section>
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
the chat-actions row — New chat + Share share ONE
.chat-actions wrapper (a normal .chat-shell column child,
@@ -145,7 +166,28 @@
resets the local (localStorage) conversation and clears the
rendered list. The binding lives in header.js — dispatches
"bor:new-chat" which app.js acts on (it owns the
in-flight-turn guard + list reset). -->
in-flight-turn guard + list reset).
Phase 65 (task 01, 2026-09-01, `TODO.md` L3, owner
confirmation 2026-09-01): the row was relocated from
the top of the column to the bottom (above the
composer), so the cluster sits where the
tune/retry/save-doc meta actions live; the top of the
column is now banner → steering → announcer →
messages.
Phase 65 (task 02, 2026-09-01, `TODO.md` L3, owner-locked
A1): the row + the composer below are wrapped in ONE
.chat-bottom unit (the LAST child of .chat-shell) —
position: sticky; bottom:
env(safe-area-inset-bottom, 0), NO z-index (the sticky
header stays on top — the phase-46 stacking pinned by
tests/e2e/test_pinned_composer.py). The composer's OWN
sticky declaration is kept: it is redundant inside the
wrapper (its containing block is the wrapper, so it cannot
shift) but tests/e2e/test_pinned_composer.py asserts the
computed style sticky on #composer. -->
<div class="chat-bottom">
<div class="chat-actions">
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
@@ -185,27 +227,6 @@
</button>
</div>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin
only, phase 15) and Retry (every visitor; the LAST brain
bubble only, redo-in-place). No static markup: both are
injected like the source chips. -->
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
<div class="empty-state" id="empty-state">
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M10 8h28a4 4 0 0 1 4 4v18a4 4 0 0 1-4 4H24l-9 8v-8h-5a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z"/><path d="m15 17 5 4-5 4"/><path d="M24 25h8"/></svg>
</div>
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
<p class="empty-state-sub">
I've read through your documents — ask me anything,
and I'll point you at the exact doc. You've got this.
</p>
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
<!-- suggestion chips rendered by app.js from /api/suggestions -->
</div>
</div>
</section>
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
@@ -238,6 +259,7 @@
</button>
</form>
</div>
</div>
</main>
<footer class="app-footer">
+576
View File
@@ -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"
+9 -4
View File
@@ -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 <main>, 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'<button[^>]*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("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
"the button sits below the #messages section, above the composer"
)
# No other page carries the button anymore (owner request 2026-08-28).
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
+47 -20
View File
@@ -21,6 +21,15 @@ block. The pin is therefore TWO rules, and both are pinned here:
bottom edge at every scroll position, settling back into flow (above
the footer) once the document bottom is reached;
Phase 65 (task 02, owner-locked A1, 2026-09-01, `TODO.md` L3): the pin
moved up one level — the `.chat-bottom` wrapper (the row + this form,
the LAST child of `.chat-shell`) carries `position: sticky; bottom:
env(safe-area-inset-bottom, 0)` and is the unit that stays glued to the
viewport bottom; the composer's own sticky pair above and the
`.messages` grow stay exactly as phase 52 pinned them (the composer's
sticky is redundant inside the wrapper but kept — and pinned here —
for the computed-style contract).
The pin is CSS-only:
* `.composer` carries the sticky pair (``position: sticky`` + the
@@ -34,15 +43,11 @@ The pin is CSS-only:
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
gain no ``overflow`` and `.messages` gains no inner scroller — the page
keeps scrolling at the document level (the phase-42 model);
* ``index.html`` needs no change: the composer is already the LAST child
of `.chat-shell` (the sticky shift range is that column's box) and the
`#message-input` / `#send-btn` / `#send-status` markup is untouched;
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
gain no ``overflow`` and `.messages` gains no inner scroller — the page
keeps scrolling at the document level (the phase-42 model);
* ``index.html`` needs no change: the composer is already the LAST child
of `.chat-shell` (the sticky shift range is that column's box) and the
`#message-input` / `#send-btn` / `#send-status` markup is untouched;
* ``index.html`` (phase 65 task 02, owner-locked A1): the row + composer
are wrapped in the `.chat-bottom` unit — the wrapper (NO id) is now
the LAST child of `.chat-shell` (the sticky shift range is still that
column's box) and the `#message-input` / `#send-btn` / `#send-status`
markup is untouched;
* ``app.js`` gains NO page-scroll call site — the phase-42 invariant
(``scrollReveal`` is still the single ``window.scrollTo``; no
``scrollIntoView``, no ``window.scrollY``) is re-pinned here so the
@@ -303,25 +308,47 @@ def test_document_level_scroll_is_untouched() -> 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 <form> 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, (
+45 -27
View File
@@ -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 <main>, 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 <main> — 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'<button[^>]*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("</button>", btn.start())]
assert '>Share</span>' 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("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < new_idx < btn.start() < composer_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, below "
"#messages, above the composer (phase 65)"
)
assert messages_end < new_idx, ("the row moved below the #messages section")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
@@ -673,13 +681,15 @@ def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
"""index.html (task 05, A5): ONE ``<div class="chat-actions">``
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
``<div class="chat-actions">`` 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("</p>", ann_idx):start]
assert "id=" not in between and "<button" not in between, (
"no other element lands between the announcer and the row"
)
after = html[end:messages_idx]
assert "id=" not in after and "<button" not in after, (
"nothing but the phase-49 comment lands between the row and #messages"
messages_end = html.find("</section>", messages_idx)
composer_idx = html.find('<form class="composer" id="composer"')
assert -1 < shell_idx < messages_end < start < end < composer_idx, (
"the row is a .chat-shell column child, below #messages, above the composer"
)
between = html[messages_end:start]
assert (
"id=" not in between
and "<button" not in between
and "<section" not in between
and "<form" not in between
), ("nothing but the row's comment lands between #messages and the row")
after = html[end:composer_idx]
assert (
"id=" not in after
and "<button" not in after
and "<section" not in after
and "<form" not in after
), ("nothing but the composer comment lands between the row and the composer")
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert "chat-actions" not in other.read_text(encoding="utf-8"), (