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 = (