refactor(site): promote search to full-width sidebar nav item (#25595)

Move the chat search button from a subtle icon next to the filter in the
Chats header to a prominent full-width nav item below New Agent. The
search bar shows a magnifying glass icon and "Search" label by default,
with the keyboard shortcut badge (`⌘ K` / `Ctrl K`) and background
appearing on hover/focus.

Also pull the Chats header and filter row out of the scroll area so the
scrollbar only covers the chat list content, and align the logo row with
the nav item content inset.

> 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux
This commit is contained in:
TJ
2026-05-28 15:03:20 -07:00
committed by GitHub
parent ee4126e913
commit d3bedb4a93
4 changed files with 263 additions and 75 deletions
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { useEffect, useState } from "react";
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,10 +13,25 @@ import {
withDashboardProvider,
} from "#/testHelpers/storybook";
import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings";
import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters";
import { DEFAULT_AGENT_SIDEBAR_FILTERS as defaultSidebarFilters } from "../../utils/agentSidebarFilters";
import type { ModelSelectorOption } from "../ChatElements";
import { ChatsSidebar } from "./ChatsSidebar";
// 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>;
};
// Probe element used by the settings-link preservation story to surface the
// state.from value passed when navigating to settings.
const SettingsStateProbe = () => {
const location = useLocation();
const from = (location.state as { from?: string })?.from ?? "";
return <div data-testid="settings-state-from">{from}</div>;
};
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
@@ -25,13 +41,6 @@ const defaultModelOptions: ModelSelectorOption[] = [
},
];
const defaultSidebarFilters: AgentSidebarFilters = {
archiveStatus: "active",
groupBy: "date",
prStatuses: [],
chatStatuses: ["unread", "read"],
};
const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
{
id: "config-openai-gpt-4o",
@@ -106,8 +115,8 @@ const meta: Meta<typeof ChatsSidebar> = {
isCreating: false,
regeneratingTitleChatIds: [],
sidebarFilters: defaultSidebarFilters,
onSidebarFiltersChange: fn(),
isPersonalModelOverridesEnabled: true,
onSidebarFiltersChange: fn(),
},
parameters: {
layout: "fullscreen",
@@ -724,6 +733,68 @@ export const SectionHeadersCollapse: Story = {
},
};
export const MobileHeaderActions: Story = {
render: ChatsSidebarWithKeybindings,
args: {
chats: sectionHeaderChats,
},
parameters: {
viewport: { defaultViewport: "mobile1" },
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
decorators: [
(Story) => (
<div style={{ height: 500, width: 360 }}>
<Story />
</div>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const searchButton = canvas.getByRole("button", { name: "Search chats" });
const filterButton = canvas.getByRole("button", { name: "Filter agents" });
const searchRect = searchButton.getBoundingClientRect();
const filterRect = filterButton.getBoundingClientRect();
await expect(searchButton).not.toHaveTextContent("Search");
expect(Math.round(searchRect.width)).toBeGreaterThanOrEqual(28);
expect(Math.round(filterRect.width)).toBeGreaterThanOrEqual(28);
expect(searchRect.right).toBeLessThan(filterRect.left);
},
};
export const SidebarFilterMenu: Story = {
args: {
chats: sectionHeaderChats,
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", { name: "Filter agents" }),
);
await expect(
await body.findByRole("radio", { name: /Archived/i }),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(
body.queryByRole("radio", { name: /Archived/i }),
).not.toBeInTheDocument();
});
},
};
export const SearchDialogKeyboardShortcut: Story = {
render: ChatsSidebarWithKeybindings,
args: {
@@ -740,11 +811,23 @@ export const SearchDialogKeyboardShortcut: Story = {
const body = within(document.body);
const searchButton = canvas.getByRole("button", { name: "Search chats" });
await userEvent.hover(searchButton);
const tooltip = await body.findByRole("tooltip");
await expect(tooltip).toHaveTextContent("Search chats");
await expect(tooltip).toHaveTextContent("Ctrl");
await expect(tooltip).toHaveTextContent("K");
await expect(searchButton).toHaveTextContent("Search");
await expect(searchButton).toHaveTextContent("Ctrl");
await expect(searchButton).toHaveTextContent("K");
await userEvent.click(searchButton);
const clickedSearchInput = await body.findByRole("combobox", {
name: "Search chats",
});
await waitFor(() => {
expect(clickedSearchInput).toHaveFocus();
});
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(
body.queryByRole("combobox", { name: "Search chats" }),
).not.toBeInTheDocument();
});
await userEvent.keyboard("{Control>}k{/Control}");
@@ -1346,10 +1429,7 @@ export const ArchivedFilterShowsArchivedAgents: Story = {
updated_at: recentTimestamp,
}),
],
sidebarFilters: {
...defaultSidebarFilters,
archiveStatus: "archived",
},
sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" },
},
parameters: {
reactRouter: reactRouterParameters({
@@ -1367,6 +1447,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,
}),
],
sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "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: [
@@ -1757,10 +1875,7 @@ export const ArchivedAgentUnarchiveOption: Story = {
updated_at: recentTimestamp,
}),
],
sidebarFilters: {
...defaultSidebarFilters,
archiveStatus: "archived",
},
sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" },
},
parameters: {
reactRouter: reactRouterParameters({
@@ -2100,3 +2215,43 @@ export const SettingsAdminAgentsEntryPreserved: Story = {
expect(canvas.getByText("Manage Agents")).toBeInTheDocument();
},
};
export const PreservesArchivedFilterOnSettingsNavigation: Story = {
args: {
chats: [
buildChat({
id: "archived-settings-1",
title: "Archived settings target",
archived: true,
updated_at: recentTimestamp,
}),
],
sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" },
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents",
searchParams: { archived: "archived" },
},
routing: [
{
path: "/agents/settings",
element: <SettingsStateProbe />,
},
...agentsRouting,
],
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const settingsLink = await canvas.findByRole("link", { name: "Settings" });
await userEvent.click(settingsLink);
await waitFor(() => {
const fromValue =
canvas.getByTestId("settings-state-from").textContent ?? "";
expect(fromValue).toContain("/agents");
expect(fromValue).toContain("archived=archived");
});
},
};
@@ -30,11 +30,6 @@ import { ProductLogo } from "#/components/Icons/ProductLogo";
import { Kbd, KbdGroup } from "#/components/Kbd/Kbd";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { getOSKey } from "#/utils/platform";
import {
@@ -350,8 +345,11 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
aria-hidden={isSettingsPanel}
inert={isSettingsPanel ? true : undefined}
>
<div className="hidden border-b border-border-default px-2 py-1.5 sm:block">
<div className="flex items-center justify-between mb-2.5">
<nav
aria-label="Sidebar"
className="hidden border-b border-border-default px-2 py-1.5 sm:flex sm:flex-col sm:gap-1"
>
<div className="flex items-center justify-between mb-2.5 ml-2.5">
<div className="flex items-center gap-2">
<NavLink to="/workspaces" className="inline-flex">
<ProductLogo className="size-6" />
@@ -397,10 +395,50 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
onClick={onBeforeNewAgent}
disabled={isCreating}
/>
</div>
<div className="relative min-h-0 flex-1">
{onOpenSearchDialog && (
<SettingsNavItem
icon={SearchIcon}
label="Search"
active={false}
ariaLabel="Search chats"
onClick={onOpenSearchDialog}
className="group focus-visible:bg-surface-tertiary/50 focus-visible:text-content-primary"
trailing={
<KbdGroup className="opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<Kbd>{getOSKey()}</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
}
/>
)}
</nav>
<div className="relative min-h-0 flex-1 flex flex-col">
<div className="mx-2 mt-4 mb-2">
<div className="ml-2.5 mr-2 flex h-7 items-center justify-between">
<h2 className="m-0 text-sm font-normal leading-6 text-content-primary">
Chats
</h2>
<div className="flex items-center gap-1">
{onOpenSearchDialog && (
<Button
variant="subtle"
size="icon"
aria-label="Search chats"
onClick={onOpenSearchDialog}
className="h-7 w-7 sm:hidden"
>
<SearchIcon />
</Button>
)}
<FilterPopover
filters={sidebarFilters}
onFiltersChange={onSidebarFiltersChange}
/>
</div>
</div>
</div>
<ScrollArea
className="h-full [&_[data-radix-scroll-area-viewport]>div]:!block"
className="min-h-0 flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
viewportClassName={cn(
"[mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
@@ -408,40 +446,7 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
"sm:[mask-image:none] sm:[-webkit-mask-image:none]",
)}
>
<div className="flex flex-col gap-2 px-2 pb-3 pt-6">
<div className="ml-2.5 mr-2 flex h-7 items-center justify-between">
<h2 className="m-0 text-sm font-normal leading-6 text-content-primary">
Chats
</h2>
<div className="flex flex-row -space-x-1">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Search chats"
onClick={onOpenSearchDialog}
className="size-7 justify-end px-0"
>
<SearchIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
<span className="flex items-center gap-1">
<KbdGroup>
<Kbd>{getOSKey()}</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
<span>Search chats</span>
</span>
</TooltipContent>
</Tooltip>
<FilterPopover
filters={sidebarFilters}
onFiltersChange={onSidebarFiltersChange}
/>
</div>
</div>
<div className="flex flex-col gap-2 px-2 pb-3 pt-5">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
@@ -226,7 +226,7 @@ export const FilterPopover: FC<FilterPopoverProps> = ({
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 justify-end rounded-none px-0 text-content-secondary hover:text-content-primary",
"h-7 w-7 min-w-0 -mr-0.5 justify-end px-0 text-content-secondary hover:text-content-primary",
hasActiveFilters(filters) && "text-content-primary",
)}
>
@@ -1,6 +1,6 @@
import { ShieldIcon } from "lucide-react";
import type { FC } from "react";
import { Link, type To } from "react-router";
import type { ComponentProps, FC, ReactNode } from "react";
import { Link } from "react-router";
import {
Tooltip,
TooltipContent,
@@ -13,32 +13,52 @@ type SettingsNavItemProps = {
label: string;
active: boolean;
adminOnly?: boolean;
ariaLabel?: string;
className?: string;
disabled?: boolean;
trailing?: ReactNode;
trailingIcon?: FC<{ className?: string }>;
} & (
| { to: To; replace?: boolean; state?: unknown; onClick?: () => void }
| {
to: ComponentProps<typeof Link>["to"];
replace?: boolean;
state?: unknown;
onClick?: () => void;
}
| { to?: never; replace?: never; state?: never; onClick: () => void }
);
const navItemClassName = (active: boolean, disabled: boolean | undefined) =>
const navItemClassName = (
active: boolean,
disabled: boolean | undefined,
className: string | undefined,
) =>
cn(
"flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline",
active
? "bg-surface-quaternary/25 text-content-primary font-medium"
: "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
disabled && "opacity-50 pointer-events-none",
className,
);
const NavItemContent: FC<{
icon: FC<{ className?: string }>;
label: string;
adminOnly?: boolean;
trailing?: ReactNode;
trailingIcon?: FC<{ className?: string }>;
}> = ({ icon: Icon, label, adminOnly, trailingIcon: TrailingIcon }) => (
}> = ({
icon: Icon,
label,
adminOnly,
trailing,
trailingIcon: TrailingIcon,
}) => (
<>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1">{label}</span>
{(adminOnly || TrailingIcon) && (
{(adminOnly || trailing || TrailingIcon) && (
<span className="ml-auto flex items-center gap-2">
{adminOnly && (
<Tooltip>
@@ -51,6 +71,7 @@ const NavItemContent: FC<{
</Tooltip>
)}
{TrailingIcon && <TrailingIcon className="size-4 shrink-0" />}
{trailing}
</span>
)}
</>
@@ -61,7 +82,10 @@ export const SettingsNavItem: FC<SettingsNavItemProps> = ({
label,
active,
adminOnly,
ariaLabel,
className,
disabled,
trailing,
trailingIcon,
...rest
}) => {
@@ -72,14 +96,16 @@ export const SettingsNavItem: FC<SettingsNavItemProps> = ({
replace={rest.replace}
state={rest.state}
onClick={rest.onClick}
className={navItemClassName(active, disabled)}
className={navItemClassName(active, disabled, className)}
aria-current={active ? "page" : undefined}
aria-label={ariaLabel}
tabIndex={disabled ? -1 : undefined}
>
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailing={trailing}
trailingIcon={trailingIcon}
/>
</Link>
@@ -91,13 +117,15 @@ export const SettingsNavItem: FC<SettingsNavItemProps> = ({
type="button"
onClick={rest.onClick}
disabled={disabled}
className={navItemClassName(active, disabled)}
className={navItemClassName(active, disabled, className)}
aria-current={active ? "page" : undefined}
aria-label={ariaLabel}
>
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailing={trailing}
trailingIcon={trailingIcon}
/>
</button>