From e18ce505ec7639231815e2eb73895e881f387093 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 10 Mar 2026 06:55:33 -0700 Subject: [PATCH] feat(coderd): add pagination to chat list endpoint (#22887) Adds offset and cursor-based pagination to the `GET /api/experimental/chats` endpoint, following the exact same patterns used by `GetUsers` and `GetTemplateVersionsByTemplateID`. ## Changes ### Database - Add `after_id`, `offset_opt`, `limit_opt` params to `GetChatsByOwnerID` SQL query - Use composite `(updated_at, id) DESC` cursor for stable, deterministic pagination - Add migration with composite index on `chats (owner_id, updated_at DESC, id DESC)` ### Backend - Use `ParsePagination()` in `listChats` handler (matches `users.go` pattern) - Add `Pagination` field to `ListChatsOptions` SDK struct ### Frontend - Add `infiniteChats()` query factory using `useInfiniteQuery` with offset-based page params (same pattern as `infiniteWorkspaceBuilds`) - Update `AgentsPage` to use `useInfiniteQuery` - Add "Show more" button at the bottom of the agents sidebar (matches `HistorySidebar` pattern) - Keep existing `chats()` query for non-paginated uses (e.g., parent chat lookup in `AgentDetail`) ### Tests - Add `TestListChats/Pagination` covering `limit`, `after_id` cursor, `offset`, and no-limit behavior --- coderd/chats.go | 10 +++ coderd/chats_test.go | 84 +++++++++++++++++++ coderd/database/dump.sql | 2 + .../000430_chat_pagination_index.down.sql | 1 + .../000430_chat_pagination_index.up.sql | 1 + coderd/database/queries.sql.go | 42 +++++++++- coderd/database/queries/chats.sql | 27 +++++- codersdk/chats.go | 17 ++-- site/src/api/api.ts | 10 ++- site/src/api/queries/chats.ts | 30 ++++++- site/src/api/typesGenerated.ts | 2 +- site/src/pages/AgentsPage/AgentsPage.tsx | 15 +++- site/src/pages/AgentsPage/AgentsPageView.tsx | 6 ++ site/src/pages/AgentsPage/AgentsSidebar.tsx | 16 ++++ 14 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 coderd/database/migrations/000430_chat_pagination_index.down.sql create mode 100644 coderd/database/migrations/000430_chat_pagination_index.up.sql diff --git a/coderd/chats.go b/coderd/chats.go index a6db4c7a13..622ae35469 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -179,8 +179,18 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + paginationParams, ok := ParsePagination(rw, r) + if !ok { + return + } + params := database.GetChatsByOwnerIDParams{ OwnerID: apiKey.UserID, + AfterID: paginationParams.AfterID, + // #nosec G115 - Pagination offsets are small and fit in int32 + OffsetOpt: int32(paginationParams.Offset), + // #nosec G115 - Pagination limits are small and fit in int32 + LimitOpt: int32(paginationParams.Limit), } if v := r.URL.Query().Get("archived"); v != "" { b, err := strconv.ParseBool(v) diff --git a/coderd/chats_test.go b/coderd/chats_test.go index c534556fc1..fe5321b960 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -387,6 +387,90 @@ func TestListChats(t *testing.T) { _, err := unauthenticatedClient.ListChats(ctx, nil) requireSDKError(t, err, http.StatusUnauthorized) }) + + t.Run("Pagination", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _ := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + // Create 5 chats. + const totalChats = 5 + createdChats := make([]codersdk.Chat, 0, totalChats) + for i := 0; i < totalChats; i++ { + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: fmt.Sprintf("chat-%d", i), + }, + }, + }) + require.NoError(t, err) + createdChats = append(createdChats, chat) + } + + // Fetch first page with limit=2. + page1, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Pagination: codersdk.Pagination{Limit: 2}, + }) + require.NoError(t, err) + require.Len(t, page1, 2) + + // Fetch second page using after_id from last item of page 1. + page2, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Pagination: codersdk.Pagination{ + AfterID: uuid.MustParse(page1[len(page1)-1].ID.String()), + Limit: 2, + }, + }) + require.NoError(t, err) + require.Len(t, page2, 2) + + // Ensure page1 and page2 have no overlap. + page1IDs := make(map[uuid.UUID]struct{}) + for _, c := range page1 { + page1IDs[c.ID] = struct{}{} + } + for _, c := range page2 { + _, overlap := page1IDs[c.ID] + require.False(t, overlap, "page2 should not contain items from page1") + } + + // Fetch third page — should have 1 remaining chat. + page3, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Pagination: codersdk.Pagination{ + AfterID: uuid.MustParse(page2[len(page2)-1].ID.String()), + Limit: 2, + }, + }) + require.NoError(t, err) + require.Len(t, page3, 1) + + // All 5 chats should be accounted for. + allIDs := make(map[uuid.UUID]struct{}) + for _, c := range append(append(page1, page2...), page3...) { + allIDs[c.ID] = struct{}{} + } + for _, c := range createdChats { + _, found := allIDs[c.ID] + require.True(t, found, "chat %s should appear in paginated results", c.ID) + } + + // Fetch with offset=3, limit=2 — should return 2 chats. + offsetPage, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Pagination: codersdk.Pagination{Offset: 3, Limit: 2}, + }) + require.NoError(t, err) + require.Len(t, offsetPage, 2) + + // No limit should return all chats. + allChats, err := client.ListChats(ctx, nil) + require.NoError(t, err) + require.Len(t, allChats, totalChats) + }) } func TestListChatModels(t *testing.T) { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2cb2071ede..cccccb710c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3534,6 +3534,8 @@ CREATE INDEX idx_chats_last_model_config_id ON chats USING btree (last_model_con CREATE INDEX idx_chats_owner ON chats USING btree (owner_id); +CREATE INDEX idx_chats_owner_updated_id ON chats USING btree (owner_id, updated_at DESC, id DESC); + CREATE INDEX idx_chats_parent_chat_id ON chats USING btree (parent_chat_id); CREATE INDEX idx_chats_pending ON chats USING btree (status) WHERE (status = 'pending'::chat_status); diff --git a/coderd/database/migrations/000430_chat_pagination_index.down.sql b/coderd/database/migrations/000430_chat_pagination_index.down.sql new file mode 100644 index 0000000000..3415fbfa2e --- /dev/null +++ b/coderd/database/migrations/000430_chat_pagination_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_chats_owner_updated_id; diff --git a/coderd/database/migrations/000430_chat_pagination_index.up.sql b/coderd/database/migrations/000430_chat_pagination_index.up.sql new file mode 100644 index 0000000000..ea5aaf861b --- /dev/null +++ b/coderd/database/migrations/000430_chat_pagination_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_chats_owner_updated_id ON chats (owner_id, updated_at DESC, id DESC); diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7606e160bd..9632fd21b1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3454,17 +3454,51 @@ WHERE WHEN $2 :: boolean IS NULL THEN true ELSE chats.archived = $2 :: boolean END + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the updated_at field, so select all + -- rows before the cursor. + (updated_at, id) < ( + SELECT + updated_at, id + FROM + chats + WHERE + id = $3 + ) + ) + ELSE true + END ORDER BY - updated_at DESC + -- Deterministic and consistent ordering of all rows, even if they share + -- a timestamp. This is to ensure consistent pagination. + (updated_at, id) DESC OFFSET $4 +LIMIT + -- The chat list is unbounded and expected to grow large. + -- Default to 50 to prevent accidental excessively large queries. + COALESCE(NULLIF($5 :: int, 0), 50) ` type GetChatsByOwnerIDParams struct { - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - Archived sql.NullBool `db:"archived" json:"archived"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + Archived sql.NullBool `db:"archived" json:"archived"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, arg GetChatsByOwnerIDParams) ([]Chat, error) { - rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, arg.OwnerID, arg.Archived) + rows, err := q.db.QueryContext(ctx, getChatsByOwnerID, + arg.OwnerID, + arg.Archived, + arg.AfterID, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c52c89c25c..79257ebe58 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -113,8 +113,33 @@ WHERE WHEN sqlc.narg('archived') :: boolean IS NULL THEN true ELSE chats.archived = sqlc.narg('archived') :: boolean END + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the updated_at field, so select all + -- rows before the cursor. + (updated_at, id) < ( + SELECT + updated_at, id + FROM + chats + WHERE + id = @after_id + ) + ) + ELSE true + END ORDER BY - updated_at DESC; + -- Deterministic and consistent ordering of all rows, even if they share + -- a timestamp. This is to ensure consistent pagination. + (updated_at, id) DESC OFFSET @offset_opt +LIMIT + -- The chat list is unbounded and expected to grow large. + -- Default to 50 to prevent accidental excessively large queries. + COALESCE(NULLIF(@limit_opt :: int, 0), 50); -- name: ListChildChatsByParentID :many SELECT diff --git a/codersdk/chats.go b/codersdk/chats.go index 0b4ab414a9..9100278090 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -7,7 +7,6 @@ import ( "io" "mime" "net/http" - "net/url" "strings" "time" @@ -540,15 +539,23 @@ type chatStreamEnvelope struct { // ListChatsOptions are optional parameters for ListChats. type ListChatsOptions struct { Archived *bool + Pagination } // ListChats returns all chats for the authenticated user. func (c *Client) ListChats(ctx context.Context, opts *ListChatsOptions) ([]Chat, error) { - qp := url.Values{} - if opts != nil && opts.Archived != nil { - qp.Set("archived", fmt.Sprintf("%t", *opts.Archived)) + var reqOpts []RequestOption + if opts != nil { + reqOpts = append(reqOpts, opts.Pagination.asRequestOption()) + if opts.Archived != nil { + reqOpts = append(reqOpts, func(r *http.Request) { + q := r.URL.Query() + q.Set("archived", fmt.Sprintf("%t", *opts.Archived)) + r.URL.RawQuery = q.Encode() + }) + } } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats?%s", qp.Encode()), nil) + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats", nil, reqOpts...) if err != nil { return nil, err } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0d8f406a29..105711ec63 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2937,13 +2937,17 @@ class ApiMethods { }; // Chat API methods - getChats = async (): Promise => { + getChats = async (req?: { + after_id?: string; + limit?: number; + offset?: number; + archived?: string; + }): Promise => { const response = await this.axios.get( - "/api/experimental/chats", + getURLWithSearchParams("/api/experimental/chats", req), ); return response.data; }; - getChat = async (chatId: string): Promise => { const response = await this.axios.get( `/api/experimental/chats/${chatId}`, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index c254d66e79..5266375d2a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1,10 +1,38 @@ import { API, type ChatDiffStatusResponse } from "api/api"; import type * as TypesGen from "api/typesGenerated"; -import type { QueryClient } from "react-query"; +import type { QueryClient, UseInfiniteQueryOptions } from "react-query"; export const chatsKey = ["chats"] as const; export const chatKey = (chatId: string) => ["chats", chatId] as const; +const DEFAULT_CHAT_PAGE_LIMIT = 50; + +export const infiniteChats = (opts?: { archived?: boolean }) => { + const limit = DEFAULT_CHAT_PAGE_LIMIT; + + return { + queryKey: [...chatsKey, opts], + getNextPageParam: (lastPage: TypesGen.Chat[], pages: TypesGen.Chat[][]) => { + if (lastPage.length < limit) { + return undefined; + } + return pages.length + 1; + }, + initialPageParam: 0, + queryFn: ({ pageParam }: { pageParam: unknown }) => { + if (typeof pageParam !== "number") { + throw new Error("pageParam must be a number"); + } + return API.getChats({ + limit, + offset: pageParam <= 0 ? 0 : (pageParam - 1) * limit, + archived: opts?.archived?.toString(), + }); + }, + refetchOnWindowFocus: true as const, + } satisfies UseInfiniteQueryOptions; +}; + export const chats = () => ({ queryKey: chatsKey, queryFn: () => API.getChats(), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6dc2d94c77..0adb9cd195 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3260,7 +3260,7 @@ export interface LinkConfig { /** * ListChatsOptions are optional parameters for ListChats. */ -export interface ListChatsOptions { +export interface ListChatsOptions extends Pagination { readonly Archived: boolean | null; } diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 28b5c1de3d..a6aa704d08 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -8,9 +8,9 @@ import { chatModelConfigs, chatModels, chatSystemPrompt, - chats, chatsKey, createChat, + infiniteChats, unarchiveChat, updateChatSystemPrompt, } from "api/queries/chats"; @@ -40,7 +40,12 @@ import { useRef, useState, } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "react-query"; import { useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import { createReconnectingWebSocket } from "utils/reconnectingWebSocket"; @@ -144,7 +149,7 @@ const AgentsPage: FC = () => { }; }, []); - const chatsQuery = useQuery(chats()); + const chatsQuery = useInfiniteQuery(infiniteChats()); const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); const createMutation = useMutation(createChat(queryClient)); @@ -247,7 +252,7 @@ const AgentsPage: FC = () => { return next; }); }, []); - const chatList = chatsQuery.data ?? []; + const chatList = chatsQuery.data?.pages.flat() ?? []; const isArchiving = archiveAgentMutation.isPending || archiveAndDeleteMutation.isPending; const archivingChatId = @@ -517,6 +522,8 @@ const AgentsPage: FC = () => { isModelConfigsLoading={chatModelConfigsQuery.isLoading} modelCatalogError={chatModelsQuery.error} isAgentsAdmin={isAgentsAdmin} + hasNextPage={chatsQuery.hasNextPage} + onLoadMore={() => void chatsQuery.fetchNextPage()} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 42de3f742a..3b9db9e763 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -53,6 +53,8 @@ interface AgentsPageViewProps { isModelCatalogLoading: boolean; isModelConfigsLoading: boolean; modelCatalogError: unknown; + hasNextPage: boolean | undefined; + onLoadMore: () => void; } export const AgentsPageView: FC = ({ @@ -79,6 +81,8 @@ export const AgentsPageView: FC = ({ isModelCatalogLoading, isModelConfigsLoading, modelCatalogError, + hasNextPage, + onLoadMore, }) => { const { chatErrorReasons, @@ -116,6 +120,8 @@ export const AgentsPageView: FC = ({ isLoading={isChatsLoading} loadError={chatsLoadError} onRetryLoad={onRetryChatsLoad} + hasNextPage={hasNextPage} + onLoadMore={onLoadMore} onCollapse={onCollapseSidebar} /> diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index f00259e5ad..093e2f3183 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -72,6 +72,8 @@ interface AgentsSidebarProps { isLoading?: boolean; loadError?: unknown; onRetryLoad?: () => void; + hasNextPage?: boolean; + onLoadMore?: () => void; onCollapse?: () => void; } @@ -537,6 +539,8 @@ export const AgentsSidebar: FC = (props) => { isLoading = false, loadError, onRetryLoad, + hasNextPage, + onLoadMore, onCollapse, } = props; const { agentId, chatId } = useParams<{ @@ -793,6 +797,18 @@ export const AgentsSidebar: FC = (props) => { )} )} + {hasNextPage && ( +
+ +
+ )} )}