From da441ea799e77b2c0f42d2c7bba20e745db72aae Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 11 Oct 2026 00:19:04 -0400 Subject: [PATCH] chore(agent): phase roadmap from TODO.md, phase 175 (history pagination) LLM-Generated: true --- .../todo/175_history_pagination/00_phase.md | 54 ++++++++++++++++ .../01_api_pagination.md | 38 +++++++++++ .../175_history_pagination/02_pager_ui.md | 63 +++++++++++++++++++ .../03_e2e_history_pagination.md | 46 ++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 .agents/phases/todo/175_history_pagination/00_phase.md create mode 100644 .agents/phases/todo/175_history_pagination/01_api_pagination.md create mode 100644 .agents/phases/todo/175_history_pagination/02_pager_ui.md create mode 100644 .agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md diff --git a/.agents/phases/todo/175_history_pagination/00_phase.md b/.agents/phases/todo/175_history_pagination/00_phase.md new file mode 100644 index 0000000..7382ccc --- /dev/null +++ b/.agents/phases/todo/175_history_pagination/00_phase.md @@ -0,0 +1,54 @@ +# Phase 175 — History page: paginate the saved-chat list + +**Source:** TODO.md L3 (owner 2026-10-10): "Need pagination on the history page - it gets long fast" +**Story:** n/a (owner request, this chat 2026-10-10) +**Context:** `app/api/chats.py` — the module docstring's route list (L60–66) and `list_chats` (L175–188): the admin-only `GET /api/chats` selects **all** `SavedChat` rows (`updated_at desc, id desc`), reads `current_sources_version` once per request, and returns a `SavedChatList` (the `chats` key only); `app/schemas.py` — `SavedChatRow` (L1699) and `SavedChatList` (L1725: `chats: list[SavedChatRow]`); `frontend/assets/history.js` — `loadChats()` (fetches bare `/api/chats`, re-entrant row replacement), the `#history-refresh` binding, the `showEmptyIfLast()` / `showEmptyState()` helpers, the `#history-status` live region; `frontend/index.html` — `#view-history` (L976+): the `.page-head` (h1 + the Refresh pill), `#history-gate`, `#history-status`, and `.table-wrap#history-table-wrap` (L1044) → `table.history-table` with `#history-tbody` (a hidden `#history-empty-row` ships in the tbody); `frontend/assets/styles.css` — the house `.table-wrap` scroll region (L2836–2844: `max-height: min(70vh, 64rem)` + the sticky thead), the `.history-refresh` pill language (L3995–4018, incl. the `:disabled` opacity + the svg sizing), the ≤640px full-width block (L6471), `.history-table` (L4050+); `tests/integration/test_chats_api.py` — the list pins (L518 `test_list_empty` asserts the body byte-exactly `{"chats": []}` — must absorb the new `total` key; L524 `test_list_orders_by_updated_at_desc` — the `ROW_KEYS` set is per-row and unaffected; L557 `test_list_reports_message_count`); `tests/e2e/test_chat_history.py` (the DB-isolation idiom: distinctive titles, never assert on absolute row counts, delete created rows in a `finally`), `tests/e2e/auth_helpers.py::login`, `tests/e2e/conftest.py` (the shared `app_server` / `mock_llm` / `browser` fixtures); `tests/unit/test_git_sources_card_rhythm.py` (the static-pin style task 02 extends). + +## Objective +The History page's saved-chat list is paginated **server-side** — `GET /api/chats` accepts `?limit=` (default 25, clamped 1–100; out-of-range 422) and `?offset=`, and reports the full `total` on every response; the view gains a Prev / "Page N of M" / Next pager under the table that walks the pages while the re-entrant load contract, the anonymous gate, the empty state, and the Refresh behavior stay intact. The full list is no longer fetched — a 30-row history ships 25 rows plus a count. + +## Design (shared by all tasks — the executor reads this, not the chat) +- **D1 — the query (task 01).** `list_chats` takes `limit: int = Query(25, ge=1, le=100)` and `offset: int = Query(0, ge=0)` (FastAPI validation → 422 on `limit=0`, `limit=101`, `offset=-1` — the house fixed-422 contract; no hand-rolled checks). The row fetch is the EXISTING select with `.offset(offset).limit(limit)` appended (the order is untouched — `updated_at desc, id desc`). `total`: one extra `select(func.count(SavedChat.id))` per request (two small queries; the once-per-request `current_sources_version` read is untouched). `SavedChatList` gains `total: int` — **required, no default** (the `stale` convention: the API must always compute it — there is no wire shape without the count). +- **D2 — wire compatibility (task 01, proven in 03).** The change is additive: the body gains one key (`total`), and the query string is optional (a bare `GET /api/chats` = the first 25 rows + `total`). The only consumers are `history.js` and the test suites. The ONE existing pin that breaks — `test_list_empty` (L518, asserts `{"chats": []}` byte-exactly) — is updated to `{"chats": [], "total": 0}` in task 01 (the only modification to an existing test in this phase). Per-row keys are untouched (`ROW_KEYS` stays green). +- **D3 — the pager (task 02).** A `.history-pager` div in `#view-history`, directly BELOW `.table-wrap#history-table-wrap` (inside the `.history-shell` container). Contents, in order: a Prev button (the house inline-SVG chevron `aria-hidden` + the visible "Prev" label + `aria-label="Previous page of saved chats"`), a `#history-pager-status` span ("Page N of M"), a Next button (mirrored, `aria-label="Next page of saved chats"`). The buttons reuse the `.history-refresh` pill language (solid brand pill, the `:disabled` opacity rule, the svg sizing — **no new palette variable, B3**, no new spacing token). The pager is **hidden unless `total > limit`** (A4: no dead "Page 1 of 1" control; it disappears with the empty state). At the edges: Prev is `disabled` on page 1, Next `disabled` on the last page (the real `disabled` attribute — the Refresh idiom). +- **D4 — the load contract (task 02).** `loadChats(offset)` fetches `/api/chats?limit=&offset=` (the module keeps `PAGE_SIZE = 25` — matching the server default; A1: no size picker in the UI), and reads `total` from the body. Module state: the current `offset` (starts at 0). Prev/Next adjust the offset by `PAGE_SIZE` (the disabled edges make clamping trivial), re-run the re-entrant load (rows are **replaced**, never appended), and land `Showing – of .` in `#history-status` (the never-stale contract — every page change is announced). **Refresh re-fetches the *current* page** (A5 — `refreshChats` passes the module's current `offset`; it never resets to 0). The empty-state helpers (`showEmptyIfLast` / `showEmptyState`) stay unchanged — they are DOM-local. A local Delete does NOT recompute the pager (the last page's rows can vanish locally while the pager's `total` is still the server's last truth; the next load — a page click or a Refresh — corrects it). A 0-row fetch (a deleted-behind-us race) shows the empty state and `updatePager` runs on the fresh `total`. +- **D5 — anonymous (task 02, pinned in 03).** The whoami gate is unchanged: anonymous sees `#history-gate`, the table wrap hides, the pager hides (its own `hidden` default — the JS never touches it on the anonymous branch), and NO `/api/chats` request is made (the E2E pins the request log). +- **D6 — mobile (task 02).** ≤640px: the pager buttons go full width (the `.history-refresh` ≤640px idiom, L6471) — one small addition to the existing media-query block, no new values. + +## Dependencies +- `174_git_sources_card_rhythm` (todo, the preceding phase) +- `50_chat_history` (complete) — the list endpoint + the history view this phase paginates +- `156_table_ux_polish` (complete) — the `.table-wrap` scroll region + the pill button language the pager reuses (context only) + +## Tasks +1. `01_api_pagination.md` — `GET /api/chats` takes `?limit=` / `?offset=` and `SavedChatList` gains `total`; the integration pins (incl. the `test_list_empty` wire-pin update). +2. `02_pager_ui.md` — the `.history-pager` control (`index.html` + `history.js` + `styles.css`, D3–D6); the static pins. +3. `03_e2e_history_pagination.md` — `tests/e2e/test_history_pagination.py` (the phase's dedicated Playwright suite, AGENTS rule 4), the regression set, the full gates, the atomic commit. + +## Testing & Quality +- **Integration:** `tests/integration/test_chats_api.py` extended (task 01) — the default page + `total`, explicit limit/offset slices, order preserved across pages (the concatenation of the pages == the full-list order), 422 on out-of-range params, an offset beyond the end → empty `chats` with the honest `total`. +- **Unit:** `tests/unit/test_history_pagination.py` (new module, task 02, the `test_git_sources_card_rhythm.py` pin style): the `#history-pager` structure in `index.html` (below `#history-table-wrap`, the button `aria-label`s, the `aria-hidden` SVGs, the shipped `hidden`), the pager rules in `styles.css` (the flex row + the ≤640px block + the token reuse — every `var(--…)` the pager rules use is already declared in `:root`), and the `history.js` contract (the parameterized fetch, the `total` read, the hidden-when-single-page branch, the disabled edges, refresh-keeps-current-page, the `Showing …` status line). +- **E2E (AGENTS rule 4, task 03):** `tests/e2e/test_history_pagination.py`, run **in isolation** — the 30-row fixture (via the public `POST /api/chats`, distinctive titles, `finally` cleanup — the `test_chat_history.py` DB-isolation idiom), the page-1→2→1 walk with row counts + pager state + status lines, refresh-on-page-2 stays on page 2, delete-to-empty on the last page, the single-page hide, the anonymous gate pin (no `/api/chats` request in the log), and the standard a11y / no-CDN light pass. +- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`). +- **Lint/types:** `uv run ruff check . && uv run pyright`. +- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's) and the gates above are green. + +## Documentation & Consistency +- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. +- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the `?limit=` / `?offset=` params + `total` are a user-visible API surface — update `README.md` only if it documents the `GET /api/chats` shape; the pager is UI with no setup/command/config surface). + +## Completion Criteria +- [ ] `GET /api/chats` (bare) returns the first 25 rows (`updated_at desc, id desc`) + `total` (the full count); `?limit=10&offset=20` returns the correct slice; out-of-range `limit` / `offset` 422; an offset beyond the end returns `{"chats": [], "total": N}` (integration pins). +- [ ] On `/history.html` (admin, a 30-row fixture): the table shows 25 rows and the pager reads "Page 1 of 2" (Prev disabled, Next enabled); Next → 5 rows, "Page 2 of 2" (Next disabled, the page-1 rows gone — replaced, not appended); Prev → back to 25 (E2E pins); every page change lands a `Showing X–Y of Z.` line in the live region. +- [ ] Refresh on page 2 re-fetches page 2 (E2E pin — never resets to page 1); deleting the last page's rows shows the empty state locally; ≤1 page → the pager is fully hidden (no dead control; E2E pin). +- [ ] Anonymous: the gate shows, the table + pager are hidden, and NO `/api/chats` request appears in the log (E2E pin). +- [ ] The ≤640px pager layout is intact (full-width buttons — the E2E mobile pin). +- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean. +- [ ] `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` green **in isolation**; `test_chat_history.py` + `test_history_page_width.py` + `test_share_chat.py` + `test_stale_saved_chats.py` + `test_history_copy.py` green in isolation (the regression set). +- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass). +- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moves to `.agents/phases/complete/`. + +## Commit +```bash +git add app/ frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(history): paginate the saved-chat list — server-side ?limit/?offset with total + the view's pager" +``` diff --git a/.agents/phases/todo/175_history_pagination/01_api_pagination.md b/.agents/phases/todo/175_history_pagination/01_api_pagination.md new file mode 100644 index 0000000..5509dc0 --- /dev/null +++ b/.agents/phases/todo/175_history_pagination/01_api_pagination.md @@ -0,0 +1,38 @@ +# Task 01 — `GET /api/chats` is paginated server-side (`?limit=`, `?offset=`, `total`) + +**Phase:** `175_history_pagination` · **Story:** n/a (owner request 2026-10-10) + +## Objective +The list endpoint ships a page instead of the full list: 25 rows by default, a `?limit=` (1–100) / `?offset=` the client can walk, an honest `total` on every response, and a FastAPI 422 on out-of-range params — the wire change is additive everywhere (a bare `GET` = the first page + the count). + +## Work +1. `app/api/chats.py` — `list_chats` (L175–188): + - imports: add `Query` to the `fastapi` import line; add `func` to the `sqlalchemy` import line (`from sqlalchemy import func, select, text`). + - signature: `limit: int = Query(25, ge=1, le=100)` and `offset: int = Query(0, ge=0)` (declared before the `db` dependency; FastAPI serves the house field-message 422 for `limit=0` / `limit=101` / `offset=-1` — no hand-rolled validation). + - body: the existing select gains `.offset(offset).limit(limit)` appended (the order is untouched — `SavedChat.updated_at.desc(), SavedChat.id.desc()`); `total = db.scalar(select(func.count(SavedChat.id)))` (one small count query per request — the once-per-request `current_sources_version` read is untouched). + - return `SavedChatList(chats=[_to_row(row, current_version) for row in rows], total=total)`. + - docstrings: `list_chats`' docstring + the module docstring's route list (L60–66, the "``GET`` (list, …)" entry) — describe the two params + `total` in the house thoroughness (the 422 contract, the default, the once-per-request count read). + - NO other route in this file changes (detail / create / re-Save / delete / share / unshare / the public read + page route are untouched). +2. `app/schemas.py` — `SavedChatList` (L1725): + - add `total: int` — **required, no default** (the `stale` convention: the API must always compute it — there is no wire shape without the count); update the class docstring (`chats` + `total` described). + - `SavedChatRow` and its per-row key set (the `ROW_KEYS` in the integration suite) are untouched. +3. `tests/integration/test_chats_api.py` — + - **update the ONE existing pin that breaks:** `test_list_empty` (L518): `assert r.json() == {"chats": []}` becomes `assert r.json() == {"chats": [], "total": 0}` (the D2 wire-pin update — the body is now the two-key shape, byte-exact). + - new pins (the file's existing idiom: the `admin_client` fixture, direct `SavedChat` ORM adds with staggered `updated_at` — the L524 idiom — or `POST /api/chats` creates; distinctive titles): + - `test_list_default_page_is_25_and_reports_total` — 30 rows with distinct, staggered `updated_at` → bare `GET /api/chats`: exactly 25 rows, `total == 30`, and the first page's `id` order == the first 25 of the full ordered list (`updated_at desc`); + - `test_list_limit_offset_slice` — the same 30 rows → `GET /api/chats?limit=10&offset=20`: exactly 10 rows (the last 10 of the ordered list — pinned by title), `total == 30`; + - `test_list_order_preserved_across_pages` — `?limit=10` at offsets 0 / 10 / 20, the three pages concatenated == the full ordered `id` sequence (no duplicates, no gaps); + - `test_list_rejects_out_of_range_params` — `?limit=0`, `?limit=101`, `?offset=-1` each 422 (the house fixed-422 contract; one request per case, status asserted); + - `test_list_offset_beyond_total_returns_empty_with_total` — `?offset=100` on the 30-row fixture → `{"chats": [], "total": 30}` (an honest count, not an error). + +## Testing & Quality +- Integration: the new pins + the updated `test_list_empty`; `uv run pytest tests/integration/test_chats_api.py -v` green. +- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`). +- Lint/types: `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] The bare `GET /api/chats` returns 25 rows + `total`; the explicit `limit` / `offset` slices are correct; out-of-range params 422; an offset beyond the end is honest. +- [ ] `test_list_empty` updated to the two-key body (and green); every other existing pin green **unchanged** (per-row shape byte-exact — `ROW_KEYS`). +- [ ] `uv run pytest` green (the full unit + integration suite); `app/` coverage >90%; ruff + pyright clean. +- [ ] README.md: update the endpoint's shape only if `README.md` documents `GET /api/chats` (check first; if absent, no edit) — AGENTS.md needs no change (no setup/command/config surface). +- [ ] No behavior change in completed work (create / detail / re-Save / delete / share / unshare / the public read + page route untouched — the list route's only diff is the two query params + the `total` key + the select's `.offset()/.limit()`). diff --git a/.agents/phases/todo/175_history_pagination/02_pager_ui.md b/.agents/phases/todo/175_history_pagination/02_pager_ui.md new file mode 100644 index 0000000..f7dde25 --- /dev/null +++ b/.agents/phases/todo/175_history_pagination/02_pager_ui.md @@ -0,0 +1,63 @@ +# Task 02 — the History view's pager (Prev / "Page N of M" / Next) + +**Phase:** `175_history_pagination` · **Story:** n/a (owner request 2026-10-10) + +## Objective +The view walks the paginated list: the pager control under the table (D3), the parameterized re-entrant load (D4), hidden-when-single-page / hidden-when-empty (A4), refresh-keeps-current-page (A5), the untouched anonymous gate (D5), the ≤640px block (D6) — all in existing tokens and house idioms. + +## Work +1. `frontend/index.html` — in `#view-history` (L976+), **directly after** the `.table-wrap#history-table-wrap` div (L1044 — the table card is the view's last child today; the pager lands below it, inside the same `.history-shell` container), with a phase-175 comment (the house comment style — the reason + the idioms it reuses): + ```html + + + ``` + The anonymous gate / page-head / `#history-status` span / table markup are all untouched. +2. `frontend/assets/history.js` — + - module header comment: a phase-175 block (the house comment style — the parameterized fetch + `total` + the pager lifecycle). + - element lookups (scoped to `root`, the house style): `#history-pager`, `#history-pager-prev`, `#history-pager-next`, `#history-pager-status`. + - state: a `const PAGE_SIZE = 25;` (matches the server default — A1, no size picker in the UI; the comment says so, and the E2E suite catches drift) + `let offset = 0;` (the current page's offset). + - `loadChats(pageOffset)`: the fetch becomes `/api/chats?limit=` + `PAGE_SIZE` + `&offset=` + `pageOffset` (manual string join — the house style in this file); on 200, `const { chats, total } = await r.json();` then `updatePager(total)`. The 0-row / non-2xx / network-error branches keep their exact current behavior (empty state + the house error lines; a failed load leaves the pager as it was — the previous state is the last known truth). + - `updatePager(total)`: `const pages = Math.ceil(total / PAGE_SIZE);` — if `total <= PAGE_SIZE` → the pager is `hidden` (A4: no "Page 1 of 1" dead control); otherwise the pager shows, `#history-pager-status` gets `Page ` + (`offset / PAGE_SIZE + 1`) + ` of ` + `pages`, `#history-pager-prev.disabled = (offset === 0)`, `#history-pager-next.disabled = (offset + PAGE_SIZE >= total)`. + - Prev/Next binding (ADMIN branch only — the same gate as Refresh; the anonymous branch never binds them, D5): click → `offset += / -= PAGE_SIZE` (the disabled edges make clamping trivial) → `loadChats(offset)` → on success land `Showing ` + (`offset + 1`) + `–` + `Math.min(offset + PAGE_SIZE, total)` + ` of ` + `total` + `.` in `#history-status` (the existing live region — the never-stale contract). + - `refreshChats()`: pass the module's current `offset` to `loadChats` (A5 — re-fetch the *current* page; never reset to 0). + - boot: the initial `loadChats(0)` — the view opens on page 1. + - a local Delete does NOT touch the pager or `offset` (D4 — the next load corrects it). +3. `frontend/assets/styles.css` — + - the `.history-pager` rule (placed near the `.history-refresh` block, L3995–4018, with a phase-175 comment — root cause + token reuse): `display: flex; align-items: center; gap: 0.75rem;` (the existing `0.75rem` gap the page already uses — no new spacing token). The buttons: add `.history-pager-btn` to the existing `.history-refresh` declaration (a selector union on the pill rule, the `:disabled` rule, and the `svg` sizing rule — the executor's choice of union vs duplication, but **no new palette variable (B3)** and no value not already in the file); `#history-pager-status` needs no rule beyond the inherited text (if a min-width or text-center is added, it uses existing tokens only). + - the ≤640px media-query block (L6471): add `.history-pager-btn` to the existing `.history-refresh` full-width line (the house idiom). + - NOTHING else in styles.css changes (the `.table-wrap` scroll region, the sticky thead, the `.history-table` rules, the `.history-status` rule are all untouched). +4. `tests/unit/test_history_pagination.py` — new module (the `test_git_sources_card_rhythm.py` pin style: `FRONTEND` / `STYLES_CSS` / `SHELL_HTML` path constants, raw text assertions, a module docstring citing the phase + the TODO line): + - `index.html`: `#history-pager` sits in `#view-history` and AFTER `#history-table-wrap` (the structural pin — the pager is under the table card, not in the page-head); both buttons carry their exact `aria-label`s; both SVGs carry `aria-hidden="true"`; the div ships `hidden` (the hidden-when-single-page default the JS flips); + - `styles.css`: the `.history-pager` rule with `display: flex` and `gap: 0.75rem`; `.history-pager-btn` is covered by the pill / `:disabled` / svg-sizing declarations (extract with the small-regex house style — pinned by presence, not by a particular CSS authoring choice); the ≤640px block carries `.history-pager-btn` full width; **no new token** — every `var(--x)` used in any pager rule is declared in the file's `:root` block (the B3 pin); + - `history.js`: the parameterized fetch URL (`/api/chats?limit=` + the `&offset=` join), the `total` read from the JSON body, `PAGE_SIZE = 25`, the hidden-when-single-page branch (`total <= PAGE_SIZE`), the two `disabled` edge lines, the `Showing ` status-line format, the refresh path passing the current `offset`, and the pager lookups scoped to `root`. + +## Testing & Quality +- Unit: the pins above; `uv run pytest tests/unit/test_history_pagination.py -v` green. +- Coverage: **>90%** on `app/` (this task changes no `app/` code — the gate must stay green, not drop). +- Lint/types: `uv run ruff check . && uv run pyright` clean. +- The E2E proof is task 03 (the browser-level pins); this task's pins keep the static contract honest between runs. + +## Completion Criteria +- [ ] `uv run pytest tests/unit/test_history_pagination.py -v` green. +- [ ] `uv run pytest` green (the full unit + integration suite — no pin broke). +- [ ] ruff + pyright clean; no new CSS token (B3 pin green). +- [ ] README.md/AGENTS.md need no change for this task (UI with no setup/command/config surface; the phase's final pass re-checks both project-wide). +- [ ] No behavior change in completed work (the anonymous gate, the Refresh lifecycle, the empty-state helpers, the Delete/Share row actions are untouched — the load only gains two query params + `total` + the pager state). diff --git a/.agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md b/.agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md new file mode 100644 index 0000000..874fd0a --- /dev/null +++ b/.agents/phases/todo/175_history_pagination/03_e2e_history_pagination.md @@ -0,0 +1,46 @@ +# Task 03 — E2E (Playwright): the History page's pager + +**Phase:** `175_history_pagination` · **Story:** n/a (owner request 2026-10-10) + +## Objective +The phase's dedicated E2E suite (AGENTS rule 4 — one file per phase, run in isolation): `tests/e2e/test_history_pagination.py` proves the pager contract in a real browser against the real app + DB, then the regression set, the full gates, and the atomic commit. + +## Work +1. `tests/e2e/test_history_pagination.py` — new file (the `test_chat_history.py` house pattern: a module docstring citing the phase + the TODO line + the run-in-isolation instructions `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` (DB up: `podman compose up -d db`); the `conftest` fixtures — `app_server` / `page`; `auth_helpers.login` for the admin cookie). The suite touches ONLY `saved_chats` (no KB reset idiom needed — it never drives the chat page): + - **fixture helper** (the `test_chat_history.py` DB-isolation idiom — distinctive titles, never assert on absolute row counts, delete the rows it creates in a `finally` via admin `DELETE /api/chats/`): `make_chats(n)` — POST `/api/chats` (the public write surface) `n` conversations, each with a distinctive auto-title (e.g. `Pagination probe of — what is the capital of France?`) and a minimal `messages` payload (the house `_simple_conversation` shape); then, to make the `updated_at desc` order fully deterministic, re-Save (`PUT /api/chats/` — public, bumps `updated_at` via `onupdate`) the rows in a controlled loop, so the last re-Saved chat is row 1. Pin the order by the first row of page 1 (the last re-Saved) and the first row of the last page (the earliest re-Saved) — never by the full middle order. + - `test_pager_shows_page_1_of_2_for_30_rows` — admin, `/history.html`: the table holds 25 rows, the pager is visible, `#history-pager-status` reads "Page 1 of 2", `#history-pager-prev` is `disabled`, `#history-pager-next` is enabled, `#history-status` carries "Showing 1–25 of 30."; + - `test_next_walks_to_page_2` — click Next: the table holds 5 rows (pinned by the fixture's earliest-re-Saved title first — the order), "Page 2 of 2", Next disabled, Prev enabled, the live region reads "Showing 26–30 of 30."; the 25 page-1 rows are GONE (replaced, never appended — the row count is exactly 5); + - `test_prev_walks_back_to_page_1` — from page 2, click Prev: 25 rows again, "Page 1 of 2", Prev disabled, "Showing 1–25 of 30."; + - `test_refresh_on_page_2_stays_on_page_2` — walk to page 2, click `#history-refresh` (wait for its re-enable), still 5 rows + "Page 2 of 2" (A5 — never reset to page 1), and the "Saved chats refreshed." line lands; + - `test_delete_all_rows_of_page_2_then_prev_single_page_hides_pager` — on page 2 (5 rows), use the inline two-step confirm (the `Delete? [Yes] [No]` pair appears — the owner-locked no-native-dialog pin) to delete all five: `#history-empty-row` becomes visible (the local delete contract — the pager is NOT recomputed on a local delete, D4); then click Prev: 25 rows, and the pager is now HIDDEN (total 25 → single page, A4 — the fresh `total` from the page-1 load corrects it); + - `test_single_page_hides_the_pager` — a 10-row fixture: the table lists all 10, the pager is fully hidden (A4 — no dead control); + - `test_anonymous_sees_gate_and_never_fetches` — no login: `#history-gate` visible, `#history-table-wrap` + `#history-pager` hidden, and NO `GET /api/chats` in the request log (the house log-pin idiom); + - `test_pager_mobile_375px` — viewport 375px: the pager buttons are full width (bounding-box pin — the phase-171 rhythm-suite idiom) and the table still scrolls horizontally inside its card; + - the standard a11y light pass (the house pattern from the phase-171/167 suites): the pager buttons' `:focus-visible` outline, the `aria-label`s exposed in the accessibility tree, the pager text contrast via the existing tokens (computed-style spot check), and the no-CDN pin (no external-origin `script`/`link` — the house integration check on the index page). +2. the regression set (green in isolation, UNCHANGED — task 01's `test_list_empty` pin update is the only touch to an existing test in this phase): + - `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` + - `uv run pytest tests/e2e/test_history_page_width.py -v --no-cov` + - `uv run pytest tests/e2e/test_share_chat.py -v --no-cov` + - `uv run pytest tests/e2e/test_stale_saved_chats.py -v --no-cov` + - `uv run pytest tests/e2e/test_history_copy.py -v --no-cov` + - `uv run pytest tests/integration/test_chats_api.py -v` +3. the full gates: + - `uv run pytest` (the entire unit + integration suite — 100% green) + - `uv run pytest --cov=app --cov-report=term-missing` (app/ >90%) + - `uv run ruff check . && uv run pyright` + - the Documentation & Consistency final pass (README.md + AGENTS.md against the project as it stands — fix every inconsistency found, including pre-existing ones). +4. the atomic commit (the phase dir moves to `.agents/phases/complete/` **after** the commit): + ```bash + git add app/ frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(history): paginate the saved-chat list — server-side ?limit/?offset with total + the view's pager" + ``` + +## Testing & Quality +- E2E: the suite above green **in isolation**; the regression set green in isolation; the full gates green. +- The phase is "Complete" only when every test in the project passes 100% and the gates are green (the 00_phase.md contract). + +## Completion Criteria +- [ ] `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` green in isolation. +- [ ] The five-file E2E regression set + the integration `test_chats_api.py` green (unchanged apart from task 01's wire-pin update). +- [ ] `uv run pytest` 100% green; `app/` coverage >90%; ruff + pyright clean. +- [ ] README.md + AGENTS.md verified consistent (the final pass). +- [ ] One atomic `--no-gpg-sign` Conventional Commit; the phase dir moved to `.agents/phases/complete/`.