diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 919da2fce2..5521642a38 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -44,6 +44,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 { archiveChatAndDeleteWorkspace, resolveArchiveAndDeleteAction, @@ -67,9 +68,7 @@ const AgentsPage: FC = () => { const { appearance } = useDashboard(); const isAgentsAdmin = permissions.editDeploymentConfig; - const [archivedFilter, setArchivedFilter] = useState<"active" | "archived">( - "active", - ); + const [archivedFilter, setArchivedFilter] = useArchivedFilterParam(); // The global CSS sets scrollbar-gutter: stable on to prevent // layout shift on pages that toggle scrollbars. The agents page diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 66fcb0b36e..cc4694ae26 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -378,6 +378,28 @@ type Story = StoryObj; export const EmptyState: Story = {}; +export const ArchivedEmptyState: Story = { + args: { + archivedFilter: "archived", + chatList: [], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents", + searchParams: { archived: "archived" }, + }, + routing: agentsRouting, + }), + }, + play: async () => { + await expect(await screen.findByText("No archived agents")).toBeVisible(); + await expect( + screen.getByRole("button", { name: /back to active/i }), + ).toBeVisible(); + }, +}; + export const WithChatList: Story = { args: { chatList: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 1cce646b73..ac109c470e 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +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"; @@ -12,6 +13,13 @@ import { import type { ModelSelectorOption } from "../ChatElements"; import { AgentsSidebar } from "./AgentsSidebar"; +// Probe element used by the archived-filter preservation story to surface the +// search string of whatever child route the sidebar's NavLink ends up at. +const ChildSearchProbe = () => { + const location = useLocation(); + return
{location.search}
; +}; + const defaultModelOptions: ModelSelectorOption[] = [ { id: "openai:gpt-4o", @@ -907,6 +915,44 @@ export const ArchivedFilterShowsArchivedAgents: Story = { }, }; +export const PreservesArchivedFilterOnChatNavigation: Story = { + args: { + chats: [ + buildChat({ + id: "archived-nav-1", + title: "Archived nav target", + archived: true, + updated_at: recentTimestamp, + }), + ], + archivedFilter: "archived", + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents", + searchParams: { archived: "archived" }, + }, + routing: [ + { path: "/agents", useStoryElement: true }, + { path: "/agents/:agentId", element: }, + ], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const link = await canvas.findByRole("link", { + name: /Archived nav target/, + }); + await userEvent.click(link); + await waitFor(() => { + expect(canvas.getByTestId("child-search")).toHaveTextContent( + "archived=archived", + ); + }); + }, +}; + export const NoArchivedSection: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index be8cac9519..9befd779e5 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -1,4 +1,5 @@ -import { act, render } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { MemoryRouter } from "react-router"; @@ -119,6 +120,47 @@ const defaultProps: React.ComponentProps = { // ---- Tests ---- +describe("AgentsSidebar archived filter", () => { + it("calls the filter change callback from the dropdown", async () => { + const user = userEvent.setup(); + const onArchivedFilterChange = vi.fn(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Filter agents" })); + await user.click(screen.getByRole("menuitem", { name: /archived/i })); + + expect(onArchivedFilterChange).toHaveBeenCalledWith("archived"); + }); + + it("calls the filter change callback from the empty-state link", async () => { + const user = userEvent.setup(); + const onArchivedFilterChange = vi.fn(); + + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: /back to active/i })); + + expect(onArchivedFilterChange).toHaveBeenCalledWith("active"); + }); +}); + describe("AgentsSidebar load-more behavior", () => { beforeEach(() => { observerCallback = null; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 488c0595be..197efefc17 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -466,6 +466,7 @@ interface ChatTreeNodeProps { } const ChatTreeNode: FC = ({ chat, isChildNode }) => { + const location = useLocation(); const { chatTree, chatById, @@ -645,7 +646,10 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { )} {({ isActive }) => ( diff --git a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts new file mode 100644 index 0000000000..aa341725b3 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts @@ -0,0 +1,67 @@ +import { act, waitFor } from "@testing-library/react"; +import { renderHookWithAuth } from "#/testHelpers/hooks"; +import { useArchivedFilterParam } from "./useArchivedFilterParam"; + +describe(useArchivedFilterParam.name, () => { + describe("parsing the URL param", () => { + it.each([ + { route: "/agents", expected: "active" }, + { route: "/agents?archived=active", expected: "active" }, + { route: "/agents?archived=archived", expected: "archived" }, + { route: "/agents?archived=garbage", expected: "active" }, + ])("returns $expected for $route", async ({ route, expected }) => { + const { result } = await renderHookWithAuth( + () => useArchivedFilterParam(), + { routingOptions: { path: "/agents", route } }, + ); + + expect(result.current[0]).toEqual(expected); + }); + }); + + describe("setting the filter", () => { + it("writes ?archived=archived when set to 'archived'", async () => { + const { result, getLocationSnapshot } = await renderHookWithAuth( + () => useArchivedFilterParam(), + { routingOptions: { path: "/agents", route: "/agents" } }, + ); + + act(() => result.current[1]("archived")); + await waitFor(() => expect(result.current[0]).toEqual("archived")); + + const { search } = getLocationSnapshot(); + expect(search.get("archived")).toEqual("archived"); + }); + + it("removes the param when set to 'active' (does not write archived=active)", async () => { + const { result, getLocationSnapshot } = await renderHookWithAuth( + () => useArchivedFilterParam(), + { + routingOptions: { + path: "/agents", + route: "/agents?archived=archived", + }, + }, + ); + + act(() => result.current[1]("active")); + await waitFor(() => expect(result.current[0]).toEqual("active")); + + const { search } = getLocationSnapshot(); + expect(search.get("archived")).toEqual(null); + }); + + it("removes the param when set to 'active' from a clean URL (idempotent)", async () => { + const { result, getLocationSnapshot } = await renderHookWithAuth( + () => useArchivedFilterParam(), + { routingOptions: { path: "/agents", route: "/agents" } }, + ); + + act(() => result.current[1]("active")); + await waitFor(() => expect(result.current[0]).toEqual("active")); + + const { search } = getLocationSnapshot(); + expect(search.get("archived")).toEqual(null); + }); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts new file mode 100644 index 0000000000..3ce54b5f72 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts @@ -0,0 +1,31 @@ +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; +};