mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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._
This commit is contained in:
@@ -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 <html> to prevent
|
||||
// layout shift on pages that toggle scrollbars. The agents page
|
||||
|
||||
@@ -378,6 +378,28 @@ 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: [
|
||||
|
||||
@@ -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 <div data-testid="child-search">{location.search}</div>;
|
||||
};
|
||||
|
||||
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: <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: [
|
||||
|
||||
@@ -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<typeof AgentsSidebar> = {
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("AgentsSidebar archived filter", () => {
|
||||
it("calls the filter change callback from the dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onArchivedFilterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentsSidebar
|
||||
{...defaultProps}
|
||||
onArchivedFilterChange={onArchivedFilterChange}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<AgentsSidebar
|
||||
{...defaultProps}
|
||||
chats={[]}
|
||||
archivedFilter="archived"
|
||||
onArchivedFilterChange={onArchivedFilterChange}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /back to active/i }));
|
||||
|
||||
expect(onArchivedFilterChange).toHaveBeenCalledWith("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsSidebar load-more behavior", () => {
|
||||
beforeEach(() => {
|
||||
observerCallback = null;
|
||||
|
||||
@@ -466,6 +466,7 @@ interface ChatTreeNodeProps {
|
||||
}
|
||||
|
||||
const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
const location = useLocation();
|
||||
const {
|
||||
chatTree,
|
||||
chatById,
|
||||
@@ -645,7 +646,10 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
)}
|
||||
</div>
|
||||
<NavLink
|
||||
to={`/agents/${chat.id}`}
|
||||
to={{
|
||||
pathname: `/agents/${chat.id}`,
|
||||
search: location.search,
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user