diff --git a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx index e6f51e2da8..cbd02d2bae 100644 --- a/studio/frontend/src/features/chat/components/chat-search-dialog.tsx +++ b/studio/frontend/src/features/chat/components/chat-search-dialog.tsx @@ -12,23 +12,32 @@ import { Cancel01Icon, Message01Icon, Search01Icon } from "@hugeicons/core-free- import { HugeiconsIcon } from "@hugeicons/react"; import { useNavigate } from "@tanstack/react-router"; import { Command as CommandPrimitive } from "cmdk"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useChatSearchIndex } from "../hooks/use-chat-search-index"; import { useChatSearchStore } from "../stores/chat-search-store"; -// cmdk's default fuzzy scorer keeps non-matching rows visible (issue #5572), so -// require every whitespace token to be a substring of the item's keywords. -// `value` is the unique thread id (cmdk selection); title/preview come via keywords. -export function chatSearchFilter( - _value: string, - search: string, - keywords?: string[], -): number { - const query = search.trim().toLowerCase(); - if (query === "") return 1; - const haystack = (keywords ?? []).join(" ").toLowerCase(); - const tokens = query.split(/\s+/); - return tokens.every((token) => haystack.includes(token)) ? 1 : 0; +// Lowercased whitespace tokens of the query (haystacks are lowercased in the index). +function queryTokens(search: string): string[] { + return search.trim().toLowerCase().split(/\s+/).filter(Boolean); +} + +function haystackMatches(haystack: string, tokens: string[]): boolean { + return tokens.every((token) => haystack.includes(token)); +} + +// We filter rows here (cmdk runs with shouldFilter=false) so we control the +// two-tier behavior and avoid cmdk's fuzzy scorer keeping non-matches visible +// (issue #5572): every whitespace token must be a substring. User messages are +// searched first; expand to the full conversation only when user text alone +// matches nothing anywhere (user messages are short, assistant replies can be huge). +export function selectVisibleChats< + T extends { userSearchText: string; searchText: string }, +>(items: T[], search: string): T[] { + const tokens = queryTokens(search); + if (tokens.length === 0) return items; + const userHits = items.filter((it) => haystackMatches(it.userSearchText, tokens)); + if (userHits.length > 0) return userHits; + return items.filter((it) => haystackMatches(it.searchText, tokens)); } function formatRelative(createdAt: number): string { @@ -46,6 +55,16 @@ export function ChatSearchDialog() { const close = useChatSearchStore((s) => s.close); const navigate = useNavigate(); const { items, loading } = useChatSearchIndex(isOpen); + const [query, setQuery] = useState(""); + + const visibleItems = useMemo( + () => selectVisibleChats(items, query), + [items, query], + ); + + useEffect(() => { + if (!isOpen) setQuery(""); + }, [isOpen]); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -67,7 +86,7 @@ export function ChatSearchDialog() { className="chat-search-surface rounded-3xl! top-1/2 -translate-y-1/2 w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 ring-0 sm:max-w-[635px]" overlayClassName="bg-transparent" > - +