diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index e0d6b9afc5e..4de04eb1f58 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -5,12 +5,26 @@ 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 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 +57,10 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +const MODEL_PICKER_SEARCH_ICON = ( + +); + // 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. @@ -57,6 +75,64 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s }; } +const ModelPickerSearchInput = memo(function ModelPickerSearchInput(props: { + inputRef: RefObject; + onNormalizedQueryChange: (query: string) => void; + onRequestClose: (() => void) | undefined; + onHighlightedModelSelect: () => boolean; +}) { + const { inputRef, onHighlightedModelSelect, onNormalizedQueryChange, onRequestClose } = props; + const [rawSearchQuery, setRawSearchQuery] = useState(""); + const lastNormalizedSearchQueryRef = useRef(""); + + const handleSearchQueryChange = useCallback( + (event: ChangeEvent) => { + const nextRawSearchQuery = event.target.value; + setRawSearchQuery(nextRawSearchQuery); + + const nextNormalizedSearchQuery = normalizeModelPickerSearchQuery(nextRawSearchQuery); + if (nextNormalizedSearchQuery === lastNormalizedSearchQueryRef.current) { + return; + } + lastNormalizedSearchQueryRef.current = nextNormalizedSearchQuery; + onNormalizedQueryChange(nextNormalizedSearchQuery); + }, + [onNormalizedQueryChange], + ); + + return ( + { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onRequestClose?.(); + return; + } + if (event.key === "Enter" && onHighlightedModelSelect()) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + return; + } + event.stopPropagation(); + }} + onMouseDown={(event) => event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + size="sm" + unstyled + /> + ); +}); + export const ModelPickerContent = memo(function ModelPickerContent(props: { /** The instance currently selected in the composer (combobox "value"). */ activeInstanceId: ProviderInstanceId; @@ -96,7 +172,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { getModelDisabledReason, onInstanceModelChange, } = props; - const [searchQuery, setSearchQuery] = useState(""); + const [normalizedSearchQuery, setNormalizedSearchQuery] = useState(""); const [showTopScrollFade, setShowTopScrollFade] = useState(false); const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); const searchInputRef = useRef(null); @@ -221,7 +297,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); const isLocked = props.lockedProvider !== null; - const isSearching = searchQuery.trim().length > 0; + const isSearching = normalizedSearchQuery.length > 0; const lockedDisabledInstanceIds = useMemo(() => { if (!isLocked) { return undefined; @@ -261,7 +337,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 (normalizedSearchQuery) { const rankedMatches = result .map((model) => ({ model, @@ -274,7 +350,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { providerDisplayName: model.instanceDisplayName, isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), }, - searchQuery, + normalizedSearchQuery, ), isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), tieBreaker: buildModelPickerSearchText({ @@ -357,8 +433,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { flatModels, instanceOrder, matchesLockedProvider, + normalizedSearchQuery, props.lockedProvider, - searchQuery, selectedInstanceId, ]); @@ -385,6 +461,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }, [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], ); + const handleHighlightedModelSelect = useCallback(() => { + 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 +662,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..a3a15ead2a8 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 whitespace-only changes to the same semantic query", () => { + expect(normalizeModelPickerSearchQuery(" ")).toBe(""); + expect(normalizeModelPickerSearchQuery(" Claude Opus ")).toBe("claude opus"); + }); +}); + 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..c175a6977d7 100644 --- a/apps/web/src/components/chat/modelPickerSearch.ts +++ b/apps/web/src/components/chat/modelPickerSearch.ts @@ -52,13 +52,16 @@ export function buildModelPickerSearchText(model: ModelPickerSearchableModel): s ); } +export function normalizeModelPickerSearchQuery(query: string): string { + return normalizeSearchQuery(query).replace(/\s+/gu, " "); +} + export function scoreModelPickerSearch( model: ModelPickerSearchableModel, query: string, ): number | null { - const tokens = normalizeSearchQuery(query) - .split(/\s+/u) - .filter((token) => token.length > 0); + const normalizedQuery = normalizeModelPickerSearchQuery(query); + const tokens = normalizedQuery.length > 0 ? normalizedQuery.split(" ") : []; if (tokens.length === 0) { return 0;