From e8fce67e70277e11b1d147e65611199383d44e66 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:40:13 -0300 Subject: [PATCH] feat(dashboard): add search to Playground model picker dropdown (#4086) (#6811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add search to Playground model picker dropdown (#4086) The shared ModelSelectModal (combo builder + CLI-code cards) already had search, but the Playground's raw model ` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`. diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index ecf79741d2..73e43b8165 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -2,7 +2,8 @@ // src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import ParamSliders, { type PlaygroundParams } from "./ParamSliders"; import type { PlaygroundEndpoint } from "@/lib/playground/codeExport"; import { endpointToPath } from "@/lib/playground/codeExport"; @@ -15,7 +16,7 @@ import { CLAUDE_CODE_COMPATIBLE_PREFIX, OPENAI_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; -import { pickDefaultModel, resolveModelFilterKey } from "./modelSelection"; +import { filterModelsByQuery, pickDefaultModel, resolveModelFilterKey } from "./modelSelection"; import ReasoningControls from "./ReasoningControls"; import { resolveReasoningControls, @@ -63,7 +64,11 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [ * - SLOT_IMPROVE: ImprovePromptButton will be injected here */ export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) { + const t = useTranslations("common"); const [collapsed, setCollapsed] = useState(false); + // #4086: search/filter query for the Model dropdown — flat provider catalogs (e.g. + // 50+ OpenRouter models) made the plain update("model", e.target.value)} - disabled={loadingModels} - 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" - > - {availableModels.map((m) => ( - - ))} - + <> + {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"); + }); +});