feat: add agents sidebar filters (#25402)

This commit is contained in:
Danielle Maywood
2026-05-26 20:00:37 +01:00
committed by GitHub
parent d03986e3f6
commit c96c1ab563
21 changed files with 1322 additions and 552 deletions
+77 -22
View File
@@ -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<typeof infiniteChatsKey>[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<InfiniteData>(infiniteChatsTestKey, {
queryClient.setQueryData<InfiniteData>(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<InfiniteData>(infiniteChatsTestKey);
const data = queryClient.getQueryData<InfiniteData>(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,
+92 -41
View File
@@ -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<ChatListPRStatusFilter>(
CHAT_LIST_PR_STATUS_ORDER,
);
type InfiniteChatsCacheData = InfiniteData<TypesGen.Chat[]>;
/** Shared ordering keeps URL serialization stable. */
export const canonicalizeChatListPRStatuses = (
prStatuses: Iterable<unknown>,
): readonly ChatListPRStatusFilter[] => {
const selected = new Set<ChatListPRStatusFilter>();
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<InfiniteChatsCacheData>(
{ 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<InfiniteChatsCacheData>(
{ 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<InfiniteChatsCacheData>({
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;
@@ -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> = {},
): 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,
);
});
});
+45 -19
View File
@@ -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<TypesGen.ChatWatchEventKind>([
"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 <html> 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}
/>
<ConfirmDialog
open={pendingArchiveChatId !== null}
@@ -47,9 +47,17 @@ import {
LEFT_SIDEBAR_STORAGE_KEY,
} from "./components/ChatsSidebar/sidebarWidth";
import { ChatTopBar } from "./components/ChatTopBar";
import type { AgentSidebarFilters } from "./utils/agentSidebarFilters";
const defaultModelConfigID = "model-config-1";
const defaultSidebarFilters: AgentSidebarFilters = {
archiveStatus: "active",
groupBy: "date",
prStatuses: [],
chatStatuses: ["unread", "read"],
};
const defaultModelOptions: ModelSelectorOption[] = [
{
id: defaultModelConfigID,
@@ -282,8 +290,8 @@ const defaultArgs: ComponentProps<typeof AgentsPageView> = {
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<typeof AgentsPageView>;
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: [
+7 -6
View File
@@ -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<AgentsPageViewProps> = ({
@@ -115,8 +116,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
hasNextPage,
onLoadMore,
isFetchingNextPage,
archivedFilter,
onArchivedFilterChange,
sidebarFilters,
onSidebarFiltersChange,
}) => {
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
@@ -203,8 +204,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
hasNextPage={hasNextPage}
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
sidebarFilters={sidebarFilters}
onSidebarFiltersChange={onSidebarFiltersChange}
onCollapse={onCollapseSidebar}
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAgentsAdmin}
@@ -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 <div data-testid="child-search">{location.search}</div>;
};
// 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 <div data-testid="settings-state-from">{from}</div>;
};
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<typeof ChatsSidebar> = {
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: <ChildSearchProbe /> },
],
}),
},
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: <SettingsStateProbe />,
},
...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");
});
},
};
@@ -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<PropsWithChildren> = ({ children }) => {
);
};
const defaultSidebarFilters: AgentSidebarFilters = {
archiveStatus: "active",
groupBy: "date",
prStatuses: [],
chatStatuses: ["unread", "read"],
};
const defaultProps: React.ComponentProps<typeof ChatsSidebar> = {
chats: [buildChat({ id: "chat-1", title: "Chat One" })],
chatErrorReasons: {},
@@ -120,49 +128,122 @@ const defaultProps: React.ComponentProps<typeof ChatsSidebar> = {
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(
<Wrapper>
<ChatsSidebar
{...defaultProps}
onArchivedFilterChange={onArchivedFilterChange}
sidebarFilters={defaultSidebarFilters}
onSidebarFiltersChange={onSidebarFiltersChange}
/>
</Wrapper>,
);
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(
<Wrapper>
<ChatsSidebar
{...defaultProps}
chats={[]}
archivedFilter="archived"
onArchivedFilterChange={onArchivedFilterChange}
sidebarFilters={sidebarFilters}
onSidebarFiltersChange={onSidebarFiltersChange}
/>
</Wrapper>,
);
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(
<Wrapper>
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
id: "unread-chat",
title: "Unread chat",
has_unread: true,
}),
buildChat({
id: "read-chat",
title: "Read chat",
}),
]}
sidebarFilters={{
...defaultSidebarFilters,
groupBy: "chat_status",
}}
/>
</Wrapper>,
);
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(
<Wrapper>
<ChatsSidebar
@@ -341,7 +422,7 @@ describe("ChatsSidebar load-more behavior", () => {
</Wrapper>,
);
// 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(
<Wrapper>
@@ -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<ChatsSidebarProps> = (props) => {
hasNextPage,
onLoadMore,
isFetchingNextPage,
archivedFilter,
onArchivedFilterChange,
sidebarFilters,
onSidebarFiltersChange,
onCollapse,
isPersonalModelOverridesEnabled = false,
isAdmin = false,
@@ -128,8 +129,8 @@ export const ChatsSidebar: FC<ChatsSidebarProps> = (props) => {
hasNextPage={hasNextPage}
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
sidebarFilters={sidebarFilters}
onSidebarFiltersChange={onSidebarFiltersChange}
onCollapse={onCollapse}
activeChatId={activeChatId}
isSettingsPanel={isSettingsPanel}
@@ -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<string, string>;
@@ -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<ChatsPanelProps> = ({
hasNextPage,
onLoadMore,
isFetchingNextPage,
archivedFilter,
onArchivedFilterChange,
sidebarFilters,
onSidebarFiltersChange,
onCollapse,
activeChatId,
isSettingsPanel,
isChatsActive,
location,
}) => {
const normalizedSearch = "";
const locationSearch = normalizeLocationSearch(location.search);
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const [collapsedSections, setCollapsedSections] = useState<
Record<string, boolean>
@@ -138,7 +146,7 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
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<ChatsPanelProps> = ({
.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<ChatsPanelProps> = ({
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<ChatsPanelProps> = ({
chatTree,
chatById,
visibleChatIDs,
normalizedSearch,
normalizedSearch: "",
expandedById,
modelOptions,
modelConfigs,
@@ -286,6 +305,42 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
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 (
<div
className={cn(
@@ -316,7 +371,7 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
>
<Link
to="/agents/settings"
state={{ from: location.pathname + location.search }}
state={{ from: location.pathname + locationSearch }}
>
<SettingsIcon />
</Link>
@@ -338,7 +393,7 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
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<ChatsPanelProps> = ({
</span>
</TooltipContent>
</Tooltip>
<FilterDropdown
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
<FilterPopover
filters={sidebarFilters}
onFiltersChange={onSidebarFiltersChange}
/>
</div>
</div>
@@ -419,102 +474,100 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
</>
) : (
<ChatTreeContext value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
<p className="m-0">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</p>
<button
type="button"
className="mt-2 cursor-pointer border-none bg-transparent p-0 text-xs text-content-secondary hover:text-content-primary hover:underline"
onClick={() =>
onArchivedFilterChange?.(
archivedFilter === "archived" ? "active" : "archived",
)
}
>
{archivedFilter === "archived"
? "← Back to active"
: "View archived →"}
</button>
</div>
) : (
<div className="pb-2">
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
label={PINNED_SECTION_KEY}
count={pinnedChats.length}
expanded={!collapsedSections[PINNED_SECTION_KEY]}
onToggle={() => toggleSection(PINNED_SECTION_KEY)}
testId={getSectionToggleTestId(PINNED_SECTION_KEY)}
/>
{!collapsedSections[PINNED_SECTION_KEY] && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
>
<div className="pb-2">
{isShowingEmptyState ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
<p className="m-0">{emptyStateMessage}</p>
{hasAppliedResultFilters && (
<button
type="button"
className="mt-2 cursor-pointer border-none bg-transparent p-0 text-xs text-content-secondary hover:text-content-primary hover:underline"
onClick={clearResultFilters}
>
Clear filters
</button>
)}
</div>
) : (
<>
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
label={PINNED_SECTION_KEY}
count={pinnedChats.length}
expanded={!collapsedSections[PINNED_SECTION_KEY]}
onToggle={() => toggleSection(PINNED_SECTION_KEY)}
testId={getSectionToggleTestId(PINNED_SECTION_KEY)}
/>
{!collapsedSections[PINNED_SECTION_KEY] &&
(disablePinnedReordering ? (
<div className="flex flex-col gap-0.5">
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}
{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 (
<div key={group} className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
label={group}
count={groupChats.length}
expanded={isGroupExpanded}
onToggle={() => toggleSection(group)}
testId={getSectionToggleTestId(group)}
/>
{isGroupExpanded && (
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
)}
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
key={chat.id}
chat={chat}
/>
))}
</div>
</SortableContext>
</DndContext>
))}
</div>
);
})}
</div>
)}
)}
{chatSections.map((section) => {
const isSectionExpanded =
!collapsedSections[section.key];
return (
<div
key={section.key}
className="[&:not(:first-child)]:mt-3"
>
<ChatSectionHeader
label={section.label}
count={section.chats.length}
expanded={isSectionExpanded}
onToggle={() => toggleSection(section.key)}
testId={getSectionToggleTestId(section.key)}
/>
{isSectionExpanded && (
<div className="flex flex-col gap-0.5">
{section.chats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
)}
</div>
);
})}
</>
)}
</div>
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
@@ -1,33 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { FilterDropdown } from "./FilterDropdown";
const meta: Meta<typeof FilterDropdown> = {
title: "pages/AgentsPage/FilterDropdown",
component: FilterDropdown,
args: {
archivedFilter: "active",
onArchivedFilterChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof FilterDropdown>;
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();
},
};
@@ -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<FilterDropdownProps> = ({
archivedFilter,
onArchivedFilterChange,
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"size-7 min-w-0 justify-end rounded-none px-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top-below-header [&_[role=menuitem]]:text-[13px]"
>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("active")}>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto size-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("archived")}>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto size-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -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<typeof FilterPopover> = {
title: "pages/AgentsPage/FilterPopover",
component: FilterPopover,
args: {
filters: DEFAULT_AGENT_SIDEBAR_FILTERS,
onFiltersChange: fn(),
},
render: (args) => {
const [filters, setFilters] = useState(args.filters);
return (
<FilterPopover
filters={filters}
onFiltersChange={(nextFilters) => {
setFilters(nextFilters);
args.onFiltersChange(nextFilters);
}}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof FilterPopover>;
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"],
});
},
};
@@ -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<AgentPRStatusFilter, string> = {
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<AgentChatStatusFilter, string> = {
unread: "Unread",
read: "Read",
};
const ARCHIVE_STATUS_LABELS: Record<AgentArchiveStatusFilter, string> = {
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<ComponentProps<"h2">> = ({ className, ...props }) => (
<h2
className={cn(
"m-0 text-xs font-semibold leading-[18px] text-content-secondary",
className,
)}
{...props}
/>
);
const FilterGroupHeading: FC<ComponentProps<"h3">> = ({
className,
...props
}) => (
<h3
className={cn(
"m-0 text-sm font-normal leading-[18px] text-content-disabled",
className,
)}
{...props}
/>
);
const OptionRow: FC<{ readonly children: ReactNode }> = ({ children }) => (
<div className="flex h-6 items-center gap-2 rounded-sm">{children}</div>
);
interface FilterPopoverProps {
readonly filters: AgentSidebarFilters;
readonly onFiltersChange: (filters: AgentSidebarFilters) => void;
}
const haveSameSelections = <T extends string>(
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<FilterPopoverProps> = ({
filters,
onFiltersChange,
}) => {
const id = useId();
const [open, setOpen] = useState(false);
const [stagedFilters, setStagedFilters] =
useState<AgentSidebarFilters>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 justify-end rounded-none px-0 text-content-secondary hover:text-content-primary",
hasActiveFilters(filters) && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
aria-label="Filter agents"
role="dialog"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top-below-header w-64 overflow-hidden p-0 text-sm"
>
<div className="border-0 border-b border-solid border-border px-3 py-2">
<section className="space-y-2">
<SectionHeading id={`${id}-group-heading`}>Group</SectionHeading>
<RadioGroup
aria-labelledby={`${id}-group-heading`}
value={stagedFilters.groupBy}
onValueChange={setGroupBy}
className="gap-2"
>
{GROUP_OPTIONS.map((option) => {
const optionId = `${id}-group-${option.value}`;
return (
<OptionRow key={option.value}>
<RadioGroupItem
id={optionId}
value={option.value}
className="m-0 my-1"
/>
<label
className="flex flex-1 cursor-pointer items-center text-sm font-normal leading-5 text-content-primary"
htmlFor={optionId}
>
{option.label}
</label>
</OptionRow>
);
})}
</RadioGroup>
</section>
</div>
<div className="px-3 pt-2">
<section>
<SectionHeading>Filter by</SectionHeading>
<SearchField
value={optionSearch}
onChange={setOptionSearch}
placeholder="Search filters..."
aria-label="Search filters"
className="mt-2 h-9 [&_input]:h-9 [&_input]:text-xs [&_input]:font-normal [&_svg]:size-4"
/>
<ScrollArea
type="always"
className="mt-5 h-[240px] [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
viewportClassName="pr-3"
>
<div className="space-y-4">
{visiblePRStatuses.length > 0 && (
<div className="space-y-1.5">
<FilterGroupHeading>PR status</FilterGroupHeading>
<div className="space-y-2">
{visiblePRStatuses.map((status) => {
const checked =
stagedFilters.prStatuses.includes(status);
const checkboxId = `${id}-pr-${status}`;
return (
<OptionRow key={status}>
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(nextChecked) =>
setPRStatus(status, nextChecked === true)
}
className="m-0 my-[3px]"
/>
<label
htmlFor={checkboxId}
className="flex flex-1 cursor-pointer items-center text-sm font-normal leading-5 text-content-primary"
>
{PR_STATUS_LABELS[status]}
</label>
</OptionRow>
);
})}
</div>
</div>
)}
{visibleChatStatusOptions.length > 0 && (
<div className="space-y-1.5">
<FilterGroupHeading>Chat status</FilterGroupHeading>
<div className="space-y-2">
{visibleChatStatusOptions.map((option) => {
const optionId = `${id}-chat-status-${option.value}`;
return (
<OptionRow key={option.value}>
<Checkbox
id={optionId}
checked={stagedFilters.chatStatuses.includes(
option.value,
)}
onCheckedChange={(nextChecked) =>
setChatStatus(
option.value,
nextChecked === true,
)
}
className="m-0 my-[3px]"
/>
<label
htmlFor={optionId}
className="flex flex-1 cursor-pointer items-center text-sm font-normal leading-5 text-content-primary"
>
{option.label}
</label>
</OptionRow>
);
})}
</div>
</div>
)}
{visibleArchiveOptions.length > 0 && (
<div className="space-y-1.5">
<FilterGroupHeading id={`${id}-archive-heading`}>
Archive status
</FilterGroupHeading>
<RadioGroup
aria-labelledby={`${id}-archive-heading`}
value={stagedFilters.archiveStatus}
onValueChange={setArchiveStatus}
className="gap-2"
>
{visibleArchiveOptions.map((option) => {
const optionId = `${id}-archive-${option.value}`;
return (
<OptionRow key={option.value}>
<RadioGroupItem
id={optionId}
value={option.value}
className="m-0 my-1"
/>
<label
htmlFor={optionId}
className="flex flex-1 cursor-pointer items-center text-sm font-normal leading-5 text-content-primary"
>
{option.label}
</label>
</OptionRow>
);
})}
</RadioGroup>
</div>
)}
{!showFilterOptions && (
<p className="m-0 py-5 text-sm text-content-secondary">
No filters found
</p>
)}
</div>
</ScrollArea>
</section>
</div>
<div className="flex items-center justify-between gap-2 px-3 py-3">
<Button
variant="subtle"
size="sm"
onClick={clearFilters}
className="h-8 min-w-0 px-0 text-xs font-normal"
>
Clear all
</Button>
<Button
variant="default"
size="sm"
onClick={applyFilters}
className="h-8 min-w-[64px] px-3 text-xs font-normal"
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,2 @@
export const normalizeLocationSearch = (search: string): string =>
search === "" || search.startsWith("?") ? search : `?${search}`;
@@ -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 }
);
@@ -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<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const location = useLocation();
const locationSearch = normalizeLocationSearch(location.search);
const {
chatTree,
chatById,
@@ -261,7 +263,7 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
<NavLink
to={{
pathname: `/agents/${chat.id}`,
search: location.search,
search: locationSearch,
}}
className="flex min-h-0 min-w-0 flex-1 items-start gap-2 rounded-[inherit] py-1 pr-0.5 text-inherit no-underline"
>
@@ -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);
});
});
});
@@ -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;
};
@@ -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");
});
});
@@ -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<AgentChatStatusFilter>(
AGENT_CHAT_STATUS_ORDER,
);
const canonicalizeChatStatuses = (
values: Iterable<string>,
): readonly AgentChatStatusFilter[] => {
const selected = new Set<AgentChatStatusFilter>();
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];
};