The scroll-up history pager added in 45ee5a71 never activates. A pre-existing route on the sessions router shadows the paginated history endpoint, so the backend returns the full history on every request and has_more_before is never sent.
Root cause
routes/session_routes.py builds its router with prefix="/api" (session_routes.py:125) and defines a legacy GET /history/{sid} (session_routes.py:748) — effective path /api/history/{sid}. That handler ignores limit/offset and returns the entire history.
routes/history/history_routes.py:118 defines the paginated GET /api/history/{session_id} (get_session_history), which is where limit/offset/has_more_before come from (history_routes.py:161).
Both routes match the same path. app.py registers the sessions router first (app.py:658) and the history router second (app.py:687), and FastAPI dispatches to the first matching route — so the legacy handler wins and the paginated one is dead.
The two handlers have coexisted at /api/history since the e5c99a5e base, but the collision was benign until 45ee5a71: before then get_session_history also returned the full history, so which handler won made no difference. 45ee5a71 added the pagination branch (limit/offset/has_more_before, history_routes.py:161) to get_session_history — which is what made the shadowing consequential, because the handler that now paginates is the one that never runs. It shipped without removing (or reordering) the legacy handler.
Impact
GET /api/history/{id}?limit=24 returns every message, with no total and no has_more_before. The frontend then short-circuits at if (!box || !pageInfo || !pageInfo.has_more_before) return; (sessions.js:176), so _installHistoryPager never installs and the whole conversation is rendered into the DOM on session open — the unbounded growth the pager was written to prevent. This hits long histories hardest, and mobile in particular, which is what 45ee5a71 ("Polish mobile UI and editor workflows") set out to improve.
Reproduction
- Open a session with more messages than one page (>
HISTORY_PAGE_LIMIT_DESKTOP).
curl '.../api/history/<id>?limit=24' — the response carries the full history, with no total and no has_more_before.
- In the UI, scroll to the top; no older-message paging occurs, because the pager never installed.
Fix
Remove the legacy get_history handler (session_routes.py:748-755). get_session_history already serves the no-limit case through its fallback and returns the same {role, content, metadata} shape as ChatMessage.to_dict(), so it subsumes the route. The four no-limit callers — documentLibrary copy and preview, sessions.js "Copy Chat", and the archived-session peek — read only role/content. The fallback additionally strips inline base64 media and drops hidden compaction rows, both of which are improvements here (e.g. "Copy Chat" previously JSON.stringify'd raw base64 image bytes into the clipboard).
Environment
Present on upstream/dev at c67deaa6 (route order and both handlers as cited). #5090 (6f6cb6ea) is unrelated — it only moved the history routes into routes/history/; it neither introduced nor fixed this.
The fork's develop carried the same collision, and its own scroll pager (_fetchOlderFromServer) was equally inert. Fixed on develop in 268d713c, with an end-to-end regression guard in tests/test_chat_history_render_paging_playwright.py.
Branch: fix/chat-history-dom-eviction (from upstream-mirror), commit 1 of 2. Upstream-candidate.
The scroll-up history pager added in
45ee5a71never activates. A pre-existing route on the sessions router shadows the paginated history endpoint, so the backend returns the full history on every request andhas_more_beforeis never sent.Root cause
routes/session_routes.pybuilds its router withprefix="/api"(session_routes.py:125) and defines a legacyGET /history/{sid}(session_routes.py:748) — effective path/api/history/{sid}. That handler ignoreslimit/offsetand returns the entire history.routes/history/history_routes.py:118defines the paginatedGET /api/history/{session_id}(get_session_history), which is wherelimit/offset/has_more_beforecome from (history_routes.py:161).Both routes match the same path.
app.pyregisters the sessions router first (app.py:658) and the history router second (app.py:687), and FastAPI dispatches to the first matching route — so the legacy handler wins and the paginated one is dead.The two handlers have coexisted at
/api/historysince thee5c99a5ebase, but the collision was benign until45ee5a71: before thenget_session_historyalso returned the full history, so which handler won made no difference.45ee5a71added the pagination branch (limit/offset/has_more_before, history_routes.py:161) toget_session_history— which is what made the shadowing consequential, because the handler that now paginates is the one that never runs. It shipped without removing (or reordering) the legacy handler.Impact
GET /api/history/{id}?limit=24returns every message, with nototaland nohas_more_before. The frontend then short-circuits atif (!box || !pageInfo || !pageInfo.has_more_before) return;(sessions.js:176), so_installHistoryPagernever installs and the whole conversation is rendered into the DOM on session open — the unbounded growth the pager was written to prevent. This hits long histories hardest, and mobile in particular, which is what45ee5a71("Polish mobile UI and editor workflows") set out to improve.Reproduction
HISTORY_PAGE_LIMIT_DESKTOP).curl '.../api/history/<id>?limit=24'— the response carries the full history, with nototaland nohas_more_before.Fix
Remove the legacy
get_historyhandler (session_routes.py:748-755).get_session_historyalready serves the no-limitcase through its fallback and returns the same{role, content, metadata}shape asChatMessage.to_dict(), so it subsumes the route. The four no-limitcallers —documentLibrarycopy and preview,sessions.js"Copy Chat", and the archived-session peek — read onlyrole/content. The fallback additionally strips inline base64 media and dropshiddencompaction rows, both of which are improvements here (e.g. "Copy Chat" previouslyJSON.stringify'd raw base64 image bytes into the clipboard).Environment
Present on
upstream/devatc67deaa6(route order and both handlers as cited).#5090(6f6cb6ea) is unrelated — it only moved the history routes intoroutes/history/; it neither introduced nor fixed this.The fork's develop carried the same collision, and its own scroll pager (
_fetchOlderFromServer) was equally inert. Fixed on develop in268d713c, with an end-to-end regression guard intests/test_chat_history_render_paging_playwright.py.Branch:
fix/chat-history-dom-eviction(fromupstream-mirror), commit 1 of 2. Upstream-candidate.