feat: paginate chat messages endpoint with cursor-based infinite scroll (#23083)

Adds cursor-based pagination to the chat messages endpoint.

## Backend

- New `GetChatMessagesByChatIDPaginated` SQL query: returns messages in
`id DESC` order with a `before_id` keyset cursor and configurable
`limit`
- Handler parses `?before_id=N&limit=N` query params, uses the `LIMIT
N+1` trick to set `has_more` without a separate COUNT query
- Queued messages only returned on the first page (no cursor) since
they're always the most recent
- SDK client updated with `ChatMessagesPaginationOptions`
- Fully backward compatible: omitting params returns the 50 newest
messages

## Frontend

- Switches `getChatMessages` from `useQuery` to `useInfiniteQuery` with
cursor chaining via `getNextPageParam`
- Pages flattened and sorted by `id` ascending for chronological display
- `MessagesPaginationSentinel` component uses `IntersectionObserver`
(200px rootMargin prefetch) inside the existing `flex-col-reverse`
scroll container
- `flex-col-reverse` handles scroll anchoring natively when older
messages are prepended — no manual `scrollTop` adjustment needed (same
pattern as coder/blink)

## Why cursor-based instead of offset/limit

Offset-based pagination breaks when new messages arrive while paginating
backward (offsets shift, causing duplicates or missed messages). The
`before_id` cursor is stable regardless of inserts — each page is
deterministic.
This commit is contained in:
Kyle Carberry
2026-03-16 16:40:59 +00:00
committed by GitHub
parent 32a894d4a7
commit 741af057dc
20 changed files with 437 additions and 82 deletions
+66
View File
@@ -3814,6 +3814,72 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes
return items, nil
}
const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
FROM
chat_messages
WHERE
chat_id = $1::uuid
AND CASE
WHEN $2::bigint > 0 THEN id < $2::bigint
ELSE true
END
AND visibility IN ('user', 'both')
ORDER BY
id DESC
LIMIT
COALESCE(NULLIF($3::int, 0), 50)
`
type GetChatMessagesByChatIDDescPaginatedParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
BeforeID int64 `db:"before_id" json:"before_id"`
LimitVal int32 `db:"limit_val" json:"limit_val"`
}
func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, arg GetChatMessagesByChatIDDescPaginatedParams) ([]ChatMessage, error) {
rows, err := q.db.QueryContext(ctx, getChatMessagesByChatIDDescPaginated, arg.ChatID, arg.BeforeID, arg.LimitVal)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChatMessage
for rows.Next() {
var i ChatMessage
if err := rows.Scan(
&i.ID,
&i.ChatID,
&i.ModelConfigID,
&i.CreatedAt,
&i.Role,
&i.Content,
&i.Visibility,
&i.InputTokens,
&i.OutputTokens,
&i.TotalTokens,
&i.ReasoningTokens,
&i.CacheCreationTokens,
&i.CacheReadTokens,
&i.ContextLimit,
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatMessagesForPromptByChatID = `-- name: GetChatMessagesForPromptByChatID :many
WITH latest_compressed_summary AS (
SELECT