mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add agents sidebar filters (#25402)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
Reference in New Issue
Block a user