chore(agent): phase roadmap from TODO.md, phase 175 (history pagination)
LLM-Generated: true
This commit is contained in:
@@ -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=<PAGE_SIZE>&offset=<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 <first>–<last> of <total>.` 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"
|
||||||
|
```
|
||||||
@@ -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()`).
|
||||||
@@ -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
|
||||||
|
<!-- Phase 175 (TODO.md L3): the list pager — visible only when
|
||||||
|
there is more than one page (total > the 25-row page size —
|
||||||
|
history.js toggles `hidden`), so a one-page list never shows a
|
||||||
|
dead control. The buttons reuse the .history-refresh pill
|
||||||
|
language (the house inline-SVG glyph + visible label +
|
||||||
|
aria-label; the ≤640px full-width block joins
|
||||||
|
.history-refresh). #history-pager-status carries "Page N of M";
|
||||||
|
page-change outcome lines land in #history-status (the existing
|
||||||
|
live region). -->
|
||||||
|
<div class="history-pager" id="history-pager" hidden>
|
||||||
|
<button type="button" class="history-pager-btn" id="history-pager-prev" aria-label="Previous page of saved chats">
|
||||||
|
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||||
|
<span class="history-pager-label">Prev</span>
|
||||||
|
</button>
|
||||||
|
<span class="history-pager-status" id="history-pager-status"></span>
|
||||||
|
<button type="button" class="history-pager-btn" id="history-pager-next" aria-label="Next page of saved chats">
|
||||||
|
<span class="history-pager-label">Next</span>
|
||||||
|
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
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).
|
||||||
@@ -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/<id>`): `make_chats(n)` — POST `/api/chats` (the public write surface) `n` conversations, each with a distinctive auto-title (e.g. `Pagination probe <i> of <n> — 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/<id>` — 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/`.
|
||||||
Reference in New Issue
Block a user