From 68c8499c9a36224c6da1ce5d8e0a9f80f5b8d59c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Apr 2026 11:41:50 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): support archived URL query (#24742) Persists the agents page archived filter in the URL via `?archived=archived`, so deep-linking to archived agents and restoring the filter from history work as expected. Unknown values fall back to active. Toggling back to active removes the param from the URL so the default state has one canonical form. Also fixes a regression that surfaced once the filter became URL-derived: clicking a chat in the sidebar previously navigated to `/agents/:id` with no search params, silently resetting the filter. The sidebar's chat `NavLink` now preserves `location.search`. Coverage: - `useArchivedFilterParam` is unit-tested with `renderHook`, covering URL parsing variants and the `deleteValue` semantics for the default state. - Cross-route preservation is covered by a Storybook play story (`PreservesArchivedFilterOnChatNavigation`) that renders the real `NavLink` and asserts on a probe child route. - The pre-existing sidebar callback tests in `AgentsSidebar.test.tsx` cover wiring that exists on `main`; they're kept here for completeness rather than as new feature gates. _Generated by Coder Agent._ --- site/src/pages/AgentsPage/AgentsPage.tsx | 5 +- .../AgentsPage/AgentsPageView.stories.tsx | 22 ++++++ .../Sidebar/AgentsSidebar.stories.tsx | 46 +++++++++++++ .../components/Sidebar/AgentsSidebar.test.tsx | 44 +++++++++++- .../components/Sidebar/AgentsSidebar.tsx | 6 +- .../hooks/useArchivedFilterParam.test.ts | 67 +++++++++++++++++++ .../hooks/useArchivedFilterParam.ts | 31 +++++++++ 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts create mode 100644 site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts 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; +};