diff --git a/changelog.d/features/6811-playground-model-picker-search.md b/changelog.d/features/6811-playground-model-picker-search.md new file mode 100644 index 0000000000..2022587e21 --- /dev/null +++ b/changelog.d/features/6811-playground-model-picker-search.md @@ -0,0 +1 @@ +- **feat(dashboard):** search box on the Playground's raw model ` unusable without scrolling. + const [modelQuery, setModelQuery] = useState(""); const { provider, setProvider, @@ -91,6 +96,17 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio const { availableModels, modelCapabilities, loading: loadingModels } = useAvailableModels(modelFilterKey); + // #4086: filter the dropdown by the search query, but always keep the currently selected + // model in the list even when it doesn't match — otherwise typing a query would silently + // change the active selection out from under the user. + const filteredModels = useMemo(() => { + const filtered = filterModelsByQuery(availableModels, modelQuery); + if (configState.model && !filtered.includes(configState.model)) { + return [configState.model, ...filtered]; + } + return filtered; + }, [availableModels, modelQuery, configState.model]); + // #6241: resolve the reasoning controls for the currently selected model from the capability // flags the /models catalog exposes (supportsThinking / effort_tiers). const reasoningSpec = resolveReasoningControls(modelCapabilities[configState.model]); @@ -207,18 +223,30 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio Model {availableModels.length > 0 ? ( - + <> + {availableModels.length > 1 && ( + setModelQuery(e.target.value)} + placeholder={t("search")} + aria-label={t("search")} + className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main" + /> + )} + + ) : ( ` had no way to narrow a long list, e.g. 50+ OpenRouter models). + * Accent/case-insensitive, Turkish-safe substring match against the model id (see + * `matchesSearch` — raw `toLowerCase().includes()` mangles İ/ı). An empty/whitespace query + * returns the full list unchanged. + */ +export function filterModelsByQuery(models: string[], query: string): string[] { + if (!query.trim()) return models; + return models.filter((m) => matchesSearch(m, query)); +} diff --git a/tests/unit/playground-model-selection-3731.test.ts b/tests/unit/playground-model-selection-3731.test.ts index b59254c617..63be0684ea 100644 --- a/tests/unit/playground-model-selection-3731.test.ts +++ b/tests/unit/playground-model-selection-3731.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { pickDefaultModel, resolveModelFilterKey, + filterModelsByQuery, } from "../../src/app/(dashboard)/dashboard/playground/components/modelSelection.ts"; // Regression guards for #3731 (dup #3009): the Playground model selector was unusable @@ -48,3 +49,26 @@ test("pickDefaultModel: a current model not in the list is replaced by the first test("pickDefaultModel: a valid current model is kept (no redundant update)", () => { assert.equal(pickDefaultModel("b", ["a", "b"]), null); }); + +// Regression guards for #4086: search/filter on the raw Playground model had no search/filter, forcing users to scroll +// a flat list (e.g. 50+ OpenRouter models). Regression guard for the search box added to +// StudioConfigPane's model picker. + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/playground/codeExport", () => ({ + endpointToPath: (ep: string) => `/v1/${ep}`, +})); + +const AVAILABLE_MODELS = ["openai/gpt-4o", "anthropic/claude-3", "openrouter/mistral-large"]; + +vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels", () => ({ + useAvailableModels: () => ({ + availableModels: AVAILABLE_MODELS, + modelCapabilities: {}, + loading: false, + }), +})); + +vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions", () => ({ + useProviderOptions: () => ({ + provider: "", + setProvider: vi.fn(), + providerOptions: [], + loading: false, + }), +})); + +const { default: StudioConfigPane } = await import( + "../../../src/app/(dashboard)/dashboard/playground/components/StudioConfigPane" +); +const { DEFAULT_PARAMS } = await import( + "../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders" +); + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function makeConfig() { + return { + endpoint: "chat.completions" as const, + baseUrl: "http://localhost:20128", + model: "openai/gpt-4o", + systemPrompt: "You are a helpful assistant.", + params: { ...DEFAULT_PARAMS }, + }; +} + +function renderPane( + configState: ReturnType, + setConfigState: (s: ReturnType) => void +): HTMLDivElement { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + void} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("StudioConfigPane model search (#4086)", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders a search input above the model select", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + }); + + it("shows all models in the select when the search box is empty", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "openai/gpt-4o") + ); + expect(modelSelect).toBeTruthy(); + expect(modelSelect?.options.length).toBe(AVAILABLE_MODELS.length); + }); + + it("filters the model select options as the user types", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement; + expect(searchInput).toBeTruthy(); + + act(() => { + setInputValue(searchInput, "claude"); + }); + + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "anthropic/claude-3") + ); + expect(modelSelect).toBeTruthy(); + // The non-matching "openrouter/mistral-large" model is filtered out. The currently + // selected "openai/gpt-4o" stays pinned (see the dedicated test below) even though it + // doesn't match "claude" — so the matched model plus the pinned selection remain. + const values = Array.from(modelSelect?.options ?? []).map((o) => o.value); + expect(values).toContain("anthropic/claude-3"); + expect(values).not.toContain("openrouter/mistral-large"); + }); + + it("keeps a currently selected model visible even when it doesn't match the query", () => { + const config = makeConfig(); // model = "openai/gpt-4o" + const el = renderPane(config, vi.fn()); + + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement; + + act(() => { + setInputValue(searchInput, "claude"); + }); + + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "anthropic/claude-3") + ); + const values = Array.from(modelSelect?.options ?? []).map((o) => o.value); + expect(values).toContain("openai/gpt-4o"); + }); +});