diff --git a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx index a52be01fd..4032d97ed 100644 --- a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx +++ b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx @@ -194,6 +194,68 @@ describe("ProviderModelMenu", () => { expect(listbox.closest(".w-96")).toBe(fixedWidthPopover); }); + it("navigates and selects search results without moving focus out of search", async () => { + const onChange = vi.fn<(next: { agentKind: string; model: string }) => void>(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + + const search = await screen.findByPlaceholderText("Search models..."); + const listbox = screen.getByRole("listbox", { name: "Models" }); + await waitFor(() => expect(search).toHaveFocus()); + + fireEvent.keyDown(search, { key: "ArrowDown" }); + + expect(search).toHaveFocus(); + expect(listbox).toHaveAttribute("aria-activedescendant", expect.stringContaining("model-2")); + expect(search).toHaveAttribute("aria-controls", listbox.id); + expect(search).toHaveAttribute("aria-activedescendant", expect.stringContaining("model-2")); + + fireEvent.keyDown(search, { key: "ArrowUp" }); + + expect(search).toHaveFocus(); + expect(listbox).toHaveAttribute("aria-activedescendant", expect.stringContaining("model-1")); + + fireEvent.change(search, { target: { value: "Model 3" } }); + await waitFor(() => expect(within(listbox).getAllByRole("option")).toHaveLength(1)); + expect(search).toHaveFocus(); + + fireEvent.keyDown(search, { key: "Enter" }); + + expect(onChange).toHaveBeenCalledWith({ agentKind: "codex", model: "model-3" }); + }); + + it("selects from the current query when Enter follows typing immediately", async () => { + const onChange = vi.fn<(next: { agentKind: string; model: string }) => void>(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + const search = await screen.findByPlaceholderText("Search models..."); + + fireEvent.change(search, { target: { value: "No match" } }); + await screen.findByText("No models found"); + expect(search).not.toHaveAttribute("aria-activedescendant"); + + fireEvent.change(search, { target: { value: "Model 3" } }); + fireEvent.keyDown(search, { key: "Enter" }); + + expect(onChange).toHaveBeenCalledWith({ agentKind: "codex", model: "model-3" }); + }); + it("renders normalized model rate descriptions as muted row hints", async () => { const provider = makeProvider(1); provider.capabilities.models = [ diff --git a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.tsx b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.tsx index 344821260..dcb2b9ef9 100644 --- a/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.tsx +++ b/src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.tsx @@ -1,11 +1,12 @@ import { + forwardRef, startTransition, useDeferredValue, useEffect, useId, + useImperativeHandle, useRef, useState, - type RefObject, } from "react"; import { Trans, useLingui } from "@lingui/react/macro"; import { Check, ChevronDown, Search, Star, Zap } from "lucide-react"; @@ -271,13 +272,14 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) { const { mobile } = useResponsiveMenu(); const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); + const [activeModelItemId, setActiveModelItemId] = useState(null); const [sessionFavorites, setSessionFavorites] = useState( undefined, ); const [sessionRecents, setSessionRecents] = useState(undefined); const deferredSearch = useDeferredValue(search); const searchRef = useRef(null); - const windowedListRef = useRef(null); + const windowedListRef = useRef(null); const listboxDomIdPrefix = useId(); const favorites = useSharedSettings((s) => s.favoriteModels); @@ -332,6 +334,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) { function handleOpenChange(open: boolean) { setIsOpen(open); + if (!open) setActiveModelItemId(null); onOpenChange?.(open); } @@ -349,20 +352,22 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) { isOpen ? (sessionRecents ?? recents) : recents, presentationMode, ); - const items = isOpen - ? buildProviderModelItems({ - providers, - search: deferredSearch, - ...(lockedAgentKind ? { lockedAgentKind } : {}), - currentAgentKind: deferredAgentKind, - currentModel: deferredModel, - favorites: sectionFavorites, - favoriteStateRefs: activeFavorites, - recents: sectionRecents, - hiddenModels, - providerOrder, - }) - : []; + function buildItemsForSearch(searchValue: string) { + return buildProviderModelItems({ + providers, + search: searchValue, + ...(lockedAgentKind ? { lockedAgentKind } : {}), + currentAgentKind: deferredAgentKind, + currentModel: deferredModel, + favorites: sectionFavorites, + favoriteStateRefs: activeFavorites, + recents: sectionRecents, + hiddenModels, + providerOrder, + }); + } + + const items = isOpen ? buildItemsForSearch(deferredSearch) : []; // Highlight the current model wherever it appears (provider section, favorites, recents). const selectedKeys = new Set([ @@ -383,8 +388,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) { return true; } - function handleSelect(itemId: string) { - const selected = items.find((item) => item.id === itemId); + function selectModelItem(selected: ProviderModelItem | undefined) { if (selected?.type !== "model") return; if ( selected.providerKind === currentAgentKind && @@ -407,6 +411,10 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) { }); } + function handleSelect(itemId: string) { + selectModelItem(items.find((item) => item.id === itemId)); + } + const trigger = (