feat: implement cursor-based pagination for conversations#289
Conversation
There was a problem hiding this comment.
Important
Invalid cursor values currently surface as 500 Internal Server Error because the decode error is wrapped in a plain fmt.Errorf. A bad cursor is a client error, so it should map to 400 Bad Request before this merges.
Reviewed changes — replaced offset pagination for project conversations with keyset cursor pagination across the Go API and the React frontend.
- Added
ConversationCursorencode/decode helpers inservices/api/internal/domain/agent/cursor.go. - Changed
ListConversationssignatures to returnhasMore boolinstead oftotal int64and acceptlimit intplus aCursorAfterfilter. - Implemented keyset pagination in
AgentRepository.ListConversationsusing(created_at, id) < ($n, $n+1)withORDER BY created_at DESC, id DESC. - Updated
ConversationHandlerto readcursorandpage_sizequery params and return{items, page_size, next_cursor}. - Switched the frontend to
useInfiniteQuerywith anIntersectionObserverauto-loader and removed client-side sorting.
ℹ️ Missing tests for cursor pagination
The diff updates mock signatures but does not add behavioral coverage for the new pagination path. Consider adding tests for the encode/decode roundtrip, invalid cursor handling, keyset query behavior, and the handler response shape.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Important
The invalid-cursor fix correctly returns 400 for the repository-sentinel path, but the new error code is missing from httpStatusForCode, leaving a latent 500 path if it is ever used directly on an *apierr.Error.
Reviewed changes — the follow-up commit added invalid-cursor error handling so malformed client cursors surface as HTTP 400 Bad Request instead of 500.
- Added
ErrConversationInvalidCursorsentinel inservices/api/internal/domain/agent/errors.go - Added
CodeAgentConversationInvalidCursorinservices/api/internal/apierr/codes.go - Mapped the sentinel to HTTP 400 in
services/api/internal/transport/http/presenter/response.go - Wrapped repository cursor-decode failures with the sentinel in
services/api/internal/repository/postgres/agent_repository.go
Kimi K2 (free via Pullfrog for OSS) | 𝕏
| case errors.Is(err, agentdom.ErrConversationBusy): | ||
| return http.StatusConflict, apierr.CodeAgentConversationBusy | ||
| case errors.Is(err, agentdom.ErrConversationInvalidCursor): | ||
| return http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor |
There was a problem hiding this comment.
| return http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor | |
| case errors.Is(err, agentdom.ErrConversationInvalidCursor):\n\t\treturn http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor |
There was a problem hiding this comment.
Clarifying: the suggestion here is just pointing to the existing correct mapping in statusAndCodeFor. The actual gap is in httpStatusForCode in this same file, which has no case for CodeAgentConversationInvalidCursor. Please add it to the switch case that returns http.StatusBadRequest.
Kimi K2 (free via Pullfrog for OSS) | 𝕏

Summary
IntersectionObserversentinel as the user scrolls.GET /projects/:projectId/conversationsendpoint from offset/limit pagination to cursor-based (keyset) pagination, mirroring the pattern already used by the tasks list endpoint.Backend
ConversationCursortype (services/api/internal/domain/agent/cursor.go) — an opaque base64-encoded(created_at, id)cursor, following the same approach astaskdom.TaskCursor.ListConversationsFilternow carriesCursorAfter *stringinstead ofLimit/Offset;ListConversationsreturns(items, hasMore, error)instead of(items, total, error).WHERE (created_at, id) < (?, ?)and orders bycreated_at DESC, id DESC, fetchinglimit+1rows to computehasMorewithout a separateCOUNT(*)query.page_size/cursorquery params (waslimit/offset) and responds with{items, page_size, next_cursor}.(project_id, created_at DESC, id DESC)onagent_conversationsto keep the keyset lookup index-only.Frontend
conversationsQueryOptionsnow usesuseInfiniteQuery, withnext_cursordrivinggetNextPageParam.IntersectionObserver, callingfetchNextPage()as it scrolls into view, with a loading skeleton while the next page fetches.data.pages[0].items[0]), which is simpler than before since the first page is already ordered newest-first.Notes
totalconversation count — cursor pagination avoids theCOUNT(*)query that produced it, and nothing in the UI displayed that total.Test plan
go build ./.../go vet ./...go test ./...(excluding e2e, which doesn't cover this endpoint)tsc --noEmit/biome check .use-project-realtime.test.tsx)