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:
@@ -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
|
||||||
+17
@@ -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`
|
||||||
+72
@@ -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
|
||||||
+16
@@ -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).
|
||||||
+72
@@ -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
|
||||||
+16
@@ -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).
|
||||||
+72
@@ -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
|
||||||
+19
@@ -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`
|
||||||
+72
@@ -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
|
||||||
+17
@@ -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`
|
||||||
+72
@@ -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
|
||||||
@@ -423,17 +423,31 @@ html::after {
|
|||||||
(a normal .chat-shell column child). As a flex ITEM of the column it
|
(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
|
spans the column width, but as a flex ROW with align-items: center
|
||||||
(not the column default stretch) each pill keeps its INTRINSIC
|
(not the column default stretch) each pill keeps its INTRINSIC
|
||||||
content width — two pills side by side, left-aligned in the column,
|
content width — two pills side by side on desktop. The ≤640px block
|
||||||
on desktop. The ≤640px block flips this to a vertical stack
|
flips this to a vertical stack (flex-direction: column +
|
||||||
(flex-direction: column + align-items: stretch — full-width pills,
|
align-items: stretch — full-width pills, New chat above Share; the
|
||||||
New chat above Share; the existing ≤640px pill rules apply to the
|
existing ≤640px pill rules apply to the stacked pills unchanged).
|
||||||
stacked pills unchanged). The reading-column contract is untouched
|
The reading-column contract is untouched (--chat-column: 46rem base,
|
||||||
(--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage).
|
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 {
|
.chat-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.6rem;
|
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,
|
so messages scrolling behind the pinned box never show through it,
|
||||||
and no z-index is added: DOM order already paints the composer over
|
and no z-index is added: DOM order already paints the composer over
|
||||||
`.messages`, it never overlaps the sticky header (z 20) and stays
|
`.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 {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
+110
-88
@@ -128,63 +128,6 @@
|
|||||||
</section>
|
</section>
|
||||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
|
||||||
<!-- 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,
|
|
||||||
replacing the two pills as its direct children): a
|
|
||||||
HORIZONTAL row on desktop (side by side, left-aligned, each
|
|
||||||
pill at its intrinsic width — the row's align-items: center
|
|
||||||
beats the column's stretch) and a VERTICAL stack at the
|
|
||||||
existing ≤640px breakpoint (New chat above Share,
|
|
||||||
full-width). DOM order New chat → Share in both
|
|
||||||
orientations; the 46rem column contract is untouched
|
|
||||||
(PLAN §7). The kb-banner / stale-banner / steering /
|
|
||||||
announcer structure around the row is unchanged.
|
|
||||||
|
|
||||||
Phase 14 (module-owned since phase 34 task 02): "New chat"
|
|
||||||
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). -->
|
|
||||||
<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>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
|
|
||||||
control — every conversation auto-saves itself (app.js
|
|
||||||
persistConversation at the phase-14/20 save points). The
|
|
||||||
History page is the visible proof; a failed auto-save leaves
|
|
||||||
only a one-line status note (never a button, never a banner).
|
|
||||||
|
|
||||||
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
|
|
||||||
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
|
|
||||||
(phase 51's admin-only ship-hidden gate is gone with it; task
|
|
||||||
01 opened the save/share write surface to every visitor).
|
|
||||||
|
|
||||||
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
|
||||||
turns the current conversation into a PUBLIC read-only link —
|
|
||||||
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
|
||||||
migration 0009). The save-then-share contract: an UNSAVED
|
|
||||||
(unlinked) conversation is saved AND shared in ONE action —
|
|
||||||
app.js POSTs /api/chats with { messages, share: true } (the
|
|
||||||
server sets the token in the same commit) and links the
|
|
||||||
conversation to the created row; a saved (linked) one just
|
|
||||||
POSTs /api/chats/<id>/share (idempotent — the existing token
|
|
||||||
comes back unchanged). On success the ABSOLUTE link is
|
|
||||||
copied to the clipboard; a non-secure (http) homelab origin
|
|
||||||
that rejects the clipboard gets the inline link-field
|
|
||||||
fallback instead (owner-locked — app.js renders
|
|
||||||
.share-link-fallback near the status line). app.js binds the
|
|
||||||
click to shareCurrentChat. Unsharing lives on the History
|
|
||||||
page's Share column (admin-only). -->
|
|
||||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
||||||
<span class="share-chat-label">Share</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
||||||
bubble can carry JS-injected actions (app.js) — Tune (admin
|
bubble can carry JS-injected actions (app.js) — Tune (admin
|
||||||
only, phase 15) and Retry (every visitor; the LAST brain
|
only, phase 15) and Retry (every visitor; the LAST brain
|
||||||
@@ -206,37 +149,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
|
||||||
roles — #send-btn reads "Send" when idle and morphs into the
|
the chat-actions row — New chat + Share share ONE
|
||||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
.chat-actions wrapper (a normal .chat-shell column child,
|
||||||
is in flight; a click or Enter in flight aborts the fetch
|
replacing the two pills as its direct children): a
|
||||||
(AbortController in app.js) and the partial is kept + persisted
|
HORIZONTAL row on desktop (side by side, left-aligned, each
|
||||||
with the optional `stopped` marker, rendered as the .stopped-note
|
pill at its intrinsic width — the row's align-items: center
|
||||||
meta-row note by app.js — live and on restore. The spinner span
|
beats the column's stretch) and a VERTICAL stack at the
|
||||||
stays in the markup (contract marker, reduced-motion pin) but
|
existing ≤640px breakpoint (New chat above Share,
|
||||||
never shows: the Stop label + treatment carry the in-flight
|
full-width). DOM order New chat → Share in both
|
||||||
state.
|
orientations; the 46rem column contract is untouched
|
||||||
`novalidate`: the input is cleared after send, so a `required`
|
(PLAN §7). The kb-banner / stale-banner / steering /
|
||||||
constraint would silently block the Stop click/Enter — the
|
announcer structure around the row is unchanged.
|
||||||
browser's constraint validation runs before the `submit` event
|
|
||||||
and would never reach handleSend's in-flight guard. The
|
Phase 14 (module-owned since phase 34 task 02): "New chat"
|
||||||
`!text` guard in app.js is the real empty-input check (same
|
resets the local (localStorage) conversation and clears the
|
||||||
precedent as the tuning form's noValidate). -->
|
rendered list. The binding lives in header.js — dispatches
|
||||||
<form class="composer" id="composer" novalidate>
|
"bor:new-chat" which app.js acts on (it owns the
|
||||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
in-flight-turn guard + list reset).
|
||||||
<textarea
|
|
||||||
id="message-input"
|
Phase 65 (task 01, 2026-09-01, `TODO.md` L3, owner
|
||||||
name="message"
|
confirmation 2026-09-01): the row was relocated from
|
||||||
rows="1"
|
the top of the column to the bottom (above the
|
||||||
placeholder="Ask me anything…"
|
composer), so the cluster sits where the
|
||||||
autocomplete="off"
|
tune/retry/save-doc meta actions live; the top of the
|
||||||
></textarea>
|
column is now banner → steering → announcer →
|
||||||
<button type="submit" class="send-btn" id="send-btn">
|
messages.
|
||||||
<span class="spinner" aria-hidden="true" hidden></span>
|
|
||||||
<span class="btn-label" id="send-label">Send</span>
|
Phase 65 (task 02, 2026-09-01, `TODO.md` L3, owner-locked
|
||||||
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
A1): the row + the composer below are wrapped in ONE
|
||||||
</button>
|
.chat-bottom unit (the LAST child of .chat-shell) —
|
||||||
</form>
|
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>
|
||||||
|
<span class="new-chat-label">New chat</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
|
||||||
|
control — every conversation auto-saves itself (app.js
|
||||||
|
persistConversation at the phase-14/20 save points). The
|
||||||
|
History page is the visible proof; a failed auto-save leaves
|
||||||
|
only a one-line status note (never a button, never a banner).
|
||||||
|
|
||||||
|
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
|
||||||
|
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
|
||||||
|
(phase 51's admin-only ship-hidden gate is gone with it; task
|
||||||
|
01 opened the save/share write surface to every visitor).
|
||||||
|
|
||||||
|
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
||||||
|
turns the current conversation into a PUBLIC read-only link —
|
||||||
|
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
||||||
|
migration 0009). The save-then-share contract: an UNSAVED
|
||||||
|
(unlinked) conversation is saved AND shared in ONE action —
|
||||||
|
app.js POSTs /api/chats with { messages, share: true } (the
|
||||||
|
server sets the token in the same commit) and links the
|
||||||
|
conversation to the created row; a saved (linked) one just
|
||||||
|
POSTs /api/chats/<id>/share (idempotent — the existing token
|
||||||
|
comes back unchanged). On success the ABSOLUTE link is
|
||||||
|
copied to the clipboard; a non-secure (http) homelab origin
|
||||||
|
that rejects the clipboard gets the inline link-field
|
||||||
|
fallback instead (owner-locked — app.js renders
|
||||||
|
.share-link-fallback near the status line). app.js binds the
|
||||||
|
click to shareCurrentChat. Unsharing lives on the History
|
||||||
|
page's Share column (admin-only). -->
|
||||||
|
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
<span class="share-chat-label">Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
is in flight; a click or Enter in flight aborts the fetch
|
||||||
|
(AbortController in app.js) and the partial is kept + persisted
|
||||||
|
with the optional `stopped` marker, rendered as the .stopped-note
|
||||||
|
meta-row note by app.js — live and on restore. The spinner span
|
||||||
|
stays in the markup (contract marker, reduced-motion pin) but
|
||||||
|
never shows: the Stop label + treatment carry the in-flight
|
||||||
|
state.
|
||||||
|
`novalidate`: the input is cleared after send, so a `required`
|
||||||
|
constraint would silently block the Stop click/Enter — the
|
||||||
|
browser's constraint validation runs before the `submit` event
|
||||||
|
and would never reach handleSend's in-flight guard. The
|
||||||
|
`!text` guard in app.js is the real empty-input check (same
|
||||||
|
precedent as the tuning form's noValidate). -->
|
||||||
|
<form class="composer" id="composer" novalidate>
|
||||||
|
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||||
|
<textarea
|
||||||
|
id="message-input"
|
||||||
|
name="message"
|
||||||
|
rows="1"
|
||||||
|
placeholder="Ask me anything…"
|
||||||
|
autocomplete="off"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="send-btn" id="send-btn">
|
||||||
|
<span class="spinner" aria-hidden="true" hidden></span>
|
||||||
|
<span class="btn-label" id="send-label">Send</span>
|
||||||
|
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
"""#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
|
from the shared header bar to the chat page at owner request
|
||||||
(2026-08-28): it lives ONLY in index.html — inside <main>, in
|
(2026-08-28): it lives ONLY in index.html — inside <main>, in
|
||||||
.chat-shell, above the #messages section (the single module binding
|
.chat-shell. Phase 65 (2026-09-01, `TODO.md` L3, owner confirmation)
|
||||||
+ the no-op guard are pinned in test_shared_header.py)."""
|
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()
|
html = _index()
|
||||||
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
||||||
assert btn, "index.html must contain #new-chat-btn"
|
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"')
|
shell_idx = html.find('class="container chat-shell"')
|
||||||
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
||||||
messages_idx = html.find('id="messages"')
|
messages_idx = html.find('id="messages"')
|
||||||
assert messages_idx != -1 and btn.start() < messages_idx, (
|
messages_end = html.find("</section>", messages_idx)
|
||||||
"the button sits above the #messages section"
|
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).
|
# 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):
|
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||||
|
|||||||
@@ -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
|
bottom edge at every scroll position, settling back into flow (above
|
||||||
the footer) once the document bottom is reached;
|
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:
|
The pin is CSS-only:
|
||||||
|
|
||||||
* `.composer` carries the sticky pair (``position: sticky`` + the
|
* `.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`
|
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
|
||||||
gain no ``overflow`` and `.messages` gains no inner scroller — the page
|
gain no ``overflow`` and `.messages` gains no inner scroller — the page
|
||||||
keeps scrolling at the document level (the phase-42 model);
|
keeps scrolling at the document level (the phase-42 model);
|
||||||
* ``index.html`` needs no change: the composer is already the LAST child
|
* ``index.html`` (phase 65 task 02, owner-locked A1): the row + composer
|
||||||
of `.chat-shell` (the sticky shift range is that column's box) and the
|
are wrapped in the `.chat-bottom` unit — the wrapper (NO id) is now
|
||||||
`#message-input` / `#send-btn` / `#send-status` markup is untouched;
|
the LAST child of `.chat-shell` (the sticky shift range is still that
|
||||||
* the sticky context survives: `.chat-shell` / `.app-main` / `.messages`
|
column's box) and the `#message-input` / `#send-btn` / `#send-status`
|
||||||
gain no ``overflow`` and `.messages` gains no inner scroller — the page
|
markup is untouched;
|
||||||
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;
|
|
||||||
* ``app.js`` gains NO page-scroll call site — the phase-42 invariant
|
* ``app.js`` gains NO page-scroll call site — the phase-42 invariant
|
||||||
(``scrollReveal`` is still the single ``window.scrollTo``; no
|
(``scrollReveal`` is still the single ``window.scrollTo``; no
|
||||||
``scrollIntoView``, no ``window.scrollY``) is re-pinned here so the
|
``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 ----------
|
# ---------- index.html: no DOM change needed ----------
|
||||||
|
|
||||||
|
|
||||||
def test_composer_is_last_child_of_the_chat_shell() -> None:
|
def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||||
"""The composer must remain the LAST element child of `.chat-shell`
|
"""Phase 65 (task 02, owner-locked A1): the LAST element child of
|
||||||
(the sticky containing block): the sticky shift range is that
|
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
|
||||||
column's box, so a sibling after the form would carve the range away
|
binds it; the bindings live on the inner elements, the move is pure
|
||||||
and re-break the pin. The `#message-input` / `#send-btn` /
|
HTML/CSS) — holding exactly the `.chat-actions` row and then the
|
||||||
`#send-status` markup is untouched (the pin ships no DOM change)."""
|
`#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")
|
shell = _tree().find("chat-shell")
|
||||||
last = shell["children"][-1]
|
last = shell["children"][-1]
|
||||||
assert last["tag"] == "form", (
|
assert last["tag"] == "div" and (
|
||||||
"the composer <form> must stay the last child of .chat-shell"
|
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 last["attrs"].get("id") is None, (
|
||||||
assert "novalidate" in last["attrs"], (
|
"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` "
|
"phase 48: the composer form stays `novalidate` (a `required` "
|
||||||
"input would swallow the Stop click)"
|
"input would swallow the Stop click)"
|
||||||
)
|
)
|
||||||
ids = {
|
ids = {
|
||||||
node["attrs"].get("id")
|
node["attrs"].get("id")
|
||||||
for node in _walk(last)
|
for node in _walk(form)
|
||||||
if node["attrs"].get("id")
|
if node["attrs"].get("id")
|
||||||
}
|
}
|
||||||
assert {"composer", "message-input", "send-btn", "send-status"} <= ids, (
|
assert {"composer", "message-input", "send-btn", "send-status"} <= ids, (
|
||||||
|
|||||||
@@ -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 —
|
"Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 —
|
||||||
NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
|
NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
|
||||||
ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
|
ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
|
||||||
inside <main>, above #messages — the chat-shell actions read as a
|
inside <main> — the chat-shell actions read as a pair (New chat |
|
||||||
pair (New chat | Share; the Save pill is gone, phase 55). No other
|
Share; the Save pill is gone, phase 55). Phase 65 (2026-09-01,
|
||||||
page carries it (chat-page only, like New chat)."""
|
``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()
|
html = _index()
|
||||||
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
|
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
|
||||||
assert btn, "index.html must contain #share-chat-btn"
|
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())]
|
btn_block = html[btn.start() : html.find("</button>", btn.start())]
|
||||||
assert '>Share</span>' in btn_block
|
assert '>Share</span>' in btn_block
|
||||||
# Beside New chat (the Save pill is gone): after it, still inside
|
# 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"')
|
shell_idx = html.find('class="container chat-shell"')
|
||||||
new_idx = html.find('id="new-chat-btn"')
|
new_idx = html.find('id="new-chat-btn"')
|
||||||
messages_idx = html.find('id="messages"')
|
messages_idx = html.find('id="messages"')
|
||||||
assert -1 < shell_idx < new_idx < btn.start() < messages_idx, (
|
messages_end = html.find("</section>", messages_idx)
|
||||||
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
|
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,
|
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||||
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
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:
|
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||||
"""index.html (task 05, A5): ONE ``<div class="chat-actions">``
|
"""index.html (task 05, A5; phase 65 task 01): ONE
|
||||||
wraps BOTH pills — its element children are exactly the two
|
``<div class="chat-actions">`` wraps BOTH pills — its element
|
||||||
buttons, in the A5 order New chat → Share. The wrapper replaces
|
children are exactly the two buttons, in the A5 order New chat →
|
||||||
the two pills as direct children of ``.chat-shell`` (a normal
|
Share. The wrapper is a normal ``.chat-shell`` column child, but
|
||||||
column child): inside the shell, above ``#messages``; nothing else
|
phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it
|
||||||
lands between the steering announcer and the row, and nothing but
|
from the top of the column to the bottom: below the ``#messages``
|
||||||
the phase-49 comment lands between the row and ``#messages``. No
|
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
|
other page carries ``.chat-actions`` (chat-page only, like the
|
||||||
pills)."""
|
pills)."""
|
||||||
html = _index()
|
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"')
|
new_i = wrap.find('id="new-chat-btn"')
|
||||||
share_i = wrap.find('id="share-chat-btn"')
|
share_i = wrap.find('id="share-chat-btn"')
|
||||||
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
|
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
|
||||||
# Position: a .chat-shell column child, above #messages — the
|
# Position: a .chat-shell column child, BELOW #messages and directly
|
||||||
# kb-banner / stale-banner / steering / announcer structure is
|
# above the composer (phase 65 — the top of the column is now
|
||||||
# untouched (nothing else with an id around the row).
|
# banner → steering → announcer → messages; nothing else with an id
|
||||||
|
# around the row).
|
||||||
shell_idx = html.find('class="container chat-shell"')
|
shell_idx = html.find('class="container chat-shell"')
|
||||||
messages_idx = html.find('id="messages"')
|
messages_idx = html.find('id="messages"')
|
||||||
assert -1 < shell_idx < start < end < messages_idx, (
|
messages_end = html.find("</section>", messages_idx)
|
||||||
"the row is a .chat-shell column child, above #messages"
|
composer_idx = html.find('<form class="composer" id="composer"')
|
||||||
)
|
assert -1 < shell_idx < messages_end < start < end < composer_idx, (
|
||||||
ann_idx = html.find('id="steering-announcer"')
|
"the row is a .chat-shell column child, below #messages, above the composer"
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
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,
|
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||||
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
|
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
|
||||||
|
|||||||
Reference in New Issue
Block a user