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} >