Chat window loading
Date: 2026-07-29
Source docs/en/site/mech-chat-window.md
Voice: On the user side, scrolling far back in a conversation, jumping to a question, then returning to the latest should feel smooth. Technically this is full store + a bounded front-end window, not stuffing every bubble into memory at once.
Truth: this page; model context compaction: 对话历史与上下文压缩机制解析 (the display window ≠ context sent to the model).
Date: 2026-07-29 Status: Done (Web main conversation + question index + mobile window; auxiliary surfaces take the latest page) Related code:
- Backend:
backend/internal/store/chat.go,backend/internal/api/handlers/chat.go(History/HistoryTurns) - Web:
client/web/src/chatMessageWindow.ts,client/web/src/useChatSessions.ts,client/web/src/WorkChatMessageThread.tsx,client/web/src/ChatTurnNav.tsx - Mobile:
client/mobile/src/lib/chatMessageWindow.ts,client/mobile/app/(app)/chat/[sessionId].tsx
1. Problem
Under long-form agents (literary writing and similar), a single reply can be tens of thousands of characters; when a conversation reaches hundreds to thousands of rounds:
| Old approach | Result |
|---|---|
Front-end history only grows | Memory and React re-renders run away |
Only before pagination, no scroll anchor | Load at the top / tap the first nav item → jump, blank, stacked bubbles |
| Virtual list over-estimates an “unbounded growing array” | Total height is wrong; jump and nav fail |
Compare WeChat: the store may have years of messages; the screen only mounts a nearby slice; when paging older messages, load and recycle the other side; search/locate an item by swapping the window then focusing.
2. Layer boundaries
flowchart LR
subgraph ui [Front-end display window]
W[About 80–150 items]
end
subgraph api [History API]
B[before / after / around]
end
subgraph db [Database]
M[chat_messages full set]
end
subgraph llm [Model context]
S[Rolling summary + recent originals]
end
W --> B --> M
M --> S- DB: keep everything (no TTL); deleting a conversation cascades messages.
- Front-end window: bounded; bidirectional load + recycle.
- Model: independent rolling summary; the front-end window is not required to cover everything sent to the model.
3. Window state machine
| Field | Meaning |
|---|---|
items | Messages currently in memory (chronological) |
olderCursor | Non-empty if there is an older page (points at the earliest item in the current window) |
newerCursor | Non-empty if there is a newer page (points at the latest item in the current window); null at the live edge |
atLiveEdge | Whether stuck to the conversation’s newest end; when true, new messages can append directly; near-bottom does not need after |
busy | A page request is in flight |
Constants (implementation may tune):
WINDOW_PAGE_SIZE= 10 (first open / return-to-bottom only pulls the latest slice)WINDOW_MAX= 40 (if over, recycle the opposite side by load direction)TURN_NAV_PAGE_SIZE= 12 (right-side question nav; scrolling to the top auto-loads older)
Transitions
| Event | Behavior |
|---|---|
| Open conversation / back to bottom / send | resetToLatest: pull the latest page, atLiveEdge=true, clear opposite-cursor semantics |
| Near top | loadOlder (dir=before) → prepend → keep scroll position → recycle from the tail if needed |
Near bottom and !atLiveEdge | loadNewer (dir=after) → append → keep scroll position → recycle from the head if needed |
| Nav points at an id inside the window | Fine-scroll the DOM/virtual list to the bubble |
| Nav points at an id outside the window | jumpToMessage: around=id swap window, then scroll to the anchor; atLiveEdge by whether the latest item is included |
4. API contract
4.1 Message window
GET /api/v1/chat/history
| Parameter | Behavior |
|---|---|
session_id | Required |
limit | Default 100, same upper bound as before |
| (no cursor / around) | Latest limit items; older_cursor non-empty if older exists; newer_cursor=null; at_live_edge=true |
cursor + dir=before (default) | One page strictly older than the cursor |
cursor + dir=after | One page strictly newer than the cursor |
around=message_id | Centered on that message, about limit items total before and after |
Response:
{
"items": [ /* ChatMessage chronological */ ],
"session_id": "...",
"older_cursor": "…or null",
"newer_cursor": "…or null",
"at_live_edge": true,
"next_cursor": "…same value as older_cursor (compat with old clients)"
}
Cursors remain opaque server encodings (created_at + id).
4.2 Question-nav index
GET /api/v1/chat/history/turns
| Parameter | Behavior |
|---|---|
session_id | Required |
limit | Default 100, max 500; Web opening a conversation uses 12 |
| (no cursor) | Latest limit user questions |
cursor | One page of strictly older user questions |
Opening pulls only the latest page; does not chain requests on mount. The next page is requested when the user scrolls up to the top of the nav, or taps “earlier” at the top.
Response:
{
"items": [{ "id": "…", "preview": "first-line truncated preview", "created_at": "…" }],
"session_id": "...",
"older_cursor": "…or null",
"next_cursor": "…same value as older_cursor"
}
Web right-side question nav uses this API for pagination; tapping an id outside the window then around-swaps the message window.
5. Scroll keep-position and recycle
Anchor keep-position
- Before load: find the first visible bubble in the message viewport by
data-message-id(orid=work-chat-user-…), recordoffset = el.getBoundingClientRect().top - viewport.top. - After writing
items(useLayoutEffect): locate the same id again,scrollTop += (newTop - oldTop)so the view does not jump. suppressAutoScroll: while loading, do not chase the bottom.
Recycle
- After
loadOlder, ifitems.length > WINDOW_MAX: delete extra items from the tail, generatenewerCursorfrom the new last item,atLiveEdge=false. - After
loadNewerover the window: delete from the head, updateolderCursor.
6. Versus streaming / edit-resend
- Streaming bubble: lives in document flow outside the window list (does not enter virtual-row height estimates); only when
atLiveEdgeis the user usually reading at the bottom. - Stream stick-to-bottom: stick once when the streaming bubble appears after send; after that, chase tokens only while the user is still at the bottom. After the user scrolls up (wheel/touch/drag the bar), stop chasing; stream content continues to append outside the viewport, and the finished answer is not force-pulled.
- New assistant message while not at live edge: do not stuff it into the window; keep the “back to bottom” entry,
resetToLatest. - Send: if
!atLiveEdge,ensureAtLiveEdge/resetToLatestfirst, then send. - Edit-resend / truncate: after truncate, whole-window
refreshHistory, then send, to avoid a dirty window.
7. Surface coverage
| Surface | Behavior |
|---|---|
| Web workspace / help main conversation | Full window + turns nav + anchors |
| Mobile chat page | Bidirectional pagination + recycle + pull latest before send + “back to latest” |
| Help overlay / onboarding panel / agent history preview | Take the latest page (50–100), avoid 200+ full set into memory at once |
8. Acceptance checklist
- Continuously loading older: in-memory
items.lengthstays nearWINDOW_MAX. - Top load does not visually jump; tapping the first in-window nav item lands on the matching “you” bubble.
aroundjump: swap window first then focus; no large blank / stacked bubbles.- Back to bottom / send:
atLiveEdge=true, stream is readable; after scrolling up during stream, no force-pull; after returning to bottom, chase can continue. - A thousand-round conversation is tested as “window + API” only; do not require rendering a thousand DOM nodes at once.
- Right-side nav can load earlier questions independently (does not depend on window body).
- Mobile top-load keeps memory bounded; sending while not at latest first returns to latest.
9. Revision history
| Date | Note |
|---|---|
| 2026-07-29 | First version: WeChat-style window, bidirectional pagination, anchors, recycle, around; turns index marked P1 |
| 2026-07-29 | Done: send/truncate reset, /history/turns, Web nav hookup, mobile window, auxiliary surfaces switched to latest page |
| 2026-07-29 | First screen 10 items, window cap 40, nav 12 with top-scroll continue; fix whole-screen blank from short-line style height overestimate |
| 2026-07-29 | Streaming: stop chasing after the user scrolls up (no longer re-pin to bottom every frame) |