11 KiB
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_chatstakeslimit: int = Query(25, ge=1, le=100)andoffset: int = Query(0, ge=0)(FastAPI validation → 422 onlimit=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 extraselect(func.count(SavedChat.id))per request (two small queries; the once-per-requestcurrent_sources_versionread is untouched).SavedChatListgainstotal: int— required, no default (thestaleconvention: 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 bareGET /api/chats= the first 25 rows +total). The only consumers arehistory.jsand 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_KEYSstays green). - D3 — the pager (task 02). A
.history-pagerdiv in#view-history, directly BELOW.table-wrap#history-table-wrap(inside the.history-shellcontainer). Contents, in order: a Prev button (the house inline-SVG chevronaria-hidden+ the visible "Prev" label +aria-label="Previous page of saved chats"), a#history-pager-statusspan ("Page N of M"), a Next button (mirrored,aria-label="Next page of saved chats"). The buttons reuse the.history-refreshpill language (solid brand pill, the:disabledopacity rule, the svg sizing — no new palette variable, B3, no new spacing token). The pager is hidden unlesstotal > limit(A4: no dead "Page 1 of 1" control; it disappears with the empty state). At the edges: Prev isdisabledon page 1, Nextdisabledon the last page (the realdisabledattribute — the Refresh idiom). - D4 — the load contract (task 02).
loadChats(offset)fetches/api/chats?limit=<PAGE_SIZE>&offset=<offset>(the module keepsPAGE_SIZE = 25— matching the server default; A1: no size picker in the UI), and readstotalfrom the body. Module state: the currentoffset(starts at 0). Prev/Next adjust the offset byPAGE_SIZE(the disabled edges make clamping trivial), re-run the re-entrant load (rows are replaced, never appended), and landShowing <first>–<last> of <total>.in#history-status(the never-stale contract — every page change is announced). Refresh re-fetches the current page (A5 —refreshChatspasses the module's currentoffset; 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'stotalis 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 andupdatePagerruns on the freshtotal. - D5 — anonymous (task 02, pinned in 03). The whoami gate is unchanged: anonymous sees
#history-gate, the table wrap hides, the pager hides (its ownhiddendefault — the JS never touches it on the anonymous branch), and NO/api/chatsrequest 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 paginates156_table_ux_polish(complete) — the.table-wrapscroll region + the pill button language the pager reuses (context only)
Tasks
01_api_pagination.md—GET /api/chatstakes?limit=/?offset=andSavedChatListgainstotal; the integration pins (incl. thetest_list_emptywire-pin update).02_pager_ui.md— the.history-pagercontrol (index.html+history.js+styles.css, D3–D6); the static pins.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.pyextended (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 → emptychatswith the honesttotal. - Unit:
tests/unit/test_history_pagination.py(new module, task 02, thetest_git_sources_card_rhythm.pypin style): the#history-pagerstructure inindex.html(below#history-table-wrap, the buttonaria-labels, thearia-hiddenSVGs, the shippedhidden), the pager rules instyles.css(the flex row + the ≤640px block + the token reuse — everyvar(--…)the pager rules use is already declared in:root), and thehistory.jscontract (the parameterized fetch, thetotalread, the hidden-when-single-page branch, the disabled edges, refresh-keeps-current-page, theShowing …status line). - E2E (AGENTS rule 4, task 03):
tests/e2e/test_history_pagination.py, run in isolation — the 30-row fixture (via the publicPOST /api/chats, distinctive titles,finallycleanup — thetest_chat_history.pyDB-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/chatsrequest 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.mdandAGENTS.mdin 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.mdimmediately when their own changes are user-visible (the?limit=/?offset=params +totalare a user-visible API surface — updateREADME.mdonly if it documents theGET /api/chatsshape; 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=20returns the correct slice; out-of-rangelimit/offset422; 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 aShowing 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/chatsrequest appears in the log (E2E pin). - The ≤640px pager layout is intact (full-width buttons — the E2E mobile pin).
uv run pytestgreen,app/coverage >90%, ruff + pyright clean.uv run pytest tests/e2e/test_history_pagination.py -v --no-covgreen in isolation;test_chat_history.py+test_history_page_width.py+test_share_chat.py+test_stale_saved_chats.py+test_history_copy.pygreen 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-signConventional 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"