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:"}
+ {" "}
+
+
+ );
+}