diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index e0d6b9afc5e..5818fee212c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -5,12 +5,27 @@ import { } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { + memo, + useMemo, + useState, + useCallback, + useEffect, + useLayoutEffect, + useRef, + type ChangeEvent, + type KeyboardEvent, + type RefObject, +} from "react"; import { SearchIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; -import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + buildModelPickerSearchText, + normalizeModelPickerSearchQuery, + scoreModelPickerSearch, +} from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { @@ -43,6 +58,72 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +const ModelPickerSearchInput = memo(function ModelPickerSearchInput(props: { + inputRef: RefObject; + onNormalizedSearchQueryChange: (query: string) => void; + onRequestClose?: () => void; + onSelectHighlightedModel: () => boolean; +}) { + const { inputRef, onNormalizedSearchQueryChange, onRequestClose, onSelectHighlightedModel } = + props; + const [rawSearchQuery, setRawSearchQuery] = useState(""); + const publishedSearchQueryRef = useRef(normalizeModelPickerSearchQuery(rawSearchQuery)); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + const nextRawSearchQuery = event.target.value; + setRawSearchQuery(nextRawSearchQuery); + + const nextSearchQuery = normalizeModelPickerSearchQuery(nextRawSearchQuery); + if (nextSearchQuery === publishedSearchQueryRef.current) { + return; + } + publishedSearchQueryRef.current = nextSearchQuery; + onNormalizedSearchQueryChange(nextSearchQuery); + }, + [onNormalizedSearchQueryChange], + ); + + const handleSearchKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onRequestClose?.(); + return; + } + if (event.key === "Enter" && onSelectHighlightedModel()) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + return; + } + event.stopPropagation(); + }, + [onRequestClose, onSelectHighlightedModel], + ); + + return ( + + } + value={rawSearchQuery} + onChange={handleSearchChange} + onKeyDown={handleSearchKeyDown} + onMouseDown={(event) => event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + size="sm" + unstyled + /> + ); +}); + // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the // first colon — anything after that is the slug. @@ -221,7 +302,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); const isLocked = props.lockedProvider !== null; - const isSearching = searchQuery.trim().length > 0; + const isSearching = searchQuery.length > 0; const lockedDisabledInstanceIds = useMemo(() => { if (!isLocked) { return undefined; @@ -261,7 +342,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { let result = flatModels; // Apply tokenized fuzzy search across the combined provider/model search fields. - if (searchQuery.trim()) { + if (searchQuery) { const rankedMatches = result .map((model) => ({ model, @@ -385,6 +466,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], ); + const selectHighlightedModel = useCallback((): boolean => { + const highlightedModelKey = highlightedModelKeyRef.current; + if (!highlightedModelKey) { + return false; + } + const { instanceId, slug } = splitInstanceModelKey(highlightedModelKey); + handleModelSelect(slug, instanceId); + return true; + }, [handleModelSelect]); const toggleFavorite = useCallback( (instanceId: ProviderInstanceId, model: string) => { @@ -577,42 +667,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { {/* Search bar */}
- - } - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - props.onRequestClose?.(); - return; - } - if (e.key === "Enter" && highlightedModelKeyRef.current) { - ( - e as typeof e & { preventBaseUIHandler?: () => void } - ).preventBaseUIHandler?.(); - e.preventDefault(); - e.stopPropagation(); - const { instanceId, slug } = splitInstanceModelKey( - highlightedModelKeyRef.current, - ); - handleModelSelect(slug, instanceId); - return; - } - e.stopPropagation(); - }} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - size="sm" - unstyled +
diff --git a/apps/web/src/components/chat/modelPickerSearch.test.ts b/apps/web/src/components/chat/modelPickerSearch.test.ts index 57a7142c6b9..ea07a9404e3 100644 --- a/apps/web/src/components/chat/modelPickerSearch.test.ts +++ b/apps/web/src/components/chat/modelPickerSearch.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + buildModelPickerSearchText, + normalizeModelPickerSearchQuery, + scoreModelPickerSearch, +} from "./modelPickerSearch"; describe("buildModelPickerSearchText", () => { it("builds provider-agnostic search text from generic fields", () => { @@ -15,6 +19,13 @@ describe("buildModelPickerSearchText", () => { }); }); +describe("normalizeModelPickerSearchQuery", () => { + it("collapses search text to the effective query used by ranking", () => { + expect(normalizeModelPickerSearchQuery(" GPT 5.5 ")).toBe("gpt 5.5"); + expect(normalizeModelPickerSearchQuery(" ")).toBe(""); + }); +}); + describe("scoreModelPickerSearch", () => { it("matches typo-tolerant multi-token queries", () => { expect( diff --git a/apps/web/src/components/chat/modelPickerSearch.ts b/apps/web/src/components/chat/modelPickerSearch.ts index ff265e4a65a..2d39f8ff216 100644 --- a/apps/web/src/components/chat/modelPickerSearch.ts +++ b/apps/web/src/components/chat/modelPickerSearch.ts @@ -52,6 +52,13 @@ export function buildModelPickerSearchText(model: ModelPickerSearchableModel): s ); } +export function normalizeModelPickerSearchQuery(query: string): string { + return normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0) + .join(" "); +} + export function scoreModelPickerSearch( model: ModelPickerSearchableModel, query: string,