diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..c66ab9a1cf2b 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,71 +14,9 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { cn } from "~/lib/utils"; -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -function HighlightedSearchText(props: { text: string; query: string }) { - const query = props.query.trim(); - if (query.length === 0) return props.text; - - const normalizedText = foldAsciiCase(props.text); - const normalizedQuery = foldAsciiCase(query); - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; - - while (cursor < props.text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: props.text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: props.text.slice(matchIndex, matchIndex + query.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + query.length; - } - - return parts.map((part) => - part.highlighted ? ( - - {part.text} - - ) : ( - part.text - ), - ); -} - -function ThreadContentMatch(props: { - match: NonNullable; -}) { - const isUser = props.match.source === "user"; - return ( - - - {isUser ? "You:" : "Agent:"} - {" "} - - - ); -} - interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( @@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0a1ea308d128..76960f3f533b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -51,6 +51,7 @@ import { type SidebarSection, resolveSidebarDropVerb, } from "./Sidebar.logic"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, OrchestrationLatestTurn, @@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => { }); describe("searchSidebarThreads", () => { + const searchThread = (id: string, title: string, project: string) => ({ + environmentId: localEnvironmentId, + id: ThreadId.make(id), + title, + project, + }); const threads = [ - { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, - { id: "thread-2", title: "Review providers", project: "Workspace" }, - { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" }, + searchThread("thread-1", "Fix workspace search", "Alpha"), + searchThread("thread-2", "Review providers", "Workspace"), + searchThread("thread-3", "WORKTREE cleanup", "Beta"), ]; + const contentKeys = (...ids: ReadonlyArray) => + new Set( + ids.map((id) => + threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }), + ), + ); it("matches thread titles case-insensitively and preserves their order", () => { expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); @@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => { it("returns no results for an empty query", () => { expect(searchSidebarThreads(threads, " ")).toEqual([]); }); + + it("appends content-only matches after every title match", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([ + threads[0], + threads[2], + threads[1], + ]); + }); + + it("lists a thread matching both title and content once", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([ + threads[0], + threads[2], + ]); + }); + + it("ignores content matches for threads outside the sidebar collection", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([ + threads[0], + threads[2], + ]); + }); }); describe("filterSidebarProjectScopeItems", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 27e47d131e0d..fcedf46ce507 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,7 +5,8 @@ import { isAtomCommandInterrupted, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; @@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; +const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); + /** - * Search the already-ordered sidebar thread collection by title or linked PR. - * Keeping the input order means lifecycle ordering (active, snoozed, settled) - * remains stable while the user narrows the list. + * Search the already-ordered sidebar thread collection by title or linked PR, + * plus any thread whose messages the server matched (`contentMatchKeys`, keyed + * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering + * (active, snoozed, settled) remains stable while the user narrows the list. */ export function searchSidebarThreads< - T extends { readonly title: string } & Parameters[0], ->(threads: readonly T[], query: string): T[] { + T extends { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly title: string; + } & Parameters[0], +>( + threads: readonly T[], + query: string, + contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS, +): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => - [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + const titleMatches: T[] = []; + const contentMatches: T[] = []; + for (const thread of threads) { + const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => term.toLowerCase().includes(normalizedQuery), - ), - ); + ); + if (matchesTitle) { + titleMatches.push(thread); + } else if ( + contentMatchKeys.size > 0 && + contentMatchKeys.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + ) { + contentMatches.push(thread); + } + } + return [...titleMatches, ...contentMatches]; } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b11032987fd4..05115c57ce6a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, @@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useThreadSearch } from "../state/queries"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, @@ -210,6 +215,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { isHighlighted: boolean; isRouteActive: boolean; resultId: string; + searchMatch: EnvironmentThreadSearchMatch | null; + searchQuery: string; onHighlight: () => void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { {props.project ? ( ) : null} - {thread.title} - - {threadTimeLabel(thread)} + + + {thread.title} + + {threadTimeLabel(thread)} + + + {props.searchMatch ? ( + + ) : null} [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); + const searchEnvironmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + // useThreadSearch owns the debounce and the two-character floor. + const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); + const threadSearchMatchByKey = useMemo( + () => + new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)), + [threadSearch.matches], + ); const threadSearchResults = useMemo( - () => searchSidebarThreads(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], + () => + searchSidebarThreads( + searchableThreads, + threadSearchQuery, + new Set(threadSearchMatchByKey.keys()), + ), + [searchableThreads, threadSearchQuery, threadSearchMatchByKey], ); const threadSearchResultOrderKey = threadSearchResults .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) @@ -4575,6 +4615,15 @@ export default function Sidebar() { isHighlighted={activeSearchResultIndex === index} isRouteActive={routeThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} + searchMatch={ + threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) ?? null + } + searchQuery={threadSearchQuery} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} onFileDropThreads={handleThreadFileDrop} @@ -4588,7 +4637,7 @@ export default function Sidebar() { role="status" className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" > - No threads found + {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}

) ) : null} diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx new file mode 100644 index 000000000000..db2e286ae28d --- /dev/null +++ b/apps/web/src/components/ThreadSearchMatch.tsx @@ -0,0 +1,66 @@ +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function HighlightedSearchText(props: { text: string; query: string }) { + const query = props.query.trim(); + if (query.length === 0) return props.text; + + const normalizedText = foldAsciiCase(props.text); + const normalizedQuery = foldAsciiCase(query); + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + + while (cursor < props.text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: props.text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: props.text.slice(matchIndex, matchIndex + query.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + query.length; + } + + return parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +export function ThreadSearchMatchExcerpt(props: { + match: { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; + }; +}) { + const isUser = props.match.source === "user"; + return ( + + + {isUser ? "You:" : "Agent:"} + {" "} + + + ); +}