From dbb1d6d72bbc46cd832c3044ae7b9f9fb127194c Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Wed, 10 Jun 2026 11:00:33 +1200 Subject: [PATCH] feat(vscode): add search to model selector popover Adds a case-insensitive search input to the ModelSelector popover so users with many providers/models can quickly filter by text. Search matches against provider name/id and model name/id; a matching provider shows all of its models. The search input is anchored at the bottom of the upward-opening popover so it stays adjacent to the model button regardless of viewport. While searching, the connected-only filter applies, the footer is hidden, and collapse state is ignored so matching providers are always expanded. An empty state is shown when no matches exist. - New keys model.searchPlaceholder and model.noSearchResults across all 8 locales. - Search input font reduced to 11px to match the existing LinkButton in the footer; placeholder uses --vscode-input-placeholderForeground with a light-grey fallback. - Test 06-model-selection adds three scenarios; the filter assertion is scoped to the panel via within(...) since the trigger button always shows the selected model name. --- .../scenarios/06-model-selection.test.tsx | 38 +++++++++++++- .../ModelSelector/ModelSelector.module.css | 35 +++++++++++++ .../molecules/ModelSelector/ModelSelector.tsx | 49 +++++++++++++++++-- .../platforms/vscode/webview/locales/en.ts | 2 + .../platforms/vscode/webview/locales/es.ts | 2 + .../platforms/vscode/webview/locales/ja.ts | 2 + .../platforms/vscode/webview/locales/ko.ts | 2 + .../platforms/vscode/webview/locales/pt-br.ts | 2 + .../platforms/vscode/webview/locales/ru.ts | 2 + .../platforms/vscode/webview/locales/zh-cn.ts | 2 + .../platforms/vscode/webview/locales/zh-tw.ts | 2 + 11 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/06-model-selection.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/06-model-selection.test.tsx index a235afb..c115fc6 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/06-model-selection.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/06-model-selection.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { postMessage } from "../../vscode-api"; @@ -216,4 +216,40 @@ describe("モデル選択", () => { expect(screen.getByText("Select model")).toBeInTheDocument(); }); + + it("検索でモデル一覧を絞り込めること", async () => { + await setupWithProviders(); + const user = userEvent.setup(); + + await user.click(screen.getByText("Claude 4 Opus")); + const searchInput = screen.getByPlaceholderText("Search models..."); + await user.type(searchInput, "sonnet"); + + const panel = searchInput.parentElement?.parentElement as HTMLElement; + expect(within(panel).getByText("Claude 4 Sonnet")).toBeInTheDocument(); + expect(within(panel).queryByText("Claude 4 Opus")).not.toBeInTheDocument(); + }); + + it("検索中は未接続プロバイダーのモデルは検索結果に表示されないこと", async () => { + await setupWithProviders(); + const user = userEvent.setup(); + + await user.click(screen.getByText("Claude 4 Opus")); + await user.type(screen.getByPlaceholderText("Search models..."), "gpt"); + + expect(screen.queryByText("OpenAI")).not.toBeInTheDocument(); + expect(screen.queryByText("GPT-5")).not.toBeInTheDocument(); + expect(screen.getByText("No matching models")).toBeInTheDocument(); + }); + + it("検索結果がない場合は空状態が表示されること", async () => { + await setupWithProviders(); + const user = userEvent.setup(); + + await user.click(screen.getByText("Claude 4 Opus")); + await user.type(screen.getByPlaceholderText("Search models..."), "no-such-model"); + + expect(screen.getByText("No matching models")).toBeInTheDocument(); + expect(screen.queryByText("Claude 4 Sonnet")).not.toBeInTheDocument(); + }); }); diff --git a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css index 6a07a59..b18c31f 100644 --- a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css +++ b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.module.css @@ -74,6 +74,41 @@ border-radius: 3px; } +.searchBox { + padding: 8px; + border-top: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} + +.searchInput { + width: 100%; + box-sizing: border-box; + padding: 1px 6px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + font-size: 11px; + outline: none; +} + +.searchInput:focus { + border-color: var(--vscode-focusBorder); +} + +.searchInput::placeholder { + color: var(--vscode-input-placeholderForeground, #aaaaaa); + font-size: 11px; +} + +.noResults { + padding: 12px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + text-align: center; +} + .section { /* wrapper for provider group */ } diff --git a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx index 85e9f14..2fa9675 100644 --- a/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx +++ b/packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx @@ -34,6 +34,7 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe const t = useLocale(); const [collapsedProviders, setCollapsedProviders] = useState>(new Set()); const [showAll, setShowAll] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); // 表示用プロバイダーリスト: allProvidersData があればそちらを使い、なければ従来の providers を使う const allDisplayProviders = useMemo(() => { @@ -75,6 +76,35 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe const hasDisconnected = useMemo(() => allDisplayProviders.some((p) => !p.connected), [allDisplayProviders]); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const isSearching = normalizedSearchQuery.length > 0; + + const visibleProviders = useMemo(() => { + if (!isSearching) return displayProviders; + + return displayProviders + .map((provider) => { + const providerMatches = + provider.name.toLowerCase().includes(normalizedSearchQuery) || + provider.id.toLowerCase().includes(normalizedSearchQuery); + + const models = providerMatches + ? provider.models + : provider.models.filter((model) => { + const modelName = model.name || ""; + return ( + modelName.toLowerCase().includes(normalizedSearchQuery) || + model.id.toLowerCase().includes(normalizedSearchQuery) + ); + }); + + return { ...provider, models }; + }) + .filter((provider) => provider.models.length > 0); + }, [displayProviders, isSearching, normalizedSearchQuery]); + + const hasSearchResults = visibleProviders.length > 0; + const selectedModelName = useMemo(() => { if (!selectedModel) return t["model.selectModel"]; for (const p of allDisplayProviders) { @@ -107,14 +137,16 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe panel={({ close }) => (
- {displayProviders.map((provider) => { + {visibleProviders.map((provider) => { if (provider.models.length === 0) return null; - const isCollapsed = collapsedProviders.has(provider.id); + const isCollapsed = !isSearching && collapsedProviders.has(provider.id); return (
toggleProvider(provider.id)} + onClick={() => { + if (!isSearching) toggleProvider(provider.id); + }} > @@ -156,8 +188,17 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe
); })} + {!hasSearchResults &&
{t["model.noSearchResults"]}
} +
+
+ setSearchQuery(event.target.value)} + />
- {hasDisconnected && ( + {!isSearching && hasDisconnected && (
setShowAll((s) => !s)} diff --git a/packages/platforms/vscode/webview/locales/en.ts b/packages/platforms/vscode/webview/locales/en.ts index 109c8b5..d41a016 100644 --- a/packages/platforms/vscode/webview/locales/en.ts +++ b/packages/platforms/vscode/webview/locales/en.ts @@ -85,6 +85,8 @@ export const en = { "model.connectedOnly": "Connected only", "model.showAll": "Show all providers", "model.hideDisconnected": "Hide disconnected providers", + "model.searchPlaceholder": "Search models...", + "model.noSearchResults": "No matching models", // AgentSelector "agent.selectAgent": "Select agent", diff --git a/packages/platforms/vscode/webview/locales/es.ts b/packages/platforms/vscode/webview/locales/es.ts index 6b06935..354a672 100644 --- a/packages/platforms/vscode/webview/locales/es.ts +++ b/packages/platforms/vscode/webview/locales/es.ts @@ -87,6 +87,8 @@ export const es: LocaleSchema = { "model.connectedOnly": "Solo conectados", "model.showAll": "Mostrar todos los proveedores", "model.hideDisconnected": "Ocultar proveedores desconectados", + "model.searchPlaceholder": "Buscar modelos...", + "model.noSearchResults": "No hay modelos coincidentes", // AgentSelector "agent.selectAgent": "Seleccionar agente", diff --git a/packages/platforms/vscode/webview/locales/ja.ts b/packages/platforms/vscode/webview/locales/ja.ts index 751c6be..5c4238c 100644 --- a/packages/platforms/vscode/webview/locales/ja.ts +++ b/packages/platforms/vscode/webview/locales/ja.ts @@ -87,6 +87,8 @@ export const ja: LocaleSchema = { "model.connectedOnly": "接続済みのみ", "model.showAll": "すべてのプロバイダーを表示", "model.hideDisconnected": "未接続のプロバイダーを非表示", + "model.searchPlaceholder": "モデルを検索...", + "model.noSearchResults": "一致するモデルがありません", // AgentSelector "agent.selectAgent": "エージェントを選択", diff --git a/packages/platforms/vscode/webview/locales/ko.ts b/packages/platforms/vscode/webview/locales/ko.ts index 1a01612..82bd35a 100644 --- a/packages/platforms/vscode/webview/locales/ko.ts +++ b/packages/platforms/vscode/webview/locales/ko.ts @@ -87,6 +87,8 @@ export const ko: LocaleSchema = { "model.connectedOnly": "연결됨만", "model.showAll": "모든 제공자 표시", "model.hideDisconnected": "연결 안 된 제공자 숨기기", + "model.searchPlaceholder": "모델 검색...", + "model.noSearchResults": "일치하는 모델이 없습니다", // AgentSelector "agent.selectAgent": "에이전트 선택", diff --git a/packages/platforms/vscode/webview/locales/pt-br.ts b/packages/platforms/vscode/webview/locales/pt-br.ts index 5daa751..3488ec8 100644 --- a/packages/platforms/vscode/webview/locales/pt-br.ts +++ b/packages/platforms/vscode/webview/locales/pt-br.ts @@ -87,6 +87,8 @@ export const ptBr: LocaleSchema = { "model.connectedOnly": "Apenas conectados", "model.showAll": "Mostrar todos os provedores", "model.hideDisconnected": "Ocultar provedores desconectados", + "model.searchPlaceholder": "Pesquisar modelos...", + "model.noSearchResults": "Nenhum modelo encontrado", // AgentSelector "agent.selectAgent": "Selecionar agente", diff --git a/packages/platforms/vscode/webview/locales/ru.ts b/packages/platforms/vscode/webview/locales/ru.ts index 7053949..bb99650 100644 --- a/packages/platforms/vscode/webview/locales/ru.ts +++ b/packages/platforms/vscode/webview/locales/ru.ts @@ -87,6 +87,8 @@ export const ru: LocaleSchema = { "model.connectedOnly": "Только подключённые", "model.showAll": "Показать всех провайдеров", "model.hideDisconnected": "Скрыть отключённых провайдеров", + "model.searchPlaceholder": "Поиск моделей...", + "model.noSearchResults": "Нет подходящих моделей", // AgentSelector "agent.selectAgent": "Выбрать агента", diff --git a/packages/platforms/vscode/webview/locales/zh-cn.ts b/packages/platforms/vscode/webview/locales/zh-cn.ts index ece7aa5..20df312 100644 --- a/packages/platforms/vscode/webview/locales/zh-cn.ts +++ b/packages/platforms/vscode/webview/locales/zh-cn.ts @@ -87,6 +87,8 @@ export const zhCn: LocaleSchema = { "model.connectedOnly": "仅已连接", "model.showAll": "显示所有提供者", "model.hideDisconnected": "隐藏未连接的提供者", + "model.searchPlaceholder": "搜索模型...", + "model.noSearchResults": "没有匹配的模型", // AgentSelector "agent.selectAgent": "选择代理", diff --git a/packages/platforms/vscode/webview/locales/zh-tw.ts b/packages/platforms/vscode/webview/locales/zh-tw.ts index 4dbbef5..b1e3382 100644 --- a/packages/platforms/vscode/webview/locales/zh-tw.ts +++ b/packages/platforms/vscode/webview/locales/zh-tw.ts @@ -87,6 +87,8 @@ export const zhTw: LocaleSchema = { "model.connectedOnly": "僅已連線", "model.showAll": "顯示所有提供者", "model.hideDisconnected": "隱藏未連線的提供者", + "model.searchPlaceholder": "搜尋模型...", + "model.noSearchResults": "找不到相符的模型", // AgentSelector "agent.selectAgent": "選擇代理",