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 {...@@ -14,71 +14,9 @@ import {
14 CommandList,14 CommandList,
15 CommandShortcut,15 CommandShortcut,
16} from "./ui/command";16} from "./ui/command";
17import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
17import { cn } from "~/lib/utils";18import { 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
82interface CommandPaletteResultsProps {20interface CommandPaletteResultsProps {
83 emptyStateMessage?: string;21 emptyStateMessage?: string;
84 groups: ReadonlyArray<CommandPaletteGroup>;22 groups: ReadonlyArray<CommandPaletteGroup>;
...@@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: {...@@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: {
139 <span className="truncate">{props.item.title}</span>77 <span className="truncate">{props.item.title}</span>
140 </span>78 </span>
141 {props.item.threadContentMatch ? (79 {props.item.threadContentMatch ? (
142 <ThreadContentMatch match={props.item.threadContentMatch} />80 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
143 ) : null}81 ) : null}
144 {props.item.description ? (82 {props.item.description ? (
145 <span className="min-w-0 text-muted-foreground/70 text-xs">83 <span className="min-w-0 text-muted-foreground/70 text-xs">
...@@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: {...@@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: {
190 <span className="truncate">{props.item.title}</span>128 <span className="truncate">{props.item.title}</span>
191 </span>129 </span>
192 {props.item.threadContentMatch ? (130 {props.item.threadContentMatch ? (
193 <ThreadContentMatch match={props.item.threadContentMatch} />131 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
194 ) : null}132 ) : null}
195 {props.item.description ? (133 {props.item.description ? (
196 <span className="min-w-0 text-muted-foreground/70 text-xs">134 <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 {...@@ -51,6 +51,7 @@ import {
51 type SidebarSection,51 type SidebarSection,
52 resolveSidebarDropVerb,52 resolveSidebarDropVerb,
53} from "./Sidebar.logic";53} from "./Sidebar.logic";
54import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
54import {55import {
55 EnvironmentId,56 EnvironmentId,
56 OrchestrationLatestTurn,57 OrchestrationLatestTurn,
...@@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => {...@@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => {
816});817});
817818
818describe("searchSidebarThreads", () => {819describe("searchSidebarThreads", () => {
820 const searchThread = (id: string, title: string, project: string) => ({
821 environmentId: localEnvironmentId,
822 id: ThreadId.make(id),
823 title,
824 project,
825 });
819 const threads = [826 const threads = [
820 { id: "thread-1", title: "Fix workspace search", project: "Alpha" },827 searchThread("thread-1", "Fix workspace search", "Alpha"),
821 { id: "thread-2", title: "Review providers", project: "Workspace" },828 searchThread("thread-2", "Review providers", "Workspace"),
822 { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" },829 searchThread("thread-3", "WORKTREE cleanup", "Beta"),
823 ];830 ];
831 const contentKeys = (...ids: ReadonlyArray<string>) =>
832 new Set(
833 ids.map((id) =>
834 threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }),
835 ),
836 );
824837
825 it("matches thread titles case-insensitively and preserves their order", () => {838 it("matches thread titles case-insensitively and preserves their order", () => {
826 expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);839 expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]);
...@@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => {...@@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => {
833 it("returns no results for an empty query", () => {846 it("returns no results for an empty query", () => {
834 expect(searchSidebarThreads(threads, " ")).toEqual([]);847 expect(searchSidebarThreads(threads, " ")).toEqual([]);
835 });848 });
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 });
836});871});
837872
838describe("filterSidebarProjectScopeItems", () => {873describe("filterSidebarProjectScopeItems", () => {
apps/web/src/components/Sidebar.logic.ts+35-10
...@@ -5,7 +5,8 @@ import {...@@ -5,7 +5,8 @@ import {
5 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
6 type AtomCommandResult,6 type AtomCommandResult,
7} from "@t3tools/client-runtime/state/runtime";7} 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";
9import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";10import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
10import type { AsyncResult } from "effect/unstable/reactivity";11import type { AsyncResult } from "effect/unstable/reactivity";
11import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";12import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
...@@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c...@@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c
895export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";896export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
896export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";897export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
897898
899const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet<string> = new Set<string>();
900
898/**901/**
899 * Search the already-ordered sidebar thread collection by title or linked PR.902 * Search the already-ordered sidebar thread collection by title or linked PR,
900 * Keeping the input order means lifecycle ordering (active, snoozed, settled)903 * plus any thread whose messages the server matched (`contentMatchKeys`, keyed
901 * remains stable while the user narrows the list.904 * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering
905 * (active, snoozed, settled) remains stable while the user narrows the list.
902 */906 */
903export function searchSidebarThreads<907export function searchSidebarThreads<
904 T extends { readonly title: string } & Parameters<typeof threadPullRequestSearchTerms>[0],908 T extends {
905>(threads: readonly T[], query: string): T[] {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[] {
906 const normalizedQuery = query.trim().toLowerCase();918 const normalizedQuery = query.trim().toLowerCase();
907 if (normalizedQuery.length === 0) return [];919 if (normalizedQuery.length === 0) return [];
908 return threads.filter((thread) =>920 const titleMatches: T[] = [];
909 [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>921 const contentMatches: T[] = [];
922 for (const thread of threads) {
923 const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) =>
910 term.toLowerCase().includes(normalizedQuery),924 term.toLowerCase().includes(normalizedQuery),
911 ),925 );
912 );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];
913}938}
914939
915export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {940export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
apps/web/src/components/Sidebar.tsx+56-7
...@@ -22,6 +22,10 @@ import {...@@ -22,6 +22,10 @@ import {
22 threadWokeAt,22 threadWokeAt,
23} from "@t3tools/client-runtime/state/thread-settled";23} from "@t3tools/client-runtime/state/thread-settled";
24import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";24import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
25import {
26 threadSearchMatchKey,
27 type EnvironmentThreadSearchMatch,
28} from "@t3tools/client-runtime/state/thread-search";
25import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";29import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
26import {30import {
27 parseScopedThreadKey,31 parseScopedThreadKey,
...@@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s...@@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s
134import { vcsEnvironment } from "../state/vcs";138import { vcsEnvironment } from "../state/vcs";
135import { threadEnvironment } from "../state/threads";139import { threadEnvironment } from "../state/threads";
136import { useEnvironmentQuery } from "../state/query";140import { useEnvironmentQuery } from "../state/query";
141import { useThreadSearch } from "../state/queries";
137import { useAtomCommand } from "../state/use-atom-command";142import { useAtomCommand } from "../state/use-atom-command";
138import {143import {
139 buildThreadRouteParams,144 buildThreadRouteParams,
...@@ -210,6 +215,7 @@ import {...@@ -210,6 +215,7 @@ import {
210 type SnoozePreset,215 type SnoozePreset,
211} from "./Sidebar.snooze";216} from "./Sidebar.snooze";
212import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";217import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
218import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
213import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";219import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
214import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";220import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
215import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";221import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
...@@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2001 isHighlighted: boolean;2007 isHighlighted: boolean;
2002 isRouteActive: boolean;2008 isRouteActive: boolean;
2003 resultId: string;2009 resultId: string;
2010 searchMatch: EnvironmentThreadSearchMatch | null;
2011 searchQuery: string;
2004 onHighlight: () => void;2012 onHighlight: () => void;
2005 onSelect: () => void;2013 onSelect: () => void;
2006 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;2014 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
...@@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2091 onMouseMove={props.onHighlight}2099 onMouseMove={props.onHighlight}
2092 onClick={props.onSelect}2100 onClick={props.onSelect}
2093 className={cn(2101 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",
2095 props.isHighlighted || props.isRouteActive2103 props.isHighlighted || props.isRouteActive
2096 ? "bg-sidebar-row-active text-sidebar-foreground"2104 ? "bg-sidebar-row-active text-sidebar-foreground"
2097 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",2105 : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
...@@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2104 {props.project ? (2112 {props.project ? (
2105 <ProjectFavicon project={props.project} className="size-4 shrink-0" />2113 <ProjectFavicon project={props.project} className="size-4 shrink-0" />
2106 ) : null}2114 ) : null}
2107 <span className="min-w-0 flex-1 truncate">{thread.title}</span>2115 <span className="flex min-w-0 flex-1 flex-col">
2108 <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">2116 <span className="flex min-w-0 items-center gap-2.5">
2109 {threadTimeLabel(thread)}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}
2110 </span>2131 </span>
2111 </TooltipTrigger>2132 </TooltipTrigger>
2112 <SidebarThreadTooltip2133 <SidebarThreadTooltip
...@@ -2625,9 +2646,28 @@ export default function Sidebar() {...@@ -2625,9 +2646,28 @@ export default function Sidebar() {
2625 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],2646 () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads],
2626 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],2647 [activeThreads, pinnedThreads, settledThreads, snoozedThreads],
2627 );2648 );
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 );
2628 const threadSearchResults = useMemo(2663 const threadSearchResults = useMemo(
2629 () => searchSidebarThreads(searchableThreads, threadSearchQuery),2664 () =>
2630 [searchableThreads, threadSearchQuery],2665 searchSidebarThreads(
2666 searchableThreads,
2667 threadSearchQuery,
2668 new Set(threadSearchMatchByKey.keys()),
2669 ),
2670 [searchableThreads, threadSearchQuery, threadSearchMatchByKey],
2631 );2671 );
2632 const threadSearchResultOrderKey = threadSearchResults2672 const threadSearchResultOrderKey = threadSearchResults
2633 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))2673 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))
...@@ -4594,6 +4634,15 @@ export default function Sidebar() {...@@ -4594,6 +4634,15 @@ export default function Sidebar() {
4594 isHighlighted={activeSearchResultIndex === index}4634 isHighlighted={activeSearchResultIndex === index}
4595 isRouteActive={routeThreadKey === threadKey}4635 isRouteActive={routeThreadKey === threadKey}
4596 resultId={`sidebar-thread-search-result-${index}`}4636 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}
4597 onHighlight={() => setActiveSearchResultIndex(index)}4646 onHighlight={() => setActiveSearchResultIndex(index)}
4598 onSelect={() => selectThreadSearchResult(thread)}4647 onSelect={() => selectThreadSearchResult(thread)}
4599 onFileDropThreads={handleThreadFileDrop}4648 onFileDropThreads={handleThreadFileDrop}
...@@ -4607,7 +4656,7 @@ export default function Sidebar() {...@@ -4607,7 +4656,7 @@ export default function Sidebar() {
4607 role="status"4656 role="status"
4608 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"4657 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
4609 >4658 >
4610 No threads found4659 {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}
4611 </p>4660 </p>
4612 )4661 )
4613 ) : null}4662 ) : 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}