Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ProviderModelMenu
providers={[makeProvider(3)]}
currentAgentKind="codex"
currentModel="model-1"
onChange={onChange}
/>,
);

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(
<ProviderModelMenu
providers={[makeProvider(3)]}
currentAgentKind="codex"
currentModel="model-1"
onChange={onChange}
/>,
);

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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
const [sessionFavorites, setSessionFavorites] = useState<readonly ModelRef[] | undefined>(
undefined,
);
const [sessionRecents, setSessionRecents] = useState<readonly ModelRef[] | undefined>(undefined);
const deferredSearch = useDeferredValue(search);
const searchRef = useRef<HTMLInputElement>(null);
const windowedListRef = useRef<HTMLDivElement>(null);
const windowedListRef = useRef<WindowedProviderModelListHandle>(null);
const listboxDomIdPrefix = useId();

const favorites = useSharedSettings((s) => s.favoriteModels);
Expand Down Expand Up @@ -332,6 +334,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {

function handleOpenChange(open: boolean) {
setIsOpen(open);
if (!open) setActiveModelItemId(null);
onOpenChange?.(open);
}

Expand All @@ -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<string>([
Expand All @@ -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 &&
Expand All @@ -407,6 +411,10 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
});
}

function handleSelect(itemId: string) {
selectModelItem(items.find((item) => item.id === itemId));
}

const trigger = (
<Button
aria-label={t`Select model`}
Expand Down Expand Up @@ -459,6 +467,15 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
ref={searchRef}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted outline-none"
placeholder={t`Search models...`}
role="combobox"
aria-autocomplete="list"
aria-controls={`${listboxDomIdPrefix}-listbox`}
aria-expanded={isOpen}
aria-activedescendant={
activeModelItemId && items.length > 0
? `${listboxDomIdPrefix}-${activeModelItemId}`
: undefined
}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
Expand All @@ -467,9 +484,18 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
handleOpenChange(false);
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (search !== deferredSearch) {
selectModelItem(buildItemsForSearch(search).find((item) => item.type === "model"));
} else if (items.length > 0) {
windowedListRef.current?.selectActive();
}
return;
}
if (items.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
windowedListRef.current?.focus();
windowedListRef.current?.moveActive(e.key === "ArrowDown" ? 1 : -1);
}
}}
/>
Expand All @@ -483,10 +509,11 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
domIdPrefix={listboxDomIdPrefix}
items={items}
selectedKeys={selectedKeys}
scrollRef={windowedListRef}
ref={windowedListRef}
modelRowHeight={mobile ? MODEL_MENU_ROW_HEIGHT_MOBILE : MODEL_MENU_ROW_HEIGHT}
mobile={mobile}
mobileExpanded={mobile && expanded}
onActiveChange={setActiveModelItemId}
modelFastEnabled={modelFastEnabled}
toggleFavorite={(providerKind, modelId, rowPresentationMode) =>
toggleFavoriteModel(
Expand Down Expand Up @@ -527,36 +554,47 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
);
}

function WindowedProviderModelList(props: {
interface WindowedProviderModelListHandle {
moveActive: (delta: number) => void;
selectActive: () => void;
}

interface WindowedProviderModelListProps {
domIdPrefix: string;
items: ProviderModelItem[];
selectedKeys: Set<string>;
scrollRef: RefObject<HTMLDivElement | null>;
/** Height of a model row; larger on mobile so drawer rows are finger-sized. */
modelRowHeight: number;
mobile: boolean;
mobileExpanded: boolean;
onActiveChange: (itemId: string | null) => void;
modelFastEnabled: (providerKind: string, modelId: string) => boolean;
toggleFavorite: (
providerKind: string,
modelId: string,
presentationMode: ThreadPresentationMode | undefined,
) => void;
onSelect: (itemId: string) => void;
}) {
}

const WindowedProviderModelList = forwardRef<
WindowedProviderModelListHandle,
WindowedProviderModelListProps
>(function WindowedProviderModelList(props, ref) {
const {
domIdPrefix,
items,
selectedKeys,
scrollRef,
modelRowHeight,
mobile,
mobileExpanded,
onActiveChange,
modelFastEnabled,
toggleFavorite,
onSelect,
} = props;
const { t } = useLingui();
const scrollRef = useRef<HTMLDivElement>(null);
const [visibleRow, setVisibleRow] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [activeRowId, setActiveRowId] = useState<string | null>(() => {
Expand Down Expand Up @@ -590,6 +628,10 @@ function WindowedProviderModelList(props: {
setActiveRowId(initialActiveRowId);
}, [activeIndex, initialActiveRowId, meta]);

useEffect(() => {
onActiveChange(activeIndex >= 0 ? activeRowId : null);
}, [activeIndex, activeRowId, onActiveChange]);

const totalHeight = meta.totalHeight;
const [browserToolbarClearance] = useState(() => (mobile ? browserToolbarScrollClearance() : 0));
const scrollEndGapHeight = mobile
Expand Down Expand Up @@ -698,9 +740,22 @@ function WindowedProviderModelList(props: {
}
}

useImperativeHandle(ref, () => ({
moveActive,
selectActive() {
const activeItem =
items[activeIndex] ??
(meta.modelRowIndices[0] === undefined ? undefined : items[meta.modelRowIndices[0]]);
if (activeItem?.type === "model") {
onSelect(activeItem.id);
}
},
}));

return (
<div
ref={scrollRef}
id={`${domIdPrefix}-listbox`}
role="listbox"
aria-label={t`Models`}
aria-activedescendant={
Expand Down Expand Up @@ -916,7 +971,7 @@ function WindowedProviderModelList(props: {
/>
</div>
);
}
});

function StickyWindowedHeader(props: {
headerItem: Extract<ProviderModelItem, { type: "header-plain" | "header-provider" }> | null;
Expand Down