mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user