diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 9047f89754..79a182472b 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -21,6 +21,7 @@ import { chatDiffContentsKey, chatKey, chatMessagesKey, + chatSearch, chatsKey, createChat, createChatMessage, @@ -1528,6 +1529,21 @@ describe("infiniteChats", () => { }); }); +describe("chatSearch", () => { + it("requests chats with q and a fixed limit", async () => { + vi.mocked(API.experimental.getChats).mockResolvedValue([]); + const query = chatSearch("title:fix"); + const queryClient = createTestQueryClient(); + + expect(query.queryKey).toEqual(["chats", "search", { q: "title:fix" }]); + await queryClient.fetchQuery(query); + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: 50, + q: "title:fix", + }); + }); +}); + describe("diff_status_change invalidation scope", () => { // These tests verify the CORRECT invalidation pattern for // diff_status_change WebSocket events. The handler should diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index dbe3aea516..5190280ddf 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -481,6 +481,7 @@ export const cancelChatListRefetches = (queryClient: QueryClient) => { }; const DEFAULT_CHAT_PAGE_LIMIT = 50; +export const CHAT_SEARCH_LIMIT = 50; type UpdateChatWorkspaceVariables = { chatId: string; @@ -538,6 +539,16 @@ export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => { } satisfies UseInfiniteQueryOptions; }; +export const chatSearch = (q: string) => + queryOptions({ + queryKey: [...chatsKey, "search", { q }], + queryFn: () => + API.experimental.getChats({ + limit: CHAT_SEARCH_LIMIT, + q, + }), + }); + export const chat = (chatId: string) => ({ queryKey: chatKey(chatId), queryFn: () => API.experimental.getChat(chatId), diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index fdc67d9d37..f1c7992255 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -10,6 +10,7 @@ interface ScrollAreaProps extends React.ComponentPropsWithRef { scrollBarClassName?: string; viewportClassName?: string; + viewportTabIndex?: number; /** Which scrollbar(s) to show. Defaults to "vertical". */ orientation?: "vertical" | "horizontal" | "both"; } @@ -18,6 +19,7 @@ export const ScrollArea: React.FC = ({ className, scrollBarClassName, viewportClassName, + viewportTabIndex, orientation = "vertical", children, ...props @@ -47,6 +49,7 @@ export const ScrollArea: React.FC = ({ > diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 056ecd6224..951161453c 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -79,6 +79,7 @@ const AgentsPage: FC = () => { const isAgentsAdmin = permissions.editDeploymentConfig; const [archivedFilter, setArchivedFilter] = useArchivedFilterParam(); + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); // The global CSS sets scrollbar-gutter: stable on to prevent // layout shift on pages that toggle scrollbars. The agents page @@ -626,6 +627,7 @@ const AgentsPage: FC = () => { useAgentsPageKeybindings({ onNewAgent: handleNewAgent, + onToggleSearch: () => setIsSearchDialogOpen((open) => !open), }); // Fetch workspace name for the confirmation dialog. Only @@ -650,6 +652,8 @@ const AgentsPage: FC = () => { catalogModelOptions={catalogModelOptions} modelConfigs={chatModelConfigsQuery.data ?? []} handleNewAgent={handleNewAgent} + isSearchDialogOpen={isSearchDialogOpen} + onSearchDialogOpenChange={setIsSearchDialogOpen} isCreating={false} isArchiving={isArchiving} archivingChatId={archivingChatId} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 562f7a973a..36d9921273 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -257,6 +257,8 @@ const defaultArgs: ComponentProps = { catalogModelOptions: defaultModelOptions, modelConfigs: defaultModelConfigs, handleNewAgent: fn(), + isSearchDialogOpen: false, + onSearchDialogOpenChange: fn(), isCreating: false, isArchiving: false, archivingChatId: undefined, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index e9ccf12e0d..830c821b32 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -42,6 +42,8 @@ interface AgentsPageViewProps { catalogModelOptions: readonly ModelSelectorOption[]; modelConfigs: readonly TypesGen.ChatModelConfig[]; handleNewAgent: () => void; + isSearchDialogOpen: boolean; + onSearchDialogOpenChange: (open: boolean) => void; isCreating: boolean; isArchiving: boolean; archivingChatId: string | undefined; @@ -83,6 +85,8 @@ export const AgentsPageView: FC = ({ catalogModelOptions, modelConfigs, handleNewAgent, + isSearchDialogOpen, + onSearchDialogOpenChange, isCreating, isArchiving, archivingChatId, @@ -188,6 +192,8 @@ export const AgentsPageView: FC = ({ onProposeTitle={onProposeTitle} regeneratingTitleChatIds={regeneratingTitleChatIds} onBeforeNewAgent={handleNewAgent} + isSearchDialogOpen={isSearchDialogOpen} + onSearchDialogOpenChange={onSearchDialogOpenChange} isCreating={isCreating} isArchiving={isArchiving} archivingChatId={archivingChatId} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index f98657cccd..1577f5e4e6 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -1,4 +1,5 @@ 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"; @@ -11,6 +12,7 @@ import { withAuthProvider, withDashboardProvider, } from "#/testHelpers/storybook"; +import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings"; import type { ModelSelectorOption } from "../ChatElements"; import { ChatsSidebar } from "./ChatsSidebar"; @@ -107,6 +109,8 @@ const meta: Meta = { onUnpinAgent: fn(), onRenameTitle: fn(() => Promise.resolve()), onBeforeNewAgent: fn(), + isSearchDialogOpen: false, + onSearchDialogOpenChange: fn(), isCreating: false, regeneratingTitleChatIds: [], archivedFilter: "active" as const, @@ -126,6 +130,31 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const ChatsSidebarWithKeybindings = ( + args: ComponentProps, +) => { + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState( + args.isSearchDialogOpen, + ); + const handleSearchDialogOpenChange = (open: boolean) => { + setIsSearchDialogOpen(open); + args.onSearchDialogOpenChange(open); + }; + + useAgentsPageKeybindings({ + onNewAgent: args.onBeforeNewAgent ?? (() => {}), + onToggleSearch: () => handleSearchDialogOpenChange(!isSearchDialogOpen), + }); + + return ( + + ); +}; + export const ChatWithTurnSummary: Story = { args: { chats: [ @@ -732,6 +761,102 @@ export const SidebarFilterMenu: Story = { }, }; +export const SearchDialogKeyboardShortcut: Story = { + render: ChatsSidebarWithKeybindings, + args: { + chats: sectionHeaderChats, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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 userEvent.keyboard("{Control>}k{/Control}"); + + const searchInput = await body.findByRole("combobox", { + name: "Search chats", + }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + + await userEvent.type(searchInput, "Fix"); + await expect(searchInput).toHaveValue("Fix"); + + await userEvent.keyboard("{Control>}k{/Control}"); + await waitFor(() => { + expect( + body.queryByRole("combobox", { name: "Search chats" }), + ).not.toBeInTheDocument(); + }); + + await userEvent.keyboard("{Control>}k{/Control}"); + const reopenedSearchInput = await body.findByRole("combobox", { + name: "Search chats", + }); + await expect(reopenedSearchInput).toHaveValue(""); + }, +}; + +export const SearchDialogKeyboardShortcutHandlesRenameInput: Story = { + render: ChatsSidebarWithKeybindings, + args: { + chats: [ + buildChat({ + id: "rename-shortcut-target", + title: "Editable shortcut target", + updated_at: recentTimestamp, + }), + ], + onRenameTitle: fn(() => Promise.resolve()), + }, + 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: "Open actions for Editable shortcut target", + }), + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "Rename chat" }), + ); + + const input = await body.findByRole("textbox", { + name: "Chat title", + }); + expect(input).toHaveFocus(); + + await userEvent.keyboard("{Control>}k{/Control}"); + + const searchInput = await body.findByRole("combobox", { + name: "Search chats", + }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + }, +}; + export const RenameChatAvailableDuringRegeneration: Story = { args: { chats: [ @@ -2074,7 +2199,7 @@ export const PreservesArchivedFilterOnSettingsNavigation: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const settingsLink = await canvas.findByLabelText("Settings"); + const settingsLink = await canvas.findByRole("link", { name: "Settings" }); await userEvent.click(settingsLink); await waitFor(() => { const fromValue = diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx index c04a96bfc8..214ab6209e 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -117,6 +117,8 @@ const defaultProps: React.ComponentProps = { onRenameTitle: vi.fn(async () => {}), regeneratingTitleChatIds: [], onBeforeNewAgent: vi.fn(), + isSearchDialogOpen: false, + onSearchDialogOpenChange: vi.fn(), isCreating: false, archivedFilter: "active" as const, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx index a5854a3ceb..3798f75c57 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx @@ -5,7 +5,7 @@ import { userChatProviderConfigs } from "#/api/queries/chats"; import type { Chat, ChatModelConfig } from "#/api/typesGenerated"; import type { ModelSelectorOption } from "../ChatElements"; import { ChatsPanel } from "./chats/ChatsPanel"; -import { RenameChatDialog } from "./dialogs"; +import { ChatSearchDialog, RenameChatDialog } from "./dialogs"; import { SettingsPanel } from "./settings/SettingsPanel"; import { isSettingsView, sidebarViewFromPath } from "./sidebarView"; @@ -25,6 +25,8 @@ interface ChatsSidebarProps { onRenameTitle?: (chatId: string, title: string) => Promise; onProposeTitle?: (chatId: string) => Promise; onBeforeNewAgent?: () => void; + isSearchDialogOpen: boolean; + onSearchDialogOpenChange: (open: boolean) => void; isCreating: boolean; isArchiving?: boolean; archivingChatId?: string | null; @@ -57,6 +59,8 @@ export const ChatsSidebar: FC = (props) => { onRenameTitle, onProposeTitle, onBeforeNewAgent, + isSearchDialogOpen, + onSearchDialogOpenChange, isCreating, isArchiving = false, archivingChatId = null, @@ -112,6 +116,7 @@ export const ChatsSidebar: FC = (props) => { onUnpinAgent={onUnpinAgent} onReorderPinnedAgent={onReorderPinnedAgent} onBeforeNewAgent={onBeforeNewAgent} + onOpenSearchDialog={() => onSearchDialogOpenChange(true)} onOpenRenameDialog={onRenameTitle ? setChatPendingRename : undefined} isCreating={isCreating} isArchiving={isArchiving} @@ -141,6 +146,11 @@ export const ChatsSidebar: FC = (props) => { location={location} onCollapse={onCollapse} /> + {onRenameTitle && ( void; readonly onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; readonly onBeforeNewAgent?: () => void; + readonly onOpenSearchDialog?: () => void; readonly onOpenRenameDialog?: (chat: Chat) => void; readonly isCreating: boolean; readonly isArchiving: boolean; @@ -95,6 +108,7 @@ export const ChatsPanel: FC = ({ onUnpinAgent, onReorderPinnedAgent, onBeforeNewAgent, + onOpenSearchDialog, onOpenRenameDialog, isCreating, isArchiving, @@ -281,8 +295,8 @@ export const ChatsPanel: FC = ({ aria-hidden={isSettingsPanel} inert={isSettingsPanel ? true : undefined} > -
-
+
+
@@ -339,7 +353,40 @@ export const ChatsPanel: FC = ({ "sm:[mask-image:none] sm:[-webkit-mask-image:none]", )} > -
+
+
+

+ Chats +

+
+ + + + + + + + {getOSKey()} + K + + Search chats + + + + +
+
{loadError ? (
@@ -397,12 +444,6 @@ export const ChatsPanel: FC = ({
) : (
-
- -
{pinnedChats.length > 0 && (
= { + chat_id: "chat-1", + url: "https://github.com/coder/coder/pull/25391", + pull_request_state: "open", + pull_request_title: "Fix race condition", + pull_request_draft: false, + changes_requested: false, + additions: 143, + deletions: 125, + changed_files: 8, +}; + +const mockChat: Chat = { + id: "chat-1", + organization_id: "org-1", + owner_id: "owner-1", + owner_username: "jaayden", + title: "Fix race condition in auth middleware", + status: "completed", + last_model_config_id: "model-1", + mcp_server_ids: [], + labels: {}, + last_turn_summary: "Added migration script", + created_at: "2026-05-20T05:00:00.000Z", + updated_at: "2026-05-20T07:30:00.000Z", + archived: false, + pin_order: 0, + has_unread: true, + client_type: "ui", + children: [], + diff_status: mockDiffStatus, +}; + +const mockChats: Chat[] = [ + mockChat, + { + ...mockChat, + id: "chat-2", + title: "Fix flaky workspace search story", + last_turn_summary: "Updated keyboard interactions", + updated_at: "2026-05-20T08:45:00.000Z", + has_unread: false, + diff_status: { + ...mockDiffStatus, + chat_id: "chat-2", + pull_request_title: "Fix flaky story", + additions: 48, + deletions: 12, + changed_files: 3, + }, + }, +]; +const cappedMockChats: Chat[] = Array.from( + { length: CHAT_SEARCH_LIMIT }, + (_, index) => ({ + ...mockChat, + id: `chat-${index + 1}`, + title: `Fix capped search result ${index + 1}`, + has_unread: false, + diff_status: undefined, + }), +); + +const meta: Meta = { + title: "pages/AgentsPage/ChatSearchDialog", + component: ChatSearchDialog, + args: { + open: true, + onOpenChange: fn(), + location: { + pathname: "/agents", + search: "", + hash: "", + state: null, + key: "default", + }, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: [ + { path: "/agents", useStoryElement: true }, + { path: "/agents/:agentId", useStoryElement: true }, + { path: "/agents/settings", useStoryElement: true }, + ], + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getChats").mockResolvedValue(mockChats); + }, +}; + +export default meta; +type Story = StoryObj; + +export const EmptyState: Story = {}; + +export const LoadingState: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockImplementation( + () => + new Promise((_resolve) => { + // Keep request pending to hold loading skeleton. + }), + ); + }, + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "Fix", + ); + await expect(await body.findByText(/results/i)).toBeInTheDocument(); + await waitFor(() => { + expect( + document.body.querySelectorAll('[data-slot="skeleton"]').length, + ).toBeGreaterThan(0); + }); + }, +}; + +export const Results: Story = { + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "Fix", + ); + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: 'title:"Fix"', + }); + }); + await expect( + await body.findByText("Fix race condition in auth middleware"), + ).toBeInTheDocument(); + }, +}; + +export const CappedResults: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockResolvedValue(cappedMockChats); + }, + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "Fix", + ); + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: 'title:"Fix"', + }); + }); + await expect( + await body.findByText( + (_content, element) => + element?.textContent?.replace(/\s+/g, " ").trim() === + `Showing first ${CHAT_SEARCH_LIMIT} results.`, + ), + ).toBeInTheDocument(); + }, +}; + +export const KeyboardNavigation: Story = { + play: async ({ args }) => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + + await userEvent.type(searchInput, "Fix"); + + const firstResult = await body.findByRole("option", { + name: /Fix race condition in auth middleware/i, + }); + const secondResult = await body.findByRole("option", { + name: /Fix flaky workspace search story/i, + }); + const resultsViewport = firstResult.closest( + "[data-radix-scroll-area-viewport]", + ); + if (!resultsViewport) { + throw new Error("Expected search results to render in a scroll viewport"); + } + + await expect(resultsViewport).toHaveAttribute("tabindex", "-1"); + await expect(firstResult).toHaveAttribute("tabindex", "-1"); + await expect(secondResult).toHaveAttribute("tabindex", "-1"); + + await userEvent.keyboard("{ArrowUp}"); + await expect(secondResult).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{ArrowUp}"); + await expect(firstResult).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{ArrowDown}"); + await expect(secondResult).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{ArrowUp}"); + await expect(firstResult).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }); + }, +}; + +export const NoResults: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockResolvedValue([]); + }, + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "none", + ); + await expect( + await body.findByText("No matching chats"), + ).toBeInTheDocument(); + }, +}; + +export const ErrorState: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockRejectedValue( + new Error("Bad filter"), + ); + }, + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "title:", + ); + await expect(await body.findByRole("alert")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx new file mode 100644 index 0000000000..e7cb5c3bbe --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx @@ -0,0 +1,156 @@ +import type { FC, RefObject } from "react"; +import { type KeyboardEventHandler, useId, useRef, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { type Location, useNavigate } from "react-router"; +import { chatSearch } from "#/api/queries/chats"; +import { Dialog, DialogContent, DialogTitle } from "#/components/Dialog/Dialog"; +import { useDebouncedValue } from "#/hooks/debounce"; +import { ChatSearchInput } from "./ChatSearchInput"; +import { ChatSearchResults } from "./ChatSearchResults"; +import { normalizeChatSearchInput } from "./searchQuery"; + +type ChatSearchDialogProps = { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly location: Location; +}; + +const SEARCH_DEBOUNCE_MS = 500; + +export const ChatSearchDialog: FC = ({ + open, + onOpenChange, + location, +}) => { + const inputRef = useRef(null); + + return ( + + { + event.preventDefault(); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }} + > + + + + ); +}; + +type ChatSearchDialogContentProps = ChatSearchDialogProps & { + readonly inputRef: RefObject; +}; + +const ChatSearchDialogContent: FC = ({ + open, + onOpenChange, + location, + inputRef, +}) => { + const navigate = useNavigate(); + const [inputValue, setInputValue] = useState(""); + const [selectedChatIndex, setSelectedChatIndex] = useState< + number | undefined + >(undefined); + const listboxId = useId(); + const debouncedInput = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS); + const normalizedQuery = normalizeChatSearchInput(debouncedInput); + const hasQuery = inputValue.trim() !== "" && normalizedQuery !== undefined; + + const searchQuery = useQuery({ + ...chatSearch(normalizedQuery ?? ""), + enabled: open && hasQuery, + placeholderData: keepPreviousData, + }); + + const resultCount = searchQuery.data?.length ?? 0; + const safeSelectedChatIndex = + selectedChatIndex !== undefined && selectedChatIndex < resultCount + ? selectedChatIndex + : undefined; + const selectedChat = + safeSelectedChatIndex !== undefined + ? searchQuery.data?.[safeSelectedChatIndex] + : undefined; + const activeResultId = + safeSelectedChatIndex !== undefined + ? `${listboxId}-option-${safeSelectedChatIndex}` + : undefined; + const closeDialog = () => onOpenChange(false); + + const showResultsLoading = + hasQuery && + (searchQuery.isLoading || + (searchQuery.isFetching && (searchQuery.data?.length ?? 0) === 0)); + const handleInputKeyDown: KeyboardEventHandler = ( + event, + ) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (resultCount === 0) { + return; + } + + event.preventDefault(); + setSelectedChatIndex((previousIndex) => { + if (previousIndex === undefined || previousIndex >= resultCount) { + return event.key === "ArrowUp" ? resultCount - 1 : 0; + } + + if (event.key === "ArrowDown") { + return Math.min(previousIndex + 1, resultCount - 1); + } + + return Math.max(previousIndex - 1, 0); + }); + return; + } + + if (event.key === "Enter" && selectedChat) { + event.preventDefault(); + navigate({ + pathname: `/agents/${selectedChat.id}`, + search: location.search, + }); + closeDialog(); + } + }; + + return ( + <> + Search chats + 0} + inputRef={inputRef} + listboxId={listboxId} + value={inputValue} + onChange={(event) => { + setInputValue(event.target.value); + setSelectedChatIndex(undefined); + }} + onKeyDown={handleInputKeyDown} + /> + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx new file mode 100644 index 0000000000..265d1ac7db --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx @@ -0,0 +1,48 @@ +import { SearchIcon } from "lucide-react"; +import type { + ChangeEventHandler, + FC, + KeyboardEventHandler, + RefObject, +} from "react"; +import { Input } from "#/components/Input/Input"; + +type ChatSearchInputProps = { + readonly activeResultId: string | undefined; + readonly hasResults: boolean; + readonly inputRef: RefObject; + readonly listboxId: string; + readonly value: string; + readonly onChange: ChangeEventHandler; + readonly onKeyDown: KeyboardEventHandler; +}; + +export const ChatSearchInput: FC = ({ + activeResultId, + hasResults, + inputRef, + listboxId, + value, + onChange, + onKeyDown, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx new file mode 100644 index 0000000000..551cd2afd2 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx @@ -0,0 +1,247 @@ +import { type FC, useEffect, useRef } from "react"; +import { Link, type Location } from "react-router"; +import { CHAT_SEARCH_LIMIT } from "#/api/queries/chats"; +import type { Chat } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { cn } from "#/utils/cn"; +import { shortRelativeTime } from "#/utils/time"; +import { getChatDisplayConfig } from "../tree/statusConfig"; + +type ChatSearchResultsProps = { + readonly chats: readonly Chat[] | undefined; + readonly error: unknown; + readonly hasQuery: boolean; + readonly location: Location; + readonly listboxId: string; + readonly selectedChatIndex: number | undefined; + readonly showLoading: boolean; + readonly onSelectChat: () => void; +}; + +export const ChatSearchResults: FC = ({ + chats, + error, + hasQuery, + location, + listboxId, + selectedChatIndex, + showLoading, + onSelectChat, +}) => { + if (error) { + return ( +
+ +
+ ); + } + + if (!hasQuery) { + return ( +
+
+ Type to search by title, or use filters like{" "} + has_unread:true, archived:true,{" "} + pr_status:open, or diff_url:"...". +
+
+ ); + } + + const resultCount = chats?.length ?? 0; + const resultSummary = + resultCount === CHAT_SEARCH_LIMIT ? ( + <> + Showing first{" "} + {CHAT_SEARCH_LIMIT}{" "} + results. + + ) : ( + <> + {resultCount}{" "} + {resultCount === 1 ? "result" : "results"} + + ); + + return ( +
+
+

{resultSummary}

+ + + +
+
+ ); +}; + +type ChatSearchResultsListProps = { + readonly chats: readonly Chat[] | undefined; + readonly location: Location; + readonly listboxId: string; + readonly selectedChatIndex: number | undefined; + readonly showLoading: boolean; + readonly onSelectChat: () => void; +}; + +const ChatSearchResultsList: FC = ({ + chats, + location, + listboxId, + selectedChatIndex, + showLoading, + onSelectChat, +}) => { + if (showLoading) { + return ; + } + + if ((chats?.length ?? 0) === 0) { + return ( +

+ No matching chats +

+ ); + } + + return ( +
+ {chats?.map((chat, index) => ( + + ))} +
+ ); +}; + +type ChatSearchResultRowProps = { + readonly chat: Chat; + readonly id: string; + readonly isSelected: boolean; + readonly location: Location; + readonly onSelect: () => void; +}; + +const ChatSearchResultRow: FC = ({ + chat, + id, + isSelected, + location, + onSelect, +}) => { + const rowRef = useRef(null); + const { + icon: StatusIcon, + className: statusClassName, + diffStatus, + } = getChatDisplayConfig(chat); + const additions = diffStatus?.additions ?? 0; + const deletions = diffStatus?.deletions ?? 0; + const changedFiles = diffStatus?.changed_files ?? 0; + const hasLineStats = + Boolean(diffStatus?.url) && + (additions > 0 || deletions > 0 || changedFiles > 0); + const subtitle = chat.last_turn_summary?.trim() || "No summary available"; + + useEffect(() => { + if (isSelected) { + rowRef.current?.scrollIntoView({ block: "nearest" }); + } + }, [isSelected]); + + return ( + + +
+
+ + {chat.title} + +
+
+ {hasLineStats && ( + + +{additions} + + −{deletions} + + + )} + {subtitle} +
+
+ + {chat.has_unread && ( + + + ); +}; + +const ChatSearchResultsSkeleton: FC = () => ( +
+ {Array.from({ length: 6 }, (_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts index 6926559129..1dd92ddfaf 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts @@ -1 +1,2 @@ +export { ChatSearchDialog } from "./ChatSearchDialog"; export { RenameChatDialog } from "./RenameChatDialog"; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.test.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.test.ts new file mode 100644 index 0000000000..31cdaf24fa --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { normalizeChatSearchInput } from "./searchQuery"; + +describe("normalizeChatSearchInput", () => { + it("returns undefined for empty input", () => { + expect(normalizeChatSearchInput("")).toBeUndefined(); + expect(normalizeChatSearchInput(" ")).toBeUndefined(); + }); + + it("keeps key:value filters unchanged", () => { + expect(normalizeChatSearchInput("has_unread:true")).toBe("has_unread:true"); + expect(normalizeChatSearchInput('title:"chat title" archived:true')).toBe( + 'title:"chat title" archived:true', + ); + expect(normalizeChatSearchInput("pr_status:open,merged")).toBe( + "pr_status:open,merged", + ); + expect( + normalizeChatSearchInput( + 'diff_url:"https://github.com/coder/coder/pull/25391"', + ), + ).toBe('diff_url:"https://github.com/coder/coder/pull/25391"'); + }); + + it("converts bare search text into a title filter", () => { + expect(normalizeChatSearchInput("Fix")).toBe('title:"Fix"'); + expect(normalizeChatSearchInput("fix auth middleware")).toBe( + 'title:"fix auth middleware"', + ); + expect(normalizeChatSearchInput("fix:lint")).toBe('title:"fix:lint"'); + }); + + it("combines key:value filters with a title fallback for bare text", () => { + expect(normalizeChatSearchInput("has_unread:true fix auth")).toBe( + 'has_unread:true title:"fix auth"', + ); + expect(normalizeChatSearchInput("archived:true fix:lint")).toBe( + 'archived:true title:"fix:lint"', + ); + expect(normalizeChatSearchInput("fix has_unread:true auth")).toBe( + 'has_unread:true title:"fix auth"', + ); + expect( + normalizeChatSearchInput('archived:true title:"chat title" fix'), + ).toBe('archived:true title:"chat title fix"'); + }); + + it("combines duplicate title filters into one title filter", () => { + expect(normalizeChatSearchInput("title:Fix title:Race")).toBe( + 'title:"Fix Race"', + ); + expect( + normalizeChatSearchInput('has_unread:true title:"chat title" title:Race'), + ).toBe('has_unread:true title:"chat title Race"'); + }); + + it("strips quotes from bare text", () => { + expect(normalizeChatSearchInput('Fix "auth" middleware')).toBe( + 'title:"Fix auth middleware"', + ); + }); + + it("treats a trailing-colon filter as bare title text", () => { + // `title:` is not a well-formed key:value pair, so it should be searched + // for as a literal title substring. + expect(normalizeChatSearchInput("title:")).toBe('title:"title:"'); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.ts new file mode 100644 index 0000000000..1b26b6770e --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/searchQuery.ts @@ -0,0 +1,137 @@ +// The backend's search-query parser toggles its quoted-state on every `"` and +// has no backslash-escape handling, so escaping quotes here would produce a +// query the backend cannot parse. Stripping quotes from bare text keeps the +// resulting `title:"..."` filter well-formed for the backend. +const sanitizeChatSearchValue = (value: string): string => { + return value.replaceAll('"', ""); +}; + +// Filter keys that may pass through to the backend unchanged. `title` is not +// listed here because bare text and `title:` filters are merged into a single +// title filter; see the title-handling branch in normalizeChatSearchInput. +const passthroughChatSearchFilterKeys = new Set([ + "archived", + "diff_url", + "has_unread", + "pr_status", +]); + +const splitSearchInput = (input: string): string[] => { + const tokens: string[] = []; + let token = ""; + let quoted = false; + + for (const character of input) { + if (character === '"') { + quoted = !quoted; + } + + if (/\s/.test(character) && !quoted) { + if (token !== "") { + tokens.push(token); + token = ""; + } + continue; + } + + token += character; + } + + if (token !== "") { + tokens.push(token); + } + + return tokens; +}; + +const getKeyValueDelimiterIndex = (token: string): number | undefined => { + let quoted = false; + + for (const [index, character] of [...token].entries()) { + if (character === '"') { + quoted = !quoted; + } + + if (character === ":" && !quoted) { + return index; + } + } + + return undefined; +}; + +const getKeyValuePair = ( + token: string, +): { key: string; value: string } | undefined => { + const delimiterIndex = getKeyValueDelimiterIndex(token); + if ( + delimiterIndex === undefined || + delimiterIndex === 0 || + delimiterIndex === token.length - 1 + ) { + return undefined; + } + + return { + key: token.slice(0, delimiterIndex).replaceAll('"', "").toLowerCase(), + value: token.slice(delimiterIndex + 1).replace(/^"|"$/g, ""), + }; +}; + +/** + * Normalizes raw search input into a query string the chat search API accepts. + * + * Bare text and `title:` filters are merged into a single `title:"..."` + * filter (the backend rejects a parameter that appears more than once). + * Recognized `key:value` filters pass through unchanged. + */ +export const normalizeChatSearchInput = ( + rawInput: string, +): string | undefined => { + const trimmedInput = rawInput.trim(); + if (trimmedInput === "") { + return undefined; + } + + const tokens = splitSearchInput(trimmedInput); + const keyValuePairs: string[] = []; + const titleTerms: string[] = []; + let hasBareTitleText = false; + + for (const token of tokens) { + const keyValuePair = getKeyValuePair(token); + if (!keyValuePair) { + titleTerms.push(token); + hasBareTitleText = true; + continue; + } + + if (keyValuePair.key === "title") { + titleTerms.push(keyValuePair.value); + continue; + } + + if (!passthroughChatSearchFilterKeys.has(keyValuePair.key)) { + titleTerms.push(token); + hasBareTitleText = true; + continue; + } + + keyValuePairs.push(token); + } + + // Multiple title values must be merged into a single title filter because + // the backend's query parser rejects the same key appearing more than once. + if (titleTerms.length > 1) { + hasBareTitleText = true; + } + + if (!hasBareTitleText) { + return trimmedInput; + } + + return [ + ...keyValuePairs, + `title:"${sanitizeChatSearchValue(titleTerms.join(" "))}"`, + ].join(" "); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index 0c723453af..76c63136e8 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -34,11 +34,7 @@ import { asNonEmptyString } from "../../ChatConversation/blockUtils"; import { useChatTree } from "./ChatTreeContext"; import { getParentChatID } from "./chatTree"; import { getModelDisplayName } from "./modelDisplayName"; -import { - getChatDiffStatus, - getPRIconConfig, - getStatusConfig, -} from "./statusConfig"; +import { getChatDisplayConfig } from "./statusConfig"; interface ChatTreeNodeProps { readonly chat: Chat; @@ -127,14 +123,11 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; const subtitle = errorReason || streamingSubtitle || displayedTurnSummary || modelName; - const diffStatus = getChatDiffStatus(chat); - const baseConfig = getStatusConfig(chat.status); - const prConfig = - chat.status === "waiting" || chat.status === "completed" - ? getPRIconConfig(diffStatus) - : undefined; - const config = prConfig ?? baseConfig; - const StatusIcon = config.icon; + const { + icon: StatusIcon, + className: statusClassName, + diffStatus, + } = getChatDisplayConfig(chat); const hasLinkedDiffStatus = Boolean(diffStatus?.url); const changedFiles = diffStatus?.changed_files ?? 0; const additions = diffStatus?.additions ?? 0; @@ -245,7 +238,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { ? `agents-tree-executing-${chat.id}` : undefined } - className={cn("h-3.5 w-3.5 shrink-0", config.className)} + className={cn("h-3.5 w-3.5 shrink-0", statusClassName)} />
{hasChildren && ( diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts index 68b83eb045..534a12ca42 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts @@ -26,11 +26,11 @@ const statusConfig = { completed: { icon: CheckIcon, className: "text-content-secondary" }, } as const; -export const getStatusConfig = (status: ChatStatus): ChatIconConfig => { +const getStatusConfig = (status: ChatStatus): ChatIconConfig => { return statusConfig[status] ?? statusConfig.completed; }; -export const getPRIconConfig = ( +const getPRIconConfig = ( diffStatus: ChatDiffStatus | undefined, ): ChatIconConfig | undefined => { const state = diffStatus?.pull_request_state; @@ -55,6 +55,35 @@ export const getPRIconConfig = ( return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" }; }; -export const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => { +const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => { return chat.diff_status; }; + +/** + * Returns the icon and styling that represents a chat's current state. + * + * Combines `getStatusConfig` and `getPRIconConfig`: when the chat is in a + * settled state (`waiting` or `completed`) and has a linked PR, the PR icon + * takes precedence so list rows surface the merge / closed / draft state + * instead of the generic status icon. + */ +export const getChatDisplayConfig = ( + chat: Chat, +): { + icon: LucideIcon; + className: string; + diffStatus: ChatDiffStatus | undefined; +} => { + const diffStatus = getChatDiffStatus(chat); + const baseConfig = getStatusConfig(chat.status); + const prConfig = + chat.status === "waiting" || chat.status === "completed" + ? getPRIconConfig(diffStatus) + : undefined; + const config = prConfig ?? baseConfig; + return { + icon: config.icon, + className: config.className, + diffStatus, + }; +}; diff --git a/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.test.ts b/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.test.ts new file mode 100644 index 0000000000..1d24e890a6 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.test.ts @@ -0,0 +1,116 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMac } from "#/utils/platform"; +import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings"; + +vi.mock("#/utils/platform", () => ({ + isMac: vi.fn(), +})); + +const isMacMock = vi.mocked(isMac); + +const dispatchKeyDown = ( + key: string, + options: KeyboardEventInit = {}, + target: EventTarget = document, +) => { + const event = new KeyboardEvent("keydown", { + key, + cancelable: true, + bubbles: true, + ...options, + }); + target.dispatchEvent(event); + return event; +}; + +describe("useAgentsPageKeybindings", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("toggles search with Ctrl+K on non-macOS", () => { + isMacMock.mockReturnValue(false); + const onNewAgent = vi.fn(); + const onToggleSearch = vi.fn(); + + renderHook(() => + useAgentsPageKeybindings({ + onNewAgent, + onToggleSearch, + }), + ); + + const firstEvent = dispatchKeyDown("k", { ctrlKey: true }); + const secondEvent = dispatchKeyDown("k", { ctrlKey: true }); + + expect(firstEvent.defaultPrevented).toBe(true); + expect(secondEvent.defaultPrevented).toBe(true); + expect(onToggleSearch).toHaveBeenCalledTimes(2); + expect(onNewAgent).not.toHaveBeenCalled(); + }); + + it("uses Cmd instead of Ctrl on macOS", () => { + isMacMock.mockReturnValue(true); + const onNewAgent = vi.fn(); + const onToggleSearch = vi.fn(); + + renderHook(() => + useAgentsPageKeybindings({ + onNewAgent, + onToggleSearch, + }), + ); + + const ctrlEvent = dispatchKeyDown("k", { ctrlKey: true }); + const metaEvent = dispatchKeyDown("k", { metaKey: true }); + + expect(ctrlEvent.defaultPrevented).toBe(false); + expect(metaEvent.defaultPrevented).toBe(true); + expect(onToggleSearch).toHaveBeenCalledTimes(1); + }); + + it("creates a new agent with Ctrl+N", () => { + isMacMock.mockReturnValue(false); + const onNewAgent = vi.fn(); + const onToggleSearch = vi.fn(); + + renderHook(() => + useAgentsPageKeybindings({ + onNewAgent, + onToggleSearch, + }), + ); + + const event = dispatchKeyDown("n", { ctrlKey: true }); + + expect(event.defaultPrevented).toBe(true); + expect(onNewAgent).toHaveBeenCalledTimes(1); + expect(onToggleSearch).not.toHaveBeenCalled(); + }); + + it("handles shortcuts from editable elements", () => { + isMacMock.mockReturnValue(false); + const onNewAgent = vi.fn(); + const onToggleSearch = vi.fn(); + const input = document.createElement("input"); + document.body.appendChild(input); + + renderHook(() => + useAgentsPageKeybindings({ + onNewAgent, + onToggleSearch, + }), + ); + + const searchEvent = dispatchKeyDown("k", { ctrlKey: true }, input); + const newAgentEvent = dispatchKeyDown("n", { ctrlKey: true }, input); + + expect(searchEvent.defaultPrevented).toBe(true); + expect(newAgentEvent.defaultPrevented).toBe(true); + expect(onToggleSearch).toHaveBeenCalledTimes(1); + expect(onNewAgent).toHaveBeenCalledTimes(1); + + input.remove(); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.ts b/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.ts index e6b72ddee6..ae448ce910 100644 --- a/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.ts +++ b/site/src/pages/AgentsPage/hooks/useAgentsPageKeybindings.ts @@ -1,35 +1,40 @@ import { useEffect } from "react"; +import { isMac } from "#/utils/platform"; /** * Global keyboard shortcuts for the Agents page. * - * - Ctrl+N / Cmd+N — Create a new agent. + * - Ctrl+N / Cmd+N: Create a new agent. + * - Ctrl+K / Cmd+K: Toggle agent search. */ export function useAgentsPageKeybindings({ onNewAgent, + onToggleSearch, }: { onNewAgent: () => void; + onToggleSearch?: () => void; }) { useEffect(() => { const handler = (event: KeyboardEvent) => { - // Ignore events originating from inputs / textareas / contenteditable - // so we don't hijack normal typing. - const target = event.target as HTMLElement | null; - if (target) { - const tag = target.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable) { - return; - } + const isModifierPressed = isMac() ? event.metaKey : event.ctrlKey; + if (!isModifierPressed || event.altKey || event.shiftKey) { + return; } - // Ctrl+N / Cmd+N — new agent - if (event.key === "n" && (event.metaKey || event.ctrlKey)) { + const key = event.key.toLowerCase(); + if (key === "n") { event.preventDefault(); onNewAgent(); + return; + } + + if (key === "k" && onToggleSearch) { + event.preventDefault(); + onToggleSearch(); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [onNewAgent]); + }, [onNewAgent, onToggleSearch]); } diff --git a/site/src/utils/platform.ts b/site/src/utils/platform.ts index e8ac35eba1..5e2c07eab5 100644 --- a/site/src/utils/platform.ts +++ b/site/src/utils/platform.ts @@ -1,7 +1,7 @@ /** * Returns true if the current platform is macOS. */ -function isMac(): boolean { +export function isMac(): boolean { return Boolean(navigator.platform.match("Mac")); }