diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index b05e389bdb40..e73c959e6f8e 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; +import { queryMatchesProviderOnly } from "@/lib/model-picker-filter"; /** * Two-stage model picker modal. @@ -226,15 +227,30 @@ export function ModelPickerDialog(props: Props) { [providers, trimmedQuery], ); + // A query that matched the SELECTED provider by name/slug (not its models) + // located that provider — it shouldn't also hide that provider's models + // just because their ids don't share a substring with the provider name + // (e.g. typing "aws" to find "AWS Build" then finding zero of its Claude + // model ids contain "aws"). Fall back to an unfiltered model list in that + // case; a query that also matches a model id keeps filtering normally. + const queryMatchesSelectedProviderOnly = useMemo( + () => queryMatchesProviderOnly(selectedProvider, models, trimmedQuery), + [trimmedQuery, selectedProvider, models], + ); + // Fuzzy-ranked models carrying the matched character positions so the model // list can highlight why each entry matched. const filteredModels = useMemo( () => - fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({ + fuzzyRank( + models, + queryMatchesSelectedProviderOnly ? "" : trimmedQuery, + (m) => m, + ).map((r) => ({ model: r.item, positions: r.positions, })), - [models, trimmedQuery], + [models, trimmedQuery, queryMatchesSelectedProviderOnly], ); const canConfirm = !!selectedProvider && !!selectedModel && !applying; diff --git a/web/src/lib/model-picker-filter.test.ts b/web/src/lib/model-picker-filter.test.ts new file mode 100644 index 000000000000..4163d0782487 --- /dev/null +++ b/web/src/lib/model-picker-filter.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { queryMatchesProviderOnly } from "./model-picker-filter"; + +describe("queryMatchesProviderOnly", () => { + it("returns true when the query finds the provider but no model id (issue #65374)", () => { + // Reproduces the exact case from the issue: typing "aws" locates the + // "AWS Build" provider, but none of its Claude model ids contain "aws". + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "aws")).toBe(true); + }); + + it("returns false when the query also matches a model id — keeps normal filtering", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "sonnet")).toBe(false); + }); + + it("returns false when the query does not match the provider at all", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "openrouter")).toBe(false); + }); + + it("returns false for an empty query", () => { + const provider = { name: "AWS Build", slug: "aws-build" }; + const models = ["claude-sonnet-4.5"]; + + expect(queryMatchesProviderOnly(provider, models, "")).toBe(false); + }); + + it("returns false when there is no selected provider", () => { + expect(queryMatchesProviderOnly(null, ["claude-sonnet-4.5"], "aws")).toBe(false); + }); +}); diff --git a/web/src/lib/model-picker-filter.ts b/web/src/lib/model-picker-filter.ts new file mode 100644 index 000000000000..6d173f8e694f --- /dev/null +++ b/web/src/lib/model-picker-filter.ts @@ -0,0 +1,23 @@ +import { fuzzyScoreMulti } from "@/lib/fuzzy"; + +/** + * True when `trimmedQuery` located the selected provider by name/slug but + * matches none of its models by id — the case where a single search box + * filtering both the provider and model columns would otherwise leave the + * model pane empty even though the user just successfully found the + * provider they were looking for. + */ +export function queryMatchesProviderOnly( + selectedProvider: { name: string; slug: string } | null, + models: readonly string[], + trimmedQuery: string, +): boolean { + if (!trimmedQuery || !selectedProvider) return false; + + const matchesProvider = + fuzzyScoreMulti(`${selectedProvider.name} ${selectedProvider.slug}`, trimmedQuery) != + null; + const matchesAnyModel = models.some((m) => fuzzyScoreMulti(m, trimmedQuery) != null); + + return matchesProvider && !matchesAnyModel; +}