authorgravatar for 122906171+koushikxd@users.noreply.github.comKoushik_xd <122906171+koushikxd@users.noreply.github.com> 2026-09-18 23:47:15+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 11:17:15-07:00
logc557bb10aa7cbb78a50df230d1e8e24af79cb662
tree601a5a9d464158301149e76c6348ed76584be66f
parentc03b240265a1d4954536c5c0758ac2b7fa762ba8
signature Signed by PGP key B5690EEEBB952194

fix(web): sidebar search matches message content (#11761)


5 files changed, 198 insertions(+), 85 deletions(-)

apps/web/src/components/CommandPaletteResults.tsx+3-65
......@@ -14,71 +14,9 @@ import {
1414 CommandList,
1515 CommandShortcut,
1616} from "./ui/command";
17import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
1718import { cn } from "~/lib/utils";
1819
19function foldAsciiCase(value: string): string {
20 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
21}
22
23function HighlightedSearchText(props: { text: string; query: string }) {
24 const query = props.query.trim();
25 if (query.length === 0) return props.text;
26
27 const normalizedText = foldAsciiCase(props.text);
28 const normalizedQuery = foldAsciiCase(query);
29 const parts: Array<{
30 readonly text: string;
31 readonly highlighted: boolean;
32 readonly start: number;
33 }> = [];
34 let cursor = 0;
35
36 while (cursor < props.text.length) {
37 const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
38 if (matchIndex === -1) {
39 parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
40 break;
41 }
42 if (matchIndex > cursor) {
43 parts.push({
44 text: props.text.slice(cursor, matchIndex),
45 highlighted: false,
46 start: cursor,
47 });
48 }
49 parts.push({
50 text: props.text.slice(matchIndex, matchIndex + query.length),
51 highlighted: true,
52 start: matchIndex,
53 });
54 cursor = matchIndex + query.length;
55 }
56
57 return parts.map((part) =>
58 part.highlighted ? (
59 <mark className="bg-transparent font-semibold text-foreground" key={part.start}>
60 {part.text}
61 </mark>
62 ) : (
63 part.text
64 ),
65 );
66}
67
68function ThreadContentMatch(props: {
69 match: NonNullable<CommandPaletteActionItem["threadContentMatch"]>;
70}) {
71 const isUser = props.match.source === "user";
72 return (
73 <span className="truncate text-xs text-muted-foreground/85">
74 <span className={isUser ? "text-blue-400" : "text-emerald-400"}>
75 {isUser ? "You:" : "Agent:"}
76 </span>{" "}
77 <HighlightedSearchText text={props.match.snippet} query={props.match.query} />
78 </span>
79 );
80}
81
8220interface CommandPaletteResultsProps {
8321 emptyStateMessage?: string;
8422 groups: ReadonlyArray<CommandPaletteGroup>;
......@@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: {
13977 <span className="truncate">{props.item.title}</span>
14078 </span>
14179 {props.item.threadContentMatch ? (
142 <ThreadContentMatch match={props.item.threadContentMatch} />
80 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
14381 ) : null}
14482 {props.item.description ? (
14583 <span className="min-w-0 text-muted-foreground/70 text-xs">
......@@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: {
190128 <span className="truncate">{props.item.title}</span>
191129 </span>
192130 {props.item.threadContentMatch ? (
193 <ThreadContentMatch match={props.item.threadContentMatch} />
131 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
194132 ) : null}
195133 {props.item.description ? (
196134 <span className="min-w-0 text-muted-foreground/70 text-xs">
apps/web/src/components/Sidebar.logic.test.ts+38-3
......@@ -51,6 +51,7 @@ import {
5151 type SidebarSection,
5252 resolveSidebarDropVerb,
5353} from "./Sidebar.logic";
54import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
5455import {
5556 EnvironmentId,
5657 OrchestrationLatestTurn,
......@@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => {
816817});
817818
818819describe("searchSidebarThreads", () => {
820 const searchThread = (id: string, title: string, project: string) => ({
821 environmentId: localEnvironmentId,
822 id: ThreadId.make(id),
823 title,
824 project,
825 });
819826 const threads = [
820 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },
821 { id: "thread-2", title: "Review providers", project: "Workspace" },
822 { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" },
827 searchThread("thread-1", "Fix workspace search", "Alpha"),
828 searchThread("thread-2", "Review providers", "Workspace"),
829 searchThread("thread-3", "WORKTREE cleanup", "Beta"),
823830 ];
831 const contentKeys = (...ids: ReadonlyArray<string>) =>
832 new Set(
833 ids.map((id) =>
834 threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }),
835 ),
836 );
824837
825838 it("matches thread titles case-insensitively and preserves their order", () => {
826839 expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
......@@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => {
833846 it("returns no results for an empty query", () => {
834847 expect(searchSidebarThreads(threads, " ")).toEqual([]);
835848 });
849
850 it("appends content-only matches after every title match", () => {
851 expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([
852 threads[0],
853 threads[2],
854 threads[1],
855 ]);
856 });
857
858 it("lists a thread matching both title and content once", () => {
859 expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([
860 threads[0],
861 threads[2],
862 ]);
863 });
864
865 it("ignores content matches for threads outside the sidebar collection", () => {
866 expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([
867 threads[0],
868 threads[2],
869 ]);
870 });
836871});
837872
838873describe("filterSidebarProjectScopeItems", () => {
apps/web/src/components/Sidebar.logic.ts+35-10
......@@ -5,7 +5,8 @@ import {
55 isAtomCommandInterrupted,
66 type AtomCommandResult,
77} from "@t3tools/client-runtime/state/runtime";
8import type { ContextMenuItem } from "@t3tools/contracts";
8import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
9import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts";
910import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
1011import type { AsyncResult } from "effect/unstable/reactivity";
1112import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
......@@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c
895896export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
896897export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
897898
899const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>();
900
898901/**
899 * Search the already-ordered sidebar thread collection by title or linked PR.
900 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
901 * remains stable while the user narrows the list.
902 * Search the already-ordered sidebar thread collection by title or linked PR,
903 * plus any thread whose messages the server matched (`contentMatchKeys`, keyed
904 * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering
905 * (active, snoozed, settled) remains stable while the user narrows the list.
902906 */
903907export function searchSidebarThreads<
904 T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0],
905>(threads: readonly T[], query: string): T[] {
908 T extends {
909 readonly environmentId: EnvironmentId;
910 readonly id: ThreadId;
911 readonly title: string;
912 } & Parameters<typeof threadPullRequestSearchTerms>[0],
913>(
914 threads: readonly T[],
915 query: string,
916 contentMatchKeys: ReadonlySet<string> = EMPTY_CONTENT_MATCH_KEYS,
917): T[] {
906918 const normalizedQuery = query.trim().toLowerCase();
907919 if (normalizedQuery.length === 0) return [];
908 return threads.filter((thread) =>
909 [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
920 const titleMatches: T[] = [];
921 const contentMatches: T[] = [];
922 for (const thread of threads) {
923 const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
910924 term.toLowerCase().includes(normalizedQuery),
911 ),
912 );
925 );
926 if (matchesTitle) {
927 titleMatches.push(thread);
928 } else if (
929 contentMatchKeys.size > 0 &&
930 contentMatchKeys.has(
931 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
932 )
933 ) {
934 contentMatches.push(thread);
935 }
936 }
937 return [...titleMatches, ...contentMatches];
913938}
914939
915940export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
apps/web/src/components/Sidebar.tsx+56-7
......@@ -22,6 +22,10 @@ import {
2222 threadWokeAt,
2323} from "@t3tools/client-runtime/state/thread-settled";
2424import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
25import {
26 threadSearchMatchKey,
27 type EnvironmentThreadSearchMatch,
28} from "@t3tools/client-runtime/state/thread-search";
2529import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
2630import {
2731 parseScopedThreadKey,
......@@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s
134138import { vcsEnvironment } from "../state/vcs";
135139import { threadEnvironment } from "../state/threads";
136140import { useEnvironmentQuery } from "../state/query";
141import { useThreadSearch } from "../state/queries";
137142import { useAtomCommand } from "../state/use-atom-command";
138143import {
139144 buildThreadRouteParams,
......@@ -210,6 +215,7 @@ import {
210215 type SnoozePreset,
211216} from "./Sidebar.snooze";
212217import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
218import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
213219import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
214220import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
215221import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
......@@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20012007 isHighlighted: boolean;
20022008 isRouteActive: boolean;
20032009 resultId: string;
2010 searchMatch: EnvironmentThreadSearchMatch | null;
2011 searchQuery: string;
20042012 onHighlight: () => void;
20052013 onSelect: () => void;
20062014 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
......@@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20912099 onMouseMove={props.onHighlight}
20922100 onClick={props.onSelect}
20932101 className={cn(
2094 "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none",
2102 "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",
20952103 props.isHighlighted || props.isRouteActive
20962104 ? "bg-sidebar-row-active text-sidebar-foreground"
20972105 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
......@@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
21042112 {props.project ? (
21052113 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
21062114 ) : null}
2107 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
2108 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
2109 {threadTimeLabel(thread)}
2115 <span className="flex min-w-0 flex-1 flex-col">
2116 <span className="flex min-w-0 items-center gap-2.5">
2117 <span className="min-w-0 flex-1 truncate">{thread.title}</span>
2118 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
2119 {threadTimeLabel(thread)}
2120 </span>
2121 </span>
2122 {props.searchMatch ? (
2123 <ThreadSearchMatchExcerpt
2124 match={{
2125 source: props.searchMatch.source,
2126 snippet: props.searchMatch.snippet,
2127 query: props.searchQuery,
2128 }}
2129 />
2130 ) : null}
21102131 </span>
21112132 </TooltipTrigger>
21122133 <SidebarThreadTooltip
......@@ -2625,9 +2646,28 @@ export default function Sidebar() {
26252646 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
26262647 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
26272648 );
2649 const searchEnvironmentIds = useMemo(
2650 () =>
2651 environments
2652 .filter((environment) => environment.connection.phase === "connected")
2653 .map((environment) => environment.environmentId),
2654 [environments],
2655 );
2656 // useThreadSearch owns the debounce and the two-character floor.
2657 const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery);
2658 const threadSearchMatchByKey = useMemo(
2659 () =>
2660 new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)),
2661 [threadSearch.matches],
2662 );
26282663 const threadSearchResults = useMemo(
2629 () => searchSidebarThreads(searchableThreads, threadSearchQuery),
2630 [searchableThreads, threadSearchQuery],
2664 () =>
2665 searchSidebarThreads(
2666 searchableThreads,
2667 threadSearchQuery,
2668 new Set(threadSearchMatchByKey.keys()),
2669 ),
2670 [searchableThreads, threadSearchQuery, threadSearchMatchByKey],
26312671 );
26322672 const threadSearchResultOrderKey = threadSearchResults
26332673 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
......@@ -4594,6 +4634,15 @@ export default function Sidebar() {
45944634 isHighlighted={activeSearchResultIndex === index}
45954635 isRouteActive={routeThreadKey === threadKey}
45964636 resultId={`sidebar-thread-search-result-${index}`}
4637 searchMatch={
4638 threadSearchMatchByKey.get(
4639 threadSearchMatchKey({
4640 environmentId: thread.environmentId,
4641 threadId: thread.id,
4642 }),
4643 ) ?? null
4644 }
4645 searchQuery={threadSearchQuery}
45974646 onHighlight={() => setActiveSearchResultIndex(index)}
45984647 onSelect={() => selectThreadSearchResult(thread)}
45994648 onFileDropThreads={handleThreadFileDrop}
......@@ -4607,7 +4656,7 @@ export default function Sidebar() {
46074656 role="status"
46084657 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
46094658 >
4610 No threads found
4659 {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}
46114660 </p>
46124661 )
46134662 ) : null}
apps/web/src/components/ThreadSearchMatch.tsx created+66
......@@ -0,0 +1,66 @@
1function foldAsciiCase(value: string): string {
2 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
3}
4
5function HighlightedSearchText(props: { text: string; query: string }) {
6 const query = props.query.trim();
7 if (query.length === 0) return props.text;
8
9 const normalizedText = foldAsciiCase(props.text);
10 const normalizedQuery = foldAsciiCase(query);
11 const parts: Array<{
12 readonly text: string;
13 readonly highlighted: boolean;
14 readonly start: number;
15 }> = [];
16 let cursor = 0;
17
18 while (cursor < props.text.length) {
19 const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
20 if (matchIndex === -1) {
21 parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
22 break;
23 }
24 if (matchIndex > cursor) {
25 parts.push({
26 text: props.text.slice(cursor, matchIndex),
27 highlighted: false,
28 start: cursor,
29 });
30 }
31 parts.push({
32 text: props.text.slice(matchIndex, matchIndex + query.length),
33 highlighted: true,
34 start: matchIndex,
35 });
36 cursor = matchIndex + query.length;
37 }
38
39 return parts.map((part) =>
40 part.highlighted ? (
41 <mark className="bg-transparent font-semibold text-foreground" key={part.start}>
42 {part.text}
43 </mark>
44 ) : (
45 part.text
46 ),
47 );
48}
49
50export function ThreadSearchMatchExcerpt(props: {
51 match: {
52 readonly source: "user" | "assistant";
53 readonly snippet: string;
54 readonly query: string;
55 };
56}) {
57 const isUser = props.match.source === "user";
58 return (
59 <span className="truncate text-xs text-muted-foreground/85">
60 <span className={isUser ? "text-blue-400" : "text-emerald-400"}>
61 {isUser ? "You:" : "Agent:"}
62 </span>{" "}
63 <HighlightedSearchText text={props.match.snippet} query={props.match.query} />
64 </span>
65 );
66}