From d3bedb4a9399d99602851eb9c776fd14665999aa Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 28 May 2026 15:03:20 -0700 Subject: [PATCH] refactor(site): promote search to full-width sidebar nav item (#25595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ChatsSidebar/ChatsSidebar.stories.tsx | 199 ++++++++++++++++-- .../ChatsSidebar/chats/ChatsPanel.tsx | 93 ++++---- .../ChatsSidebar/filters/FilterPopover.tsx | 2 +- .../ChatsSidebar/settings/SettingsNavItem.tsx | 44 +++- 4 files changed, 263 insertions(+), 75 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 5294ba61c0..a15c7cee8c 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -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
{location.search}
; +}; + +// 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
{from}
; +}; + 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 = { 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) => ( +
+ +
+ ), + ], + 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: }, + ], + }), + }, + 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: , + }, + ...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"); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx index d3190229ec..dca8dfb6d3 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -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 = ({ aria-hidden={isSettingsPanel} inert={isSettingsPanel ? true : undefined} > -
-
+ +
+
+
+

+ Chats +

+
+ {onOpenSearchDialog && ( + + )} + +
+
+
= ({ "sm:[mask-image:none] sm:[-webkit-mask-image:none]", )} > -
-
-

- Chats -

-
- - - - - - - - {getOSKey()} - K - - Search chats - - - - -
-
+
{loadError ? (
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx index 4241209507..9c017a0e74 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx @@ -226,7 +226,7 @@ export const FilterPopover: FC = ({ 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", )} > diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx index 83566ff389..3282b6596f 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx @@ -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["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, +}) => ( <> {label} - {(adminOnly || TrailingIcon) && ( + {(adminOnly || trailing || TrailingIcon) && ( {adminOnly && ( @@ -51,6 +71,7 @@ const NavItemContent: FC<{ )} {TrailingIcon && } + {trailing} )} @@ -61,7 +82,10 @@ export const SettingsNavItem: FC = ({ label, active, adminOnly, + ariaLabel, + className, disabled, + trailing, trailingIcon, ...rest }) => { @@ -72,14 +96,16 @@ export const SettingsNavItem: FC = ({ 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} > @@ -91,13 +117,15 @@ export const SettingsNavItem: FC = ({ type="button" onClick={rest.onClick} disabled={disabled} - className={navItemClassName(active, disabled)} + className={navItemClassName(active, disabled, className)} aria-current={active ? "page" : undefined} + aria-label={ariaLabel} >