Chat search: search all messages, user messages first (#6350)

* Chat search: match across all messages, not just title and preview

The Cmd/Ctrl+K chat search only matched the thread title and a 120
character preview of the newest message, so keywords anywhere else in a
conversation were unfindable. Build a per-thread haystack from the title
plus every message's text and match against that.

The haystack is lowercased once at index build time, so the keystroke
filter only normalizes the short query instead of re-lowercasing the
whole conversation per item.

* Chat search: search user messages first, expand to all only if no hit

User messages are short while assistant replies can be very long, so
matching the whole conversation on every keystroke scales with the
assistant text. Store a separate userSearchText (title plus user
messages) and match it first, expanding to the full per thread
searchText only when nothing matches user text anywhere. Row filtering
moves into selectVisibleChats with cmdk shouldFilter disabled so the
tier choice is deterministic.

* Chat search: include tool calls and sources in the full-text tier

extractText now also pulls reasoning/thinking, tool call name/args/result
and cited source title/url, so the expanded full-conversation tier finds
keywords that only appear in tool activity. Drop the now-unused preview
field since search no longer matches on it.

* Chat search: drop base64 image/audio payloads from the index

extractText stringified tool-call results wholesale, so an
image_generation result (image_b64) or audio payload would pour megabytes
of base64 into searchText and get lowercased on every rebuild. Add
searchableText, which keeps readable tool args/results (tool name, prompt,
text) but skips binary keys and strips data URLs, long base64 runs and the
__IMAGES__ suffix.
This commit is contained in:
Daniel Han 2026-06-15 23:48:37 -07:00 committed by GitHub
commit f3991ac416
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 97 additions and 36 deletions

View file

@ -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"
>
<Command className="rounded-3xl p-0" filter={chatSearchFilter}>
<Command className="rounded-3xl p-0" shouldFilter={false}>
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
<HugeiconsIcon
icon={Search01Icon}
@ -76,6 +95,7 @@ export function ChatSearchDialog() {
/>
<CommandPrimitive.Input
placeholder="Search chats..."
onValueChange={setQuery}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
@ -96,11 +116,10 @@ export function ChatSearchDialog() {
: "No chats match."}
</CommandEmpty>
<CommandGroup className="p-0">
{items.map((item) => (
{visibleItems.map((item) => (
<CommandPrimitive.Item
key={item.id}
value={item.id}
keywords={[item.title, item.preview]}
onSelect={() => {
navigate({
to: "/chat",

View file

@ -13,37 +13,72 @@ export interface ChatSearchItem {
type: "single" | "compare";
id: string;
title: string;
preview: string;
// Lowercased title + user messages only (short); searched first.
userSearchText: string;
// Lowercased title + every message (incl. tool calls); fallback when user
// text matches nothing. Prebuilt so filtering never re-lowercases per keystroke.
searchText: string;
createdAt: number;
projectId?: string | null;
}
const THREAD_LIMIT = 200;
const PREVIEW_MAX = 120;
const SEARCH_REBUILD_DEBOUNCE_MS = 300;
// Keys whose values are base64 image/audio payloads, not searchable text.
const BINARY_KEY = /b64|base64|^(images?|audio|video)$/i;
// Readable text from tool args/results, dropping base64 image/audio blobs so
// they never bloat the index (object fields by key, plus data URLs / long
// base64 runs and the "__IMAGES__" suffix inside strings).
function searchableText(value: unknown, depth = 0): string {
if (typeof value === "string") {
const cut = value.indexOf("\n__IMAGES__:");
return (cut === -1 ? value : value.slice(0, cut))
.replace(/data:[^;,\s]+;base64,[A-Za-z0-9+/=]+/g, " ")
.replace(/[A-Za-z0-9+/]{120,}={0,2}/g, " ");
}
if (value == null || depth > 4) return "";
if (Array.isArray(value)) {
return value.map((v) => searchableText(v, depth + 1)).join(" ");
}
if (typeof value === "object") {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
if (!BINARY_KEY.test(k)) out.push(searchableText(v, depth + 1));
}
return out.join(" ");
}
return "";
}
// Pull searchable text from a message: plain text, reasoning/thinking, tool
// calls (name + args + result) and cited sources (title + url).
function extractText(message: MessageRecord): string {
const content = message.content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as { type?: string; text?: unknown };
if (
(p.type === "text" || p.type === "reasoning") &&
typeof p.text === "string"
) {
const p = part as Record<string, unknown>;
if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") {
parts.push(p.text);
} else if (p.type === "thinking") {
const t = typeof p.thinking === "string" ? p.thinking : p.text;
if (typeof t === "string") parts.push(t);
} else if (p.type === "tool-call") {
if (typeof p.toolName === "string") parts.push(p.toolName);
const args = searchableText(typeof p.argsText === "string" ? p.argsText : p.args);
if (args) parts.push(args);
const result = searchableText(p.result);
if (result) parts.push(result);
} else if (p.type === "source") {
for (const v of [p.title, p.url]) if (typeof v === "string") parts.push(v);
}
}
return parts.join(" ").replace(/\s+/g, " ").trim();
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max).trimEnd()}`;
}
async function buildIndex(): Promise<ChatSearchItem[]> {
const active = (
await listStoredChatThreads({ includeArchived: false })
@ -51,7 +86,10 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
const itemThreadIds = new Map<
string,
{ item: Omit<ChatSearchItem, "preview">; threadIds: string[] }
{
item: Omit<ChatSearchItem, "searchText" | "userSearchText">;
threadIds: string[];
}
>();
const seenPairs = new Set<string>();
@ -125,15 +163,19 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
}
merged.sort((a, b) => b.createdAt - a.createdAt);
let preview = "";
// Two tiers: user messages (short, searched first) and the full
// conversation incl. tool calls (fallback when user text matches nothing).
const userParts: string[] = [item.title];
const allParts: string[] = [item.title];
for (const m of merged) {
const text = extractText(m);
if (text) {
preview = truncate(text, PREVIEW_MAX);
break;
}
if (!text) continue;
allParts.push(text);
if (m.role === "user") userParts.push(text);
}
results.push({ ...item, preview });
const userSearchText = userParts.join(" ").toLowerCase();
const searchText = allParts.join(" ").toLowerCase();
results.push({ ...item, userSearchText, searchText });
}
results.sort((a, b) => b.createdAt - a.createdAt);