phase: 175_history_pagination
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
This commit is contained in:
2026-10-11 04:12:53 -04:00
parent 938d6cf09d
commit 375169bbfc
31 changed files with 2339 additions and 61 deletions
@@ -0,0 +1,13 @@
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.
**Phase 175 final verification pass — report**
- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6451 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,12 @@
All gates green. Final verification summary — task 01 is complete.
**Report**
- **Implemented (task 01, API pagination):**
- `app/api/chats.py`: `list_chats` now takes `limit: int = Query(25, ge=1, le=100)` / `offset: int = Query(0, ge=0)` (FastAPI field-message 422, no hand-rolled checks); select gains `.offset(offset).limit(limit)` (order untouched); `total` via one `select(func.count(...))` per request (house `or 0` idiom for the stub's `int | None`); module + function docstrings describe the params/422/total contract. No other route touched.
- `app/schemas.py`: `SavedChatList` gains required `total: int` (no default, the `stale` convention), docstring updated.
- `tests/integration/test_chats_api.py`: `test_list_empty` → `{"chats": [], "total": 0}`; two other byte-exact empty-body pins (`_assert_no_chats` helper, `test_delete_returns_204_and_removes`) absorbed the same D2 wire change; 5 new pins added (default 25+total, `?limit=10&offset=20` slice by title, 3-page concatenation == raw-SQL ordered id sequence, 422 ×3 params, offset-beyond-end → `{"chats": [], "total": 30}`).
- **Results:** `uv run pytest tests/integration/test_chats_api.py -v` → 90 passed; `uv run pytest --cov=app --cov-report=term-missing` → 4121 passed, `app/` 99% (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
- **Docs:** no doc impact — README.md does not document `GET /api/chats` (checked; per task, no edit); AGENTS.md has no setup/command/config surface change.
- **Decisions:** the two extra empty-body pin updates beyond `test_list_empty` are the same D2 additive wire change (body is now the two-key shape everywhere); per-row `ROW_KEYS` pins untouched and green.
- **Next pending task:** `02_pager_ui.md` (the `.history-pager` control — `index.html` / `history.js` / `styles.css`, D3–D6).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6451 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
All gates green. Task 02 complete.
**Report — phase 175, task 02 (pager UI)**
- **`frontend/index.html`**: `#history-pager` div (Prev / `#history-pager-status` / Next, exact aria-labels, aria-hidden SVGs, ships `hidden`) directly after `.table-wrap#history-table-wrap`, inside `.history-shell`, with the phase-175 house comment.
- **`frontend/assets/history.js`**: phase-175 header block; root-scoped pager lookups; `PAGE_SIZE = 25` + `offset`/`total` state; `loadChats(pageOffset)` with the manual `?limit=`/`?offset=` join reading `total` and running `updatePager(total)` on 200 (failure branches untouched); `updatePager` (hidden when `total <= PAGE_SIZE`, "Page N of M", Prev/Next disabled edges); `changePage` landing `Showing X–Y of Z.` in `#history-status`; refresh + re-show listener pass the current `offset` (A5); Prev/Next bound admin-branch only (D5); boot `loadChats(0)`; Delete untouched (D4).
- **`frontend/assets/styles.css`**: `.history-pager` flex row (`gap: 0.75rem` — existing token); `.history-pager-btn` selector unions on the pill/`:hover`/`:disabled`/svg-sizing `.history-refresh` rules (no new palette variable — B3); ≤640px block: full-width union + the row stacks (`flex-direction: column; align-items: center`, the `.settings-actions` idiom — required so two `flex-shrink: 0` full-width pills don't side-by-side overflow a phone).
- **Existing pins absorbed (intentional D3/D4 wire change)**: `test_history_page.py` (parameterized-fetch count + `loadChats(0)` boot), `test_frontend_router.py` (view-refresh `loadChats(offset)`, `refreshChats` A5, `.history-refresh` union shape).
- **New** `tests/unit/test_history_pagination.py` — 15 pins (structure, a11y labels, shipped `hidden`, flex/gap, pill-union presence, ≤640px, no-new-token B3, fetch/total/state/edges/Showing/refresh-keeps-page/root-scoped/D5/D4).
- **Results**: `uv run pytest tests/unit/test_history_pagination.py -v` → 15 passed; `uv run pytest --cov=app --cov-report=term-missing` → **4136 passed**, `app/` **99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
- **Docs**: no doc impact (pager is UI with no setup/command/config surface; README doesn't document `GET /api/chats` — per task criterion).
- **Decisions**: ≤640px row-stack added alongside the full-width union (layout-correctness for two full-width pills; all values pre-existing in file); `updatePager` takes the fresh count into module `total` state so the `Showing` line uses the last settled total.
- **Next pending task**: `03_e2e_history_pagination.md` (the phase's dedicated Playwright suite + regression set + atomic commit).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6451 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All changes are in the working tree, index untouched (the `D`/`complete/` entries are the harness's own moves of tasks 01/02). Everything is green.
## Report
**Implemented (task 03 — phase 175 E2E):**
- `tests/e2e/test_history_pagination.py` — the phase's dedicated Playwright suite (9 tests): 30-row page-1 state (25 rows, "Page 1 of 2", disabled edge, "Showing 1–25 of 30."), Next→page 2 (5 rows, replaced-not-appended, order pins first/last page), Prev→page 1 round trip, Refresh-on-page-2 stays (A5), delete-all-5-on-page-2 (inline two-step confirm pin, empty state, pager NOT recomputed per D4, fresh total hides it per A4), 10-row single-page hide, anonymous gate (table+pager hidden, no `/api/chats` in the request log), 375px mobile (stacked full-width buttons, in-card table scroll), a11y light pass (real-Tab 3px focus walk, aria-labels/aria-hidden, live region, computed-style contrast, no-CDN). Fixture: public `POST`/`PUT /api/chats`, distinctive titles, controlled re-Save loop for deterministic `updated_at desc` order, `finally` cleanup + a `TRUNCATE saved_chats` autouse reset (known-total pins; the `test_history_page_width.py` precedent).
- **Defect fix in task 02's JS:** task 03's spec pins "Showing 1–25 of 30." at the initial load, but `loadChats` announced nothing on the first load — the settled-load `Showing` line now lands in `loadChats` (gated on `total > PAGE_SIZE`, so single-page/empty behavior is unchanged); matching static pin added to `tests/unit/test_history_pagination.py`.
**Gates (all green):**
- `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` — 9 passed (in isolation)
- Regression set in isolation: `test_chat_history.py` 5, `test_history_page_width.py` 5, `test_share_chat.py` 4, `test_stale_saved_chats.py` 3, `test_history_copy.py` 1, `tests/integration/test_chats_api.py` 90 — all passed, unchanged
- `uv run pytest` — 4137 passed; `uv run pytest --cov=app --cov-report=term-missing` — app/ 99% (>90%); `uv run ruff check .` + `uv run pyright` — clean
**Docs:** README.md Saved-chats section now documents the pager + `?limit=`/`?offset=`/`total` (README didn't document the endpoint shape otherwise; full README sweep — pages table, 79-var config reference, auth/token sections — found no other inconsistencies). AGENTS.md: verified consistent, no change needed.
**Decisions:** no commit / no phase-dir move — the harness owns the atomic commit and phase move (per binding rules); the task file's commit step is superseded.
**Next pending task:** none in `175_history_pagination/` — phase 175 is complete, awaiting the harness's commit + move.
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6451 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+5 -1
View File
@@ -206,7 +206,11 @@ List, edit, or remove them here — no conversation required.
### Saved chats (`/history.html`)
Every conversation is saved automatically. Click a title to return to that
chat. Admin-only view.
chat. Admin-only view. The list is paginated server-side (25 rows per
page) — with more than 25, a Prev / “Page N of M” / Next pager appears
under the table to walk it, and **Refresh** re-fetches the current page.
`GET /api/chats` carries the same contract: `?limit=` (default 25,
clamped 1–100) / `?offset=` and a full `total` on every response.
### Access tokens (`/tokens.html`)
+41 -14
View File
@@ -27,9 +27,15 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
(phase 14 — raw text, never HTML), so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
History's Share and Stale columns render without a second fetch),
Routes: ``GET`` (list — paginated, phase 175 task 01: ``limit``
(default 25, clamped 1–100) + ``offset`` (default 0); out-of-range
values 422 via FastAPI's field-message validation — no hand-rolled
checks — and the body reports the full ``total`` (one small count
query per request) so a client can walk the pages; rows are one
PAGE in the History page's table order, latest activity first
(``updated_at desc, id desc``); no payloads, but the phase-51
``share_url`` and the phase-53 ``stale`` flag so the History's
Share and Stale columns render without a second fetch),
``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract;
@@ -74,9 +80,9 @@ from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import select, text
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.config import get_settings
@@ -173,20 +179,41 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def list_chats(
limit: int = Query(25, ge=1, le=100), # noqa: B008
offset: int = Query(0, ge=0), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order, admin-only (the owner's
History surface; phase 55 moved the gate per-route). Each row
carries the
phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries."""
"""One PAGE of the saved chats (phase 175, task 01), latest
activity first (``updated_at desc, id desc``) — the History
page's table order, admin-only (the owner's History surface;
phase 55 moved the gate per-route).
``limit`` (default 25, clamped 1–100) and ``offset`` (default 0)
are FastAPI-validated: ``limit=0`` / ``limit=101`` / ``offset=-1``
422 with the house field-message contract (no hand-rolled checks).
A bare ``GET`` is the first 25 rows; an offset beyond the end is
honest — an empty ``chats`` list with the real ``total``.
``total`` is the FULL count across all saved chats: one small
``count`` query per request (two small queries total; the
once-per-request ``current_sources_version`` read is untouched),
so a client can walk the pages and know when it has seen them
all. Each row carries the phase-53 ``stale`` flag: the current
generation is read ONCE per request (one PK read of the seeded
row) and compared against every row's stamp in the serializer
helpers — no per-row queries."""
current_version = current_sources_version(db)
rows = db.scalars(
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
select(SavedChat)
.order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
.offset(offset)
.limit(limit)
).all()
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
total = db.scalar(select(func.count(SavedChat.id))) or 0
return SavedChatList(
chats=[_to_row(row, current_version) for row in rows],
total=total,
)
@router.post("", response_model=SavedChatOut, status_code=201)
+10 -2
View File
@@ -1723,10 +1723,18 @@ class SavedChatRow(BaseModel):
class SavedChatList(BaseModel):
"""``GET /api/chats`` response: saved chats, latest activity first
(``updated_at desc, id desc``)."""
"""``GET /api/chats`` response: one PAGE of saved chats (phase
175, task 01 — ``limit``/``offset``), latest activity first
(``updated_at desc, id desc``).
``total`` is the FULL count across all saved chats (not the
page's length) — one small count query per request; the wire
shape always carries it."""
chats: list[SavedChatRow]
#: Required (no default): the API must always compute the full
#: count server-side — there is no wire shape without it.
total: int
class SharedChatOut(BaseModel):
+168 -9
View File
@@ -112,6 +112,47 @@
* — try again.` on a non-2xx), so EVERY caller of a failed load —
* the mount's first load, a re-show, the button — sees the outcome
* (the §7.4 never-stale contract; a silent empty table is gone).
*
* Phase 175 (task 02) — the list pager (TODO.md L3, owner 2026-10-10:
* "Need pagination on the history page - it gets long fast"):
*
* • the load is parameterized: `loadChats(pageOffset)` fetches
* `/api/chats?limit=<PAGE_SIZE>&offset=<pageOffset>` (the manual
* string join — the file's house style) and reads the body's
* `total` (the server's full count) into the pager via
* `updatePager(total)`. Module state: `PAGE_SIZE` (25 — the
* server's default page; A1 — no size picker in the UI, the E2E
* suite catches a drift) and the current page's `offset`
* (starts at 0 — the view opens on page 1).
* • `updatePager(total)` runs on EVERY 200 (a 0-row fetch included
* — the deleted-behind-us race): `total <= PAGE_SIZE` → the
* pager is hidden (A4 — no dead "Page 1 of 1" control; it
* disappears with the empty state); otherwise it shows, the
* status reads `Page N of M`, Prev is disabled on page 1, Next
* on the last page (the real `disabled` — the Refresh idiom;
* the edges make the click clamping trivial). A FAILED load
* (non-2xx / network) leaves the pager as it was — the
* previous state is the last known truth.
* • Prev/Next (bound in the ADMIN branch only — the SAME gate as
* Refresh, D5; the anonymous branch never touches the pager)
* move the offset by PAGE_SIZE, re-run the re-entrant load (the
* rows are REPLACED, never appended), and land the never-stale
* `Showing <first>–<last> of <total>.` line in #history-status.
* Refresh (the button AND the re-show listener) re-fetches the
* CURRENT page — `loadChats(offset)`, never 0 (A5). A local
* Delete does NOT touch the pager or the offset — the next load
* (a page click or a Refresh) corrects it (D4).
*
* • a settled load lands its OWN never-stale `Showing …` line
* (task 03): the mount is fire-and-forget (the house-style
* `loadChats(0);` line), so the announcement lives in
* `loadChats` itself, where the settled `total` is in hand. It
* lands ONLY while the pager is visible (total > PAGE_SIZE):
* a one-page list IS the whole list (A4 — no redundant line),
* and the 0-row branch shows the empty state (nothing to show).
* A page change's identical line overwrites it (changePage),
* and Refresh's `Saved chats refreshed.` overwrites it on the
* refresh path.
*/
import { fetchIsAdmin } from "./header.js";
@@ -126,6 +167,16 @@ export async function mount(root) {
// Phase 77 (task 03): the explicit refresh control — the page-head
// button (outside the table wrap, so the empty state never hides it).
const refreshBtn = root.querySelector("#history-refresh");
// Phase 175 (task 02, D3): the list pager — under the table card
// (Prev / "Page N of M" / Next). It SHIPS hidden in the markup;
// only the ADMIN branch's load lifecycle (updatePager) reveals it,
// and only while total > PAGE_SIZE (A4 — no dead "Page 1 of 1"
// control). The anonymous branch never touches it (D5 — no dead
// control beside the gate).
const pagerEl = root.querySelector("#history-pager");
const pagerPrevBtn = root.querySelector("#history-pager-prev");
const pagerNextBtn = root.querySelector("#history-pager-next");
const pagerStatusEl = root.querySelector("#history-pager-status");
/* Action feedback — the role="status" live region above the table
(the "never stale" contract: every row action lands a line here,
@@ -499,8 +550,67 @@ export async function mount(root) {
if (emptyRow) emptyRow.hidden = false;
}
/* GET /api/chats → render the rows (latest activity first — the
server's order). A 0-row fetch shows the empty-state row.
/* Phase 175 (task 02, D4): the pager's state. PAGE_SIZE matches
the server's default page (A1: no size picker in the UI — the
E2E suite catches a limit drift); `offset` is the current
page's offset (it starts at 0 — the view opens on page 1);
`total` is the server's last full count — the "of N" truth (a
failed load leaves it; the previous state is the last known
truth). */
const PAGE_SIZE = 25;
let offset = 0;
let total = 0;
/* Phase 175 (task 02, D3): the pager's single source of truth —
the load's `updatePager(total)` runs it on EVERY 200 (a 0-row
fetch included — the deleted-behind-us race):
• `total <= PAGE_SIZE` → the pager is hidden (A4 — no dead
"Page 1 of 1" control; it disappears with the empty state);
• otherwise it shows, #history-pager-status reads `Page N of M`,
Prev is disabled on page 1, Next on the last page (the real
`disabled` attribute — the Refresh idiom; the edges make the
click clamping trivial).
A FAILED load (non-2xx / network) never reaches here — the
pager keeps its previous state, the last known truth. */
function updatePager(count) {
total = count;
if (!pagerEl) return;
const pages = Math.ceil(total / PAGE_SIZE);
if (total <= PAGE_SIZE) {
pagerEl.hidden = true; // A4 — a one-page list never shows a dead control
return;
}
pagerEl.hidden = false;
if (pagerStatusEl) {
pagerStatusEl.textContent =
"Page " + (offset / PAGE_SIZE + 1) + " of " + pages;
}
if (pagerPrevBtn) pagerPrevBtn.disabled = offset === 0;
if (pagerNextBtn) pagerNextBtn.disabled = offset + PAGE_SIZE >= total;
}
/* Phase 175 (task 02, D4): a page change — the Prev/Next click has
ALREADY moved the module's offset by PAGE_SIZE (the button's
disabled edge made it a legal move), this re-runs the
re-entrant load (rows are REPLACED, never appended) and, on
success, lands the never-stale `Showing <first>–<last> of
<total>.` line in #history-status. A failed load keeps the
pager (loadChats' own error line is the feedback — the §7.4
contract). */
async function changePage() {
const ok = await loadChats(offset);
if (ok) {
announce(
"Showing " + (offset + 1) + "–" +
Math.min(offset + PAGE_SIZE, total) + " of " + total + ".",
);
}
}
/* GET /api/chats?limit=<PAGE_SIZE>&offset=<pageOffset> (phase 175:
the parameterized fetch) → render the rows (latest activity
first — the server's order). A 0-row fetch shows the
empty-state row.
Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the
list, not append a duplicate set — the data rows (every <tr>
EXCEPT the hidden #history-empty-row, which the load itself
@@ -510,8 +620,13 @@ export async function mount(root) {
"try again.") — and the load RETURNS the outcome: true when the
fetch settled (a 0-row fetch is a SUCCESS — the empty state is
the honest view), false on non-2xx / network error, so the
refresh button's handler can land its own success line. */
async function loadChats() {
refresh button's handler can land its own success line. A failed
load leaves the pager as it was (the previous state is the last
known truth).
Phase 175 (task 02): the 200 path reads the body's `total` (the
server's full count) and runs updatePager — the pager tracks
every settled load. */
async function loadChats(pageOffset) {
if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) {
if (tr !== emptyRow) tr.remove();
@@ -520,7 +635,10 @@ export async function mount(root) {
if (emptyRow) emptyRow.hidden = true;
let r;
try {
r = await fetch("/api/chats");
/* Phase 175 (task 02, D4): the parameterized fetch — the
manual string join (the file's house style): the 25-row page
(PAGE_SIZE — the server's default) at the given offset. */
r = await fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset);
} catch {
announce("Couldn't load saved chats — is the app reachable?");
showEmptyState();
@@ -531,7 +649,8 @@ export async function mount(root) {
showEmptyState();
return false;
}
const { chats } = await r.json();
const { chats, total } = await r.json();
updatePager(total); // phase 175: the pager tracks the fresh total (a 0-row fetch included — the deleted-behind-us race)
if (!chats.length) {
showEmptyState();
return true;
@@ -539,6 +658,21 @@ export async function mount(root) {
for (const chat of chats) {
tbody.appendChild(makeRow(chat));
}
/* Phase 175 (task 03, the never-stale contract's first-load
half): a settled load lands its own `Showing <first>–<last>
of <total>.` line (the same formula a page change lands —
changePage's line overwrites it with the identical text;
Refresh's `Saved chats refreshed.` overwrites it on the
refresh path). It lands ONLY while the pager is visible
(total > PAGE_SIZE): a one-page list IS the whole list
(A4 — no redundant line) and the 0-row branch above never
reaches here (the empty state is the honest view). */
if (total > PAGE_SIZE) {
announce(
"Showing " + (pageOffset + 1) + "–" +
Math.min(pageOffset + PAGE_SIZE, total) + " of " + total + ".",
);
}
return true;
}
@@ -549,7 +683,10 @@ export async function mount(root) {
async function refreshChats() {
let ok = false;
try {
ok = await loadChats();
/* Phase 175 (task 02, A5): refresh re-fetches the CURRENT
page — the module's offset, never 0 (a page-2 refresh must
land page-2 rows, not reset the list). */
ok = await loadChats(offset);
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
@@ -581,7 +718,7 @@ export async function mount(root) {
the listener can only ever re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) loadChats();
if (started) loadChats(offset); // phase 175 (A5): the re-show re-fetches the CURRENT page (never resets to 0)
});
/* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) —
the button's own lifecycle: click → disable (no double-fire while
@@ -595,6 +732,28 @@ export async function mount(root) {
void refreshChats();
});
}
/* Phase 175 (task 02, D5): the pager's Prev / Next — bound HERE,
in the admin branch only (the SAME gate as Refresh; the
anonymous branch never touches the pager — it keeps its
markup-shipped hidden state, D5). Each click moves the
module's offset by PAGE_SIZE in its direction (the button's
disabled edge made it a legal move — clamping is trivial) and
re-runs the re-entrant load; on success the `Showing …` line
lands in the live region (changePage). A local Delete does NOT
touch the pager or the offset — the next load (a page click or
a Refresh) corrects it (D4). */
if (pagerPrevBtn) {
pagerPrevBtn.addEventListener("click", () => {
offset -= PAGE_SIZE;
void changePage();
});
}
if (pagerNextBtn) {
pagerNextBtn.addEventListener("click", () => {
offset += PAGE_SIZE;
void changePage();
});
}
started = true;
loadChats();
loadChats(0); // phase 175: the view opens on page 1 (offset 0)
}
+39 -7
View File
@@ -4010,7 +4010,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
gap: 0.75rem 1.5rem;
}
#view-history .page-head .page-head-title { min-width: 0; }
.history-refresh {
.history-refresh, .history-pager-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -4028,12 +4028,38 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
cursor: pointer;
flex-shrink: 0;
}
.history-refresh:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
.history-refresh:hover, .history-pager-btn:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled, .history-pager-btn:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); below 640px it joins the visible label in the full-width
pill (the <=640 block — the icon-only squeeze is gone). */
.history-refresh svg { width: 16px; height: 16px; display: none; }
pill (the <=640 block — the icon-only squeeze is gone). The
phase-175 pager buttons join the rule (the selector union — the
same glyph language: visible label at every width, the glyph is
the mobile companion). */
.history-refresh svg, .history-pager-btn svg { width: 16px; height: 16px; display: none; }
/* ---------- History pager (phase 175, task 02 — TODO.md L3) ----------
"Need pagination on the history page - it gets long fast" (owner
2026-10-10). Root cause: the saved-chat list used to fetch and
render EVERY row; the server now ships a 25-row page + the full
count, and this row is the walk control — Prev / "Page N of M" /
Next, sitting UNDER the table card (index.html, phase 175).
Token reuse (B3): the buttons join the .history-refresh pill
through the selector unions above (the solid brand pill, the
:disabled opacity, the svg sizing — NO new palette variable), and
the row's gap is the page's OWN 0.75rem (the #view-history
.page-head row gap — no new spacing token). Hidden by default
(the markup ships `hidden`): history.js's updatePager() reveals
the row only while `total > PAGE_SIZE` (A4 — no dead "Page 1 of
1" control; it disappears with the empty state). The status span
needs no rule beyond the inherited text. Below 640px the row
stacks (the .settings-actions idiom) and the pills go full width
(the .history-refresh mobile idiom — the ≤640px block below). */
.history-pager {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ---------- History page (phase 50) ----------
/history.html: the admin-only saved-chats list (task 04). The
@@ -6486,8 +6512,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.sync-btn { width: 100%; }
.sync-cancel-btn { width: 100%; } /* phase 134: the Cancel pill stacks full-width under the Sync pill (the row already wraps) */
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
.history-refresh { width: 100%; }
.history-refresh svg { display: block; }
.history-refresh, .history-pager-btn { width: 100%; }
.history-refresh svg, .history-pager-btn svg { display: block; }
/* Phase 175 (task 02, D6): the pager row STACKS (the
.settings-actions idiom) so the full-width Prev / Next pills
don't side-by-side overflow the phone width (the pill rule's
flex-shrink: 0 would keep each at 100%); the "Page N of M"
status sits between them (the 0.75rem gap goes vertical). */
.history-pager { flex-direction: column; align-items: center; }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
+20
View File
@@ -1061,6 +1061,26 @@
</tbody>
</table>
</div>
<!-- Phase 175 (TODO.md L3): the list pager — visible only when
there is more than one page (total > the 25-row page size —
history.js toggles `hidden`), so a one-page list never shows a
dead control. The buttons reuse the .history-refresh pill
language (the house inline-SVG glyph + visible label +
aria-label; the ≤640px full-width block joins
.history-refresh). #history-pager-status carries "Page N of M";
page-change outcome lines land in #history-status (the existing
live region). -->
<div class="history-pager" id="history-pager" hidden>
<button type="button" class="history-pager-btn" id="history-pager-prev" aria-label="Previous page of saved chats">
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
<span class="history-pager-label">Prev</span>
</button>
<span class="history-pager-status" id="history-pager-status"></span>
<button type="button" class="history-pager-btn" id="history-pager-next" aria-label="Next page of saved chats">
<span class="history-pager-label">Next</span>
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
</div>
</section>
+700
View File
@@ -0,0 +1,700 @@
"""Phase 175 E2E (Playwright): the History page's list pager.
TODO.md L3 (owner 2026-10-10): "Need pagination on the history page
- it gets long fast" — the saved-chat list is now paginated
SERVER-side (``GET /api/chats`` takes ``?limit=`` (default 25,
clamped 1–100) / ``?offset=`` and reports the full ``total`` on
every response), and the view walks it through the ``.history-pager``
control under the table card (Prev / "Page N of M" / Next — the
``.history-refresh`` pill language; hidden while the list is a single
page, A4; Refresh keeps the current page, A5; the anonymous gate is
untouched, D5; the ≤640px full-width block, D6).
Run in isolation (the DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_history_pagination.py -v --no-cov
The contract under test:
* **page 1 of 2 (a 30-row fixture)** — the table holds 25 rows (the
25-row page at offset 0), the pager is visible, ``#history-pager-
status`` reads "Page 1 of 2", Prev ``disabled`` / Next enabled, and
the live region carries "Showing 1–25 of 30." (the never-stale
first-load line — the settled load announces its own page);
* **Next → page 2** — the 5 remaining rows (rows are REPLACED, never
appended — the 25 page-1 rows are gone), "Page 2 of 2", Next
``disabled`` / Prev enabled, "Showing 26–30 of 30.";
* **Prev → page 1** — back to 25 rows, "Page 1 of 2", Prev
``disabled``, "Showing 1–25 of 30.";
* **Refresh on page 2** — re-fetches page 2 (A5 — never resets to
page 1): still 5 rows + "Page 2 of 2" and "Saved chats
refreshed." lands;
* **delete-to-empty on the last page** — the inline two-step confirm
(the owner-locked no-native-dialog pin — the "Delete? [Yes] [No]"
pair appearing in place is the contract) deletes all five page-2
rows: the empty state appears, and the pager is NOT recomputed on
the local delete (D4 — it still reads "Page 2 of 2", the last
known truth); the next load (Prev) corrects it with the fresh
``total`` (25 → single page → the pager hides, A4);
* **single page** — a 10-row list: all ten rows, the pager fully
hidden (A4 — no dead "Page 1 of 1" control);
* **anonymous** — the gate shows, the table AND the pager stay
hidden, and NO ``GET /api/chats`` request appears in the log (the
house request-log pin — the router 403s them);
* **≤640px** — the pager row stacks and the buttons go full width
(the ``.history-refresh`` mobile idiom, D6) while the table still
scrolls inside its card.
DB isolation: the pagination pins are EXACT (25 of 30, 10 of 10,
"Page N of M"), so every test starts from an EMPTY ``saved_chats``
(the ``test_history_page_width.py`` TRUNCATE house precedent — the
state is under test, not a byproduct; the KB tables are never
touched — the suite never drives the chat page). Fixture rows are
created through the PUBLIC ``POST /api/chats`` write surface with
DISTINCTIVE auto-titles, re-Saved (``PUT /api/chats/<id>`` — the
public upsert, ``onupdate`` bumps ``updated_at``) in a controlled
loop (a short sleep between bumps — Postgres' ``now()`` is µs-
resolution, the HTTP round-trip ms; the sleep makes the
``updated_at desc`` order FULLY deterministic) so the last re-Saved
chat is row 1, and they are deleted in a ``finally`` (admin cookie —
the ``test_chat_history.py`` idiom). The order is pinned by the first
row of page 1 (the last re-Saved) and the first / last rows of the
last page (the earliest re-Saved) — never the full middle order.
"""
from __future__ import annotations
import re
import time
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login
@pytest.fixture(autouse=True)
def clean_saved_chats(db_ready: None) -> None:
"""Every test here pins an EXACT total (25 of 30, 10 of 10 —
"Page N of M"), so the shared dev DB's leftover rows from other
suites would break it: each test starts from an EMPTY
``saved_chats`` (the ``test_history_page_width.py`` TRUNCATE
house precedent — the state is under test, not a byproduct).
The KB tables are never touched — the suite never drives the
chat page."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# ---------------------------------------------------------------------------
# Fixture helpers (the test_chat_history.py DB-isolation idiom, adapted
# to a known-total fixture: the suite's pins are exact, so the rows
# are created through the public API with distinctive auto-titles,
# order-controlled, and deleted in a finally)
# ---------------------------------------------------------------------------
def _title(i: int, n: int) -> str:
"""The fixture row's distinctive auto-title (the first user
message — the auto-title convention: whitespace-collapsed,
120-char cap)."""
return f"Pagination probe {i} of {n} — what is the capital of France?"
def _make_chat(app_url: str, title: str) -> str:
"""POST /api/chats (the PUBLIC write surface, phase 55): the
auto-title IS the first user message, so the title goes in as
the question; the minimal house conversation shape (one user +
one brain reply)."""
r = httpx.post(
f"{app_url}/api/chats",
json={
"messages": [
{"who": "user", "text": title},
{"who": "brain", "text": "That's a good question."},
]
},
timeout=10,
)
assert r.status_code == 201, r.text
return r.json()["id"]
def _re_save(app_url: str, chat_id: str, title: str) -> None:
"""PUT /api/chats/<id> (the public re-Save upsert): a full
message replacement that bumps ``updated_at``
(``onupdate=func.now()``) — the controlled order bump."""
r = httpx.put(
f"{app_url}/api/chats/{chat_id}",
json={
"messages": [
{"who": "user", "text": title},
{"who": "brain", "text": "That's a good question."},
]
},
timeout=10,
)
assert r.status_code == 200, r.text
def _make_chats(app_url: str, n: int) -> list[str]:
"""n fixture conversations (distinctive auto-titles), then the
controlled re-Save loop — a short sleep between bumps makes the
``updated_at desc`` order FULLY deterministic, so the last
re-Saved chat is row 1. Returns the ids in re-Save order (index
0 = the OLDEST = the last row of the last page)."""
ids: list[str] = []
for i in range(1, n + 1):
ids.append(_make_chat(app_url, _title(i, n)))
for i, chat_id in enumerate(ids, start=1):
time.sleep(0.02)
_re_save(app_url, chat_id, _title(i, n))
return ids
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form
login — used to call the admin API with plain httpx (the
``test_chat_history.py`` idiom)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _delete_all(app_url: str, cookies: dict[str, str], ids: list[str]) -> None:
"""Best-effort row cleanup (a 403 — not signed in yet — or a 404
— already deleted — is fine; the next test's TRUNCATE covers
any leak)."""
for chat_id in ids:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _settle_admin_history(page: Page, app_url: str, rows: int) -> None:
"""Real form login onto /history.html + wait for the view's
first load to have settled: the N data rows (the title links)
IS the settle signal — the pager's state and the first-load
"Showing" line land in the same settled load (updatePager runs
before the row append; the announcement after it)."""
login(page, app_url, next="/history.html")
expect(page).to_have_url(app_url + "/history.html", timeout=30_000)
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(rows, timeout=15_000)
# ---------------------------------------------------------------------------
# Contrast helpers (copied from test_responsive_polish.py — suites
# stay self-contained; only auth_helpers is imported)
# ---------------------------------------------------------------------------
def _rgb(value: str) -> tuple[int, int, int]:
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, (
f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
)
# ---------------------------------------------------------------------------
# 1. Page 1 of 2 for 30 rows: the 25-row page, the pager's visible
# state, the never-stale first-load line
# ---------------------------------------------------------------------------
def test_pager_shows_page_1_of_2_for_30_rows(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
# The table holds the first 25 of 30 (the 25-row page)…
expect(page.locator("#history-pager")).to_be_visible()
expect(page.locator("#history-pager-status")).to_have_text(
"Page 1 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_disabled()
expect(page.locator("#history-pager-next")).to_be_enabled()
# …and the live region carries the first-load line (the
# settled load announces its own page — task 03).
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 30."
)
# Order pinned by the first row of page 1 (the last
# re-Saved — the newest).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(30, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 2. Next → page 2: the 5 rows (replaced, never appended), the
# disabled edge, the live-region line
# ---------------------------------------------------------------------------
def test_next_walks_to_page_2(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
# The 25 page-1 rows are GONE (replaced, never appended —
# an append would leave 30; the count IS the pin)…
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-next")).to_be_disabled()
expect(page.locator("#history-pager-prev")).to_be_enabled()
expect(page.locator("#history-status")).to_have_text(
"Showing 26–30 of 30."
)
# …reinforced: the newest page-1 title (row 1) is not on
# the page at all.
expect(
page.locator(
f'#history-tbody a.history-title-link:text-is("{_title(30, 30)}")'
)
).to_have_count(0)
# Order: the first row of the last page (the 5th re-Saved —
# row 26) …
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(5, 30))
# …and the last row is the earliest re-Saved (row 30).
expect(
page.locator("#history-tbody a.history-title-link").last
).to_have_text(_title(1, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 3. Prev → page 1: the round trip (25 rows again, the edge back,
# the line)
# ---------------------------------------------------------------------------
def test_prev_walks_back_to_page_1(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
page.locator("#history-pager-prev").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(25, timeout=15_000)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 1 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_disabled()
expect(page.locator("#history-pager-next")).to_be_enabled()
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 30."
)
# Order round-trips: the first row is the last re-Saved
# again.
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(30, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 4. Refresh on page 2 stays on page 2 (A5 — never resets to
# page 1)
# ---------------------------------------------------------------------------
def test_refresh_on_page_2_stays_on_page_2(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
# The Refresh lifecycle: click → disabled while in flight →
# re-enabled in the finally — waiting for the re-enable IS
# the settle gate (the house idiom).
page.locator("#history-refresh").click()
expect(page.locator("#history-refresh")).to_be_enabled(
timeout=15_000
)
# A5: the re-fetch landed page 2, not page 1 — still the 5
# page-2 rows + "Page 2 of 2" (a reset to page 1 would show
# 25 rows + "Page 1 of 2" — the pins fail it).
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5)
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-next")).to_be_disabled()
expect(page.locator("#history-status")).to_have_text(
"Saved chats refreshed."
)
# The page-2 order is intact (the 5th re-Saved first).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(5, 30))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 5. Delete-to-empty on the last page: the inline two-step confirm
# (no native dialog), the empty state, the pager NOT recomputed
# on the local delete (D4), the next load's fresh total hides
# it (A4)
# ---------------------------------------------------------------------------
def test_delete_all_rows_of_page_2_then_prev_single_page_hides_pager(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
page.locator("#history-pager-next").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(5, timeout=15_000)
# Delete all five page-2 rows through the inline two-step
# confirm — the owner-locked no-native-dialog pin: the
# "Delete? [Yes] [No]" pair appearing IN PLACE is the
# contract (a real window.confirm would hang Playwright).
for remaining in (4, 3, 2, 1, 0):
tr = page.locator(
"#history-tbody tr",
has=page.locator("a.history-title-link"),
).first
expect(tr).to_be_visible()
tr.locator("button.history-delete").click()
expect(tr.locator(".history-confirm-yes")).to_be_visible()
expect(tr.locator(".history-confirm-no")).to_be_visible()
tr.locator(".history-confirm-yes").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(remaining)
# The last row's deletion reveals the empty state…
expect(page.locator("#history-empty-row")).to_be_visible()
# …and the pager is NOT recomputed on the local delete
# (D4 — the last known truth: still "Page 2 of 2", the
# server's 30-row count from the page-2 load; only the next
# load corrects it).
expect(page.locator("#history-pager")).to_be_visible()
expect(page.locator("#history-pager-status")).to_have_text(
"Page 2 of 2"
)
expect(page.locator("#history-pager-prev")).to_be_enabled()
expect(page.locator("#history-pager-next")).to_be_disabled()
# The next load (Prev) corrects the pager with the fresh
# total: 25 rows (the page-1 rows — the page-2 ones are
# deleted) → single page → the pager hides (A4).
page.locator("#history-pager-prev").click()
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(25, timeout=15_000)
expect(page.locator("#history-pager")).to_be_hidden()
expect(page.locator("#history-status")).to_have_text(
"Showing 1–25 of 25."
)
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 6. Single page: the pager is fully hidden (A4 — no dead "Page 1
# of 1" control)
# ---------------------------------------------------------------------------
def test_single_page_hides_the_pager(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
ids = _make_chats(app_url, 10)
_settle_admin_history(page, app_url, 10)
try:
# The whole list is on the page (10 of 10)…
expect(
page.locator("#history-tbody a.history-title-link")
).to_have_count(10)
# …and the pager is fully hidden (the container's `hidden`
# takes the whole row away — the buttons with it).
expect(page.locator("#history-pager")).to_be_hidden()
expect(page.locator("#history-pager-prev")).to_be_hidden()
expect(page.locator("#history-pager-next")).to_be_hidden()
# Order: the first row is the last re-Saved (the newest).
expect(
page.locator("#history-tbody a.history-title-link").first
).to_have_text(_title(10, 10))
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 7. Anonymous: the gate, the table AND the pager hidden, and NO
# /api/chats request in the log (D5)
# ---------------------------------------------------------------------------
def test_anonymous_sees_gate_and_never_fetches(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
# A fresh context is anonymous by construction (no login).
requests: list[str] = []
page.on("request", lambda r: requests.append(r.url))
page.goto(app_url + "/history.html")
# Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
# …the table is wrapped away AND the pager stays hidden (the
# markup-shipped state — the anonymous branch never touches
# it, D5 — no dead control beside the gate).
expect(page.locator("#history-table-wrap")).to_be_hidden()
expect(page.locator("#history-pager")).to_be_hidden()
# …and the view NEVER calls /api/chats (the router 403s
# anonymous) — a short settle wait so the claim is made on a
# settled page (the house log-pin idiom).
page.wait_for_timeout(500)
assert not any("/api/chats" in u for u in requests), (
f"the anonymous History page must not fetch /api/chats: {requests}"
)
# ---------------------------------------------------------------------------
# 8. ≤640px (D6): the pager row stacks, the buttons go full width
# (the .history-refresh mobile idiom), the table still scrolls
# inside its card
# ---------------------------------------------------------------------------
def test_pager_mobile_375px(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
page.set_viewport_size({"width": 375, "height": 720})
ids = _make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
try:
expect(page.locator("#history-pager")).to_be_visible()
pager = page.locator("#history-pager").bounding_box()
assert pager is not None, "the pager must render at 375px"
# The D6 block: the row STACKS (the .settings-actions idiom)
# and the buttons go FULL WIDTH (the .history-refresh mobile
# idiom) — each button's width is the pager's (±1px
# subpixel; the pills are `width: 100%` of the stacked row).
direction = page.evaluate(
"() => getComputedStyle("
"document.getElementById('history-pager')).flexDirection"
)
assert direction == "column", (
f"the pager row must stack at 375px (got {direction!r})"
)
for sel in ("#history-pager-prev", "#history-pager-next"):
box = page.locator(sel).bounding_box()
assert box is not None, f"{sel} must render at 375px"
assert abs(box["width"] - pager["width"]) <= 1, (
f"{sel} must be full width: {box['width']:.1f}px vs the "
f"pager's {pager['width']:.1f}px"
)
assert pager["x"] + pager["width"] <= 375 + 1, (
f"the pager's right edge ({pager['x'] + pager['width']:.1f}) "
f"reaches/past the 375px viewport"
)
# No page-level horizontal scroll (the table's own overflow-x
# scroll is the mobile scroll)…
scroll_width = page.evaluate(
"() => document.scrollingElement.scrollWidth"
)
assert scroll_width <= 375, (
f"page-level horizontal scroll at 375px "
f"(scrollWidth {scroll_width})"
)
# …and the 640px table still scrolls INSIDE its card (the
# phase-86 contract the D6 block must not break).
report = page.evaluate(
"() => { const el = document.getElementById("
"'history-table-wrap'); "
"return { scroll: el.scrollWidth, client: el.clientWidth }; }"
)
assert report["scroll"] > report["client"], (
f"the 640px table must scroll inside its card at 375px "
f"(scrollWidth {report['scroll']} <= clientWidth "
f"{report['client']})"
)
finally:
_delete_all(app_url, _admin_cookies(page), ids)
# ---------------------------------------------------------------------------
# 9. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
# ---------------------------------------------------------------------------
def test_pager_a11y_and_no_cdn_basics(
page: Page, app_url: str, clean_saved_chats: None
) -> None:
page.set_default_timeout(30_000)
_make_chats(app_url, 30)
_settle_admin_history(page, app_url, 25)
expect(page.locator("#history-pager")).to_be_visible()
# Standard app frame (PLAN §7.2).
expect(page.locator("header.app-header")).to_have_count(1)
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
# The pager buttons' accessible names (the aria-labels — what
# the accessibility tree exposes; the visible "Prev" / "Next"
# labels stay in both) and the decorative SVGs (aria-hidden).
assert page.locator("#history-pager-prev").get_attribute("aria-label") == (
"Previous page of saved chats"
), "the Prev button's accessible name"
assert page.locator("#history-pager-next").get_attribute("aria-label") == (
"Next page of saved chats"
), "the Next button's accessible name"
assert (
page.locator("#history-pager-prev .history-pager-icon")
.get_attribute("aria-hidden")
== "true"
), "the Prev chevron is decorative (aria-hidden)"
assert (
page.locator("#history-pager-next .history-pager-icon")
.get_attribute("aria-hidden")
== "true"
), "the Next chevron is decorative (aria-hidden)"
# The live region that announces the page lines (the
# never-stale contract).
assert page.locator("#history-status").get_attribute("role") == "status"
assert (
page.locator("#history-status").get_attribute("aria-live")
== "polite"
), "#history-status must be a polite live region"
# The pager button reaches the 3px :focus-visible outline via a
# REAL keyboard Tab walk (the phase-171 walk shape: the skip
# link is focused first — the document's FIRST tabbable
# element; every move after it is a real Tab keypress). The
# walk crosses the header, the Refresh pill, the table region
# (tabindex=0) and the 25 row's controls (title link + share
# Create link + Delete — 3 tabbables per row) before it lands
# on the pager's Next (Prev is `disabled` on page 1 — not
# tabbable).
page.evaluate("() => document.querySelector('a.skip-link').focus()")
seen: list[str] = []
reached = False
for _ in range(150):
page.keyboard.press("Tab")
active = page.evaluate(
"() => (document.activeElement && document.activeElement.id)"
" || ''"
)
seen.append(active)
if active == "history-pager-next":
reached = True
break
assert reached, (
f"the walk never reached the pager's Next control "
f"(seen={seen[-12:]})"
)
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", (
f"focus-visible outline missing on the pager button: {outline!r}"
)
# Contrast (the existing tokens — the phase-08 AA palette,
# computed-style spot check): the "Page N of M" status text on
# the page bg (the inherited text color), and the button ink on
# the brand pill.
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) =>
getComputedStyle(document.querySelector(sel))[prop];
const root = getComputedStyle(document.documentElement);
const body = getComputedStyle(document.body);
const bg =
root.backgroundColor !== "rgba(0, 0, 0, 0)"
? root.backgroundColor
: (body.backgroundColor !== "rgba(0, 0, 0, 0)"
? body.backgroundColor
: "rgb(0, 0, 0)");
return {
status: [cs("#history-pager-status", "color"), bg],
next_btn: [
cs("#history-pager-next", "color"),
cs("#history-pager-next", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["status"], "pager status text on the page bg")
_assert_aa(pairs["next_btn"], "pager button ink on brand")
# No CDN (rule 6): no https:// asset tags; every script/link
# ref is same-origin or a data: URI (the house check).
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)
+113 -4
View File
@@ -9,7 +9,11 @@ surface (list / detail / delete / unshare) stays admin-only (guests get
403 on exactly those four routes). The admin CRUD pins stay green
unchanged: the auto-title convention (first
user message, whitespace-collapsed, 120-char cap + the no-user-message
fallback), the list order (``updated_at desc, id desc``), the
fallback), the list order (``updated_at desc, id desc``; phase 175
task 01: server-side pagination — ``limit`` (default 25, clamped
1–100) / ``offset`` + the full ``total`` on every response; out-of-
range params 422; an offset beyond the end → an empty list with the
honest ``total``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
``sources``/``thinking``/``tools``/``stopped``/``failed``/``error``
survives losslessly),
@@ -244,7 +248,7 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _assert_no_chats(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats").json() == {"chats": []}
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
# ---------- guest (no session): the public write surface (phase 55,
@@ -518,7 +522,7 @@ def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
def test_list_empty(admin_client: TestClient) -> None:
r = admin_client.get("/api/chats")
assert r.status_code == 200
assert r.json() == {"chats": []}
assert r.json() == {"chats": [], "total": 0}
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
@@ -560,6 +564,111 @@ def test_list_reports_message_count(admin_client: TestClient) -> None:
assert [c["message_count"] for c in body["chats"]] == [2]
# ---------- list — pagination (phase 175, task 01) ----------
def _seed_staggered_chats(db: Session, n: int) -> list[str]:
"""``n`` saved chats with distinctive titles and DISTINCT,
staggered ``updated_at`` (the
``test_list_orders_by_updated_at_desc`` idiom, scaled up): the
ordered list is fully determined by ``updated_at desc`` (the
uuid4 id tie-break never fires). Returns the titles in ORDERED
order (newest first); ``clean_chats`` truncates around the test."""
base = datetime.now(UTC)
db.add_all(
[
SavedChat(
title=f"page175 row {i:02d}",
messages=[_user(f"question {i}")],
updated_at=base + timedelta(hours=i), # i=0 oldest
)
for i in range(n)
]
)
db.commit()
return [f"page175 row {i:02d}" for i in range(n - 1, -1, -1)]
def _ordered_chat_ids(db: Session) -> list[str]:
"""The full ordered id sequence (``updated_at desc, id desc``)
via raw SQL — the ground truth a page concatenation is checked
against (bypassing the API itself)."""
return [
str(r[0])
for r in db.execute(
text(
"SELECT id FROM saved_chats ORDER BY updated_at DESC, "
"id DESC"
)
)
]
def test_list_default_page_is_25_and_reports_total(
admin_client: TestClient, db: Session
) -> None:
ordered = _seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats")
assert r.status_code == 200
body = r.json()
assert set(body) == {"chats", "total"}
assert body["total"] == 30 # the FULL count, not the page length
assert len(body["chats"]) == 25
# The page is the FIRST 25 of the full ordered list
# (``updated_at desc`` — the staggered fixture pins the order).
assert [c["title"] for c in body["chats"]] == ordered[:25]
for c in body["chats"]:
assert set(c) == ROW_KEYS
uuid.UUID(c["id"])
def test_list_limit_offset_slice(admin_client: TestClient, db: Session) -> None:
ordered = _seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats", params={"limit": 10, "offset": 20})
assert r.status_code == 200
body = r.json()
assert body["total"] == 30
# Exactly the LAST 10 of the ordered list (the fixture's ten
# oldest), pinned by title.
assert [c["title"] for c in body["chats"]] == ordered[20:]
def test_list_order_preserved_across_pages(
admin_client: TestClient, db: Session
) -> None:
_seed_staggered_chats(db, 30)
pages: list[dict[str, Any]] = []
for offset in (0, 10, 20):
body = admin_client.get(
"/api/chats", params={"limit": 10, "offset": offset}
).json()
assert body["total"] == 30
pages.extend(body["chats"])
# The three pages concatenated == the full ordered id sequence
# (the raw-SQL ground truth) — no duplicates, no gaps.
assert [c["id"] for c in pages] == _ordered_chat_ids(db)
assert len({c["id"] for c in pages}) == 30
def test_list_rejects_out_of_range_params(admin_client: TestClient) -> None:
# The house fixed-422 contract (FastAPI field-message validation
# — no hand-rolled checks): one request per case, status
# asserted.
for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}):
assert admin_client.get("/api/chats", params=params).status_code == 422
def test_list_offset_beyond_total_returns_empty_with_total(
admin_client: TestClient, db: Session
) -> None:
_seed_staggered_chats(db, 30)
r = admin_client.get("/api/chats", params={"offset": 100})
assert r.status_code == 200
# An honest count, not an error: the list is empty but ``total``
# still reports the fixture's 30 rows.
assert r.json() == {"chats": [], "total": 30}
# ---------- get ----------
@@ -708,7 +817,7 @@ def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
assert admin_client.get("/api/chats").json() == {"chats": []}
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
assert db.scalars(select(SavedChat)).all() == []
+36 -18
View File
@@ -516,11 +516,12 @@ def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
"""Phase 77: the History view re-fetches on a user-initiated
re-show — history.js registers a ``bor:view-refresh`` listener on
the view's root that re-runs the (now re-entrant) ``loadChats()``.
The listener is armed only AFTER the whoami gate passes: anonymous
shows the gate and never fetches (the phase-50 contract the story
E2E pins), and the ``started`` flag means the listener can only
re-run a load the mount already made."""
the view's root that re-runs the (now re-entrant) load. Phase 175:
the re-show re-fetches the CURRENT page — ``loadChats(offset)``,
never 0 (A5). The listener is armed only AFTER the whoami gate
passes: anonymous shows the gate and never fetches (the phase-50
contract the story E2E pins), and the ``started`` flag means the
listener can only re-run a load the mount already made."""
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
assert 'addEventListener("bor:view-refresh"' in history_js, (
"history.js must listen for the refresh event on the view root"
@@ -530,20 +531,21 @@ def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
assert 0 <= gate < listener, (
"the listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
"the listener is gated on the first load (started)"
assert re.search(r"if \(started\)\s+loadChats\(offset\)", history_js), (
"the listener is gated on the first load (started) — and "
"re-fetches the CURRENT page (phase 175, A5: never resets to 0)"
)
# Re-entrancy: a re-load drops the data rows (except the hidden
# empty-state row) before fetching — the list is replaced, not
# duplicated.
load = history_js.find("async function loadChats()")
load = history_js.find("async function loadChats(pageOffset)")
assert load != -1, "loadChats must exist"
load_body = history_js[load : history_js.find("\n }", load)]
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
"loadChats must remove the data rows (the empty row stays) first"
)
clear_i = load_body.find("tr !== emptyRow")
fetch_i = load_body.find('fetch("/api/chats")')
fetch_i = load_body.find('fetch("/api/chats?limit="')
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
@@ -763,12 +765,15 @@ def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
fn = js.find("async function refreshChats()")
assert fn != -1, "refreshChats must exist"
fn_body = js[fn:js.find("\n }", fn)]
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
assert "loadChats(offset)" in fn_body, (
"the button re-runs the (re-entrant) load — the CURRENT page "
"(phase 175, A5: a page-2 refresh never resets to page 1)"
)
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
"the button re-enables on success AND failure (the finally)"
)
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
load = js.find("async function loadChats()")
load = js.find("async function loadChats(pageOffset)")
load_body = js[load:js.find("\n }", load)]
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
"the network-error line lives in loadChats (every caller sees it)"
@@ -788,20 +793,33 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
The page-head flex row is SCOPED to #view-history (the other four
views' page-heads are untouched). Below 640px the pill goes full
width with the label visible (the icon-only squeeze is gone —
the glyph joins the label)."""
the glyph joins the label). Phase 175: the pager buttons join
the rule (the selector union — this pin names the union shape,
so the pill language stays pinned on .history-refresh itself)."""
css = _asset("styles.css")
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
block = re.search(
r"\.history-refresh(?:,\s*\.history-pager-btn)? \{([\s\S]*?)\n\}", css
)
assert block, "styles.css must style .history-refresh"
body = block.group(1)
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
assert "min-height: 44px" in body, "the comfortable touch target"
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
assert (
".history-refresh:hover, .history-pager-btn:hover {"
" background: var(--brand-hover); color: var(--bg); }"
) in css
assert (
".history-refresh:disabled, .history-pager-btn:disabled {"
" opacity: 0.6; cursor: wait; }"
) in css, (
"the in-flight disabled state is dimmed (the house language)"
)
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
assert (
".history-refresh svg, .history-pager-btn svg {"
" width: 16px; height: 16px; display: none; }"
) in css, (
"desktop: the label carries the pill (the glyph is hidden)"
)
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
@@ -811,10 +829,10 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".history-refresh { width: 100%; }" in mbody, (
assert ".history-refresh, .history-pager-btn { width: 100%; }" in mbody, (
"full width below 640px (the icon-only squeeze is gone)"
)
assert ".history-refresh svg { display: block; }" in mbody, (
assert ".history-refresh svg, .history-pager-btn svg { display: block; }" in mbody, (
"the glyph joins the visible label below 640px"
)
+10 -6
View File
@@ -325,14 +325,17 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
zero extra), hides the table, shows the gate, and RETURNS — no
``/api/chats`` request on the wire (the router 403s anonymous; the
story E2E pins the request log). Only the admin path reaches
``loadChats()``. The single ``fetch("/api/chats")`` in the file
lives in loadChats."""
``loadChats(pageOffset)``. The single list fetch in the file —
phase 175's parameterized form (``fetch("/api/chats?limit=" +
PAGE_SIZE + "&offset=" + pageOffset)``) — lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
assert js.count('fetch("/api/chats?limit="') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
)
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
assert (
'fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset)' in load
), "the parameterized list fetch lives in loadChats (phase 175)"
# Phase 76 (task 03): the view module never boots the shell's
# header — no CALL to the header boot (a docstring may name it;
@@ -359,10 +362,11 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
# The admin path: the gate hides, then the list loads on page 1
# (phase 175: the view opens on page 1 — offset 0).
after = js[js.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
assert "loadChats(0);" in after
def test_admin_load_renders_rows_or_empty_state() -> None:
+593
View File
@@ -0,0 +1,593 @@
"""Unit: the History page's list pager (phase 175, task 02).
TODO.md L3 (owner 2026-10-10): "Need pagination on the history
page - it gets long fast" — the saved-chat list is now paginated
SERVER-side (task 01: ``GET /api/chats`` takes ``?limit=`` /
``?offset=`` and reports the full ``total`` on every response), and
this module pins the view's pager — the ``.history-pager`` control
under the table card: Prev / "Page N of M" / Next, reusing the
``.history-refresh`` pill language (D3: NO new palette variable, B3;
no new spacing token) and walking the pages through the re-entrant
load (D4: refresh-keeps-current-page, A5; the anonymous gate
untouched, D5; the ≤640px full-width block, D6).
This module pins the source-level contract a silent regression would
break; the browser behavior (the 30-row page walk, the status lines,
the anonymous request log, the mobile pin) is E2E-gated by the
phase-175 story suite (``tests/e2e/test_history_pagination.py``,
task 03).
Pinned:
* ``index.html``: ``#history-pager`` sits in ``#view-history`` and
AFTER ``#history-table-wrap`` (the pager is under the table card,
not in the page-head — a direct child of the ``.history-shell``
container, following the table card), both buttons carry their
exact ``aria-label``s, both inline-SVG chevrons carry
``aria-hidden="true"``, the visible "Prev" / "Next" labels ship,
and the div ships ``hidden`` (the hidden-when-single-page default
the JS flips);
* ``styles.css``: the ``.history-pager`` rule carries
``display: flex`` + ``gap: 0.75rem`` (the page's OWN 0.75rem —
no new spacing token); ``.history-pager-btn`` is covered by the
pill / ``:disabled`` / svg-sizing declarations (the
``.history-refresh`` pill language — pinned by PRESENCE in the
declarations, not by a particular CSS authoring choice); the
≤640px block takes the pager buttons full width (the
``.history-refresh`` mobile idiom) and stacks the row (the
``.settings-actions`` idiom); and NO new token: every ``var(--x)``
used in any pager rule is already declared in the file's ``:root``
block (the B3 pin);
* ``history.js``: the parameterized fetch URL (the manual
``/api/chats?limit=…&offset=…`` join — the file's house style),
the ``total`` read from the JSON body, ``PAGE_SIZE = 25`` (the
server's default — A1, no size picker in the UI; the E2E suite
catches a drift), the hidden-when-single-page branch
(``total <= PAGE_SIZE``), the two ``disabled`` edge lines, the
``Showing <first>–<last> of <total>.`` status-line format (the
page-change line AND the first-load line — the settled load
announces its own page while the pager is visible, task 03), the
refresh path passing the current ``offset`` (A5 — a page-2
refresh never resets to page 1), the local Delete leaving the
pager and the offset alone (D4 — the next load corrects it), the
pager lookups scoped to ``root``, and the D5 pin: the anonymous
branch never touches the pager (it keeps its markup-shipped
hidden state and never fetches).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
HISTORY_JS = FRONTEND / "assets" / "history.js"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _strip_comments(text: str, kind: str) -> str:
"""The text without its comments — a comment's tag-like / rule-like
prose (an ``<!-- … <id> … -->`` in the markup, a ``/* … */``
between CSS rules) must not leak into the tag / selector walk."""
if kind == "html":
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
return re.sub(r"/\*.*?\*/", "", text, flags=re.S)
def _tag_span(html: str, open_tag: str, tag: str) -> str:
"""The substring from an opening <tag …> to its matching </tag>
(balanced tag counting — the test_git_sources_card_rhythm.py
brace-counting helper, applied to a named tag; comments are
stripped first so a comment's tag-like prose can't unbalance the
count)."""
html = _strip_comments(html, "html")
start = html.index(open_tag)
depth = 0
for m in re.finditer(rf"<{tag}\b|</{tag}>", html[start:]):
if m.group(0) == f"</{tag}>":
depth -= 1
if depth == 0:
return html[start : start + m.end()]
else:
depth += 1
raise AssertionError(f"unbalanced {tag}s from {open_tag!r}")
def _direct_children_tags(html: str) -> list[str]:
"""The element tags that open at DEPTH 1 (direct children) of the
substring (the leading <div> is depth 0 — the counted element).
Void elements (input/br/img/… — this markup uses non-self-closing
<input …>) change no depth."""
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr"}
html = _strip_comments(html, "html")
out: list[str] = []
depth = 1 # we are inside the counted div
for m in re.finditer(r"</?[a-zA-Z][^>]*?>", html):
tag = m.group(0)
mname = re.match(r"</?([a-zA-Z]+)", tag)
name = mname.group(1).lower() if mname else ""
if tag.startswith("</"):
depth -= 1
if depth == 0:
break
elif name not in VOID and not tag.endswith("/>"):
if depth == 1:
out.append(tag)
depth += 1
return out
def _css_rules(css: str) -> list[tuple[str, str]]:
"""Every (selector-list, body) pair in the file — top-level AND
inside @media blocks alike (the small-regex house style, with a
single brace-walk so the ≤640px block's inner rules are pinned
too). Selector lists and bodies are whitespace-normalized."""
out: list[tuple[str, str]] = []
def walk(text: str) -> None:
i = 0
n = len(text)
while i < n:
brace = text.find("{", i)
if brace == -1:
return
# the selector list is the slice's NON-comment text (a
# /* … */ comment between rules must not leak into it —
# it would mask the @media at-rule and pollute the
# selector):
selector = " ".join(
re.sub(r"/\*.*?\*/", " ", text[i:brace], flags=re.S).split()
)
depth = 1
j = brace + 1
while j < n and depth:
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
j += 1
body = re.sub(r"/\*.*?\*/", " ", text[brace + 1 : j - 1], flags=re.S)
if selector.startswith("@media"):
walk(body)
elif selector:
out.append((selector, " ".join(body.split())))
i = j
walk(css)
return out
def _root_block(css: str) -> str:
"""The file's :root block (balanced-brace slice)."""
m = re.search(r"^:root\s*\{", css, re.M)
assert m is not None, "the :root block must exist"
start = m.end() - 1
depth = 0
for i in range(start, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
return css[start : i + 1]
raise AssertionError("unbalanced :root block")
def _js() -> str:
return _read(HISTORY_JS)
def _fn(js: str, name: str) -> str:
"""The source of the first ``function <name>`` in history.js
(brace-balanced — the test_history_page.py house helper; since
phase 76 task 03 the functions live inside ``mount(root)``)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} must be defined in history.js"
open_idx = js.find("{", fn)
depth = 0
for i in range(open_idx, len(js)):
c = js[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[fn : i + 1]
raise AssertionError(f"unbalanced braces in {name}")
# ---------- index.html: the pager's structure ----------
def test_pager_div_sits_below_the_table_card_in_the_view() -> None:
"""D3 (the structural pin): #history-pager sits in #view-history
and AFTER #history-table-wrap — a direct child of the
.history-shell container, following the table card (the pager is
under the table, not in the page-head). If a future refactor
moves the pager to the page-head, this pin names it."""
html = _read(SHELL_HTML)
view = _tag_span(
html, '<section class="view" id="view-history"', "section"
)
wrap_open = (
'<div class="table-wrap history-table-wrap" id="history-table-wrap"'
)
assert wrap_open in view, "the table card must be in the History view"
pager_open = '<div class="history-pager" id="history-pager"'
assert pager_open in view, "the pager must be in the History view"
assert view.index(pager_open) > view.index(wrap_open), (
"the pager must sit AFTER the table card (under it, not in "
"the page-head)"
)
# A direct child of the .history-shell container, after the wrap:
shell_open = '<div class="container history-shell">'
assert shell_open in view, "the .history-shell container must be in the view"
shell = _tag_span(view, shell_open, "div")[len(shell_open):]
children = _direct_children_tags(shell)
pagers = [t for t in children if 'id="history-pager"' in t]
wraps = [t for t in children if 'id="history-table-wrap"' in t]
assert len(pagers) == 1, (
f"exactly ONE #history-pager direct child of the shell: {children}"
)
assert len(wraps) == 1, "exactly ONE #history-table-wrap direct child"
assert children.index(pagers[0]) > children.index(wraps[0]), (
"the pager must follow the table card among the shell's children"
)
def test_pager_buttons_carry_their_aria_labels_and_hidden_svgs() -> None:
"""D3: both buttons carry their EXACT aria-labels (the accessible
name the mobile squeeze relies on — the label is visible at every
width, the aria-label keeps it in both), both inline-SVG
chevrons carry aria-hidden="true" (decorative — the accessible
name is the label's), and the visible "Prev" / "Next" labels
ship (the house inline-SVG glyph + visible label + aria-label)."""
html = _read(SHELL_HTML)
pager = _tag_span(
html, '<div class="history-pager" id="history-pager"', "div"
)
assert pager.count('aria-label="Previous page of saved chats"') == 1, (
"the Prev button's exact aria-label (once)"
)
assert pager.count('aria-label="Next page of saved chats"') == 1, (
"the Next button's exact aria-label (once)"
)
assert 'class="history-pager-icon"' in pager, "the house inline-SVG glyph"
assert pager.count('aria-hidden="true"') == 2, (
"both SVG chevrons are aria-hidden (decorative)"
)
assert re.search(r'<span class="history-pager-label">Prev</span>', pager)
assert re.search(r'<span class="history-pager-label">Next</span>', pager)
def test_pager_div_ships_hidden_with_an_empty_status() -> None:
"""A4: the div SHIPS hidden — the hidden-when-single-page
default; history.js's updatePager() is the only thing that
reveals it (and only while total > PAGE_SIZE). A markup without
`hidden` would show a dead "Page 1 of 1" control on a one-page
list. The status span ships empty (the JS writes "Page N of M")."""
html = _read(SHELL_HTML)
m = re.search(r'<div [^>]*id="history-pager"[^>]*>', html)
assert m is not None, "the #history-pager div must ship in the markup"
tag = m.group(0)
assert " hidden" in tag or tag.rstrip(">").endswith("hidden"), (
f"the pager div must ship hidden: {tag}"
)
assert (
'<span class="history-pager-status" id="history-pager-status"></span>'
in html
), "the status span ships empty (the JS writes `Page N of M`)"
# ---------- styles.css: the pager's rules ----------
def test_pager_rule_is_the_flex_row_with_the_page_gap() -> None:
"""D3: the .history-pager row rule — display: flex,
align-items: center, gap: 0.75rem (the page's OWN 0.75rem — the
#view-history .page-head row gap; any other gap value is a new
spacing token and fails — no new spacing value)."""
rules = _css_rules(_read(STYLES_CSS))
row = [b for s, b in rules if s == ".history-pager"]
assert row, "the top-level .history-pager row rule must exist"
body = row[0]
assert "display: flex" in body, body
assert "align-items: center" in body, body
gaps = re.findall(r"gap\s*:\s*([^;]+)", body)
assert gaps == ["0.75rem"], f"gap regression (a new spacing token): {gaps}"
def test_pager_buttons_reuse_the_refresh_pill_declarations() -> None:
"""D3 (the B3 pin's structural half): .history-pager-btn is
covered by the pill / :disabled / svg-sizing declarations — the
.history-refresh pill language (pinned by PRESENCE in the
declarations, not by a particular CSS authoring choice — the
selector union or a duplication both pass)."""
rules = _css_rules(_read(STYLES_CSS))
# The pill (the desktop .history-refresh pill language — the
# brand fill, the ≥44px target, the 999px shape, the borderless
# solid pill):
pill = [
b
for s, b in rules
if ".history-pager-btn" in s
and ":disabled" not in s
and "svg" not in s
and ":hover" not in s
]
assert pill, "the pill rule must carry .history-pager-btn"
all_pill = " ; ".join(pill)
for decl in (
"display: inline-flex",
"min-height: 44px",
"border-radius: 999px",
"border: 0",
"background: var(--brand)",
"color: var(--bg)",
):
assert decl in all_pill, f"the pill declaration is missing: {decl}"
# The :disabled idiom (the in-flight dimming — the Refresh idiom;
# the Prev/Next edges use the real `disabled` attribute):
dis = [b for s, b in rules if ".history-pager-btn:disabled" in s]
assert dis, "the :disabled rule must carry .history-pager-btn:disabled"
dis_all = " ; ".join(dis)
assert "opacity: 0.6" in dis_all and "cursor: wait" in dis_all, dis_all
# The svg sizing (the glyph hidden on desktop — visible in the
# ≤640px block; the .history-refresh idiom):
svg = [b for s, b in rules if ".history-pager-btn svg" in s]
assert svg, "the svg-sizing rule must carry .history-pager-btn svg"
assert any("width: 16px" in b and "height: 16px" in b for b in svg), svg
def test_le640_pager_buttons_go_full_width_and_the_row_stacks() -> None:
"""D6: the ≤640px block — the pager buttons join the
.history-refresh full-width idiom (width: 100% + the svg glyph
visible), and the row STACKS (the .settings-actions idiom) so the
two full-width pills don't side-by-side overflow the phone width
(the pill rule's flex-shrink: 0 would keep each at 100%)."""
rules = _css_rules(_read(STYLES_CSS))
fw = [b for s, b in rules if ".history-pager-btn" in s and "width: 100%" in b]
assert fw, "the ≤640px block must take .history-pager-btn full width"
svg = [
b
for s, b in rules
if ".history-pager-btn svg" in s and "display: block" in b
]
assert svg, (
"the ≤640px block must show the pager glyph (the .history-refresh "
"svg idiom)"
)
stack = [
b for s, b in rules if s == ".history-pager" and "flex-direction: column" in b
]
assert stack, (
"the ≤640px block must stack the pager row (the full-width "
"pills must not side-by-side overflow the phone width)"
)
def test_pager_rules_introduce_no_new_tokens() -> None:
"""B3: no new palette variable — every var(--x) used in ANY pager
rule (the row rule, the pill union, the :disabled / svg rules —
desktop AND the ≤640px block) is already declared in the file's
:root block. (The row rule itself uses no tokens — the gap is a
plain 0.75rem — but the pin covers the union declarations too, so
a future `var(--new)` rides no pager change undetected.)"""
css = _read(STYLES_CSS)
declared = set(re.findall(r"(--[a-z0-9-]+)\s*:", _root_block(css)))
used: set[str] = set()
for s, b in _css_rules(css):
if "history-pager" in s:
used.update(re.findall(r"var\(\s*(--[a-z0-9-]+)\s*\)", b))
missing = used - declared
assert not missing, (
f"pager rules use undeclared tokens (a new palette variable, B3): "
f"{missing}"
)
# ---------- history.js: the pager's load contract ----------
def test_fetch_is_parameterized_with_limit_and_offset() -> None:
"""D4: loadChats(pageOffset) fetches the 25-row page at the given
offset — the manual string join (the file's house style) — and
reads the body's `total` (the server's full count) into the pager
(updatePager, on the 200 path). The failed-load branches keep the
house error lines + the empty state (a failed load leaves the
pager as it was — the previous state is the last known truth)."""
js = _js()
assert "async function loadChats(pageOffset)" in js, (
"the load must be parameterized on the page's offset"
)
load = _fn(js, "loadChats")
assert re.search(
r'fetch\("/api/chats\?limit="\s*\+\s*PAGE_SIZE\s*\+\s*"&offset="\s*\+\s*pageOffset\)',
load,
), "the fetch must be the manual /api/chats?limit=…&offset=… join"
assert re.search(r"const \{ chats, total \} = await r\.json\(\);", load), (
"the 200 path must read `total` from the JSON body"
)
assert "updatePager(total)" in load, (
"the 200 path must run the pager on the fresh total"
)
# The failure branches keep the house error lines (unchanged):
assert "Couldn't load saved chats — is the app reachable?" in load
assert "Couldn't load saved chats — try again." in load
assert load.count("showEmptyState()") == 3, (
"network failure, non-2xx and a 0-row list all show the empty state"
)
def test_settled_load_lands_its_own_showing_line_for_the_visible_pager() -> None:
"""D4 (task 03, the first-load half of the never-stale
contract): a settled load announces its own page — the mount is
fire-and-forget (the house-style ``loadChats(0);`` line), so the
announcement lives in ``loadChats`` itself, where the settled
``total`` is in hand. It lands ONLY while the pager is visible
(total > PAGE_SIZE): a one-page list IS the whole list (A4 — no
redundant line) and the 0-row branch shows the empty state
(nothing to show)."""
js = _js()
fn = _fn(js, "loadChats")
assert re.search(r"if \(total > PAGE_SIZE\)", fn), (
"the first-load line must land only while the pager is "
"visible (A4 — no redundant line)"
)
assert re.search(
r'"Showing "\s*\+\s*\(pageOffset \+ 1\)\s*\+\s*"–"', fn
), "the line starts `Showing <first>–` (the en-dash, the settled page)"
assert re.search(r"Math\.min\(pageOffset \+ PAGE_SIZE, total\)", fn), (
"the last row of the page is min(pageOffset + PAGE_SIZE, total)"
)
assert re.search(r'" of "\s*\+\s*total\s*\+\s*"\."', fn), (
"the line ends ` of <total>.`"
)
def test_pager_state_is_the_25_row_page_and_the_offset() -> None:
"""A1: PAGE_SIZE = 25 matches the server's default page (no size
picker in the UI — the E2E suite catches a drift); the module
keeps the current page's offset (it starts at 0 — the view opens
on page 1)."""
js = _js()
assert "const PAGE_SIZE = 25;" in js, "PAGE_SIZE must be the 25-row page"
assert "let offset = 0;" in js, "the module keeps the current page's offset"
def test_update_pager_hides_when_single_page_and_edges_disable() -> None:
"""D3/A4: updatePager — `total <= PAGE_SIZE` → the pager is
hidden (no dead "Page 1 of 1" control; it disappears with the
empty state); otherwise it shows, the status reads `Page N of M`,
Prev is disabled on page 1, Next on the last page (the real
`disabled` attribute — the Refresh idiom; the edges make the
click clamping trivial)."""
js = _js()
fn = _fn(js, "updatePager")
assert "Math.ceil(total / PAGE_SIZE)" in fn, "the page count is the ceil"
assert "total <= PAGE_SIZE" in fn, "the hidden-when-single-page branch"
assert "pagerEl.hidden = true" in fn, "the branch hides the pager"
assert "pagerEl.hidden = false" in fn, "the multi-page state shows it"
assert re.search(
r'"Page "\s*\+\s*\(offset / PAGE_SIZE \+ 1\)\s*\+\s*" of "\s*\+\s*pages',
fn,
), "the status reads `Page N of M`"
# The two disabled edge lines:
assert "pagerPrevBtn.disabled = offset === 0" in fn, (
"Prev is disabled on page 1"
)
assert "pagerNextBtn.disabled = offset + PAGE_SIZE >= total" in fn, (
"Next is disabled on the last page"
)
def test_page_change_lands_the_showing_line_in_the_live_region() -> None:
"""D4 (the never-stale contract): a page change re-runs the
re-entrant load (rows are REPLACED, never appended) and, on
success, lands `Showing <first>–<last> of <total>.` in
#history-status (the existing live region — announce()). A failed
load keeps the pager (no Showing line; loadChats' own error line
is the feedback)."""
js = _js()
fn = _fn(js, "changePage")
assert "loadChats(offset)" in fn, "the change re-runs the re-entrant load"
assert re.search(r'"Showing "\s*\+\s*\(offset \+ 1\)\s*\+\s*"–"', fn), (
"the line starts `Showing <first>–` (the en-dash)"
)
assert "Math.min(offset + PAGE_SIZE, total)" in fn, (
"the last row of the page is min(offset + PAGE_SIZE, total)"
)
assert re.search(r'" of "\s*\+\s*total\s*\+\s*"."', fn), (
"the line ends ` of <total>.`"
)
assert "announce(" in fn, "the line lands in the live region (announce)"
def test_refresh_and_resend_keep_the_current_page_and_boot_on_page_1() -> None:
"""A5: refreshChats re-fetches the CURRENT page — the module's
offset, never 0 (a page-2 refresh must land page-2 rows); the
re-show listener does the same; the mount boots on page 1
(offset 0). The Prev/Next handlers move the offset by PAGE_SIZE
(the disabled edges make clamping trivial)."""
js = _js()
fn = _fn(js, "refreshChats")
assert "loadChats(offset)" in fn, "refresh re-fetches the CURRENT page"
assert "loadChats(0)" not in fn, "refresh must never reset to page 1"
assert re.search(r"if \(started\)\s+loadChats\(offset\)", js), (
"the re-show listener re-fetches the CURRENT page (A5)"
)
assert re.search(r"^\s*loadChats\(0\);", js, re.M), (
"the mount boots on page 1 (the view opens on page 1)"
)
assert "offset -= PAGE_SIZE" in js, "Prev moves the offset back a page"
assert "offset += PAGE_SIZE" in js, "Next moves the offset forward a page"
def test_pager_lookups_are_scoped_to_root() -> None:
"""Phase 76's view-module contract: every DOM lookup scopes to
root (the view's section) — the four pager elements included (the
view ids stay unique across the shell; the scoped lookups keep
the module honest and testable)."""
js = _js()
for sel in (
'root.querySelector("#history-pager")',
'root.querySelector("#history-pager-prev")',
'root.querySelector("#history-pager-next")',
'root.querySelector("#history-pager-status")',
):
assert sel in js, f"the pager lookup must be scoped to root: {sel}"
def test_pager_is_bound_in_the_admin_branch_only() -> None:
"""D5: the whoami gate is unchanged — the anonymous branch hides
the table, shows the gate, hides Refresh, and RETURNS without
touching the pager (it keeps its markup-shipped hidden state) and
without a /api/chats request (the E2E pins the request log). The
Prev/Next bindings — like Refresh's — are armed ONLY after the
gate passes, in the admin branch."""
js = _js()
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1, "the whoami gate must run in mount"
branch = js[gate_i : js.find("return;", gate_i)]
for name in ("pagerEl", "pagerPrevBtn", "pagerNextBtn", "pagerStatusEl"):
assert name not in branch, (
f"the anonymous branch must not touch {name} (D5 — the pager "
"keeps its markup-shipped hidden state)"
)
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything"
)
# The bindings are armed in the admin branch (after the return):
after = js[js.find("return;", gate_i):]
assert "pagerPrevBtn.addEventListener" in after, (
"Prev is bound in the admin branch only (the same gate as Refresh)"
)
assert "pagerNextBtn.addEventListener" in after, (
"Next is bound in the admin branch only (the same gate as Refresh)"
)
assert "loadChats(0);" in after, "the admin path boots the list on page 1"
def test_local_delete_does_not_touch_the_pager_or_the_offset() -> None:
"""D4: a local Delete removes the row (the empty state reappears
when it was the last one) but does NOT recompute the pager or the
offset — the pager's `total` stays the server's last truth; the
next load (a page click or a Refresh) corrects it."""
js = _js()
fn = _fn(js, "confirmDelete")
for name in (
"pagerEl",
"pagerPrevBtn",
"pagerNextBtn",
"pagerStatusEl",
"offset",
"updatePager",
):
assert name not in fn, (
f"confirmDelete must not touch {name} (D4 — the next load "
"corrects the pager)"
)