Files
brain-of-reese/.agents/phases/todo/175_history_pagination/00_phase.md
T

11 KiB
Raw Blame History

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-labels, 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

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"