diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 79a182472b..8b17d17e06 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -28,12 +28,14 @@ import { deleteChatQueuedMessage, editChatMessage, infiniteChats, + infiniteChatsKey, interruptChat, invalidateChatListQueries, mergeWatchedChatIntoCaches, mergeWatchedChatSummary, paginatedChatCostUsers, pinChat, + prependToInfiniteChatsCache, promoteChatQueuedMessage, proposeChatTitle, regenerateChatTitle, @@ -73,9 +75,9 @@ vi.mock("#/api/api", () => ({ }, })); -// The infinite query key used by useInfiniteQuery(infiniteChats()) -// is [...chatsKey, undefined] = ["chats", undefined]. -const infiniteChatsTestKey = [...chatsKey, undefined]; +type InfiniteChatsTestOptions = Parameters[0]; + +const infiniteChatsTestKey = infiniteChatsKey(); type InfiniteData = { pages: TypesGen.Chat[][]; @@ -86,8 +88,9 @@ type InfiniteData = { const seedInfiniteChats = ( queryClient: QueryClient, chats: TypesGen.Chat[], + opts?: InfiniteChatsTestOptions, ) => { - queryClient.setQueryData(infiniteChatsTestKey, { + queryClient.setQueryData(infiniteChatsKey(opts), { pages: [chats], pageParams: [0], }); @@ -96,8 +99,9 @@ const seedInfiniteChats = ( /** Read chats back from the infinite query cache. */ const readInfiniteChats = ( queryClient: QueryClient, + opts?: InfiniteChatsTestOptions, ): TypesGen.Chat[] | undefined => { - const data = queryClient.getQueryData(infiniteChatsTestKey); + const data = queryClient.getQueryData(infiniteChatsKey(opts)); return data?.pages.flat(); }; @@ -191,7 +195,7 @@ describe("invalidateChatListQueries", () => { // Sidebar queries. queryClient.setQueryData(chatsKey, [makeChat(chatId)]); - queryClient.setQueryData([...chatsKey, { archived: false }], { + queryClient.setQueryData(infiniteChatsKey({ archived: false }), { pages: [[makeChat(chatId)]], pageParams: [0], }); @@ -212,7 +216,7 @@ describe("invalidateChatListQueries", () => { "flat chats should be invalidated", ).toBe(true); expect( - queryClient.getQueryState([...chatsKey, { archived: false }]) + queryClient.getQueryState(infiniteChatsKey({ archived: false })) ?.isInvalidated, "infinite chats should be invalidated", ).toBe(true); @@ -240,7 +244,7 @@ describe("invalidateChatListQueries", () => { it("invalidates the infinite query with undefined opts", async () => { const queryClient = createTestQueryClient(); - queryClient.setQueryData([...chatsKey, undefined], { + queryClient.setQueryData(infiniteChatsKey(), { pages: [[makeChat("chat-1")]], pageParams: [0], }); @@ -248,7 +252,7 @@ describe("invalidateChatListQueries", () => { await invalidateChatListQueries(queryClient); expect( - queryClient.getQueryState([...chatsKey, undefined])?.isInvalidated, + queryClient.getQueryState(infiniteChatsKey())?.isInvalidated, "infinite chats with undefined opts should be invalidated", ).toBe(true); }); @@ -273,6 +277,21 @@ describe("invalidateChatListQueries", () => { "other chat's chatMessagesKey should NOT be invalidated", ).not.toBe(true); }); + + it("prepends new root chats to filtered list caches", () => { + const queryClient = createTestQueryClient(); + const activeChat = makeChat("active-created", { archived: false }); + + seedInfiniteChats(queryClient, [makeChat("active-existing")], { + archived: false, + }); + + prependToInfiniteChatsCache(queryClient, activeChat); + + expect(readInfiniteChats(queryClient, { archived: false })?.[0]).toEqual( + activeChat, + ); + }); }); describe("updateChatPlanMode optimistic update", () => { @@ -374,7 +393,7 @@ describe("archiveChat optimistic update", () => { // Verify the optimistic update took effect. expect(readInfiniteChats(queryClient)?.[0].archived).toBe(true); - // Simulate an error — the onError handler invalidates the + // Simulate an error, the onError handler invalidates the // cache so a re-fetch restores the correct state. mutation.onError(new Error("server error"), chatId, context); @@ -542,7 +561,7 @@ describe("pinChat optimistic update", () => { makeChat(chatId), makeChat("chat-pinned-2", { pin_order: 2 }), ]); - queryClient.setQueryData([...chatsKey, { archived: true }], { + queryClient.setQueryData(infiniteChatsKey({ archived: true }), { pages: [[makeChat("chat-pinned-archived", { pin_order: 4 })]], pageParams: [0], }); @@ -787,7 +806,7 @@ describe("chat cost query factories", () => { describe("mutation invalidation scope", () => { // These tests assert the CORRECT (narrow) invalidation behaviour. // Each mutation should only invalidate the queries it actually - // needs to refresh — not the entire ["chats"] prefix tree. The + // needs to refresh, not the entire ["chats"] prefix tree. The // WebSocket stream already delivers real-time updates for // messages, status changes, and sidebar ordering, so broad // prefix invalidation causes a burst of redundant HTTP requests @@ -797,7 +816,7 @@ describe("mutation invalidation scope", () => { * observed on the /agents/:id detail page. */ const seedAllActiveQueries = (queryClient: QueryClient, chatId: string) => { // Infinite sidebar list: ["chats", { archived: false }] - queryClient.setQueryData([...chatsKey, { archived: false }], { + queryClient.setQueryData(infiniteChatsKey({ archived: false }), { pages: [[makeChat(chatId)]], pageParams: [0], }); @@ -1205,7 +1224,7 @@ describe("mutation invalidation scope", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; - // Page 0 (newest): IDs 10–6. Page 1 (older): IDs 5–1. + // Page 0 (newest): IDs 10 to 6. Page 1 (older): IDs 5 to 1. const page0 = [10, 9, 8, 7, 6].map((id) => makeMsg(chatId, id)); const page1 = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id)); const optimisticMessage = buildOptimisticMessage(requireMessage(page0, 7)); @@ -1374,7 +1393,10 @@ describe("mutation invalidation scope", () => { for (const { label, key } of [ { label: "flat chats", key: chatsKey }, - { label: "infinite chats", key: [...chatsKey, { archived: false }] }, + { + label: "infinite chats", + key: infiniteChatsKey({ archived: false }), + }, { label: "chat detail", key: chatKey(chatId) }, { label: "messages", key: chatMessagesKey(chatId) }, ...unrelatedKeys(chatId), @@ -1404,7 +1426,7 @@ describe("mutation invalidation scope", () => { "flat chats should be invalidated", ).toBe(true); expect( - queryClient.getQueryState([...chatsKey, { archived: false }]) + queryClient.getQueryState(infiniteChatsKey({ archived: false })) ?.isInvalidated, "infinite chats should be invalidated", ).toBe(true); @@ -1520,6 +1542,39 @@ describe("infiniteChats", () => { }); }); + it("builds q from archived, prStatuses, and chatStatus", async () => { + vi.mocked(API.experimental.getChats).mockResolvedValue([]); + const { queryFn } = infiniteChats({ + archived: true, + prStatuses: ["draft", "open", "merged"], + chatStatus: "unread", + }); + + await queryFn({ pageParam: 0 }); + + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: PAGE_LIMIT, + offset: 0, + q: "archived:true pr_status:draft,open,merged has_unread:true", + }); + }); + + it("builds q for read chat status", async () => { + vi.mocked(API.experimental.getChats).mockResolvedValue([]); + const { queryFn } = infiniteChats({ + archived: false, + chatStatus: "read", + }); + + await queryFn({ pageParam: 0 }); + + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: PAGE_LIMIT, + offset: 0, + q: "archived:false has_unread:false", + }); + }); + it("throws when pageParam is not a number", () => { const { queryFn } = infiniteChats(); expect(() => queryFn({ pageParam: "bad" })).toThrow( @@ -1548,7 +1603,7 @@ describe("diff_status_change invalidation scope", () => { // These tests verify the CORRECT invalidation pattern for // diff_status_change WebSocket events. The handler should // invalidate only the individual chat detail and diff-contents - // queries — NOT the chat list (sidebar) or messages. + // queries, NOT the chat list (sidebar) or messages. it("exact chatKey invalidation does not cascade to messages or diff-contents", async () => { const queryClient = createTestQueryClient(); @@ -1560,7 +1615,7 @@ describe("diff_status_change invalidation scope", () => { queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] }); queryClient.setQueryData(chatsKey, [makeChat(chatId)]); - // This is what the fixed handler does — exact: true. + // This is what the fixed handler does, exact: true. await queryClient.invalidateQueries({ queryKey: chatKey(chatId), exact: true, @@ -1599,7 +1654,7 @@ describe("diff_status_change invalidation scope", () => { queryClient.setQueryData(chatMessagesKey(chatId), []); queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] }); - // This is what the OLD (broken) handler did — no exact: true. + // This is what the OLD (broken) handler did, no exact: true. await queryClient.invalidateQueries({ queryKey: chatKey(chatId), }); @@ -1715,7 +1770,7 @@ describe("cancelChatListRefetches", () => { seedInfiniteChats(queryClient, [makeChat(chatId, { title: "original" })]); - // Start an in-flight refetch (no fetchMeta — simulates a + // Start an in-flight refetch (no fetchMeta, simulates a // regular invalidation or window-focus refetch). const fetchDone = queryClient.prefetchQuery({ queryKey: infiniteChatsTestKey, @@ -1778,7 +1833,7 @@ describe("cancelChatListRefetches", () => { await cancelChatListRefetches(queryClient); await fetchDone; - // The fetch was NOT cancelled — the new data landed. + // The fetch was NOT cancelled, the new data landed. const title = readInfiniteChats(queryClient)?.find( (c) => c.id === chatId, )?.title; @@ -1825,7 +1880,7 @@ describe("cancelChatListRefetches", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; - // Do NOT seed the cache — simulate the very first fetch + // Do NOT seed the cache, simulate the very first fetch // where no data exists yet. const fetchDone = queryClient.prefetchQuery({ queryKey: infiniteChatsTestKey, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 88e6c15797..0da5ec2197 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -27,6 +27,51 @@ export const chatPromptsKey = (chatId: string) => export const chatACLKey = (chatId: string) => ["chats", chatId, "acl"] as const; +export type ChatListPRStatusFilter = "draft" | "open" | "merged" | "closed"; +export type ChatListStatusFilter = "read" | "unread"; + +type InfiniteChatsFilters = Readonly<{ + archived?: boolean; + prStatuses?: readonly ChatListPRStatusFilter[]; + chatStatus?: ChatListStatusFilter; +}>; + +export const infiniteChatsKey = (filters?: { + archived?: boolean; + prStatuses?: readonly ChatListPRStatusFilter[]; + chatStatus?: ChatListStatusFilter; +}) => [...chatsKey, filters] as const; + +export const CHAT_LIST_PR_STATUS_ORDER = [ + "draft", + "open", + "merged", + "closed", +] as const satisfies readonly ChatListPRStatusFilter[]; + +const chatListPRStatusSet = new Set( + CHAT_LIST_PR_STATUS_ORDER, +); + +type InfiniteChatsCacheData = InfiniteData; + +/** Shared ordering keeps URL serialization stable. */ +export const canonicalizeChatListPRStatuses = ( + prStatuses: Iterable, +): readonly ChatListPRStatusFilter[] => { + const selected = new Set(); + for (const prStatus of prStatuses) { + if ( + typeof prStatus === "string" && + chatListPRStatusSet.has(prStatus as ChatListPRStatusFilter) + ) { + selected.add(prStatus as ChatListPRStatusFilter); + } + } + + return CHAT_LIST_PR_STATUS_ORDER.filter((status) => selected.has(status)); +}; + export const chatsByWorkspaceKeyPrefix = [...chatsKey, "by-workspace"] as const; export const chatsByWorkspace = (workspaceIds: string[]) => { @@ -48,17 +93,16 @@ export const updateInfiniteChatsCache = ( updater: (chats: TypesGen.Chat[]) => TypesGen.Chat[], ) => { // Update ALL infinite chat queries regardless of their filter opts. - queryClient.setQueriesData<{ - pages: TypesGen.Chat[][]; - pageParams: unknown[]; - }>({ queryKey: chatsKey, predicate: isChatListQuery }, (prev) => { - if (!prev) return prev; - if (!prev.pages) return prev; - const nextPages = prev.pages.map((page) => updater(page)); - // Only return a new reference if something actually changed. - const changed = nextPages.some((page, i) => page !== prev.pages[i]); - return changed ? { ...prev, pages: nextPages } : prev; - }); + queryClient.setQueriesData( + { queryKey: chatsKey, predicate: isChatListQuery }, + (prev) => { + if (!prev?.pages) return prev; + const nextPages = prev.pages.map((page) => updater(page)); + // Only return a new reference if something actually changed. + const changed = nextPages.some((page, i) => page !== prev.pages[i]); + return changed ? { ...prev, pages: nextPages } : prev; + }, + ); }; /** @@ -72,22 +116,22 @@ export const prependToInfiniteChatsCache = ( queryClient: QueryClient, chat: TypesGen.Chat, ) => { - queryClient.setQueriesData<{ - pages: TypesGen.Chat[][]; - pageParams: unknown[]; - }>({ queryKey: chatsKey, predicate: isChatListQuery }, (prev) => { - if (!prev?.pages) return prev; - // Check across ALL pages to avoid duplicates. - const exists = prev.pages.some((page) => - page.some((c) => c.id === chat.id), - ); - if (exists) return prev; - // Only prepend to the first page. - const nextPages = prev.pages.map((page, i) => - i === 0 ? [chat, ...page] : page, - ); - return { ...prev, pages: nextPages }; - }); + queryClient.setQueriesData( + { queryKey: chatsKey, predicate: isChatListQuery }, + (prev) => { + if (!prev?.pages) return prev; + // Check across ALL pages to avoid duplicates. + const exists = prev.pages.some((page) => + page.some((c) => c.id === chat.id), + ); + if (exists) return prev; + // Only prepend to the first page. + const nextPages = prev.pages.map((page, i) => + i === 0 ? [chat, ...page] : page, + ); + return { ...prev, pages: nextPages }; + }, + ); }; /** @@ -97,10 +141,10 @@ export const prependToInfiniteChatsCache = ( export const readInfiniteChatsCache = ( queryClient: QueryClient, ): TypesGen.Chat[] | undefined => { - const queries = queryClient.getQueriesData<{ - pages: TypesGen.Chat[][]; - pageParams: unknown[]; - }>({ queryKey: chatsKey, predicate: isChatListQuery }); + const queries = queryClient.getQueriesData({ + queryKey: chatsKey, + predicate: isChatListQuery, + }); for (const [, data] of queries) { if (data?.pages) { return data.pages.flat(); @@ -504,21 +548,28 @@ const toChatPlanModePayload = ( return planMode ?? CLEAR_PLAN_MODE_WIRE_VALUE; }; -export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => { - const limit = DEFAULT_CHAT_PAGE_LIMIT; - - // Build the search query string including the archived filter. +const getInfiniteChatsQueryString = ( + filters: InfiniteChatsFilters | undefined, +): string | undefined => { const qParts: string[] = []; - if (opts?.q) { - qParts.push(opts.q); + if (filters?.archived !== undefined) { + qParts.push(`archived:${filters.archived}`); } - if (opts?.archived !== undefined) { - qParts.push(`archived:${opts.archived}`); + if (filters?.prStatuses?.length) { + qParts.push(`pr_status:${filters.prStatuses.join(",")}`); } - const q = qParts.length > 0 ? qParts.join(" ") : undefined; + if (filters?.chatStatus) { + qParts.push(`has_unread:${filters.chatStatus === "unread"}`); + } + return qParts.length > 0 ? qParts.join(" ") : undefined; +}; + +export const infiniteChats = (filters?: InfiniteChatsFilters) => { + const limit = DEFAULT_CHAT_PAGE_LIMIT; + const q = getInfiniteChatsQueryString(filters); return { - queryKey: [...chatsKey, opts], + queryKey: infiniteChatsKey(filters), getNextPageParam: (lastPage: TypesGen.Chat[], pages: TypesGen.Chat[][]) => { if (lastPage.length < limit) { return undefined; diff --git a/site/src/pages/AgentsPage/AgentsPage.test.ts b/site/src/pages/AgentsPage/AgentsPage.test.ts index a47bb5f562..90cc143110 100644 --- a/site/src/pages/AgentsPage/AgentsPage.test.ts +++ b/site/src/pages/AgentsPage/AgentsPage.test.ts @@ -1,5 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type * as TypesGen from "#/api/typesGenerated"; +import { shouldInvalidateFilteredChatList } from "./AgentsPage"; import { emptyInputStorageKey, useEmptyStateDraft, @@ -885,3 +887,45 @@ describe("useFileAttachments processResizes", () => { unmount(); }); }); + +const chatForFilterInvalidation = ( + overrides: Partial = {}, +): TypesGen.Chat => + ({ + id: "chat-1", + archived: false, + parent_chat_id: null, + ...overrides, + }) as TypesGen.Chat; + +describe(shouldInvalidateFilteredChatList.name, () => { + it.each<{ + name: string; + updatedChat: TypesGen.Chat; + eventKind: TypesGen.ChatWatchEventKind; + expected: boolean; + }>([ + { + name: "invalidates root chats for membership events", + updatedChat: chatForFilterInvalidation(), + eventKind: "diff_status_change", + expected: true, + }, + { + name: "ignores non-membership events", + updatedChat: chatForFilterInvalidation(), + eventKind: "title_change", + expected: false, + }, + { + name: "excludes child chats", + updatedChat: chatForFilterInvalidation({ parent_chat_id: "parent-1" }), + eventKind: "diff_status_change", + expected: false, + }, + ])("$name", ({ updatedChat, eventKind, expected }) => { + expect(shouldInvalidateFilteredChatList(updatedChat, eventKind)).toBe( + expected, + ); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 951161453c..6faaa1507e 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -5,7 +5,12 @@ import { useQuery, useQueryClient, } from "react-query"; -import { useLocation, useNavigate, useParams } from "react-router"; +import { + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "react-router"; import { toast } from "sonner"; import { API, watchChats } from "#/api/api"; import { getErrorMessage } from "#/api/errors"; @@ -51,7 +56,7 @@ import { AgentsPageView } from "./AgentsPageView"; import { emptyInputStorageKey } from "./components/AgentCreateForm"; import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings"; import { useAgentsPWA } from "./hooks/useAgentsPWA"; -import { useArchivedFilterParam } from "./hooks/useArchivedFilterParam"; +import { getAgentSidebarFilters } from "./utils/agentSidebarFilters"; import { archiveChatAndDeleteWorkspace, resolveArchiveAndDeleteAction, @@ -67,18 +72,33 @@ import { export type { AgentsOutletContext } from "./AgentsPageView"; +const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([ + "diff_status_change", + "status_change", +]); + +export const shouldInvalidateFilteredChatList = ( + chat: TypesGen.Chat, + eventKind: TypesGen.ChatWatchEventKind, +): boolean => + !chat.parent_chat_id && FILTER_MEMBERSHIP_EVENT_KINDS.has(eventKind); + const AgentsPage: FC = () => { useAgentsPWA(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const { agentId } = useParams(); const { permissions, user } = useAuthenticated(); const { organizations } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const isAgentsAdmin = permissions.editDeploymentConfig; - const [archivedFilter, setArchivedFilter] = useArchivedFilterParam(); + const [sidebarFilters, setSidebarFilters] = getAgentSidebarFilters( + searchParams, + setSearchParams, + ); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); // The global CSS sets scrollbar-gutter: stable on to prevent @@ -124,8 +144,17 @@ const AgentsPage: FC = () => { }; }, []); + const archivedFilter = sidebarFilters.archiveStatus === "archived"; + const chatStatusFilter = + sidebarFilters.chatStatuses.length === 1 + ? sidebarFilters.chatStatuses[0] + : undefined; const chatsQuery = useInfiniteQuery( - infiniteChats({ archived: archivedFilter === "archived" }), + infiniteChats({ + archived: archivedFilter, + prStatuses: sidebarFilters.prStatuses, + chatStatus: chatStatusFilter, + }), ); // Model queries are kept here for the sidebar, which displays // model info alongside each chat. Child routes that need models @@ -368,8 +397,9 @@ const AgentsPage: FC = () => { queryFn: () => API.getWorkspaceBuilds(workspaceId), }), () => - readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId) - ?.created_at, + readInfiniteChatsCache(queryClient)?.find( + (chat) => chat.id === chatId, + )?.created_at, ); if (action === "proceed") { archiveAndDeleteMutation.mutate( @@ -511,6 +541,7 @@ const AgentsPage: FC = () => { }); return changed ? next : chats; }); + void invalidateChatListQueries(queryClient); }, [agentId, queryClient]); useEffect(() => { return createReconnectingWebSocket({ @@ -524,13 +555,9 @@ const AgentsPage: FC = () => { } const chatEvent = event.parsedMessage; const updatedChat = chatEvent.chat; - // Read the previous status from the infinite chat list - // cache before we write the update below. The per-chat - // query cache (chatKey) only exists for chats the user - // has opened, so reading from the list cache ensures - // prevStatus is available for background agents too. + // The old membership is only available before the cache write below. const prevStatus = readInfiniteChatsCache(queryClient)?.find( - (c) => c.id === updatedChat.id, + (chat) => chat.id === updatedChat.id, )?.status; // Only play the chime for top-level chats, not sub-agents. if (!updatedChat.parent_chat_id) { @@ -592,11 +619,6 @@ const AgentsPage: FC = () => { }); } - // For "created" events, use a cross-page existence - // check and prepend only to the first page. - // updateInfiniteChatsCache runs the updater per - // page, so a naive prepend would duplicate the - // chat into every loaded page. if (chatEvent.kind === "created") { if (updatedChat.parent_chat_id) { // Child chat: add to its parent's children @@ -609,12 +631,16 @@ const AgentsPage: FC = () => { ); } else { prependToInfiniteChatsCache(queryClient, updatedChat); + void invalidateChatListQueries(queryClient); } } else { mergeWatchedChatIntoCaches(queryClient, updatedChat, { eventKind: chatEvent.kind, activeChatId: activeChatIDRef.current, }); + if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { + void invalidateChatListQueries(queryClient); + } } }); return ws; @@ -684,8 +710,8 @@ const AgentsPage: FC = () => { hasNextPage={chatsQuery.hasNextPage} onLoadMore={() => void chatsQuery.fetchNextPage()} isFetchingNextPage={chatsQuery.isFetchingNextPage} - archivedFilter={archivedFilter} - onArchivedFilterChange={setArchivedFilter} + sidebarFilters={sidebarFilters} + onSidebarFiltersChange={setSidebarFilters} /> = { regeneratingTitleChatIds: [], onToggleSidebarCollapsed: fn(), isAgentsAdmin: false, - archivedFilter: "active", - onArchivedFilterChange: fn(), + sidebarFilters: defaultSidebarFilters, + onSidebarFiltersChange: fn(), hasNextPage: false, onLoadMore: fn(), isFetchingNextPage: false, @@ -429,28 +437,6 @@ type Story = StoryObj; export const EmptyState: Story = {}; -export const ArchivedEmptyState: Story = { - args: { - archivedFilter: "archived", - chatList: [], - }, - parameters: { - reactRouter: reactRouterParameters({ - location: { - path: "/agents", - searchParams: { archived: "archived" }, - }, - routing: agentsRouting, - }), - }, - play: async () => { - await expect(await screen.findByText("No archived agents")).toBeVisible(); - await expect( - screen.getByRole("button", { name: /back to active/i }), - ).toBeVisible(); - }, -}; - export const WithChatList: Story = { args: { chatList: [ diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 830c821b32..817c0f7c29 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -10,6 +10,7 @@ import { sidebarViewFromPath, } from "./components/ChatsSidebar/ChatsSidebar"; import { ResizableChatsSidebarFrame } from "./components/ChatsSidebar/ResizableChatsSidebarFrame"; +import type { AgentSidebarFilters } from "./utils/agentSidebarFilters"; import type { ChatDetailError } from "./utils/usageLimitMessage"; export interface AgentsOutletContext { @@ -75,8 +76,8 @@ interface AgentsPageViewProps { hasNextPage: boolean | undefined; onLoadMore: () => void; isFetchingNextPage: boolean; - archivedFilter: "active" | "archived"; - onArchivedFilterChange: (filter: "active" | "archived") => void; + sidebarFilters: AgentSidebarFilters; + onSidebarFiltersChange: (filters: AgentSidebarFilters) => void; } export const AgentsPageView: FC = ({ @@ -115,8 +116,8 @@ export const AgentsPageView: FC = ({ hasNextPage, onLoadMore, isFetchingNextPage, - archivedFilter, - onArchivedFilterChange, + sidebarFilters, + onSidebarFiltersChange, }) => { const location = useLocation(); const sidebarView = sidebarViewFromPath(location.pathname); @@ -203,8 +204,8 @@ export const AgentsPageView: FC = ({ hasNextPage={hasNextPage} onLoadMore={onLoadMore} isFetchingNextPage={isFetchingNextPage} - archivedFilter={archivedFilter} - onArchivedFilterChange={onArchivedFilterChange} + sidebarFilters={sidebarFilters} + onSidebarFiltersChange={onSidebarFiltersChange} onCollapse={onCollapseSidebar} isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled} isAdmin={isAgentsAdmin} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 1577f5e4e6..5294ba61c0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { useEffect, useState } from "react"; -import { useLocation } from "react-router"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { userChatProviderConfigsKey } from "#/api/queries/chats"; @@ -13,24 +12,10 @@ import { withDashboardProvider, } from "#/testHelpers/storybook"; import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings"; +import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters"; import type { ModelSelectorOption } from "../ChatElements"; import { ChatsSidebar } from "./ChatsSidebar"; -// Probe element used by the archived-filter preservation story to surface the -// search string of whatever child route the sidebar's NavLink ends up at. -const ChildSearchProbe = () => { - const location = useLocation(); - return
{location.search}
; -}; - -// Probe element used by the settings-link preservation story to surface the -// state.from value passed when navigating to settings. -const SettingsStateProbe = () => { - const location = useLocation(); - const from = (location.state as { from?: string })?.from ?? ""; - return
{from}
; -}; - const defaultModelOptions: ModelSelectorOption[] = [ { id: "openai:gpt-4o", @@ -40,6 +25,13 @@ const defaultModelOptions: ModelSelectorOption[] = [ }, ]; +const defaultSidebarFilters: AgentSidebarFilters = { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread", "read"], +}; + const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ { id: "config-openai-gpt-4o", @@ -113,9 +105,9 @@ const meta: Meta = { onSearchDialogOpenChange: fn(), isCreating: false, regeneratingTitleChatIds: [], - archivedFilter: "active" as const, + sidebarFilters: defaultSidebarFilters, + onSidebarFiltersChange: fn(), isPersonalModelOverridesEnabled: true, - onArchivedFilterChange: fn(), }, parameters: { layout: "fullscreen", @@ -732,35 +724,6 @@ export const SectionHeadersCollapse: Story = { }, }; -export const SidebarFilterMenu: Story = { - args: { - chats: sectionHeaderChats, - }, - parameters: { - reactRouter: reactRouterParameters({ - location: { path: "/agents" }, - routing: agentsRouting, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(document.body); - - await userEvent.click( - canvas.getByRole("button", { name: "Filter agents" }), - ); - await expect( - await body.findByRole("menuitem", { name: /Archived/i }), - ).toBeInTheDocument(); - await userEvent.keyboard("{Escape}"); - await waitFor(() => { - expect( - body.queryByRole("menuitem", { name: /Archived/i }), - ).not.toBeInTheDocument(); - }); - }, -}; - export const SearchDialogKeyboardShortcut: Story = { render: ChatsSidebarWithKeybindings, args: { @@ -1349,7 +1312,7 @@ export const ActiveFilterShowsActiveAgents: Story = { updated_at: recentTimestamp, }), ], - archivedFilter: "active", + sidebarFilters: defaultSidebarFilters, }, parameters: { reactRouter: reactRouterParameters({ @@ -1383,7 +1346,10 @@ export const ArchivedFilterShowsArchivedAgents: Story = { updated_at: recentTimestamp, }), ], - archivedFilter: "archived", + sidebarFilters: { + ...defaultSidebarFilters, + archiveStatus: "archived", + }, }, parameters: { reactRouter: reactRouterParameters({ @@ -1401,44 +1367,6 @@ export const ArchivedFilterShowsArchivedAgents: Story = { }, }; -export const PreservesArchivedFilterOnChatNavigation: Story = { - args: { - chats: [ - buildChat({ - id: "archived-nav-1", - title: "Archived nav target", - archived: true, - updated_at: recentTimestamp, - }), - ], - archivedFilter: "archived", - }, - parameters: { - reactRouter: reactRouterParameters({ - location: { - path: "/agents", - searchParams: { archived: "archived" }, - }, - routing: [ - { path: "/agents", useStoryElement: true }, - { path: "/agents/:agentId", element: }, - ], - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const link = await canvas.findByRole("link", { - name: /Archived nav target/, - }); - await userEvent.click(link); - await waitFor(() => { - expect(canvas.getByTestId("child-search")).toHaveTextContent( - "archived=archived", - ); - }); - }, -}; - export const NoArchivedSection: Story = { args: { chats: [ @@ -1829,7 +1757,10 @@ export const ArchivedAgentUnarchiveOption: Story = { updated_at: recentTimestamp, }), ], - archivedFilter: "archived", + sidebarFilters: { + ...defaultSidebarFilters, + archiveStatus: "archived", + }, }, parameters: { reactRouter: reactRouterParameters({ @@ -2169,43 +2100,3 @@ export const SettingsAdminAgentsEntryPreserved: Story = { expect(canvas.getByText("Manage Agents")).toBeInTheDocument(); }, }; - -export const PreservesArchivedFilterOnSettingsNavigation: Story = { - args: { - chats: [ - buildChat({ - id: "archived-settings-1", - title: "Archived settings target", - archived: true, - updated_at: recentTimestamp, - }), - ], - archivedFilter: "archived", - }, - parameters: { - reactRouter: reactRouterParameters({ - location: { - path: "/agents", - searchParams: { archived: "archived" }, - }, - routing: [ - { - path: "/agents/settings", - element: , - }, - ...agentsRouting, - ], - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const settingsLink = await canvas.findByRole("link", { name: "Settings" }); - await userEvent.click(settingsLink); - await waitFor(() => { - const fromValue = - canvas.getByTestId("settings-state-from").textContent ?? ""; - expect(fromValue).toContain("/agents"); - expect(fromValue).toContain("archived=archived"); - }); - }, -}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx index 214ab6209e..3f6aaa0c6e 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -17,6 +17,7 @@ import { MockUserOwner, } from "#/testHelpers/entities"; import themes, { DEFAULT_THEME } from "#/theme"; +import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters"; import { ChatsSidebar } from "./ChatsSidebar"; // ---- IntersectionObserver mock ---- @@ -104,6 +105,13 @@ const Wrapper: FC = ({ children }) => { ); }; +const defaultSidebarFilters: AgentSidebarFilters = { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread", "read"], +}; + const defaultProps: React.ComponentProps = { chats: [buildChat({ id: "chat-1", title: "Chat One" })], chatErrorReasons: {}, @@ -120,49 +128,122 @@ const defaultProps: React.ComponentProps = { isSearchDialogOpen: false, onSearchDialogOpenChange: vi.fn(), isCreating: false, - archivedFilter: "active" as const, + sidebarFilters: defaultSidebarFilters, + onSidebarFiltersChange: vi.fn(), }; // ---- Tests ---- -describe("ChatsSidebar archived filter", () => { - it("calls the filter change callback from the dropdown", async () => { +describe("ChatsSidebar filters", () => { + it("calls the sidebar filter change callback after Apply is clicked", async () => { const user = userEvent.setup(); - const onArchivedFilterChange = vi.fn(); + const onSidebarFiltersChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Filter agents" })); - await user.click(screen.getByRole("menuitem", { name: /archived/i })); + await user.click(screen.getByRole("radio", { name: "Archived" })); - expect(onArchivedFilterChange).toHaveBeenCalledWith("archived"); + expect(onSidebarFiltersChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(onSidebarFiltersChange).toHaveBeenCalledWith({ + ...defaultSidebarFilters, + archiveStatus: "archived", + }); }); - it("calls the filter change callback from the empty-state link", async () => { + it("clears only result filters when applied filters return no agents", async () => { const user = userEvent.setup(); - const onArchivedFilterChange = vi.fn(); + const onSidebarFiltersChange = vi.fn(); + const sidebarFilters: AgentSidebarFilters = { + ...defaultSidebarFilters, + archiveStatus: "archived", + groupBy: "chat_status", + prStatuses: ["draft"], + chatStatuses: ["unread"], + }; render( , ); - await user.click(screen.getByRole("button", { name: /back to active/i })); + expect( + screen.getByRole("button", { name: "Filter agents" }), + ).toBeInTheDocument(); + expect( + screen.getByText("No agents match these filters"), + ).toBeInTheDocument(); - expect(onArchivedFilterChange).toHaveBeenCalledWith("active"); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + + expect(onSidebarFiltersChange).toHaveBeenCalledWith({ + ...sidebarFilters, + prStatuses: [], + chatStatuses: ["unread", "read"], + }); + }); + + it("groups unpinned chats by chat status", () => { + render( + + + , + ); + + const unreadSection = screen.getByTestId("agents-section-toggle-Unread"); + const readSection = screen.getByTestId("agents-section-toggle-Read"); + const unreadNode = screen.getByTestId("agents-tree-node-unread-chat"); + const readNode = screen.getByTestId("agents-tree-node-read-chat"); + + expect( + screen.queryByTestId("agents-section-toggle-Today"), + ).not.toBeInTheDocument(); + expect( + unreadSection.compareDocumentPosition(unreadNode) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + unreadNode.compareDocumentPosition(readSection) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + readSection.compareDocumentPosition(readNode) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); }); }); @@ -329,7 +410,7 @@ describe("ChatsSidebar load-more behavior", () => { const countAfterMount = observeCount; expect(countAfterMount).toBe(1); - // Start fetching — observer is torn down. + // Start fetching, observer is torn down. rerender( { , ); - // Fetch completes — a fresh observer is created, firing + // Fetch completes, a fresh observer is created, firing // an initial entry that detects the still-visible sentinel. rerender( diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx index f5fd55d35c..9bc3e69bdf 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx @@ -3,6 +3,7 @@ import { useQuery } from "react-query"; import { useLocation, useParams } from "react-router"; import { userChatProviderConfigs } from "#/api/queries/chats"; import type { Chat, ChatModelConfig } from "#/api/typesGenerated"; +import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters"; import type { ModelSelectorOption } from "../ChatElements"; import { ChatsPanel } from "./chats/ChatsPanel"; import { ChatSearchDialog, RenameChatDialog } from "./dialogs"; @@ -37,8 +38,8 @@ interface ChatsSidebarProps { hasNextPage?: boolean; onLoadMore?: () => void; isFetchingNextPage?: boolean; - archivedFilter: "active" | "archived"; - onArchivedFilterChange?: (filter: "active" | "archived") => void; + sidebarFilters: AgentSidebarFilters; + onSidebarFiltersChange: (filters: AgentSidebarFilters) => void; onCollapse?: () => void; isPersonalModelOverridesEnabled?: boolean; isAdmin?: boolean; @@ -71,8 +72,8 @@ export const ChatsSidebar: FC = (props) => { hasNextPage, onLoadMore, isFetchingNextPage, - archivedFilter, - onArchivedFilterChange, + sidebarFilters, + onSidebarFiltersChange, onCollapse, isPersonalModelOverridesEnabled = false, isAdmin = false, @@ -128,8 +129,8 @@ export const ChatsSidebar: FC = (props) => { hasNextPage={hasNextPage} onLoadMore={onLoadMore} isFetchingNextPage={isFetchingNextPage} - archivedFilter={archivedFilter} - onArchivedFilterChange={onArchivedFilterChange} + sidebarFilters={sidebarFilters} + onSidebarFiltersChange={onSidebarFiltersChange} onCollapse={onCollapse} activeChatId={activeChatId} isSettingsPanel={isSettingsPanel} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx index d7a5591896..d3190229ec 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -37,9 +37,14 @@ import { } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { getOSKey } from "#/utils/platform"; +import { + AGENT_CHAT_STATUS_ORDER, + type AgentSidebarFilters, +} from "../../../utils/agentSidebarFilters"; import { getTimeGroup, TIME_GROUPS } from "../../../utils/timeGroups"; import type { ModelSelectorOption } from "../../ChatElements"; -import { FilterDropdown } from "../filters/FilterDropdown"; +import { FilterPopover } from "../filters/FilterPopover"; +import { normalizeLocationSearch } from "../locationSearch"; import { SettingsNavItem } from "../settings/SettingsNavItem"; import { ChatTreeContext, @@ -60,6 +65,9 @@ import { import { LoadMoreSentinel } from "./LoadMoreSentinel"; import { UserSidebarFooter } from "./UserSidebarFooter"; +const UNREAD_SECTION_KEY = "Unread"; +const READ_SECTION_KEY = "Read"; + interface ChatsPanelProps { readonly chats: readonly Chat[]; readonly chatErrorReasons: Record; @@ -87,8 +95,8 @@ interface ChatsPanelProps { readonly hasNextPage?: boolean; readonly onLoadMore?: () => void; readonly isFetchingNextPage?: boolean; - readonly archivedFilter: "active" | "archived"; - readonly onArchivedFilterChange?: (filter: "active" | "archived") => void; + readonly sidebarFilters: AgentSidebarFilters; + readonly onSidebarFiltersChange: (filters: AgentSidebarFilters) => void; readonly onCollapse?: () => void; readonly activeChatId: string | undefined; readonly isSettingsPanel: boolean; @@ -120,15 +128,15 @@ export const ChatsPanel: FC = ({ hasNextPage, onLoadMore, isFetchingNextPage, - archivedFilter, - onArchivedFilterChange, + sidebarFilters, + onSidebarFiltersChange, onCollapse, activeChatId, isSettingsPanel, isChatsActive, location, }) => { - const normalizedSearch = ""; + const locationSearch = normalizeLocationSearch(location.search); const [expandedById, setExpandedById] = useState>({}); const [collapsedSections, setCollapsedSections] = useState< Record @@ -138,7 +146,7 @@ export const ChatsPanel: FC = ({ const chatById = chatTree.chatById; const visibleChatIDs = collectVisibleChatIDs({ chats, - search: normalizedSearch, + search: "", tree: chatTree, }); const visibleRootIDs = chatTree.rootIds.filter((chatID) => @@ -149,6 +157,13 @@ export const ChatsPanel: FC = ({ .map((id) => chatById.get(id)) .filter((chat): chat is Chat => (chat?.pin_order ?? 0) > 0) .sort((a, b) => a.pin_order - b.pin_order); + const unpinnedChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter((chat): chat is Chat => chat !== undefined && chat.pin_order === 0); + const hasAppliedResultFilters = + sidebarFilters.prStatuses.length > 0 || + sidebarFilters.chatStatuses.length !== AGENT_CHAT_STATUS_ORDER.length; + const disablePinnedReordering = hasAppliedResultFilters; // Local override for pinned order during drag. Applied // synchronously so there's no flash between the dnd-kit @@ -207,6 +222,10 @@ export const ChatsPanel: FC = ({ const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; + if (disablePinnedReordering) { + return; + } + lastDragEndedAtRef.current = performance.now(); if (!over || active.id === over.id) return; const activeId = String(active.id); @@ -268,7 +287,7 @@ export const ChatsPanel: FC = ({ chatTree, chatById, visibleChatIDs, - normalizedSearch, + normalizedSearch: "", expandedById, modelOptions, modelConfigs, @@ -286,6 +305,42 @@ export const ChatsPanel: FC = ({ onOpenRenameDialog, }; + const chatSections = ( + sidebarFilters.groupBy === "chat_status" + ? [ + { + key: UNREAD_SECTION_KEY, + label: UNREAD_SECTION_KEY, + chats: unpinnedChats.filter((chat) => chat.has_unread), + }, + { + key: READ_SECTION_KEY, + label: READ_SECTION_KEY, + chats: unpinnedChats.filter((chat) => !chat.has_unread), + }, + ] + : TIME_GROUPS.map((group) => ({ + key: group, + label: group, + chats: unpinnedChats.filter( + (chat) => getTimeGroup(chat.updated_at) === group, + ), + })) + ).filter((section) => section.chats.length > 0); + const isShowingEmptyState = visibleRootIDs.length === 0; + const emptyStateMessage = hasAppliedResultFilters + ? "No agents match these filters" + : sidebarFilters.archiveStatus === "archived" + ? "No archived agents" + : "No agents yet"; + const clearResultFilters = () => { + onSidebarFiltersChange({ + ...sidebarFilters, + prStatuses: [], + chatStatuses: AGENT_CHAT_STATUS_ORDER, + }); + }; + return (
= ({ > @@ -338,7 +393,7 @@ export const ChatsPanel: FC = ({ icon={SquarePenIcon} label="New Agent" active={isChatsActive} - to={`/agents${location.search}`} + to={{ pathname: "/agents", search: locationSearch }} onClick={onBeforeNewAgent} disabled={isCreating} /> @@ -381,9 +436,9 @@ export const ChatsPanel: FC = ({ -
@@ -419,102 +474,100 @@ export const ChatsPanel: FC = ({ ) : ( - {visibleRootIDs.length === 0 ? ( -
-

- {normalizedSearch - ? "No matching agents" - : archivedFilter === "archived" - ? "No archived agents" - : "No agents yet"} -

- -
- ) : ( -
- {pinnedChats.length > 0 && ( -
- toggleSection(PINNED_SECTION_KEY)} - testId={getSectionToggleTestId(PINNED_SECTION_KEY)} - /> - {!collapsedSections[PINNED_SECTION_KEY] && ( - - -
+
+ {isShowingEmptyState ? ( +
+

{emptyStateMessage}

+ {hasAppliedResultFilters && ( + + )} +
+ ) : ( + <> + {pinnedChats.length > 0 && ( +
+ toggleSection(PINNED_SECTION_KEY)} + testId={getSectionToggleTestId(PINNED_SECTION_KEY)} + /> + {!collapsedSections[PINNED_SECTION_KEY] && + (disablePinnedReordering ? ( +
{sortedPinnedChats.map((chat) => ( - ))}
- - - )} -
- )} - {TIME_GROUPS.map((group) => { - const groupChats = visibleRootIDs - .map((id) => chatById.get(id)) - .filter( - (chat): chat is Chat => - chat !== undefined && - getTimeGroup(chat.updated_at) === group && - chat.pin_order === 0, - ); - if (groupChats.length === 0) return null; - const isGroupExpanded = !collapsedSections[group]; - return ( -
- toggleSection(group)} - testId={getSectionToggleTestId(group)} - /> - {isGroupExpanded && ( -
- {groupChats.map((chat) => ( - - ))} -
- )} + ) : ( + + +
+ {sortedPinnedChats.map((chat) => ( + + ))} +
+
+
+ ))}
- ); - })} -
- )} + )} + {chatSections.map((section) => { + const isSectionExpanded = + !collapsedSections[section.key]; + return ( +
+ toggleSection(section.key)} + testId={getSectionToggleTestId(section.key)} + /> + {isSectionExpanded && ( +
+ {section.chats.map((chat) => ( + + ))} +
+ )} +
+ ); + })} + + )} +
{(hasNextPage || isFetchingNextPage) && ( = { - title: "pages/AgentsPage/FilterDropdown", - component: FilterDropdown, - args: { - archivedFilter: "active", - onArchivedFilterChange: fn(), - }, -}; - -export default meta; -type Story = StoryObj; - -export const OpensFilterMenu: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(document.body); - - await userEvent.click( - canvas.getByRole("button", { name: "Filter agents" }), - ); - - await expect( - await body.findByRole("menuitem", { name: /Active/i }), - ).toBeInTheDocument(); - await expect( - await body.findByRole("menuitem", { name: /Archived/i }), - ).toBeInTheDocument(); - }, -}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx deleted file mode 100644 index b01811050e..0000000000 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { CheckIcon, FilterIcon } from "lucide-react"; -import type { FC } from "react"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { cn } from "#/utils/cn"; - -type ArchivedFilter = "active" | "archived"; - -interface FilterDropdownProps { - readonly archivedFilter: ArchivedFilter; - readonly onArchivedFilterChange?: (filter: ArchivedFilter) => void; -} - -export const FilterDropdown: FC = ({ - archivedFilter, - onArchivedFilterChange, -}) => ( - - - - - - onArchivedFilterChange?.("active")}> - Active - {archivedFilter === "active" && ( - - )} - - onArchivedFilterChange?.("archived")}> - Archived - {archivedFilter === "archived" && ( - - )} - - - -); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx new file mode 100644 index 0000000000..43826d05a0 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { + type AgentSidebarFilters, + DEFAULT_AGENT_SIDEBAR_FILTERS, +} from "../../../utils/agentSidebarFilters"; +import { FilterPopover } from "./FilterPopover"; + +const meta: Meta = { + title: "pages/AgentsPage/FilterPopover", + component: FilterPopover, + args: { + filters: DEFAULT_AGENT_SIDEBAR_FILTERS, + onFiltersChange: fn(), + }, + render: (args) => { + const [filters, setFilters] = useState(args.filters); + return ( + { + setFilters(nextFilters); + args.onFiltersChange(nextFilters); + }} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +const openFilterDialog = async (canvasElement: HTMLElement) => { + await userEvent.click( + within(canvasElement).getByRole("button", { name: "Filter agents" }), + ); + return within( + await within(document.body).findByRole("dialog", { + name: "Filter agents", + }), + ); +}; + +export const AppliesStagedFilters: Story = { + args: { + onFiltersChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const dialog = await openFilterDialog(canvasElement); + + await userEvent.click(dialog.getByRole("radio", { name: "Chat status" })); + await userEvent.click(dialog.getByRole("checkbox", { name: "Draft" })); + await userEvent.click(dialog.getByRole("checkbox", { name: "Read" })); + + expect(args.onFiltersChange).not.toHaveBeenCalled(); + + await userEvent.click(dialog.getByRole("button", { name: "Apply" })); + + await expect(args.onFiltersChange).toHaveBeenCalledWith({ + archiveStatus: "active", + groupBy: "chat_status", + prStatuses: ["draft"], + chatStatuses: ["unread"], + }); + }, +}; + +export const KeepsOneChatStatusSelected: Story = { + args: { + filters: { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread"], + } satisfies AgentSidebarFilters, + onFiltersChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const dialog = await openFilterDialog(canvasElement); + + await userEvent.click(dialog.getByRole("checkbox", { name: "Unread" })); + + expect(dialog.getByRole("checkbox", { name: "Unread" })).toBeChecked(); + expect(dialog.getByRole("checkbox", { name: "Read" })).not.toBeChecked(); + + await userEvent.click(dialog.getByRole("button", { name: "Apply" })); + + await expect(args.onFiltersChange).toHaveBeenCalledWith({ + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread"], + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx new file mode 100644 index 0000000000..4241209507 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx @@ -0,0 +1,419 @@ +import { FilterIcon } from "lucide-react"; +import { + type ComponentProps, + type FC, + type ReactNode, + useId, + useState, +} from "react"; +import { Button } from "#/components/Button/Button"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "#/components/Popover/Popover"; +import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { SearchField } from "#/components/SearchField/SearchField"; +import { cn } from "#/utils/cn"; +import { + AGENT_ARCHIVE_STATUS_ORDER, + AGENT_CHAT_STATUS_ORDER, + AGENT_PR_STATUS_ORDER, + type AgentArchiveStatusFilter, + type AgentChatStatusFilter, + type AgentPRStatusFilter, + type AgentSidebarFilters, + type AgentSidebarGroupBy, + DEFAULT_AGENT_SIDEBAR_FILTERS, +} from "../../../utils/agentSidebarFilters"; + +const PR_STATUS_LABELS: Record = { + draft: "Draft", + open: "Open", + merged: "Merged", + closed: "Closed", +}; + +const GROUP_OPTIONS: readonly Readonly<{ + value: AgentSidebarGroupBy; + label: string; +}>[] = [ + { value: "date", label: "Date" }, + { value: "chat_status", label: "Chat status" }, +]; + +const CHAT_STATUS_LABELS: Record = { + unread: "Unread", + read: "Read", +}; + +const ARCHIVE_STATUS_LABELS: Record = { + active: "Active", + archived: "Archived", +}; + +const CHAT_STATUS_OPTIONS: readonly Readonly<{ + value: AgentChatStatusFilter; + label: string; +}>[] = AGENT_CHAT_STATUS_ORDER.map((status) => ({ + value: status, + label: CHAT_STATUS_LABELS[status], +})); + +const ARCHIVE_OPTIONS: readonly Readonly<{ + value: AgentArchiveStatusFilter; + label: string; +}>[] = AGENT_ARCHIVE_STATUS_ORDER.map((status) => ({ + value: status, + label: ARCHIVE_STATUS_LABELS[status], +})); + +const SectionHeading: FC> = ({ className, ...props }) => ( +

+); + +const FilterGroupHeading: FC> = ({ + className, + ...props +}) => ( +

+); + +const OptionRow: FC<{ readonly children: ReactNode }> = ({ children }) => ( +
{children}
+); + +interface FilterPopoverProps { + readonly filters: AgentSidebarFilters; + readonly onFiltersChange: (filters: AgentSidebarFilters) => void; +} + +const haveSameSelections = ( + left: readonly T[], + right: readonly T[], +): boolean => { + return ( + left.length === right.length && left.every((value) => right.includes(value)) + ); +}; + +const hasActiveFilters = (filters: AgentSidebarFilters): boolean => { + return ( + filters.archiveStatus !== DEFAULT_AGENT_SIDEBAR_FILTERS.archiveStatus || + filters.groupBy !== DEFAULT_AGENT_SIDEBAR_FILTERS.groupBy || + filters.prStatuses.length > 0 || + !haveSameSelections( + filters.chatStatuses, + DEFAULT_AGENT_SIDEBAR_FILTERS.chatStatuses, + ) + ); +}; + +export const FilterPopover: FC = ({ + filters, + onFiltersChange, +}) => { + const id = useId(); + const [open, setOpen] = useState(false); + const [stagedFilters, setStagedFilters] = + useState(filters); + const [optionSearch, setOptionSearch] = useState(""); + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setStagedFilters(filters); + setOptionSearch(""); + } + setOpen(nextOpen); + }; + + const normalizedOptionSearch = optionSearch.trim().toLowerCase(); + const matchesOption = (...labels: readonly string[]) => + normalizedOptionSearch === "" || + labels.some((label) => + label.toLowerCase().includes(normalizedOptionSearch), + ); + + const visiblePRStatuses = AGENT_PR_STATUS_ORDER.filter((status) => + matchesOption("PR status", PR_STATUS_LABELS[status]), + ); + const visibleChatStatusOptions = CHAT_STATUS_OPTIONS.filter((option) => + matchesOption("Chat status", option.label), + ); + const visibleArchiveOptions = ARCHIVE_OPTIONS.filter((option) => + matchesOption("Archive status", option.label), + ); + const showFilterOptions = + visiblePRStatuses.length > 0 || + visibleChatStatusOptions.length > 0 || + visibleArchiveOptions.length > 0; + + const setGroupBy = (value: string) => { + if (value !== "date" && value !== "chat_status") { + return; + } + const groupBy: AgentSidebarGroupBy = value; + setStagedFilters({ ...stagedFilters, groupBy }); + }; + + const setPRStatus = (status: AgentPRStatusFilter, checked: boolean) => { + const selected = new Set(stagedFilters.prStatuses); + if (checked) { + selected.add(status); + } else { + selected.delete(status); + } + setStagedFilters({ + ...stagedFilters, + prStatuses: AGENT_PR_STATUS_ORDER.filter((value) => selected.has(value)), + }); + }; + + const setChatStatus = (status: AgentChatStatusFilter, checked: boolean) => { + const selected = new Set(stagedFilters.chatStatuses); + if (checked) { + selected.add(status); + } else { + selected.delete(status); + } + if (selected.size === 0) { + return; + } + setStagedFilters({ + ...stagedFilters, + chatStatuses: AGENT_CHAT_STATUS_ORDER.filter((value) => + selected.has(value), + ), + }); + }; + + const setArchiveStatus = (value: string) => { + if (value !== "active" && value !== "archived") { + return; + } + setStagedFilters({ ...stagedFilters, archiveStatus: value }); + }; + + const applyFilters = () => { + onFiltersChange(stagedFilters); + setOpen(false); + }; + + const clearFilters = () => { + setStagedFilters(DEFAULT_AGENT_SIDEBAR_FILTERS); + setOptionSearch(""); + }; + + return ( + + + + + +
+
+ Group + + {GROUP_OPTIONS.map((option) => { + const optionId = `${id}-group-${option.value}`; + return ( + + + + + ); + })} + +
+
+ +
+
+ Filter by + + +
+ {visiblePRStatuses.length > 0 && ( +
+ PR status +
+ {visiblePRStatuses.map((status) => { + const checked = + stagedFilters.prStatuses.includes(status); + const checkboxId = `${id}-pr-${status}`; + return ( + + + setPRStatus(status, nextChecked === true) + } + className="m-0 my-[3px]" + /> + + + ); + })} +
+
+ )} + + {visibleChatStatusOptions.length > 0 && ( +
+ Chat status +
+ {visibleChatStatusOptions.map((option) => { + const optionId = `${id}-chat-status-${option.value}`; + return ( + + + setChatStatus( + option.value, + nextChecked === true, + ) + } + className="m-0 my-[3px]" + /> + + + ); + })} +
+
+ )} + + {visibleArchiveOptions.length > 0 && ( +
+ + Archive status + + + {visibleArchiveOptions.map((option) => { + const optionId = `${id}-archive-${option.value}`; + return ( + + + + + ); + })} + +
+ )} + + {!showFilterOptions && ( +

+ No filters found +

+ )} +
+
+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/locationSearch.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/locationSearch.ts new file mode 100644 index 0000000000..549934012d --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/locationSearch.ts @@ -0,0 +1,2 @@ +export const normalizeLocationSearch = (search: string): string => + search === "" || search.startsWith("?") ? search : `?${search}`; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx index 52dc28f0c1..83566ff389 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx @@ -1,6 +1,6 @@ import { ShieldIcon } from "lucide-react"; import type { FC } from "react"; -import { Link } from "react-router"; +import { Link, type To } from "react-router"; import { Tooltip, TooltipContent, @@ -16,7 +16,7 @@ type SettingsNavItemProps = { disabled?: boolean; trailingIcon?: FC<{ className?: string }>; } & ( - | { to: string; replace?: boolean; state?: unknown; onClick?: () => void } + | { to: To; replace?: boolean; state?: unknown; onClick?: () => void } | { to?: never; replace?: never; state?: never; onClick: () => void } ); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index 60d92f6b19..155058f4c0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -31,6 +31,7 @@ import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; import { shortRelativeTime } from "#/utils/time"; import { asNonEmptyString } from "../../ChatConversation/blockUtils"; +import { normalizeLocationSearch } from "../locationSearch"; import { useChatTree } from "./ChatTreeContext"; import { getParentChatID } from "./chatTree"; import { getModelDisplayName } from "./modelDisplayName"; @@ -43,6 +44,7 @@ interface ChatTreeNodeProps { export const ChatTreeNode: FC = ({ chat, isChildNode }) => { const location = useLocation(); + const locationSearch = normalizeLocationSearch(location.search); const { chatTree, chatById, @@ -261,7 +263,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { diff --git a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts deleted file mode 100644 index aa341725b3..0000000000 --- a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { act, waitFor } from "@testing-library/react"; -import { renderHookWithAuth } from "#/testHelpers/hooks"; -import { useArchivedFilterParam } from "./useArchivedFilterParam"; - -describe(useArchivedFilterParam.name, () => { - describe("parsing the URL param", () => { - it.each([ - { route: "/agents", expected: "active" }, - { route: "/agents?archived=active", expected: "active" }, - { route: "/agents?archived=archived", expected: "archived" }, - { route: "/agents?archived=garbage", expected: "active" }, - ])("returns $expected for $route", async ({ route, expected }) => { - const { result } = await renderHookWithAuth( - () => useArchivedFilterParam(), - { routingOptions: { path: "/agents", route } }, - ); - - expect(result.current[0]).toEqual(expected); - }); - }); - - describe("setting the filter", () => { - it("writes ?archived=archived when set to 'archived'", async () => { - const { result, getLocationSnapshot } = await renderHookWithAuth( - () => useArchivedFilterParam(), - { routingOptions: { path: "/agents", route: "/agents" } }, - ); - - act(() => result.current[1]("archived")); - await waitFor(() => expect(result.current[0]).toEqual("archived")); - - const { search } = getLocationSnapshot(); - expect(search.get("archived")).toEqual("archived"); - }); - - it("removes the param when set to 'active' (does not write archived=active)", async () => { - const { result, getLocationSnapshot } = await renderHookWithAuth( - () => useArchivedFilterParam(), - { - routingOptions: { - path: "/agents", - route: "/agents?archived=archived", - }, - }, - ); - - act(() => result.current[1]("active")); - await waitFor(() => expect(result.current[0]).toEqual("active")); - - const { search } = getLocationSnapshot(); - expect(search.get("archived")).toEqual(null); - }); - - it("removes the param when set to 'active' from a clean URL (idempotent)", async () => { - const { result, getLocationSnapshot } = await renderHookWithAuth( - () => useArchivedFilterParam(), - { routingOptions: { path: "/agents", route: "/agents" } }, - ); - - act(() => result.current[1]("active")); - await waitFor(() => expect(result.current[0]).toEqual("active")); - - const { search } = getLocationSnapshot(); - expect(search.get("archived")).toEqual(null); - }); - }); -}); diff --git a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts deleted file mode 100644 index 3ce54b5f72..0000000000 --- a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useSearchParamsKey } from "#/hooks/useSearchParamsKey"; - -type ArchivedFilter = "active" | "archived"; - -const toArchivedFilter = (value: string): ArchivedFilter => - value === "archived" ? "archived" : "active"; - -/** - * Reads and writes the agents page's archived filter via the `?archived` URL - * search param. Unknown or missing values fall back to `"active"`. Setting the - * filter back to `"active"` removes the param from the URL so the default - * state has a single canonical URL (`/agents`). - */ -export const useArchivedFilterParam = (): readonly [ - ArchivedFilter, - (next: ArchivedFilter) => void, -] => { - const param = useSearchParamsKey({ - key: "archived", - defaultValue: "active", - }); - const filter = toArchivedFilter(param.value); - const setFilter = (next: ArchivedFilter) => { - if (next === "active") { - param.deleteValue(); - return; - } - param.setValue(next); - }; - return [filter, setFilter] as const; -}; diff --git a/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts b/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts new file mode 100644 index 0000000000..fb53ecad30 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts @@ -0,0 +1,122 @@ +import { act, waitFor } from "@testing-library/react"; +import { useSearchParams } from "react-router"; +import { renderHookWithAuth } from "#/testHelpers/hooks"; +import { + type AgentSidebarFilters, + getAgentSidebarFilters, +} from "./agentSidebarFilters"; + +const defaultFilters: AgentSidebarFilters = { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread", "read"], +}; + +const archivedFilters: AgentSidebarFilters = { + archiveStatus: "archived", + groupBy: "chat_status", + prStatuses: ["draft", "merged"], + chatStatuses: ["unread"], +}; + +const renderFilters = (route = "/agents") => { + return renderHookWithAuth( + () => { + const [searchParams, setSearchParams] = useSearchParams(); + return getAgentSidebarFilters(searchParams, setSearchParams); + }, + { + routingOptions: { path: "/agents", route }, + }, + ); +}; + +describe(getAgentSidebarFilters.name, () => { + it.each<{ + name: string; + route: string; + expected: AgentSidebarFilters; + }>([ + { + name: "returns defaults for /agents", + route: "/agents", + expected: defaultFilters, + }, + { + name: "parses archived, group_by, pr_status, and chat_status", + route: + "/agents?archived=archived&group_by=chat_status&pr_status=open,draft,closed&chat_status=unread", + expected: { + archiveStatus: "archived", + groupBy: "chat_status", + prStatuses: ["draft", "open", "closed"], + chatStatuses: ["unread"], + }, + }, + { + name: "drops invalid pr_status values and canonicalizes order", + route: "/agents?pr_status=merged,bogus,draft", + expected: { + ...defaultFilters, + prStatuses: ["draft", "merged"], + }, + }, + ])("$name", async ({ route, expected }) => { + const { result } = await renderFilters(route); + expect(result.current[0]).toEqual(expected); + }); + + it("omits default values when writing filters", async () => { + const { result, getLocationSnapshot } = await renderFilters( + "/agents?archived=archived&group_by=chat_status&pr_status=draft&chat_status=unread", + ); + + act(() => { + result.current[1](defaultFilters); + }); + await waitFor(() => expect(result.current[0]).toEqual(defaultFilters)); + + const { search } = getLocationSnapshot(); + expect(search.get("archived")).toEqual(null); + expect(search.get("group_by")).toEqual(null); + expect(search.get("pr_status")).toEqual(null); + expect(search.get("chat_status")).toEqual(null); + }); + + it("writes archived status filter", async () => { + const { result, getLocationSnapshot } = await renderFilters(); + + act(() => { + result.current[1]({ ...defaultFilters, archiveStatus: "archived" }); + }); + await waitFor(() => + expect(result.current[0]).toMatchObject({ + archiveStatus: "archived", + }), + ); + + const { search } = getLocationSnapshot(); + expect(search.get("archived")).toEqual("archived"); + expect(search.get("chat_status")).toEqual(null); + }); + + it("preserves unrelated search params when writing filters", async () => { + const { result, getLocationSnapshot } = await renderFilters( + "/agents?tab=settings&foo=bar&archived=archived", + ); + + act(() => { + result.current[1](archivedFilters); + }); + await waitFor(() => expect(result.current[0]).toEqual(archivedFilters)); + + const { search } = getLocationSnapshot(); + expect(search.get("tab")).toBe("settings"); + expect(search.get("foo")).toBe("bar"); + expect(search.get("archived")).toBe("archived"); + expect(search.get("group_by")).toBe("chat_status"); + expect(search.get("pr_status")).toBe("draft,merged"); + expect(search.get("chat_status")).toBe("unread"); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts b/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts new file mode 100644 index 0000000000..d898dcac1f --- /dev/null +++ b/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts @@ -0,0 +1,126 @@ +import type { SetURLSearchParams } from "react-router"; +import { + CHAT_LIST_PR_STATUS_ORDER, + type ChatListPRStatusFilter, + type ChatListStatusFilter, + canonicalizeChatListPRStatuses, +} from "#/api/queries/chats"; + +export const AGENT_ARCHIVE_STATUS_ORDER = ["active", "archived"] as const; +export const AGENT_CHAT_STATUS_ORDER = [ + "unread", + "read", +] as const satisfies readonly ChatListStatusFilter[]; +export const AGENT_PR_STATUS_ORDER = CHAT_LIST_PR_STATUS_ORDER; + +export type AgentArchiveStatusFilter = + (typeof AGENT_ARCHIVE_STATUS_ORDER)[number]; +export type AgentChatStatusFilter = ChatListStatusFilter; +export type AgentPRStatusFilter = ChatListPRStatusFilter; +export type AgentSidebarGroupBy = "date" | "chat_status"; + +export type AgentSidebarFilters = Readonly<{ + archiveStatus: AgentArchiveStatusFilter; + groupBy: AgentSidebarGroupBy; + prStatuses: readonly AgentPRStatusFilter[]; + chatStatuses: readonly AgentChatStatusFilter[]; +}>; + +type AgentSidebarFiltersResult = readonly [ + filters: AgentSidebarFilters, + setFilters: (next: AgentSidebarFilters) => void, +]; + +export const DEFAULT_AGENT_SIDEBAR_FILTERS: AgentSidebarFilters = { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: AGENT_CHAT_STATUS_ORDER, +}; + +const agentChatStatusSet = new Set( + AGENT_CHAT_STATUS_ORDER, +); + +const canonicalizeChatStatuses = ( + values: Iterable, +): readonly AgentChatStatusFilter[] => { + const selected = new Set(); + for (const value of values) { + if (agentChatStatusSet.has(value as AgentChatStatusFilter)) { + selected.add(value as AgentChatStatusFilter); + } + } + return AGENT_CHAT_STATUS_ORDER.filter((status) => selected.has(status)); +}; + +const clearSidebarFilterParams = (searchParams: URLSearchParams) => { + searchParams.delete("archived"); + searchParams.delete("group_by"); + searchParams.delete("pr_status"); + searchParams.delete("chat_status"); +}; + +const writeSidebarFilters = ( + searchParams: URLSearchParams, + filters: AgentSidebarFilters, +) => { + clearSidebarFilterParams(searchParams); + + if (filters.archiveStatus === "archived") { + searchParams.set("archived", "archived"); + } + + if (filters.groupBy === "chat_status") { + searchParams.set("group_by", "chat_status"); + } + + const prStatuses = canonicalizeChatListPRStatuses(filters.prStatuses); + if (prStatuses.length > 0) { + searchParams.set("pr_status", prStatuses.join(",")); + } + + const chatStatuses = canonicalizeChatStatuses(filters.chatStatuses); + if (chatStatuses.length === 1) { + searchParams.set("chat_status", chatStatuses[0]); + } +}; + +export const getAgentSidebarFilters = ( + searchParams: URLSearchParams, + setSearchParams: SetURLSearchParams, +): AgentSidebarFiltersResult => { + const prStatuses = canonicalizeChatListPRStatuses( + (searchParams.get("pr_status") ?? "").split(",").filter(Boolean), + ); + const chatStatuses = canonicalizeChatStatuses( + (searchParams.get("chat_status") ?? "").split(",").filter(Boolean), + ); + + const filters: AgentSidebarFilters = { + archiveStatus: + searchParams.get("archived") === "archived" ? "archived" : "active", + groupBy: + searchParams.get("group_by") === "chat_status" + ? "chat_status" + : DEFAULT_AGENT_SIDEBAR_FILTERS.groupBy, + prStatuses, + chatStatuses: + chatStatuses.length > 0 + ? chatStatuses + : DEFAULT_AGENT_SIDEBAR_FILTERS.chatStatuses, + }; + + const setFilters = (next: AgentSidebarFilters) => { + setSearchParams( + (prev) => { + const updated = new URLSearchParams(prev); + writeSidebarFilters(updated, next); + return updated; + }, + { replace: true }, + ); + }; + + return [filters, setFilters]; +};