feat(site): add section switcher to agents sidebar header (#29829)

closes CODAGT-1179

<img width="314" height="274" alt="Screenshot 2026-09-24 at 17 10 55"
src="https://github.com/user-attachments/assets/c5f510db-324d-405a-8eca-9ad45abf43e6"
/>


Adds a dropdown to the right of the product logo in the agents sidebar
header with **Workspaces**, **Templates**, and **Agents** entries that
navigate to the respective pages. The current section (Agents) is marked
with a check.

The menu items are real links (`DropdownMenuItem asChild` + `Link`, the
same pattern as the Admin settings menu) so middle-click and cmd-click
open in a new tab and Radix roving focus keeps them keyboard reachable.
Navigation is covered in `ChatsSidebar.test.tsx` and the open state by
the `SectionSwitcherMenu` story.

<details>
<summary>Implementation notes</summary>

- The switcher lives in a new `chats/SectionSwitcher.tsx` next to
`UserSidebarFooter.tsx` rather than inline, since `ChatsPanel.tsx` is
already over 600 lines.
- `DropdownMenuRadioItem` would give `menuitemradio` semantics for free,
but its wrapper renders the indicator as a sibling of `children`, so it
cannot be used with `asChild` around a `Link`. Plain `DropdownMenuItem`
items with a manual `CheckIcon` and `aria-current="page"` were used
instead to keep the items as anchors.
- The checked item is hardcoded to Agents because this sidebar only
renders under `/agents`.
- The logo's existing link to `/workspaces` and the desktop-only
(`hidden sm:flex`) header are unchanged.

</details>

---

Generated by Coder Agents on behalf of @jaaydenh.
This commit is contained in:
Jaayden Halko
2026-09-24 15:34:51 +07:00
committed by GitHub
parent 2fcbe1957e
commit 82691e66a1
4 changed files with 116 additions and 5 deletions
@@ -757,6 +757,18 @@ export const SidebarFilterMenu: Story = {
},
};
export const SectionSwitcherMenu: Story = {
args: {
chats: sectionHeaderChats,
},
play: async ({ canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Agents" }),
);
await within(document.body).findByRole("menuitem", { name: "Agents" });
},
};
export const SearchDialogKeyboardShortcut: Story = {
render: ChatsSidebarWithKeybindings,
args: {
@@ -1,8 +1,8 @@
import { act, render, screen } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { FC, PropsWithChildren } from "react";
import type { FC, ReactNode } from "react";
import { QueryClientProvider } from "react-query";
import { MemoryRouter } from "react-router";
import { MemoryRouter, useLocation } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import type { Chat } from "#/api/typesGenerated";
@@ -76,13 +76,18 @@ const dashboardValue = {
canViewOrganizationSettings: false,
};
const Wrapper: FC<PropsWithChildren> = ({ children }) => {
type WrapperProps = {
children: ReactNode;
initialPath?: string;
};
const Wrapper: FC<WrapperProps> = ({ children, initialPath = "/agents" }) => {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<ThemeOverride theme={themes[DEFAULT_THEME]}>
<TooltipProvider>
<MemoryRouter initialEntries={["/agents"]}>
<MemoryRouter initialEntries={[initialPath]}>
<DashboardContext.Provider value={dashboardValue}>
{children}
</DashboardContext.Provider>
@@ -122,6 +127,37 @@ const defaultProps: React.ComponentProps<typeof ChatsSidebar> = {
// ---- Tests ----
describe("ChatsSidebar section switcher", () => {
const LocationProbe: FC = () => {
const location = useLocation();
return <div data-testid="location-pathname">{location.pathname}</div>;
};
it.each([
["Workspaces", "/workspaces"],
["Templates", "/templates"],
["Agents", "/agents"],
])("navigates to %s", async (label, pathname) => {
const user = userEvent.setup();
render(
<Wrapper initialPath="/agents/chat-1">
<ChatsSidebar {...defaultProps} />
<LocationProbe />
</Wrapper>,
);
await user.click(screen.getByRole("button", { name: "Agents" }));
await user.click(await screen.findByRole("menuitem", { name: label }));
await waitFor(() => {
expect(screen.getByTestId("location-pathname").textContent).toBe(
pathname,
);
});
});
});
describe("ChatsSidebar sections", () => {
it("renders unpinned shared chats in Shared with you before date sections", () => {
render(
@@ -57,6 +57,7 @@ import {
PINNED_SECTION_KEY,
} from "./ChatSectionHeader";
import { LoadMoreSentinel } from "./LoadMoreSentinel";
import { SectionSwitcher } from "./SectionSwitcher";
import { UserSidebarFooter } from "./UserSidebarFooter";
const UNREAD_SECTION_KEY = "Unread";
@@ -368,6 +369,7 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
<NavLink to="/workspaces" className="inline-flex">
<ProductLogo className="size-6" />
</NavLink>
<SectionSwitcher />
</div>
<div className="flex items-center gap-0.5 -mr-1.5">
<Button
@@ -0,0 +1,61 @@
import { CheckIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
const sections = [
{ label: "Workspaces", to: "/workspaces" },
{ label: "Templates", to: "/templates" },
{ label: "Agents", to: "/agents" },
] as const;
const currentSection = "/agents";
/**
* Switches between the top-level dashboard sections from the agents
* sidebar header. The sidebar only renders under /agents, so that
* section is always the checked one.
*/
export const SectionSwitcher: FC = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="sm"
className="group h-7 min-w-0 gap-1 px-1.5 text-sm text-content-primary"
>
Agents
<ChevronDownIcon className="text-content-secondary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{sections.map((section) => {
const isCurrent = section.to === currentSection;
return (
<DropdownMenuItem
key={section.to}
asChild
className={isCurrent ? "text-content-primary" : undefined}
>
<Link
to={section.to}
aria-current={isCurrent ? "page" : undefined}
>
{section.label}
{isCurrent && <CheckIcon className="ml-auto" />}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};