Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions site/src/pages/AgentsPage/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions site/src/pages/AgentsPage/AgentsPageView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -119,6 +120,47 @@ const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {

// ---- Tests ----

describe("AgentsSidebar archived filter", () => {
Comment thread
johnstcn marked this conversation as resolved.
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ interface ChatTreeNodeProps {
}

const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const location = useLocation();
const {
chatTree,
chatById,
Expand Down Expand Up @@ -645,7 +646,10 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
)}
</div>
<NavLink
to={`/agents/${chat.id}`}
to={{
pathname: `/agents/${chat.id}`,
search: location.search,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [DEREM-17] Sibling navigation paths still drop ?archived, silently resetting the filter. (Kite)

"Every other navigation to /agents still drops search params. ChatTopBar.tsx:90 uses <Link to="/agents"> (mobile back button). AgentsSidebar.tsx:1108 stores state={{ from: location.pathname }} without search. The return link at line 1359 restores the pathname but loses ?archived=archived." (Kite)

The sidebar NavLink fix here is correct and complete. But the same root cause (bare /agents links dropping search params) exists in two siblings:

  1. ChatTopBar.tsx:90: <Link to="/agents"> (mobile back button, md:hidden). User views archived list, taps a chat, taps Back, filter resets.
  2. AgentsSidebar.tsx:1108/1359: Settings link stores location.pathname without search; return link restores it without ?archived.

Before this PR the filter was useState-only so these paths already lost it. Now that the filter is URL-derived, users have higher expectations that it will persist. The fix is the same pattern: pass { pathname, search: location.search } or store location.pathname + location.search in state.from.

Could we address these in this PR, or are they better as a follow-up?

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do as a follow-up.

}}
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 }) => (
Expand Down
67 changes: 67 additions & 0 deletions site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
31 changes: 31 additions & 0 deletions site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading