diff --git a/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.test.tsx b/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.test.tsx new file mode 100644 index 00000000000..bb50449af4b --- /dev/null +++ b/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.test.tsx @@ -0,0 +1,146 @@ +/** + * @vitest-environment jsdom + */ + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library'; +import { createSignal, type JSX } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModelCatalogPicker } from './ModelCatalogPicker'; +import type { CatalogModelOption } from './modelCatalog'; + +vi.mock('@ui', () => { + const cn = (...args: unknown[]) => + args.flat(Infinity).filter(Boolean).join(' '); + + let onOpenAutoFocus: ((event: Event) => void) | undefined; + + const Dropdown: any = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + Dropdown.Trigger = (props: { + children?: JSX.Element; + 'aria-label'?: string; + }) => ( + + ); + Dropdown.Content = (props: { + children?: JSX.Element; + onOpenAutoFocus?: (event: Event) => void; + }) => { + onOpenAutoFocus = props.onOpenAutoFocus; + return ( + + ); + }; + Dropdown.Group = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + Dropdown.GroupLabel = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + Dropdown.Item = (props: { + children?: JSX.Element; + onSelect?: () => void; + }) => ( +
props.onSelect?.()}> + {props.children} +
+ ); + Dropdown.Separator = () =>
; + Dropdown.Sub = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + Dropdown.SubTrigger = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + Dropdown.SubContent = (props: { children?: JSX.Element }) => ( +
{props.children}
+ ); + + return { cn, Dropdown }; +}); + +const OPTIONS: CatalogModelOption[] = [ + { id: 'auto', label: 'Auto', group: 'Auto' }, + { id: 'grok', label: 'Cursor Grok 4.6 High Fast' }, + { id: 'opus', label: 'Claude Opus 5 High' }, + { id: 'sonnet', label: 'Claude Sonnet 5 High' }, + { id: 'sol', label: 'GPT-5.6 Sol High' }, + { id: 'gemini', label: 'Gemini 3.8 Flash High' }, +]; + +afterEach(() => { + cleanup(); +}); + +function mountPicker() { + render(() => { + const [value, setValue] = createSignal('auto'); + return ( + + ); + }); +} + +describe('ModelCatalogPicker search focus', () => { + it('puts caret in the search field when the menu opens', async () => { + mountPicker(); + fireEvent.click(screen.getByRole('button', { name: 'Agent model' })); + const search = screen.getByRole('textbox', { name: 'Search models' }); + await waitFor(() => { + expect(document.activeElement).toBe(search); + }); + }); + + it('puts caret in the search field when the menu is opened again', async () => { + mountPicker(); + const trigger = screen.getByRole('button', { name: 'Agent model' }); + fireEvent.click(trigger); + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole('textbox', { name: 'Search models' }) + ); + }); + + document.getElementById('model-catalog-menu')?.focus(); + expect(document.activeElement).not.toBe( + screen.getByRole('textbox', { name: 'Search models' }) + ); + + fireEvent.click(trigger); + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole('textbox', { name: 'Search models' }) + ); + }); + }); +}); diff --git a/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.tsx b/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.tsx index 5360040af0c..1bbaa3de47a 100644 --- a/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.tsx +++ b/apps/web/src/lib/core/component/AI/component/input/ModelCatalogPicker.tsx @@ -54,6 +54,21 @@ function ModelRow(props: { ); } +/** + * Kobalte menus still autofocus the collection on a deferred `setTimeout(0)` + * (`deferAutoFocus` in createSelectableList) after `onOpenAutoFocus` is + * prevented. A microtask loses that race; wait past the timeout so typing + * lands in search. + */ +function focusSearchAfterMenuOpen(input: () => HTMLInputElement | undefined) { + setTimeout(() => { + requestAnimationFrame(() => { + const search = input(); + if (search?.isConnected) search.focus(); + }); + }); +} + export function ModelCatalogPicker(props: ModelCatalogPickerProps) { const [query, setQuery] = createSignal(''); let searchRef: HTMLInputElement | undefined; @@ -100,7 +115,7 @@ export function ModelCatalogPicker(props: ModelCatalogPickerProps) { )} onOpenAutoFocus={(event) => { event.preventDefault(); - queueMicrotask(() => searchRef?.focus()); + focusSearchAfterMenuOpen(() => searchRef); }} >
diff --git a/docs/AGENT_GUIDE/ai-chat.md b/docs/AGENT_GUIDE/ai-chat.md index 94b67ff0fd5..22762d3e640 100644 --- a/docs/AGENT_GUIDE/ai-chat.md +++ b/docs/AGENT_GUIDE/ai-chat.md @@ -395,7 +395,8 @@ Reopening or resuming the session must retain that selection and logo. Both AI composers display their model trigger label at the input text size (15px), using the softer secondary text color. This includes the agent model -catalog trigger and mobile model sheet trigger. +catalog trigger and mobile model sheet trigger. Opening the agent model +catalog focuses the `Search models` field so you can type immediately. Soup and recent-chat icons recognize the provider in the saved model ID even when that model is no longer selectable. For example, `openai/gpt-5.5` retains