Skip to content

feat: implement cursor-based pagination for conversations#289

Open
pikann wants to merge 2 commits into
masterfrom
feature/implement-pagination-for-conversations
Open

feat: implement cursor-based pagination for conversations#289
pikann wants to merge 2 commits into
masterfrom
feature/implement-pagination-for-conversations

Conversation

@pikann

@pikann pikann commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add infinite-scroll pagination to the conversation list on the project conversations page — the sidebar now loads conversations in pages instead of fetching the entire list at once, triggering the next page via an IntersectionObserver sentinel as the user scrolls.
  • Switch the GET /projects/:projectId/conversations endpoint from offset/limit pagination to cursor-based (keyset) pagination, mirroring the pattern already used by the tasks list endpoint.

Backend

  • New ConversationCursor type (services/api/internal/domain/agent/cursor.go) — an opaque base64-encoded (created_at, id) cursor, following the same approach as taskdom.TaskCursor.
  • ListConversationsFilter now carries CursorAfter *string instead of Limit/Offset; ListConversations returns (items, hasMore, error) instead of (items, total, error).
  • The repository query applies WHERE (created_at, id) < (?, ?) and orders by created_at DESC, id DESC, fetching limit+1 rows to compute hasMore without a separate COUNT(*) query.
  • The handler now accepts page_size/cursor query params (was limit/offset) and responds with {items, page_size, next_cursor}.
  • New migration adding a composite index (project_id, created_at DESC, id DESC) on agent_conversations to keep the keyset lookup index-only.

Frontend

  • conversationsQueryOptions now uses useInfiniteQuery, with next_cursor driving getNextPageParam.
  • The conversations sidebar list renders a sentinel div observed via IntersectionObserver, calling fetchNextPage() as it scrolls into view, with a loading skeleton while the next page fetches.
  • The "redirect to latest conversation" loader on the conversations index route reads the new paginated cache shape (data.pages[0].items[0]), which is simpler than before since the first page is already ordered newest-first.

Notes

  • The list response no longer returns a total conversation count — cursor pagination avoids the COUNT(*) 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 .
  • Existing realtime-invalidation test suite (use-project-realtime.test.tsx)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ConversationCursor encode/decode helpers in services/api/internal/domain/agent/cursor.go.
  • Changed ListConversations signatures to return hasMore bool instead of total int64 and accept limit int plus a CursorAfter filter.
  • Implemented keyset pagination in AgentRepository.ListConversations using (created_at, id) < ($n, $n+1) with ORDER BY created_at DESC, id DESC.
  • Updated ConversationHandler to read cursor and page_size query params and return {items, page_size, next_cursor}.
  • Switched the frontend to useInfiniteQuery with an IntersectionObserver auto-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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread services/api/internal/repository/postgres/agent_repository.go Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ErrConversationInvalidCursor sentinel in services/api/internal/domain/agent/errors.go
  • Added CodeAgentConversationInvalidCursor in services/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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor
case errors.Is(err, agentdom.ErrConversationInvalidCursor):\n\t\treturn http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | View workflow run | via Pullfrog | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant