feat: modal chat search popup (#25535)

closes CODAGT-422

<img width="591" height="404" alt="Screenshot 2026-05-21 at 17 33 26"
src="https://github.com/user-attachments/assets/5ef8134a-aca7-4442-bed7-9a31698dc5d6"
/>
<img width="590" height="499" alt="Screenshot 2026-05-21 at 17 33 46"
src="https://github.com/user-attachments/assets/b8e99e53-793c-4480-8411-d90f97f9bcb6"
/>
<img width="376" height="301" alt="Screenshot 2026-05-21 at 17 36 39"
src="https://github.com/user-attachments/assets/d9c4a45c-9094-40a2-ac88-87415c45b358"
/>

---------

Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
Jaayden Halko
2026-05-21 11:39:58 +01:00
committed by GitHub
co-authored by Cian Johnston
parent b7525a9b40
commit 92d67888b8
22 changed files with 1310 additions and 42 deletions
+16
View File
@@ -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
+11
View File
@@ -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<TypesGen.Chat[]>;
};
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),
@@ -10,6 +10,7 @@ interface ScrollAreaProps
extends React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root> {
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<ScrollAreaProps> = ({
className,
scrollBarClassName,
viewportClassName,
viewportTabIndex,
orientation = "vertical",
children,
...props
@@ -47,6 +49,7 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
tabIndex={viewportTabIndex}
onWheel={handleWheel}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
+4
View File
@@ -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 <html> 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}
@@ -257,6 +257,8 @@ const defaultArgs: ComponentProps<typeof AgentsPageView> = {
catalogModelOptions: defaultModelOptions,
modelConfigs: defaultModelConfigs,
handleNewAgent: fn(),
isSearchDialogOpen: false,
onSearchDialogOpenChange: fn(),
isCreating: false,
isArchiving: false,
archivingChatId: undefined,
@@ -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<AgentsPageViewProps> = ({
catalogModelOptions,
modelConfigs,
handleNewAgent,
isSearchDialogOpen,
onSearchDialogOpenChange,
isCreating,
isArchiving,
archivingChatId,
@@ -188,6 +192,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onProposeTitle={onProposeTitle}
regeneratingTitleChatIds={regeneratingTitleChatIds}
onBeforeNewAgent={handleNewAgent}
isSearchDialogOpen={isSearchDialogOpen}
onSearchDialogOpenChange={onSearchDialogOpenChange}
isCreating={isCreating}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
@@ -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<typeof ChatsSidebar> = {
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<typeof ChatsSidebar> = {
export default meta;
type Story = StoryObj<typeof ChatsSidebar>;
const ChatsSidebarWithKeybindings = (
args: ComponentProps<typeof ChatsSidebar>,
) => {
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(
args.isSearchDialogOpen,
);
const handleSearchDialogOpenChange = (open: boolean) => {
setIsSearchDialogOpen(open);
args.onSearchDialogOpenChange(open);
};
useAgentsPageKeybindings({
onNewAgent: args.onBeforeNewAgent ?? (() => {}),
onToggleSearch: () => handleSearchDialogOpenChange(!isSearchDialogOpen),
});
return (
<ChatsSidebar
{...args}
isSearchDialogOpen={isSearchDialogOpen}
onSearchDialogOpenChange={handleSearchDialogOpenChange}
/>
);
};
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<HTMLInputElement>("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 =
@@ -117,6 +117,8 @@ const defaultProps: React.ComponentProps<typeof ChatsSidebar> = {
onRenameTitle: vi.fn(async () => {}),
regeneratingTitleChatIds: [],
onBeforeNewAgent: vi.fn(),
isSearchDialogOpen: false,
onSearchDialogOpenChange: vi.fn(),
isCreating: false,
archivedFilter: "active" as const,
};
@@ -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<void>;
onProposeTitle?: (chatId: string) => Promise<string>;
onBeforeNewAgent?: () => void;
isSearchDialogOpen: boolean;
onSearchDialogOpenChange: (open: boolean) => void;
isCreating: boolean;
isArchiving?: boolean;
archivingChatId?: string | null;
@@ -57,6 +59,8 @@ export const ChatsSidebar: FC<ChatsSidebarProps> = (props) => {
onRenameTitle,
onProposeTitle,
onBeforeNewAgent,
isSearchDialogOpen,
onSearchDialogOpenChange,
isCreating,
isArchiving = false,
archivingChatId = null,
@@ -112,6 +116,7 @@ export const ChatsSidebar: FC<ChatsSidebarProps> = (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<ChatsSidebarProps> = (props) => {
location={location}
onCollapse={onCollapse}
/>
<ChatSearchDialog
open={isSearchDialogOpen}
onOpenChange={onSearchDialogOpenChange}
location={location}
/>
{onRenameTitle && (
<RenameChatDialog
chat={chatPendingRename}
@@ -14,7 +14,12 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { PanelLeftCloseIcon, SettingsIcon, SquarePenIcon } from "lucide-react";
import {
PanelLeftCloseIcon,
SearchIcon,
SettingsIcon,
SquarePenIcon,
} from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import { Link, type Location, NavLink } from "react-router";
import type { Chat, ChatModelConfig } from "#/api/typesGenerated";
@@ -22,9 +27,16 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge";
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 { getTimeGroup, TIME_GROUPS } from "../../../utils/timeGroups";
import type { ModelSelectorOption } from "../../ChatElements";
import { FilterDropdown } from "../filters/FilterDropdown";
@@ -63,6 +75,7 @@ interface ChatsPanelProps {
readonly onUnpinAgent: (chatId: string) => 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<ChatsPanelProps> = ({
onUnpinAgent,
onReorderPinnedAgent,
onBeforeNewAgent,
onOpenSearchDialog,
onOpenRenameDialog,
isCreating,
isArchiving,
@@ -281,8 +295,8 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
aria-hidden={isSettingsPanel}
inert={isSettingsPanel ? true : undefined}
>
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 sm:block">
<div className="mb-2.5 flex items-center justify-between">
<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">
<div className="flex items-center gap-2">
<NavLink to="/workspaces" className="inline-flex">
<ProductLogo className="size-6" />
@@ -339,7 +353,40 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
"sm:[mask-image:none] sm:[-webkit-mask-image:none]",
)}
>
<div className="flex flex-col gap-2 px-2 py-3">
<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="h-7 w-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>
<FilterDropdown
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
/>
</div>
</div>
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
@@ -397,12 +444,6 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
</div>
) : (
<div className="pb-2">
<div className="mb-2 flex h-5 justify-end pr-1.5">
<FilterDropdown
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
/>
</div>
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
@@ -0,0 +1,248 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import { CHAT_SEARCH_LIMIT } from "#/api/queries/chats";
import type { Chat } from "#/api/typesGenerated";
import { ChatSearchDialog } from "./ChatSearchDialog";
const mockDiffStatus: NonNullable<Chat["diff_status"]> = {
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<typeof ChatSearchDialog> = {
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<typeof ChatSearchDialog>;
export const EmptyState: Story = {};
export const LoadingState: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChats").mockImplementation(
() =>
new Promise<Chat[]>((_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();
},
};
@@ -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<ChatSearchDialogProps> = ({
open,
onOpenChange,
location,
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[560px] gap-4 border-border-default bg-surface-primary p-6 sm:p-6"
aria-describedby={undefined}
onOpenAutoFocus={(event) => {
event.preventDefault();
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
<ChatSearchDialogContent
open={open}
onOpenChange={onOpenChange}
location={location}
inputRef={inputRef}
/>
</DialogContent>
</Dialog>
);
};
type ChatSearchDialogContentProps = ChatSearchDialogProps & {
readonly inputRef: RefObject<HTMLInputElement | null>;
};
const ChatSearchDialogContent: FC<ChatSearchDialogContentProps> = ({
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<HTMLInputElement> = (
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 (
<>
<DialogTitle className="sr-only">Search chats</DialogTitle>
<ChatSearchInput
activeResultId={activeResultId}
hasResults={resultCount > 0}
inputRef={inputRef}
listboxId={listboxId}
value={inputValue}
onChange={(event) => {
setInputValue(event.target.value);
setSelectedChatIndex(undefined);
}}
onKeyDown={handleInputKeyDown}
/>
<ChatSearchResults
chats={searchQuery.data}
error={searchQuery.error}
hasQuery={hasQuery}
location={location}
listboxId={listboxId}
selectedChatIndex={safeSelectedChatIndex}
showLoading={showResultsLoading}
onSelectChat={closeDialog}
/>
</>
);
};
@@ -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<HTMLInputElement | null>;
readonly listboxId: string;
readonly value: string;
readonly onChange: ChangeEventHandler<HTMLInputElement>;
readonly onKeyDown: KeyboardEventHandler<HTMLInputElement>;
};
export const ChatSearchInput: FC<ChatSearchInputProps> = ({
activeResultId,
hasResults,
inputRef,
listboxId,
value,
onChange,
onKeyDown,
}) => {
return (
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-content-secondary" />
<Input
ref={inputRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder="Search chats..."
className="h-10 border-border-default bg-surface-primary pl-9 pr-3 placeholder:text-content-disabled"
aria-label="Search chats"
role="combobox"
aria-controls={hasResults ? listboxId : undefined}
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-activedescendant={activeResultId}
/>
</div>
);
};
@@ -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<ChatSearchResultsProps> = ({
chats,
error,
hasQuery,
location,
listboxId,
selectedChatIndex,
showLoading,
onSelectChat,
}) => {
if (error) {
return (
<div className="min-h-[260px]">
<ErrorAlert error={error} />
</div>
);
}
if (!hasQuery) {
return (
<div className="min-h-[260px]">
<div className="pt-2 text-sm text-content-secondary">
Type to search by title, or use filters like{" "}
<code>has_unread:true</code>, <code>archived:true</code>,{" "}
<code>pr_status:open</code>, or <code>diff_url:"..."</code>.
</div>
</div>
);
}
const resultCount = chats?.length ?? 0;
const resultSummary =
resultCount === CHAT_SEARCH_LIMIT ? (
<>
Showing first{" "}
<span className="text-content-primary">{CHAT_SEARCH_LIMIT}</span>{" "}
results.
</>
) : (
<>
<span className="text-content-primary">{resultCount}</span>{" "}
{resultCount === 1 ? "result" : "results"}
</>
);
return (
<div className="min-h-[260px]">
<div className="space-y-3">
<p className="text-sm text-content-secondary">{resultSummary}</p>
<ScrollArea
className="h-[300px]"
scrollBarClassName="w-[0.375rem]"
viewportClassName="pr-3"
viewportTabIndex={-1}
>
<ChatSearchResultsList
chats={chats}
location={location}
listboxId={listboxId}
selectedChatIndex={selectedChatIndex}
showLoading={showLoading}
onSelectChat={onSelectChat}
/>
</ScrollArea>
</div>
</div>
);
};
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<ChatSearchResultsListProps> = ({
chats,
location,
listboxId,
selectedChatIndex,
showLoading,
onSelectChat,
}) => {
if (showLoading) {
return <ChatSearchResultsSkeleton />;
}
if ((chats?.length ?? 0) === 0) {
return (
<p className="px-1.5 py-2 text-sm text-content-secondary">
No matching chats
</p>
);
}
return (
<div
id={listboxId}
role="listbox"
aria-label="Chat search results"
className="space-y-1"
>
{chats?.map((chat, index) => (
<ChatSearchResultRow
key={chat.id}
chat={chat}
id={`${listboxId}-option-${index}`}
isSelected={selectedChatIndex === index}
location={location}
onSelect={onSelectChat}
/>
))}
</div>
);
};
type ChatSearchResultRowProps = {
readonly chat: Chat;
readonly id: string;
readonly isSelected: boolean;
readonly location: Location;
readonly onSelect: () => void;
};
const ChatSearchResultRow: FC<ChatSearchResultRowProps> = ({
chat,
id,
isSelected,
location,
onSelect,
}) => {
const rowRef = useRef<HTMLAnchorElement | null>(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 (
<Link
ref={rowRef}
id={id}
role="option"
aria-selected={isSelected}
tabIndex={-1}
to={{ pathname: `/agents/${chat.id}`, search: location.search }}
onClick={onSelect}
className={cn(
"flex items-start gap-2 rounded-md px-1.5 py-1 text-content-secondary no-underline hover:bg-surface-tertiary/40 hover:text-content-primary",
isSelected && "bg-surface-tertiary/40 text-content-primary",
)}
>
<StatusIcon
className={cn("mt-1 h-3.5 w-3.5 shrink-0", statusClassName)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm text-content-primary">
{chat.title}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
{hasLineStats && (
<span className="inline-flex shrink-0 items-center gap-0.5 tabular-nums">
<span className="text-git-added-bright">+{additions}</span>
<span className="text-git-deleted-bright">
&minus;{deletions}
</span>
</span>
)}
<span className="truncate text-content-secondary">{subtitle}</span>
</div>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 pt-0.5 text-xs text-content-secondary">
{chat.has_unread && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-content-link"
aria-hidden="true"
/>
)}
{shortRelativeTime(chat.updated_at)}
</span>
</Link>
);
};
const ChatSearchResultsSkeleton: FC = () => (
<div className="space-y-1.5">
{Array.from({ length: 6 }, (_, index) => (
<div
key={`search-skeleton-${index}`}
className="flex items-start gap-2 rounded-md px-1.5 py-1"
>
<Skeleton className="mt-1 h-3.5 w-3.5 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${60 + ((index * 11) % 30)}%` }}
/>
<Skeleton
className="h-3"
style={{ width: `${50 + ((index * 13) % 35)}%` }}
/>
</div>
<Skeleton className="h-3 w-6" />
</div>
))}
</div>
);
@@ -1 +1,2 @@
export { ChatSearchDialog } from "./ChatSearchDialog";
export { RenameChatDialog } from "./RenameChatDialog";
@@ -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:"');
});
});
@@ -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(" ");
};
@@ -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<ChatTreeNodeProps> = ({ 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<ChatTreeNodeProps> = ({ 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)}
/>
</div>
{hasChildren && (
@@ -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,
};
};
@@ -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();
});
});
@@ -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]);
}
+1 -1
View File
@@ -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"));
}