diff --git a/.agents/phases/todo/175_history_pagination/00_phase.md b/.agents/phases/complete/175_history_pagination/00_phase.md similarity index 100% rename from .agents/phases/todo/175_history_pagination/00_phase.md rename to .agents/phases/complete/175_history_pagination/00_phase.md diff --git a/.agents/phases/todo/175_history_pagination/01_api_pagination.md b/.agents/phases/complete/175_history_pagination/01_api_pagination.md similarity index 100% rename from .agents/phases/todo/175_history_pagination/01_api_pagination.md rename to .agents/phases/complete/175_history_pagination/01_api_pagination.md diff --git a/.agents/phases/todo/175_history_pagination/02_pager_ui.md b/.agents/phases/complete/175_history_pagination/02_pager_ui.md similarity index 100% rename from .agents/phases/todo/175_history_pagination/02_pager_ui.md rename to .agents/phases/complete/175_history_pagination/02_pager_ui.md diff --git a/.agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md b/.agents/phases/complete/175_history_pagination/03_e2e_history_pagination.md similarity index 100% rename from .agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md rename to .agents/phases/complete/175_history_pagination/03_e2e_history_pagination.md diff --git a/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.err b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md new file mode 100644 index 0000000..c03409d --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md @@ -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/`. diff --git a/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md.status b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.validate b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.validate new file mode 100644 index 0000000..bb91fa2 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.err b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md new file mode 100644 index 0000000..f9edd52 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md @@ -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). diff --git a/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md.status b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.validate b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.validate new file mode 100644 index 0000000..edc20d9 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__01_api_pagination.a1.validate @@ -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 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.err b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md new file mode 100644 index 0000000..9328b5a --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md @@ -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). diff --git a/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md.status b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.validate b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.validate new file mode 100644 index 0000000..303a8f9 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__02_pager_ui.a1.validate @@ -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 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.err b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md new file mode 100644 index 0000000..8555edd --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md @@ -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. diff --git a/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md.status b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.validate b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.validate new file mode 100644 index 0000000..bb91fa2 --- /dev/null +++ b/.agents/reports/175_history_pagination/175_history_pagination__03_e2e_history_pagination.a1.validate @@ -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 diff --git a/README.md b/README.md index 23e1696..07fc141 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/app/api/chats.py b/app/api/chats.py index 9bcdaf9..2046169 100644 --- a/app/api/chats.py +++ b/app/api/chats.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index b0dabf1..73d9539 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/frontend/assets/history.js b/frontend/assets/history.js index 353ca7c..88158f0 100644 --- a/frontend/assets/history.js +++ b/frontend/assets/history.js @@ -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=&offset=` (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 – of .` 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 – of + .` 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=&offset= (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 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 – + of .` 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) } diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index cbf12c6..f87f59a 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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 diff --git a/frontend/index.html b/frontend/index.html index af35a32..b9ec73b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1061,6 +1061,26 @@ + + diff --git a/tests/e2e/test_history_pagination.py b/tests/e2e/test_history_pagination.py new file mode 100644 index 0000000..06239f8 --- /dev/null +++ b/tests/e2e/test_history_pagination.py @@ -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/`` — 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/ (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}" + ) diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 0bd1abc..ab646c0 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -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() == [] diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py index f3d5e8f..068c944 100644 --- a/tests/unit/test_frontend_router.py +++ b/tests/unit/test_frontend_router.py @@ -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" ) diff --git a/tests/unit/test_history_page.py b/tests/unit/test_history_page.py index 7286577..02a4417 100644 --- a/tests/unit/test_history_page.py +++ b/tests/unit/test_history_page.py @@ -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: diff --git a/tests/unit/test_history_pagination.py b/tests/unit/test_history_pagination.py new file mode 100644 index 0000000..92a2de1 --- /dev/null +++ b/tests/unit/test_history_pagination.py @@ -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 – of .`` 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 ```` 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 to its matching + (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|", html[start:]): + if m.group(0) == f"": + 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
is depth 0 — the counted element). + Void elements (input/br/img/… — this markup uses non-self-closing + ) 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"]*?>", html): + tag = m.group(0) + mname = re.match(r""): + 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 `` 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, '
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 = '
' + 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, '
Prev', pager) + assert re.search(r'Next', 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'
]*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 ( + '' + 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 –` (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 .`" + ) + + +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 – of .` 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 –` (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 .`" + ) + 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)" + )