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"
|
||||
```
|
||||
Reference in New Issue
Block a user