authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 05:30:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 05:30:11-07:00
log4b71a2ae2ffbbb7b6936051552094b71364cefd4
tree3c82f48b778637347a0cb13317ba5bbf565501c7
parent197d3487116372ca2f16661f57d4e869fc3cb6c0
signature Signed by PGP key B5690EEEBB952194

feat(search): find threads by conversation content (#4959)


34 files changed, 1345 insertions(+), 72 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+16-14
......@@ -25,7 +25,7 @@ export function HomeRouteScreen() {
2525 const { layout } = useAdaptiveWorkspaceLayout();
2626 const projects = useProjects();
2727 const threads = useThreadShells();
28 const { state: catalogState } = useWorkspaceState();
28 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
2929 const { savedConnectionsById } = useSavedRemoteConnections();
3030 const navigation = useNavigation();
3131 const [searchQuery, setSearchQuery] = useState("");
......@@ -33,20 +33,22 @@ export function HomeRouteScreen() {
3333 useThreadListActions();
3434 const pendingTasks = usePendingNewTasks();
3535 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
36 const environments = useMemo(
37 () =>
38 Arr.sort(
39 Object.values(savedConnectionsById).map((connection) => ({
40 environmentId: connection.environmentId,
41 label: connection.environmentLabel,
42 })),
43 Order.mapInput(
44 Order.String,
45 (environment: { readonly label: string }) => environment.label,
46 ),
36 const environments = useMemo(() => {
37 const connectionStateByEnvironmentId = new Map(
38 workspaceEnvironments.map(
39 (environment) => [environment.environmentId, environment.connectionState] as const,
4740 ),
48 [savedConnectionsById],
49 );
41 );
42 return Arr.sort(
43 Object.values(savedConnectionsById).map((connection) => ({
44 environmentId: connection.environmentId,
45 label: connection.environmentLabel,
46 connectionState:
47 connectionStateByEnvironmentId.get(connection.environmentId) ?? "available",
48 })),
49 Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label),
50 );
51 }, [savedConnectionsById, workspaceEnvironments]);
5052 const availableEnvironmentIds = useMemo(
5153 () => new Set(environments.map((environment) => environment.environmentId)),
5254 [environments],
apps/mobile/src/features/home/HomeScreen.tsx+97-27
......@@ -7,6 +7,10 @@ import {
77 type EnvironmentProject,
88 type EnvironmentThreadShell,
99} from "@t3tools/client-runtime/state/shell";
10import {
11 threadSearchMatchKey,
12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";
1014import type {
1115 EnvironmentId,
1216 SidebarProjectGroupingMode,
......@@ -22,11 +26,12 @@ import { useThemeColor } from "../../lib/useThemeColor";
2226
2327import { AppText as Text } from "../../components/AppText";
2428import { EmptyState } from "../../components/EmptyState";
25import type { WorkspaceState } from "../../state/workspaceModel";
29import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
2630import type { SavedRemoteConnection } from "../../lib/connection";
2731import { scopedProjectKey } from "../../lib/scopedEntities";
2832import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2933import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
34import { useThreadSearch } from "../../state/queries";
3035import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3136import { environmentServerConfigsAtom } from "../../state/server";
3237import type { PendingNewTask } from "../../state/use-pending-new-tasks";
......@@ -72,7 +77,9 @@ interface HomeScreenProps {
7277 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
7378 readonly catalogState: WorkspaceState;
7479 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
75 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
80 readonly environments: ReadonlyArray<
81 HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState">
82 >;
7683 readonly searchQuery: string;
7784 readonly selectedEnvironmentId: EnvironmentId | null;
7885 readonly selectedProjectKey: string | null;
......@@ -189,6 +196,35 @@ export function HomeScreen(props: HomeScreenProps) {
189196 const listRef = useRef<LegendListRef | null>(null);
190197 const insets = useSafeAreaInsets();
191198 const accentColor = useThemeColor("--color-icon-muted");
199 const searchEnvironmentIds = useMemo(
200 () =>
201 props.selectedEnvironmentId === null
202 ? props.environments
203 .filter((environment) => environment.connectionState === "connected")
204 .map((environment) => environment.environmentId)
205 : props.environments.some(
206 (environment) =>
207 environment.environmentId === props.selectedEnvironmentId &&
208 environment.connectionState === "connected",
209 )
210 ? [props.selectedEnvironmentId]
211 : [],
212 [props.environments, props.selectedEnvironmentId],
213 );
214 const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery);
215 const threadSearchMatchByKey = useMemo(() => {
216 const matches = new Map<string, EnvironmentThreadSearchMatch>();
217 for (const match of threadSearch.matches) {
218 if (match.source === "user" || match.source === "assistant") {
219 matches.set(threadSearchMatchKey(match), match);
220 }
221 }
222 return matches;
223 }, [threadSearch.matches]);
224 const matchedThreadKeys = useMemo(
225 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
226 [threadSearch.matches],
227 );
192228 const effectiveGroupDisplayStates = useMemo(() => {
193229 const next = new Map(groupDisplayStates);
194230 if (!AsyncResult.isSuccess(preferencesResult)) {
......@@ -318,6 +354,7 @@ export function HomeScreen(props: HomeScreenProps) {
318354 pendingTasks: scopedPendingTasks,
319355 environmentId: props.selectedEnvironmentId,
320356 searchQuery: props.searchQuery,
357 matchedThreadKeys,
321358 projectSortOrder: props.projectSortOrder,
322359 threadSortOrder: props.threadSortOrder,
323360 projectGroupingMode: props.projectGroupingMode,
......@@ -328,6 +365,7 @@ export function HomeScreen(props: HomeScreenProps) {
328365 props.searchQuery,
329366 props.selectedEnvironmentId,
330367 props.threadSortOrder,
368 matchedThreadKeys,
331369 scopedPendingTasks,
332370 scopedProjects,
333371 scopedThreads,
......@@ -516,6 +554,7 @@ export function HomeScreen(props: HomeScreenProps) {
516554 environmentId: props.selectedEnvironmentId,
517555 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
518556 searchQuery: props.searchQuery,
557 matchedThreadKeys,
519558 changeRequestStateByKey,
520559 settlementEnvironmentIds,
521560 snoozeEnvironmentIds,
......@@ -533,6 +572,7 @@ export function HomeScreen(props: HomeScreenProps) {
533572 props.searchQuery,
534573 props.selectedEnvironmentId,
535574 props.threads,
575 matchedThreadKeys,
536576 threadListV2Enabled,
537577 v2ScopedProjectGroup,
538578 ]);
......@@ -629,6 +669,13 @@ export function HomeScreen(props: HomeScreenProps) {
629669 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
630670 : null
631671 }
672 searchMatch={threadSearchMatchByKey.get(
673 threadSearchMatchKey({
674 environmentId: thread.environmentId,
675 threadId: thread.id,
676 }),
677 )}
678 searchQuery={props.searchQuery}
632679 onSelectThread={props.onSelectThread}
633680 onDeleteThread={handleDeleteThread}
634681 onArchiveThread={props.onArchiveThread}
......@@ -660,7 +707,9 @@ export function HomeScreen(props: HomeScreenProps) {
660707 props.savedConnectionsById,
661708 serverConfigs,
662709 settlementEnvironmentIds,
710 threadSearchMatchByKey,
663711 v2ProjectTitleByProjectKey,
712 props.searchQuery,
664713 ],
665714 );
666715 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
......@@ -675,19 +724,28 @@ export function HomeScreen(props: HomeScreenProps) {
675724 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
676725 serverConfigs,
677726 savedConnectionsById: props.savedConnectionsById,
727 searchQuery: props.searchQuery,
728 threadSearchMatchByKey,
678729 }),
679730 [
680731 projectByKey,
681732 projectCwdByKey,
733 props.searchQuery,
682734 props.savedConnectionsById,
683735 serverConfigs,
736 threadSearchMatchByKey,
684737 v2ProjectTitleByProjectKey,
685738 ],
686739 );
687740
688741 const extraData = useMemo(
689 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
690 [props.savedConnectionsById, projectCwdByKey],
742 () => ({
743 projectCwdByKey,
744 savedConnectionsById: props.savedConnectionsById,
745 searchQuery: props.searchQuery,
746 threadSearchMatchByKey,
747 }),
748 [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
691749 );
692750
693751 const renderItem = useCallback(
......@@ -740,6 +798,13 @@ export function HomeScreen(props: HomeScreenProps) {
740798 null
741799 }
742800 isLast={item.isLast}
801 searchMatch={threadSearchMatchByKey.get(
802 threadSearchMatchKey({
803 environmentId: thread.environmentId,
804 threadId: thread.id,
805 }),
806 )}
807 searchQuery={props.searchQuery}
743808 onArchiveThread={props.onArchiveThread}
744809 onDeleteThread={props.onDeleteThread}
745810 onSelectThread={props.onSelectThread}
......@@ -770,7 +835,9 @@ export function HomeScreen(props: HomeScreenProps) {
770835 props.onNewThreadInProject,
771836 props.onSelectPendingTask,
772837 props.onSelectThread,
838 props.searchQuery,
773839 props.savedConnectionsById,
840 threadSearchMatchByKey,
774841 updateGroupDisplay,
775842 ],
776843 );
......@@ -863,7 +930,7 @@ export function HomeScreen(props: HomeScreenProps) {
863930 const v2ListHeader = listHeader;
864931
865932 const listEmpty = !hasResults ? (
866 hasSearchQuery ? (
933 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
867934 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
868935 ) : selectedProjectScope !== null ? (
869936 <EmptyState
......@@ -886,31 +953,34 @@ export function HomeScreen(props: HomeScreenProps) {
886953 // threads yet" over an inbox that is merely all-snoozed reads as data
887954 // loss.
888955 const v2SnoozedCount = threadListV2Layout.snoozedCount;
889 const v2ListEmpty = hasSearchQuery ? (
890 v2SnoozedCount > 0 ? (
891 // The snoozed threads already passed this search filter: "No
892 // results" would claim nothing matched when matches are merely
893 // parked.
956 const v2ListEmpty =
957 hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? (
958 v2SnoozedCount > 0 ? (
959 // The snoozed threads already passed this search filter: "No
960 // results" would claim nothing matched when matches are merely
961 // parked.
962 <EmptyState
963 title={
964 v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`
965 }
966 detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}
967 />
968 ) : (
969 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
970 )
971 ) : v2SnoozedCount > 0 ? (
972 <EmptyState
973 title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`}
974 detail="Snoozed threads return when their wake time passes."
975 />
976 ) : v2ScopedProjectGroup !== null ? (
894977 <EmptyState
895 title={v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`}
896 detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}
978 title={`No threads in ${v2ScopedProjectGroup.title}`}
979 detail="Choose another project or create a new task."
897980 />
898981 ) : (
899 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
900 )
901 ) : v2SnoozedCount > 0 ? (
902 <EmptyState
903 title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`}
904 detail="Snoozed threads return when their wake time passes."
905 />
906 ) : v2ScopedProjectGroup !== null ? (
907 <EmptyState
908 title={`No threads in ${v2ScopedProjectGroup.title}`}
909 detail="Choose another project or create a new task."
910 />
911 ) : (
912 listEmpty
913 );
982 listEmpty
983 );
914984
915985 if (threadListV2Enabled) {
916986 return (
apps/mobile/src/features/home/homeThreadList.test.ts+28
......@@ -2,6 +2,7 @@ import type {
22 EnvironmentProject,
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
5import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
56import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
67import { describe, expect, it } from "vite-plus/test";
78
......@@ -661,6 +662,33 @@ describe("buildHomeThreadGroups", () => {
661662 );
662663 });
663664
665 it("includes a thread matched by message content", () => {
666 const environmentId = EnvironmentId.make("environment-1");
667 const project = makeProject({
668 environmentId,
669 id: ProjectId.make("project-1"),
670 title: "T3 Code",
671 });
672 const thread = makeThread({
673 environmentId,
674 id: ThreadId.make("thread-content"),
675 projectId: project.id,
676 title: "Unrelated title",
677 });
678
679 const groups = buildGroups([project], [thread], {
680 searchQuery: "relay reconnect",
681 matchedThreadKeys: new Set([
682 threadSearchMatchKey({
683 environmentId,
684 threadId: thread.id,
685 }),
686 ]),
687 });
688
689 expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]);
690 });
691
664692 it("targets quick new threads at the group member with the newest thread", () => {
665693 const laptopEnv = EnvironmentId.make("environment-laptop");
666694 const desktopEnv = EnvironmentId.make("environment-desktop");
apps/mobile/src/features/home/homeThreadList.ts+12-1
......@@ -12,6 +12,7 @@ import {
1212 sortThreads,
1313 toSortableTimestamp,
1414} from "@t3tools/client-runtime/state/thread-sort";
15import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1516import type {
1617 EnvironmentId,
1718 ScopedProjectRef,
......@@ -254,6 +255,7 @@ export function buildHomeThreadGroups(input: {
254255 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
255256 readonly environmentId: EnvironmentId | null;
256257 readonly searchQuery: string;
258 readonly matchedThreadKeys?: ReadonlySet<string>;
257259 readonly projectSortOrder: HomeProjectSortOrder;
258260 readonly threadSortOrder: SidebarThreadSortOrder;
259261 readonly projectGroupingMode: SidebarProjectGroupingMode;
......@@ -350,7 +352,16 @@ export function buildHomeThreadGroups(input: {
350352 group.projects.some((project) => project.title.toLocaleLowerCase().includes(query));
351353 const matchingThreads = groupMatches
352354 ? group.threads
353 : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
355 : group.threads.filter(
356 (thread) =>
357 thread.title.toLocaleLowerCase().includes(query) ||
358 input.matchedThreadKeys?.has(
359 threadSearchMatchKey({
360 environmentId: thread.environmentId,
361 threadId: thread.id,
362 }),
363 ) === true,
364 );
354365 const matchingPendingTasks = groupMatches
355366 ? group.pendingTasks
356367 : group.pendingTasks.filter((pendingTask) =>
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+73-9
......@@ -3,6 +3,10 @@ import type {
33 EnvironmentProject,
44 EnvironmentThreadShell,
55} from "@t3tools/client-runtime/state/shell";
6import {
7 threadSearchMatchKey,
8 type EnvironmentThreadSearchMatch,
9} from "@t3tools/client-runtime/state/thread-search";
610import { LegendList } from "@legendapp/list/react-native";
711import type { MenuAction } from "@react-native-menu/menu";
812import { useAtomValue } from "@effect/atom-react";
......@@ -24,6 +28,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2428import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
2529import { useThemeColor } from "../../lib/useThemeColor";
2630import { useProjects, useThreadShells } from "../../state/entities";
31import { useThreadSearch } from "../../state/queries";
2732import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
2833import { environmentServerConfigsAtom } from "../../state/server";
2934import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
......@@ -180,7 +185,7 @@ function ThreadNavigationSidebarPane(
180185 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
181186 const projects = useProjects();
182187 const threads = useThreadShells();
183 const { state: catalogState } = useWorkspaceState();
188 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
184189 const { savedConnectionsById } = useSavedRemoteConnections();
185190 const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
186191 const searchInputRef = useRef<TextInput>(null);
......@@ -209,6 +214,35 @@ function ThreadNavigationSidebarPane(
209214 );
210215 const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } =
211216 useHomeListOptions(availableEnvironmentIds);
217 const searchEnvironmentIds = useMemo(
218 () =>
219 options.selectedEnvironmentId === null
220 ? workspaceEnvironments
221 .filter((environment) => environment.connectionState === "connected")
222 .map((environment) => environment.environmentId)
223 : workspaceEnvironments.some(
224 (environment) =>
225 environment.environmentId === options.selectedEnvironmentId &&
226 environment.connectionState === "connected",
227 )
228 ? [options.selectedEnvironmentId]
229 : [],
230 [options.selectedEnvironmentId, workspaceEnvironments],
231 );
232 const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery);
233 const threadSearchMatchByKey = useMemo(() => {
234 const matches = new Map<string, EnvironmentThreadSearchMatch>();
235 for (const match of threadSearch.matches) {
236 if (match.source === "user" || match.source === "assistant") {
237 matches.set(threadSearchMatchKey(match), match);
238 }
239 }
240 return matches;
241 }, [threadSearch.matches]);
242 const matchedThreadKeys = useMemo(
243 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
244 [threadSearch.matches],
245 );
212246 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
213247 const projectScopes = useMemo(
214248 () =>
......@@ -305,11 +339,19 @@ function ThreadNavigationSidebarPane(
305339 pendingTasks: scopedPendingTasks,
306340 environmentId: options.selectedEnvironmentId,
307341 searchQuery: props.searchQuery,
342 matchedThreadKeys,
308343 projectSortOrder: options.projectSortOrder,
309344 threadSortOrder: options.threadSortOrder,
310345 projectGroupingMode: options.projectGroupingMode,
311346 }),
312 [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads],
347 [
348 matchedThreadKeys,
349 options,
350 props.searchQuery,
351 scopedPendingTasks,
352 scopedProjects,
353 scopedThreads,
354 ],
313355 );
314356 const [groupDisplayStates, setGroupDisplayStates] = useState<
315357 ReadonlyMap<string, HomeGroupDisplayState>
......@@ -432,6 +474,7 @@ function ThreadNavigationSidebarPane(
432474 environmentId: options.selectedEnvironmentId,
433475 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
434476 searchQuery: props.searchQuery,
477 matchedThreadKeys,
435478 changeRequestStateByKey,
436479 settlementEnvironmentIds,
437480 snoozeEnvironmentIds,
......@@ -445,6 +488,7 @@ function ThreadNavigationSidebarPane(
445488 snoozeWakeTick,
446489 options.selectedEnvironmentId,
447490 props.searchQuery,
491 matchedThreadKeys,
448492 settledVisibleCount,
449493 settlementEnvironmentIds,
450494 snoozeEnvironmentIds,
......@@ -687,6 +731,7 @@ function ThreadNavigationSidebarPane(
687731 projectTitleByProjectKey,
688732 savedConnectionsById,
689733 serverConfigs,
734 threadSearchMatchByKey,
690735 }),
691736 [
692737 props.selectedThreadKey,
......@@ -695,6 +740,7 @@ function ThreadNavigationSidebarPane(
695740 projectTitleByProjectKey,
696741 savedConnectionsById,
697742 serverConfigs,
743 threadSearchMatchByKey,
698744 ],
699745 );
700746 const sidebarItemsAreEqual = useCallback(
......@@ -797,6 +843,13 @@ function ThreadNavigationSidebarPane(
797843 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
798844 : null
799845 }
846 searchMatch={threadSearchMatchByKey.get(
847 threadSearchMatchKey({
848 environmentId: thread.environmentId,
849 threadId: thread.id,
850 }),
851 )}
852 searchQuery={props.searchQuery}
800853 pane="sidebar"
801854 selected={
802855 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
......@@ -876,6 +929,13 @@ function ThreadNavigationSidebarPane(
876929 null
877930 }
878931 isLast={item.isLast}
932 searchMatch={threadSearchMatchByKey.get(
933 threadSearchMatchKey({
934 environmentId: thread.environmentId,
935 threadId: thread.id,
936 }),
937 )}
938 searchQuery={props.searchQuery}
879939 selected={
880940 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
881941 }
......@@ -914,10 +974,12 @@ function ThreadNavigationSidebarPane(
914974 projectCwdByKey,
915975 projectTitleByProjectKey,
916976 props.onNewThreadInProject,
977 props.searchQuery,
917978 props.selectedThreadKey,
918979 props.width,
919980 savedConnectionsById,
920981 serverConfigs,
982 threadSearchMatchByKey,
921983 settleThread,
922984 settlementEnvironmentIds,
923985 showMoreSettled,
......@@ -977,13 +1039,15 @@ function ThreadNavigationSidebarPane(
9771039 {catalogState.isLoadingConnections
9781040 ? "Loading threads…"
9791041 : props.searchQuery.trim().length > 0
980 ? snoozedCount > 0
981 ? // Snoozed matches passed this same search filter — "No
982 // matching threads" would misreport them as nonexistent.
983 snoozedCount === 1
984 ? "1 matching thread snoozed"
985 : "All matching threads snoozed"
986 : "No matching threads"
1042 ? threadSearch.isPending && snoozedCount === 0
1043 ? "Searching thread messages…"
1044 : snoozedCount > 0
1045 ? // Snoozed matches passed this same search filter — "No
1046 // matching threads" would misreport them as nonexistent.
1047 snoozedCount === 1
1048 ? "1 matching thread snoozed"
1049 : "All matching threads snoozed"
1050 : "No matching threads"
9871051 : snoozedCount > 0
9881052 ? snoozedCount === 1
9891053 ? "1 thread snoozed"
apps/mobile/src/features/threads/thread-list-items.tsx+18
......@@ -3,6 +3,7 @@ import type {
33 EnvironmentProject,
44 EnvironmentThreadShell,
55} from "@t3tools/client-runtime/state/shell";
6import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
67import type { MenuAction } from "@react-native-menu/menu";
78import { SymbolView } from "../../components/AppSymbol";
89import { memo, useCallback, useMemo, type ComponentProps } from "react";
......@@ -21,6 +22,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
2122import type { HomeGroupDisplayAction } from "../home/homeListItems";
2223import { ThreadSwipeable } from "../home/thread-swipe-actions";
2324import { resolveThreadStatus } from "./threadPresentation";
25import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2426
2527/**
2628 * Shared presentation for the thread lists: the compact (phone) Home list and
......@@ -416,6 +418,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
416418 readonly thread: EnvironmentThreadShell;
417419 readonly environmentLabel: string | null;
418420 readonly projectCwd: string | null;
421 readonly searchMatch?: EnvironmentThreadSearchMatch;
422 readonly searchQuery?: string;
419423 readonly isLast: boolean;
420424 /** Sidebar only: the thread currently open in the detail pane. */
421425 readonly selected?: boolean;
......@@ -569,6 +573,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
569573 />
570574 </View>
571575 </View>
576 {props.searchMatch ? (
577 <ThreadSearchMatchExcerpt
578 compact
579 match={props.searchMatch}
580 query={props.searchQuery ?? ""}
581 />
582 ) : null}
572583 {subtitleRow}
573584 </View>
574585 </View>
......@@ -623,6 +634,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
623634 </Text>
624635 </View>
625636 </View>
637 {props.searchMatch ? (
638 <ThreadSearchMatchExcerpt
639 match={props.searchMatch}
640 query={props.searchQuery ?? ""}
641 selected={selected}
642 />
643 ) : null}
626644 {subtitleRow}
627645 </View>
628646 </Pressable>
apps/mobile/src/features/threads/thread-list-v2-items.tsx+31-9
......@@ -2,6 +2,7 @@ import type {
22 EnvironmentProject,
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
56import type { MenuAction } from "@react-native-menu/menu";
67import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
78import { Platform, Pressable, useWindowDimensions, View } from "react-native";
......@@ -18,6 +19,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1819import { useThreadPr } from "../../state/use-thread-pr";
1920import { ThreadSwipeable } from "../home/thread-swipe-actions";
2021import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
22import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2123
2224/**
2325 * Thread List v2 renders one flat native list: rich edge-to-edge rows for
......@@ -243,6 +245,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
243245 state: "open" | "closed" | "merged" | null,
244246 ) => void;
245247 readonly projectCwd?: string | null;
248 readonly searchMatch?: EnvironmentThreadSearchMatch;
249 readonly searchQuery?: string;
246250 readonly simultaneousSwipeGesture?: ComponentProps<
247251 typeof ThreadSwipeable
248252 >["simultaneousWithExternalGesture"];
......@@ -369,6 +373,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
369373 >
370374 {thread.title}
371375 </Text>
376 {props.searchMatch ? (
377 <View className="mt-1">
378 <ThreadSearchMatchExcerpt
379 match={props.searchMatch}
380 query={props.searchQuery ?? ""}
381 selected={selected}
382 />
383 </View>
384 ) : null}
372385 <View className="mt-1 flex-row items-center gap-2">
373386 {status === "failed" && thread.session?.lastError ? (
374387 <Text
......@@ -517,15 +530,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
517530 />
518531 </View>
519532 ) : null}
520 <Text
521 className={cn(
522 "flex-1 text-base",
523 selected ? "text-user-bubble-foreground" : "text-foreground-muted",
524 )}
525 numberOfLines={1}
526 >
527 {thread.title}
528 </Text>
533 <View className="min-w-0 flex-1">
534 <Text
535 className={cn(
536 "text-base",
537 selected ? "text-user-bubble-foreground" : "text-foreground-muted",
538 )}
539 numberOfLines={1}
540 >
541 {thread.title}
542 </Text>
543 {props.searchMatch ? (
544 <ThreadSearchMatchExcerpt
545 match={props.searchMatch}
546 query={props.searchQuery ?? ""}
547 selected={selected}
548 />
549 ) : null}
550 </View>
529551 <Text
530552 className={cn(
531553 "text-sm tabular-nums",
apps/mobile/src/features/threads/thread-search-match.tsx created+92
......@@ -0,0 +1,92 @@
1import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
2
3import { AppText as Text } from "../../components/AppText";
4import { cn } from "../../lib/cn";
5
6function foldAsciiCase(value: string): string {
7 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
8}
9
10function splitHighlightParts(text: string, query: string) {
11 const normalizedText = foldAsciiCase(text);
12 const normalizedQuery = foldAsciiCase(query.trim());
13 if (normalizedQuery.length === 0) {
14 return [{ text, highlighted: false, start: 0 }];
15 }
16
17 const parts: Array<{
18 readonly text: string;
19 readonly highlighted: boolean;
20 readonly start: number;
21 }> = [];
22 let cursor = 0;
23 while (cursor < text.length) {
24 const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
25 if (matchIndex === -1) {
26 parts.push({ text: text.slice(cursor), highlighted: false, start: cursor });
27 break;
28 }
29 if (matchIndex > cursor) {
30 parts.push({
31 text: text.slice(cursor, matchIndex),
32 highlighted: false,
33 start: cursor,
34 });
35 }
36 parts.push({
37 text: text.slice(matchIndex, matchIndex + normalizedQuery.length),
38 highlighted: true,
39 start: matchIndex,
40 });
41 cursor = matchIndex + normalizedQuery.length;
42 }
43 return parts;
44}
45
46export function ThreadSearchMatchExcerpt(props: {
47 readonly match: EnvironmentThreadSearchMatch;
48 readonly query: string;
49 readonly selected?: boolean;
50 readonly compact?: boolean;
51}) {
52 const isUser = props.match.source === "user";
53 const parts = splitHighlightParts(props.match.snippet, props.query);
54 return (
55 <Text
56 className={cn(
57 props.compact ? "text-sm" : "text-xs",
58 props.selected ? "text-user-bubble-foreground-muted" : "text-foreground-muted",
59 )}
60 numberOfLines={1}
61 >
62 <Text
63 className={cn(
64 props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium",
65 props.selected
66 ? "text-user-bubble-foreground"
67 : isUser
68 ? "text-blue-500 dark:text-blue-400"
69 : "text-emerald-600 dark:text-emerald-400",
70 )}
71 >
72 {isUser ? "You:" : "Agent:"}{" "}
73 </Text>
74 {parts.map((part) => (
75 <Text
76 className={cn(
77 props.compact ? "text-sm" : "text-xs",
78 part.highlighted && "font-t3-bold",
79 props.selected
80 ? "text-user-bubble-foreground"
81 : part.highlighted
82 ? "text-foreground"
83 : "text-foreground-muted",
84 )}
85 key={part.start}
86 >
87 {part.text}
88 </Text>
89 ))}
90 </Text>
91 );
92}
apps/mobile/src/features/threads/threadListV2.test.ts+22
......@@ -1,4 +1,5 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
23import {
34 CommandId,
45 EnvironmentId,
......@@ -263,6 +264,27 @@ describe("buildThreadListV2Items", () => {
263264 ]);
264265 });
265266
267 it("includes a thread matched by message content", () => {
268 const thread = makeThread({
269 id: ThreadId.make("content-match"),
270 title: "Unrelated title",
271 });
272 const { items } = buildThreadListV2Items({
273 threads: [thread],
274 environmentId: null,
275 searchQuery: "relay reconnect",
276 matchedThreadKeys: new Set([
277 threadSearchMatchKey({
278 environmentId,
279 threadId: thread.id,
280 }),
281 ]),
282 now: NOW,
283 });
284
285 expect(items.map((item) => item.thread.id)).toEqual(["content-match"]);
286 });
287
266288 it("scopes the flat list to one project", () => {
267289 const otherProjectId = ProjectId.make("project-2");
268290 const { items } = buildThreadListV2Items({
apps/mobile/src/features/threads/threadListV2.ts+14-1
......@@ -1,5 +1,6 @@
11import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
22import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
34import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
45
56import type { PendingNewTask } from "../../state/use-pending-new-tasks";
......@@ -183,6 +184,7 @@ export function buildThreadListV2Items(input: {
183184 readonly projectId: ProjectId;
184185 }> | null;
185186 readonly searchQuery: string;
187 readonly matchedThreadKeys?: ReadonlySet<string>;
186188 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
187189 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
188190 /** Environments whose server supports thread.settle/unsettle. Threads on
......@@ -222,7 +224,18 @@ export function buildThreadListV2Items(input: {
222224 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
223225 continue;
224226 }
225 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;
227 if (
228 query.length > 0 &&
229 !thread.title.toLocaleLowerCase().includes(query) &&
230 input.matchedThreadKeys?.has(
231 threadSearchMatchKey({
232 environmentId: thread.environmentId,
233 threadId: thread.id,
234 }),
235 ) !== true
236 ) {
237 continue;
238 }
226239 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
227240 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
228241 const changeRequestState =
apps/mobile/src/state/queries.ts+47
......@@ -1,5 +1,12 @@
11import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
2import {
3 createThreadSearchResultsAtomFamily,
4 makeThreadSearchKey,
5 type EnvironmentThreadSearchMatch,
6} from "@t3tools/client-runtime/state/thread-search";
7import { useAtomValue } from "@effect/atom-react";
28import * as Option from "effect/Option";
9import { Atom } from "effect/unstable/reactivity";
310import { useEffect, useMemo, useState } from "react";
411
512import { orchestrationEnvironment } from "./orchestration";
......@@ -15,7 +22,22 @@ import {
1522
1623const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
1724const COMPOSER_PATH_SEARCH_LIMIT = 20;
25const THREAD_SEARCH_DEBOUNCE_MS = 200;
1826const VCS_REF_LIST_LIMIT = 100;
27const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
28const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
29 matches: EMPTY_THREAD_SEARCH_MATCHES,
30 isLoading: false,
31}).pipe(Atom.withLabel("mobile:thread-search:empty"));
32
33const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({
34 getSearchAtom: (environmentId, query) =>
35 orchestrationEnvironment.threadSearch({
36 environmentId,
37 input: { query },
38 }),
39 labelPrefix: "mobile:thread-search",
40});
1941
2042export interface ThreadDetailView {
2143 readonly data: OrchestrationThread | null;
......@@ -45,6 +67,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {
4567 return debounced;
4668}
4769
70export function useThreadSearch(
71 environmentIds: ReadonlyArray<EnvironmentId>,
72 query: string,
73): {
74 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
75 readonly isPending: boolean;
76} {
77 const normalizedQuery = query.trim();
78 const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
79 const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2;
80 const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;
81 const searchKey = useMemo(
82 () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)),
83 [environmentIds, settledQuery],
84 );
85 const result = useAtomValue(
86 searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey),
87 );
88 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
89 return {
90 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
91 isPending: canSearch && (isDebouncing || result.isLoading),
92 };
93}
94
4895export function useThreadDetail(
4996 environmentId: EnvironmentId | null,
5097 threadId: ThreadId | null,
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -24,6 +24,7 @@ export const RPC_REQUIRED_SCOPES = {
2424 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
2525 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
2626 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
27 [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
2728 [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
2829 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
2930 [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
......@@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => {
108108 getThreadShellById: () => Effect.succeed(Option.none()),
109109 getThreadDetailById: () => Effect.succeed(Option.none()),
110110 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
111 searchThreads: () => Effect.succeed({ matches: [] }),
111112 }),
112113 ),
113114 );
......@@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => {
201202 getThreadShellById: () => Effect.succeed(Option.none()),
202203 getThreadDetailById: () => Effect.succeed(Option.none()),
203204 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
205 searchThreads: () => Effect.succeed({ matches: [] }),
204206 }),
205207 ),
206208 );
......@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
284286 getThreadShellById: () => Effect.succeed(Option.none()),
285287 getThreadDetailById: () => Effect.succeed(Option.none()),
286288 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
289 searchThreads: () => Effect.succeed({ matches: [] }),
287290 }),
288291 ),
289292 );
......@@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => {
352355 getThreadShellById: () => Effect.succeed(Option.none()),
353356 getThreadDetailById: () => Effect.succeed(Option.none()),
354357 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
358 searchThreads: () => Effect.succeed({ matches: [] }),
355359 }),
356360 ),
357361 );
......@@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => {
405409 getThreadShellById: () => Effect.succeed(Option.none()),
406410 getThreadDetailById: () => Effect.succeed(Option.none()),
407411 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
412 searchThreads: () => Effect.succeed({ matches: [] }),
408413 }),
409414 ),
410415 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
......@@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => {
203203 getThreadShellById: () => Effect.succeed(Option.none()),
204204 getThreadDetailById: () => Effect.succeed(Option.none()),
205205 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
206 searchThreads: () => Effect.succeed({ matches: [] }),
206207 }),
207208 ),
208209 Layer.provide(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+265
......@@ -1551,6 +1551,271 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
15511551 assert.equal(shellSnapshot.threads.length, 0);
15521552 }),
15531553 );
1554
1555 it.effect("searches active user messages and canonical assistant outputs", () =>
1556 Effect.gen(function* () {
1557 const snapshotQuery = yield* ProjectionSnapshotQuery;
1558 const sql = yield* SqlClient.SqlClient;
1559
1560 yield* sql`DELETE FROM projection_thread_messages`;
1561 yield* sql`DELETE FROM projection_turns`;
1562 yield* sql`DELETE FROM projection_threads`;
1563 yield* sql`DELETE FROM projection_projects`;
1564
1565 yield* sql`
1566 INSERT INTO projection_projects (
1567 project_id,
1568 title,
1569 workspace_root,
1570 default_model_selection_json,
1571 scripts_json,
1572 created_at,
1573 updated_at,
1574 deleted_at
1575 )
1576 VALUES (
1577 'project-search',
1578 'Project Needle',
1579 '/tmp/project-search',
1580 '{"provider":"codex","model":"gpt-5-codex"}',
1581 '[]',
1582 '2026-05-01T00:00:00.000Z',
1583 '2026-05-01T00:00:01.000Z',
1584 NULL
1585 )
1586 `;
1587
1588 yield* sql`
1589 INSERT INTO projection_threads (
1590 thread_id,
1591 project_id,
1592 title,
1593 model_selection_json,
1594 runtime_mode,
1595 interaction_mode,
1596 branch,
1597 worktree_path,
1598 latest_turn_id,
1599 latest_user_message_at,
1600 pending_approval_count,
1601 pending_user_input_count,
1602 has_actionable_proposed_plan,
1603 created_at,
1604 updated_at,
1605 archived_at,
1606 deleted_at
1607 )
1608 VALUES
1609 (
1610 'thread-active',
1611 'project-search',
1612 'Literal 100% fix',
1613 '{"provider":"codex","model":"gpt-5-codex"}',
1614 'full-access',
1615 'default',
1616 'search-branch',
1617 NULL,
1618 'turn-active',
1619 '2026-05-01T00:00:02.000Z',
1620 0,
1621 0,
1622 0,
1623 '2026-05-01T00:00:02.000Z',
1624 '2026-05-01T00:00:03.000Z',
1625 NULL,
1626 NULL
1627 ),
1628 (
1629 'thread-percent-decoy',
1630 'project-search',
1631 'Literal 100x fix',
1632 '{"provider":"codex","model":"gpt-5-codex"}',
1633 'full-access',
1634 'default',
1635 NULL,
1636 NULL,
1637 NULL,
1638 NULL,
1639 0,
1640 0,
1641 0,
1642 '2026-05-01T00:00:04.000Z',
1643 '2026-05-01T00:00:05.000Z',
1644 NULL,
1645 NULL
1646 ),
1647 (
1648 'thread-hidden',
1649 'project-search',
1650 'Archived search',
1651 '{"provider":"codex","model":"gpt-5-codex"}',
1652 'full-access',
1653 'default',
1654 NULL,
1655 NULL,
1656 NULL,
1657 NULL,
1658 0,
1659 0,
1660 0,
1661 '2026-05-01T00:00:06.000Z',
1662 '2026-05-01T00:00:07.000Z',
1663 '2026-05-01T00:00:08.000Z',
1664 NULL
1665 )
1666 `;
1667
1668 yield* sql`
1669 INSERT INTO projection_thread_messages (
1670 message_id,
1671 thread_id,
1672 turn_id,
1673 role,
1674 text,
1675 is_streaming,
1676 created_at,
1677 updated_at
1678 )
1679 VALUES
1680 (
1681 'message-user',
1682 'thread-active',
1683 'turn-active',
1684 'user',
1685 'Please find this USER needle in an old prompt.',
1686 0,
1687 '2026-05-01T00:00:12.000Z',
1688 '2026-05-01T00:00:12.000Z'
1689 ),
1690 (
1691 'message-percent',
1692 'thread-active',
1693 NULL,
1694 'user',
1695 'Literal 100% fix in a prompt.',
1696 0,
1697 '2026-05-01T00:00:11.000Z',
1698 '2026-05-01T00:00:11.000Z'
1699 ),
1700 (
1701 'message-percent-decoy',
1702 'thread-percent-decoy',
1703 NULL,
1704 'user',
1705 'Literal 100x fix in a prompt.',
1706 0,
1707 '2026-05-01T00:00:11.000Z',
1708 '2026-05-01T00:00:11.000Z'
1709 ),
1710 (
1711 'message-final',
1712 'thread-active',
1713 'turn-active',
1714 'assistant',
1715 'The canonical final needle appears in this completed answer.',
1716 0,
1717 '2026-05-01T00:00:13.000Z',
1718 '2026-05-01T00:00:13.000Z'
1719 ),
1720 (
1721 'message-interim',
1722 'thread-active',
1723 'turn-active',
1724 'assistant',
1725 'Interim needle must not be searchable.',
1726 0,
1727 '2026-05-01T00:00:14.000Z',
1728 '2026-05-01T00:00:14.000Z'
1729 ),
1730 (
1731 'message-system',
1732 'thread-active',
1733 NULL,
1734 'system',
1735 'System needle must not be searchable.',
1736 0,
1737 '2026-05-01T00:00:15.000Z',
1738 '2026-05-01T00:00:15.000Z'
1739 ),
1740 (
1741 'message-hidden',
1742 'thread-hidden',
1743 NULL,
1744 'user',
1745 'Hidden needle in archive.',
1746 0,
1747 '2026-05-01T00:00:16.000Z',
1748 '2026-05-01T00:00:16.000Z'
1749 )
1750 `;
1751
1752 yield* sql`
1753 INSERT INTO projection_turns (
1754 thread_id,
1755 turn_id,
1756 pending_message_id,
1757 assistant_message_id,
1758 state,
1759 requested_at,
1760 started_at,
1761 completed_at,
1762 checkpoint_files_json
1763 )
1764 VALUES (
1765 'thread-active',
1766 'turn-active',
1767 'message-user',
1768 'message-final',
1769 'completed',
1770 '2026-05-01T00:00:12.000Z',
1771 '2026-05-01T00:00:12.000Z',
1772 '2026-05-01T00:00:13.000Z',
1773 '[]'
1774 )
1775 `;
1776
1777 const literalPercent = yield* snapshotQuery.searchThreads({ query: "100%" });
1778 assert.deepStrictEqual(
1779 literalPercent.matches.map((match) => [match.threadId, match.source]),
1780 [[ThreadId.make("thread-active"), "user"]],
1781 );
1782
1783 const user = yield* snapshotQuery.searchThreads({ query: "user needle" });
1784 assert.equal(user.matches[0]?.source, "user");
1785 assert.match(user.matches[0]?.snippet ?? "", /USER needle/);
1786
1787 const assistant = yield* snapshotQuery.searchThreads({ query: "FINAL NEEDLE" });
1788 assert.equal(assistant.matches[0]?.source, "assistant");
1789
1790 const deduped = yield* snapshotQuery.searchThreads({ query: "needle" });
1791 assert.deepStrictEqual(
1792 deduped.matches.map((match) => [match.threadId, match.source]),
1793 [[ThreadId.make("thread-active"), "user"]],
1794 );
1795
1796 assert.deepStrictEqual(
1797 (yield* snapshotQuery.searchThreads({ query: "interim needle" })).matches,
1798 [],
1799 );
1800 assert.deepStrictEqual(
1801 (yield* snapshotQuery.searchThreads({ query: "system needle" })).matches,
1802 [],
1803 );
1804 assert.deepStrictEqual(
1805 (yield* snapshotQuery.searchThreads({ query: "hidden needle" })).matches,
1806 [],
1807 );
1808 yield* sql`
1809 UPDATE projection_threads
1810 SET deleted_at = '2026-05-01T00:00:20.000Z'
1811 WHERE thread_id = 'thread-active'
1812 `;
1813 assert.deepStrictEqual(
1814 (yield* snapshotQuery.searchThreads({ query: "user needle" })).matches,
1815 [],
1816 );
1817 }),
1818 );
15541819});
15551820
15561821it.effect(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+132
......@@ -7,6 +7,7 @@ import {
77 OrchestrationCheckpointFile,
88 OrchestrationProposedPlanId,
99 OrchestrationReadModel,
10 OrchestrationThreadSearchSource,
1011 OrchestrationShellSnapshot,
1112 OrchestrationThread,
1213 OrchestrationThreadDetailSnapshot,
......@@ -108,6 +109,17 @@ const ProjectionCountsRowSchema = Schema.Struct({
108109 projectCount: Schema.Number,
109110 threadCount: Schema.Number,
110111});
112const ProjectionThreadSearchRequest = Schema.Struct({
113 pattern: Schema.String,
114 limit: Schema.Int,
115});
116const ProjectionThreadSearchRow = Schema.Struct({
117 threadId: ThreadId,
118 projectId: ProjectId,
119 source: OrchestrationThreadSearchSource,
120 matchText: Schema.String,
121 messageCreatedAt: Schema.NullOr(IsoDateTime),
122});
111123const WorkspaceRootLookupInput = Schema.Struct({
112124 workspaceRoot: Schema.String,
113125});
......@@ -157,6 +169,31 @@ function maxIso(left: string | null, right: string): string {
157169 return left > right ? left : right;
158170}
159171
172function escapeLikePattern(value: string): string {
173 return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_");
174}
175
176function foldAsciiCase(value: string): string {
177 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
178}
179
180function buildSearchSnippet(text: string, query: string): string {
181 const normalizedText = text.replace(/\s+/g, " ").trim();
182 if (normalizedText.length <= 240) {
183 return normalizedText;
184 }
185
186 const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim());
187 const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery);
188 const bodyLength = 236;
189 const idealStart = Math.max(0, matchIndex - 72);
190 const start = Math.min(idealStart, normalizedText.length - bodyLength);
191 const end = Math.min(normalizedText.length, start + bodyLength);
192 return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${
193 end < normalizedText.length ? "…" : ""
194 }`;
195}
196
160197function computeSnapshotSequence(
161198 stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>,
162199): number {
......@@ -685,6 +722,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
685722 `,
686723 });
687724
725 const searchActiveThreadRows = SqlSchema.findAll({
726 Request: ProjectionThreadSearchRequest,
727 Result: ProjectionThreadSearchRow,
728 execute: ({ pattern, limit }) =>
729 sql`
730 WITH ranked AS (
731 SELECT
732 threads.thread_id AS thread_id,
733 threads.project_id AS project_id,
734 CASE messages.role
735 WHEN 'user' THEN 'user'
736 ELSE 'assistant'
737 END AS source,
738 messages.text AS match_text,
739 messages.created_at AS message_created_at,
740 CASE messages.role
741 WHEN 'user' THEN 0
742 ELSE 1
743 END AS match_rank,
744 threads.updated_at AS thread_updated_at,
745 ROW_NUMBER() OVER (
746 PARTITION BY threads.thread_id
747 ORDER BY
748 CASE messages.role
749 WHEN 'user' THEN 0
750 ELSE 1
751 END ASC,
752 messages.created_at DESC,
753 messages.message_id ASC
754 ) AS thread_match_rank
755 FROM projection_thread_messages AS messages
756 INNER JOIN projection_threads AS threads
757 ON threads.thread_id = messages.thread_id
758 INNER JOIN projection_projects AS projects
759 ON projects.project_id = threads.project_id
760 WHERE threads.deleted_at IS NULL
761 AND threads.archived_at IS NULL
762 AND projects.deleted_at IS NULL
763 AND messages.is_streaming = 0
764 AND (
765 messages.role = 'user'
766 OR (
767 messages.role = 'assistant'
768 AND messages.message_id IN (
769 SELECT turns.assistant_message_id
770 FROM projection_turns AS turns
771 WHERE turns.assistant_message_id IS NOT NULL
772 )
773 )
774 )
775 AND messages.text LIKE ${pattern} ESCAPE '!'
776 )
777 SELECT
778 thread_id AS "threadId",
779 project_id AS "projectId",
780 source,
781 match_text AS "matchText",
782 message_created_at AS "messageCreatedAt"
783 FROM ranked
784 WHERE thread_match_rank = 1
785 ORDER BY
786 match_rank ASC,
787 thread_updated_at DESC,
788 thread_id ASC
789 LIMIT ${limit}
790 `,
791 });
792
688793 const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({
689794 Request: WorkspaceRootLookupInput,
690795 Result: ProjectionProjectLookupRowSchema,
......@@ -1758,6 +1863,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
17581863 ),
17591864 );
17601865
1866 const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn(
1867 "ProjectionSnapshotQuery.searchThreads",
1868 )(function* (input) {
1869 const escapedQuery = escapeLikePattern(input.query);
1870 const rows = yield* searchActiveThreadRows({
1871 pattern: `%${escapedQuery}%`,
1872 limit: input.limit ?? 50,
1873 }).pipe(
1874 Effect.mapError(
1875 toPersistenceSqlOrDecodeError(
1876 "ProjectionSnapshotQuery.searchThreads:query",
1877 "ProjectionSnapshotQuery.searchThreads:decodeRows",
1878 ),
1879 ),
1880 );
1881 return {
1882 matches: rows.map((row) => ({
1883 threadId: row.threadId,
1884 projectId: row.projectId,
1885 source: row.source,
1886 snippet: buildSearchSnippet(row.matchText, input.query),
1887 messageCreatedAt: row.messageCreatedAt,
1888 })),
1889 };
1890 });
1891
17611892 const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] =
17621893 (workspaceRoot) =>
17631894 getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe(
......@@ -2131,6 +2262,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
21312262 getSnapshot,
21322263 getShellSnapshot,
21332264 getArchivedShellSnapshot,
2265 searchThreads,
21342266 getSnapshotSequence,
21352267 getCounts,
21362268 getActiveProjectByWorkspaceRoot,
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10
......@@ -12,6 +12,8 @@ import type {
1212 OrchestrationProject,
1313 OrchestrationProjectShell,
1414 OrchestrationReadModel,
15 OrchestrationSearchThreadsInput,
16 OrchestrationSearchThreadsResult,
1517 OrchestrationShellSnapshot,
1618 OrchestrationThread,
1719 OrchestrationThreadDetailSnapshot,
......@@ -94,6 +96,14 @@ export interface ProjectionSnapshotQueryShape {
9496 ProjectionRepositoryError
9597 >;
9698
99 /**
100 * Search active thread navigation metadata, user messages, and canonical
101 * assistant outputs without hydrating thread detail snapshots.
102 */
103 readonly searchThreads: (
104 input: OrchestrationSearchThreadsInput,
105 ) => Effect.Effect<OrchestrationSearchThreadsResult, ProjectionRepositoryError>;
106
97107 /**
98108 * Read the latest projection snapshot sequence without hydrating read-model
99109 * entities.
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
......@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4444 getThreadShellById: () => Effect.die("unused"),
4545 getThreadDetailById: () => Effect.die("unused"),
4646 getThreadDetailSnapshot: () => Effect.die("unused"),
47 searchThreads: () => Effect.succeed({ matches: [] }),
4748 });
4849
4950const makeTerminalManagerLayer = (
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -212,6 +212,7 @@ describe("ProviderSessionReaper", () => {
212212 ),
213213 getThreadDetailById: () => Effect.die("unused"),
214214 getThreadDetailSnapshot: () => Effect.die("unused"),
215 searchThreads: () => Effect.succeed({ matches: [] }),
215216 }),
216217 ),
217218 Layer.provideMerge(NodeServices.layer),
apps/server/src/server.test.ts+30
......@@ -729,6 +729,7 @@ const buildAppUnderTest = (options?: {
729729 threads: [],
730730 updatedAt: "1970-01-01T00:00:00.000Z",
731731 }),
732 searchThreads: () => Effect.succeed({ matches: [] }),
732733 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
733734 getProjectShellById: () => Effect.succeed(Option.none()),
734735 getThreadShellById: () => Effect.succeed(Option.none()),
......@@ -5723,6 +5724,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
57235724 layers: {
57245725 projectionSnapshotQuery: {
57255726 getSnapshot: () => Effect.succeed(snapshot),
5727 searchThreads: () =>
5728 Effect.succeed({
5729 matches: [
5730 {
5731 threadId: ThreadId.make("thread-1"),
5732 projectId: ProjectId.make("project-a"),
5733 source: "assistant",
5734 snippet: "Search reached the final response.",
5735 messageCreatedAt: now,
5736 },
5737 ],
5738 }),
57265739 },
57275740 orchestrationEngine: {
57285741 dispatch: () => Effect.succeed({ sequence: 7 }),
......@@ -5780,6 +5793,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
57805793 ),
57815794 );
57825795 assert.equal(fullDiffResult.diff, "full-diff");
5796
5797 const searchResult = yield* Effect.scoped(
5798 withWsRpcClient(wsUrl, (client) =>
5799 client[ORCHESTRATION_WS_METHODS.searchThreads]({
5800 query: "final response",
5801 }),
5802 ),
5803 );
5804 assert.deepEqual(searchResult.matches, [
5805 {
5806 threadId: ThreadId.make("thread-1"),
5807 projectId: ProjectId.make("project-a"),
5808 source: "assistant",
5809 snippet: "Search reached the final response.",
5810 messageCreatedAt: now,
5811 },
5812 ]);
57835813 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
57845814 );
57855815
apps/server/src/serverRuntimeStartup.test.ts+4
......@@ -97,6 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
9797 getThreadShellById: () => Effect.succeed(Option.none()),
9898 getThreadDetailById: () => Effect.succeed(Option.none()),
9999 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
100 searchThreads: () => Effect.succeed({ matches: [] }),
100101 }),
101102 Effect.provideService(AnalyticsService.AnalyticsService, {
102103 record: () => Effect.void,
......@@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
160161 getThreadShellById: () => Effect.die("unused"),
161162 getThreadDetailById: () => Effect.die("unused"),
162163 getThreadDetailSnapshot: () => Effect.die("unused"),
164 searchThreads: () => Effect.succeed({ matches: [] }),
163165 }),
164166 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
165167 readEvents: () => Stream.empty,
......@@ -204,6 +206,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
204206 getThreadShellById: () => Effect.die("unused"),
205207 getThreadDetailById: () => Effect.die("unused"),
206208 getThreadDetailSnapshot: () => Effect.die("unused"),
209 searchThreads: () => Effect.succeed({ matches: [] }),
207210 }),
208211 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
209212 readEvents: () => Stream.empty,
......@@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
254257 getThreadShellById: () => Effect.die("unused"),
255258 getThreadDetailById: () => Effect.die("unused"),
256259 getThreadDetailSnapshot: () => Effect.die("unused"),
260 searchThreads: () => Effect.succeed({ matches: [] }),
257261 }),
258262 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
259263 readEvents: () => Stream.empty,
apps/server/src/ws.ts+15
......@@ -28,6 +28,7 @@ import {
2828 type OrchestrationThreadStreamItem,
2929 OrchestrationGetFullThreadDiffError,
3030 OrchestrationGetSnapshotError,
31 OrchestrationSearchThreadsError,
3132 OrchestrationGetTurnDiffError,
3233 ORCHESTRATION_WS_METHODS,
3334 type ProjectId,
......@@ -1103,6 +1104,20 @@ const makeWsRpcLayer = (
11031104 ),
11041105 { "rpc.aggregate": "orchestration" },
11051106 ),
1107 [ORCHESTRATION_WS_METHODS.searchThreads]: (input) =>
1108 observeRpcEffect(
1109 ORCHESTRATION_WS_METHODS.searchThreads,
1110 projectionSnapshotQuery.searchThreads(input).pipe(
1111 Effect.mapError(
1112 (cause) =>
1113 new OrchestrationSearchThreadsError({
1114 message: "Failed to search threads",
1115 cause,
1116 }),
1117 ),
1118 ),
1119 { "rpc.aggregate": "orchestration" },
1120 ),
11061121 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>
11071122 observeRpcStreamEffect(
11081123 ORCHESTRATION_WS_METHODS.subscribeShell,
apps/web/src/components/CommandPalette.logic.test.ts+23
......@@ -169,6 +169,29 @@ describe("buildThreadActionItems", () => {
169169 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
170170 });
171171
172 it("keeps message excerpts searchable without replacing thread metadata", () => {
173 const [item] = buildThreadActionItems({
174 threads: [makeThread({ branch: "feat/search" })],
175 projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]),
176 sortOrder: "updated_at",
177 icon: null,
178 getContentMatch: () => ({
179 source: "assistant",
180 snippet: "The relay reconnect is now bounded.",
181 query: "reconnect",
182 }),
183 runThread: async (_thread) => undefined,
184 });
185
186 expect(item?.searchTerms).toContain("The relay reconnect is now bounded.");
187 expect(item?.threadContentMatch).toEqual({
188 source: "assistant",
189 snippet: "The relay reconnect is now bounded.",
190 query: "reconnect",
191 });
192 expect(item?.description).toBe("T3 Code · #feat/search");
193 });
194
172195 it("filters archived threads out of thread search items", () => {
173196 const items = buildThreadActionItems({
174197 threads: [
apps/web/src/components/CommandPalette.logic.ts+16-1
......@@ -15,12 +15,19 @@ export const RECENT_THREAD_LIMIT = 12;
1515export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
1616export const ADDON_ICON_CLASS = "size-4";
1717
18export interface CommandPaletteThreadContentMatch {
19 readonly source: "user" | "assistant";
20 readonly snippet: string;
21 readonly query: string;
22}
23
1824export interface CommandPaletteItem {
1925 readonly kind: "action" | "submenu";
2026 readonly value: string;
2127 readonly searchTerms: ReadonlyArray<string>;
2228 readonly title: ReactNode;
2329 readonly description?: string;
30 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
2431 readonly timestamp?: string;
2532 readonly icon: ReactNode;
2633 readonly disabled?: boolean;
......@@ -114,6 +121,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
114121 renderLeadingContent?: (thread: TThread) => ReactNode;
115122 /** Optional content rendered inline after the title text per-thread. */
116123 renderTrailingContent?: (thread: TThread) => ReactNode;
124 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;
117125 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
118126 limit?: number;
119127}): CommandPaletteActionItem[] {
......@@ -140,12 +148,18 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
140148
141149 const leadingContent = input.renderLeadingContent?.(thread);
142150 const trailingContent = input.renderTrailingContent?.(thread);
151 const contentMatch = input.getContentMatch?.(thread);
143152
144153 return Object.assign(
145154 {
146155 kind: "action" as const,
147156 value: `thread:${thread.id}`,
148 searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``],
157 searchTerms: [
158 thread.title,
159 projectTitle ?? ``,
160 thread.branch ?? ``,
161 contentMatch?.snippet ?? ``,
162 ],
149163 title: thread.title,
150164 description: descriptionParts.join(` · `),
151165 timestamp: formatRelativeTimeLabel(
......@@ -155,6 +169,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
155169 },
156170 leadingContent ? { titleLeadingContent: leadingContent } : {},
157171 trailingContent ? { titleTrailingContent: trailingContent } : {},
172 contentMatch ? { threadContentMatch: contentMatch } : {},
158173 {
159174 run: async () => {
160175 await input.runThread(thread);
apps/web/src/components/CommandPalette.tsx+49-2
......@@ -3,6 +3,7 @@
33import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
44import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
55import { connectionStatusText } from "@t3tools/client-runtime/connection";
6import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
67import {
78 canPreloadBrowsePath,
89 createBrowseNavigationCoordinator,
......@@ -66,6 +67,7 @@ import { useAtomCommand } from "../state/use-atom-command";
6667import { useAtomQueryRunner } from "../state/use-atom-query-runner";
6768import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
6869import { useProjects, useThreadShells } from "../state/entities";
70import { useThreadSearch } from "../state/queries";
6971import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
7072import {
7173 appendBrowsePathSegment,
......@@ -510,6 +512,26 @@ function OpenCommandPaletteDialog(props: {
510512 const providers = useAtomValue(primaryServerProvidersAtom);
511513 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
512514 const currentView = viewStack.at(-1) ?? null;
515 const environmentIds = useMemo(
516 () =>
517 environments
518 .filter((environment) => environment.connection.phase === "connected")
519 .map((environment) => environment.environmentId),
520 [environments],
521 );
522 const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : "";
523 const threadSearch = useThreadSearch(environmentIds, threadSearchQuery);
524 const threadContentMatchByKey = useMemo(
525 () =>
526 new Map(
527 threadSearch.matches.flatMap((match) =>
528 match.source === "user" || match.source === "assistant"
529 ? [[threadSearchMatchKey(match), match] as const]
530 : [],
531 ),
532 ),
533 [threadSearch.matches],
534 );
513535 const [browseGeneration, setBrowseGeneration] = useState(0);
514536 const browseNavigationRef = useRef<ReturnType<typeof createBrowseNavigationCoordinator> | null>(
515537 null,
......@@ -916,6 +938,21 @@ function OpenCommandPaletteDialog(props: {
916938 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
917939 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
918940 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
941 getContentMatch: (thread) => {
942 const match = threadContentMatchByKey.get(
943 threadSearchMatchKey({
944 environmentId: thread.environmentId,
945 threadId: thread.id,
946 }),
947 );
948 return match && (match.source === "user" || match.source === "assistant")
949 ? {
950 source: match.source,
951 snippet: match.snippet,
952 query: threadSearchQuery,
953 }
954 : undefined;
955 },
919956 runThread: async (thread) => {
920957 await navigate({
921958 to: "/$environmentId/$threadId",
......@@ -923,7 +960,15 @@ function OpenCommandPaletteDialog(props: {
923960 });
924961 },
925962 }),
926 [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads],
963 [
964 activeThreadId,
965 clientSettings.sidebarThreadSortOrder,
966 navigate,
967 projectTitleById,
968 threadContentMatchByKey,
969 threadSearchQuery,
970 threads,
971 ],
927972 );
928973 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
929974
......@@ -2194,7 +2239,9 @@ function OpenCommandPaletteDialog(props: {
21942239 emptyStateMessage:
21952240 "Press Enter to create this folder and add it as a project.",
21962241 }
2197 : {})}
2242 : threadSearch.isPending
2243 ? { emptyStateMessage: "Searching thread messages…" }
2244 : {})}
21982245 />
21992246 </CommandPanel>
22002247 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
apps/web/src/components/CommandPaletteResults.tsx+81-8
......@@ -16,6 +16,69 @@ import {
1616} from "./ui/command";
1717import { cn } from "~/lib/utils";
1818
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
1982interface CommandPaletteResultsProps {
2083 emptyStateMessage?: string;
2184 groups: ReadonlyArray<CommandPaletteGroup>;
......@@ -69,15 +132,20 @@ function DisabledCommandPaletteResultRow(props: {
69132 return (
70133 <div className="flex min-h-8 select-none items-center gap-2 rounded-sm px-2 py-1.5 text-base opacity-64 sm:min-h-7 sm:text-sm">
71134 {props.item.icon}
72 {props.item.description ? (
135 {props.item.description || props.item.threadContentMatch ? (
73136 <span className="flex min-w-0 flex-1 flex-col">
74137 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
75138 {props.item.titleLeadingContent}
76139 <span className="truncate">{props.item.title}</span>
77140 </span>
78 <span className="truncate text-muted-foreground/85 text-xs">
79 {props.item.description}
80 </span>
141 {props.item.threadContentMatch ? (
142 <ThreadContentMatch match={props.item.threadContentMatch} />
143 ) : null}
144 {props.item.description ? (
145 <span className="truncate text-muted-foreground/70 text-xs">
146 {props.item.description}
147 </span>
148 ) : null}
81149 </span>
82150 ) : (
83151 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
......@@ -115,15 +183,20 @@ function CommandPaletteResultRow(props: {
115183 }}
116184 >
117185 {props.item.icon}
118 {props.item.description ? (
186 {props.item.description || props.item.threadContentMatch ? (
119187 <span className="flex min-w-0 flex-1 flex-col">
120188 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
121189 {props.item.titleLeadingContent}
122190 <span className="truncate">{props.item.title}</span>
123191 </span>
124 <span className="truncate text-muted-foreground/85 text-xs">
125 {props.item.description}
126 </span>
192 {props.item.threadContentMatch ? (
193 <ThreadContentMatch match={props.item.threadContentMatch} />
194 ) : null}
195 {props.item.description ? (
196 <span className="truncate text-muted-foreground/70 text-xs">
197 {props.item.description}
198 </span>
199 ) : null}
127200 </span>
128201 ) : (
129202 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
apps/web/src/state/queries.ts+45
......@@ -3,6 +3,11 @@ import {
33 type CheckpointDiffTarget,
44 type ComposerPathSearchTarget,
55} from "@t3tools/client-runtime/state/threads";
6import {
7 createThreadSearchResultsAtomFamily,
8 makeThreadSearchKey,
9 type EnvironmentThreadSearchMatch,
10} from "@t3tools/client-runtime/state/thread-search";
611import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
712import type {
813 EnvironmentId,
......@@ -26,9 +31,24 @@ import { vcsEnvironment } from "./vcs";
2631
2732const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
2833const COMPOSER_PATH_SEARCH_LIMIT = 80;
34const THREAD_SEARCH_DEBOUNCE_MS = 200;
2935const VCS_REF_LIST_LIMIT = 100;
3036const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
3137const INITIAL_BRANCH_CURSORS = [undefined] as const;
38const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
39const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
40 matches: EMPTY_THREAD_SEARCH_MATCHES,
41 isLoading: false,
42}).pipe(Atom.withLabel("web:thread-search:empty"));
43
44const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({
45 getSearchAtom: (environmentId, query) =>
46 orchestrationEnvironment.threadSearch({
47 environmentId,
48 input: { query },
49 }),
50 labelPrefix: "web:thread-search",
51});
3252
3353export interface ThreadDetailView {
3454 readonly data: OrchestrationThread | null;
......@@ -52,6 +72,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {
5272 return debounced;
5373}
5474
75export function useThreadSearch(
76 environmentIds: ReadonlyArray<EnvironmentId>,
77 query: string,
78): {
79 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
80 readonly isPending: boolean;
81} {
82 const normalizedQuery = query.trim();
83 const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
84 const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2;
85 const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;
86 const searchKey = useMemo(
87 () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)),
88 [environmentIds, settledQuery],
89 );
90 const result = useAtomValue(
91 searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey),
92 );
93 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
94 return {
95 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
96 isPending: canSearch && (isDebouncing || result.isLoading),
97 };
98}
99
55100export function useThreadDetail(
56101 environmentId: EnvironmentId | null,
57102 threadId: ThreadId | null,
docs/user/keybindings.md+5
......@@ -69,6 +69,11 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
6969- `editor.openFavorite`: open current project/worktree in the last-used editor
7070- `script.{id}.run`: run a project script by id (for example `script.test.run`)
7171
72The command palette searches active thread titles, projects, branches, user messages, and final
73agent responses across connected environments. Message matches show one labeled excerpt while
74keeping the thread's project, branch, and machine context visible. Message search begins after two
75characters and uses SQLite's ASCII case-insensitive matching.
76
7277### Key Syntax
7378
7479Supported modifiers:
packages/client-runtime/package.json+4
......@@ -131,6 +131,10 @@
131131 "types": "./src/state/threadSettled.ts",
132132 "default": "./src/state/threadSettled.ts"
133133 },
134 "./state/thread-search": {
135 "types": "./src/state/threadSearch.ts",
136 "default": "./src/state/threadSearch.ts"
137 },
134138 "./state/vcs": {
135139 "types": "./src/state/vcs.ts",
136140 "default": "./src/state/vcs.ts"
packages/client-runtime/src/state/orchestration.ts+6
......@@ -16,6 +16,12 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
1616 label: "environment-data:orchestration:full-thread-diff",
1717 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,
1818 }),
19 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {
20 label: "environment-data:orchestration:thread-search",
21 tag: ORCHESTRATION_WS_METHODS.searchThreads,
22 staleTimeMs: 30_000,
23 idleTtlMs: 60_000,
24 }),
1925 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {
2026 label: "environment-data:orchestration:archived-shell-snapshot",
2127 tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
packages/client-runtime/src/state/threadSearch.test.ts created+72
......@@ -0,0 +1,72 @@
1import {
2 EnvironmentId,
3 ProjectId,
4 ThreadId,
5 type OrchestrationSearchThreadsResult,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
9import { expect, it } from "vite-plus/test";
10
11import {
12 createThreadSearchResultsAtomFamily,
13 makeThreadSearchKey,
14 threadSearchMatchKey,
15} from "./threadSearch.ts";
16
17const envA = EnvironmentId.make("env-a");
18const envB = EnvironmentId.make("env-b");
19
20it("creates stable keys regardless of environment order", () => {
21 expect(makeThreadSearchKey([envB, envA], "needle")).toBe(
22 makeThreadSearchKey([envA, envB], "needle"),
23 );
24});
25
26it("encodes scoped thread keys without delimiter collisions", () => {
27 const first = threadSearchMatchKey({
28 environmentId: EnvironmentId.make("env\u0000thread"),
29 threadId: ThreadId.make("id"),
30 });
31 const second = threadSearchMatchKey({
32 environmentId: EnvironmentId.make("env"),
33 threadId: ThreadId.make("thread\u0000id"),
34 });
35
36 expect(first).not.toBe(second);
37});
38
39it("merges successful environments and silently ignores failures", () => {
40 const result: OrchestrationSearchThreadsResult = {
41 matches: [
42 {
43 threadId: ThreadId.make("thread-a"),
44 projectId: ProjectId.make("project-a"),
45 source: "user",
46 snippet: "needle",
47 messageCreatedAt: "2026-07-30T00:00:00.000Z",
48 },
49 ],
50 };
51 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
52 getSearchAtom: (environmentId) =>
53 environmentId === envA
54 ? Atom.make(AsyncResult.success(result))
55 : Atom.make(
56 AsyncResult.failure<OrchestrationSearchThreadsResult, Error>(
57 Cause.fail(new Error("unsupported rpc")),
58 ),
59 ),
60 labelPrefix: "test:thread-search",
61 });
62 const registry = AtomRegistry.make();
63
64 const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle")));
65 expect(state).toEqual({
66 matches: [{ ...result.matches[0], environmentId: envA }],
67 isLoading: false,
68 });
69 expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]');
70
71 registry.dispose();
72});
packages/client-runtime/src/state/threadSearch.ts created+81
......@@ -0,0 +1,81 @@
1import {
2 EnvironmentId,
3 OrchestrationSearchThreadsInput,
4 type OrchestrationSearchThreadsResult,
5 type OrchestrationThreadSearchMatch,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";
10
11export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch {
12 readonly environmentId: EnvironmentId;
13}
14
15export interface ThreadSearchResultsState {
16 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
17 readonly isLoading: boolean;
18}
19
20const ThreadSearchKey = Schema.Tuple([
21 Schema.Array(EnvironmentId),
22 OrchestrationSearchThreadsInput.fields.query,
23]);
24const decodeThreadSearchKey = Schema.decodeUnknownSync(ThreadSearchKey);
25
26export function makeThreadSearchKey(
27 environmentIds: ReadonlyArray<EnvironmentId>,
28 query: string,
29): string {
30 return JSON.stringify([
31 [...environmentIds].toSorted((left, right) => left.localeCompare(right)),
32 query,
33 ]);
34}
35
36function parseThreadSearchKey(key: string) {
37 return decodeThreadSearchKey(JSON.parse(key));
38}
39
40export function threadSearchMatchKey(
41 match: Pick<EnvironmentThreadSearchMatch, "environmentId" | "threadId">,
42): string {
43 return JSON.stringify([match.environmentId, match.threadId]);
44}
45
46/**
47 * Combines one search query atom per environment. Failed and disconnected
48 * environments contribute no content matches, preserving local title search
49 * as the compatibility fallback.
50 */
51export function createThreadSearchResultsAtomFamily<E>(options: {
52 readonly getSearchAtom: (
53 environmentId: EnvironmentId,
54 query: string,
55 ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationSearchThreadsResult, E>>;
56 readonly labelPrefix: string;
57}) {
58 return Atom.family((key: string) =>
59 Atom.make((get): ThreadSearchResultsState => {
60 const [environmentIds, query] = parseThreadSearchKey(key);
61 const matches: EnvironmentThreadSearchMatch[] = [];
62 let isLoading = false;
63
64 for (const environmentId of environmentIds) {
65 const result = get(options.getSearchAtom(environmentId, query));
66 isLoading ||= result.waiting;
67 const value = Option.getOrNull(AsyncResult.value(result));
68 if (value !== null) {
69 matches.push(
70 ...value.matches.map((match) => ({
71 ...match,
72 environmentId,
73 })),
74 );
75 }
76 }
77
78 return { matches, isLoading };
79 }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)),
80 );
81}
packages/contracts/src/orchestration.ts+39
......@@ -18,6 +18,7 @@ import {
1818 ProviderItemId,
1919 ThreadId,
2020 TrimmedNonEmptyString,
21 TrimmedString,
2122 TurnId,
2223} from "./baseSchemas.ts";
2324import { ProviderInstanceId } from "./providerInstance.ts";
......@@ -26,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = {
2627 dispatchCommand: "orchestration.dispatchCommand",
2728 getTurnDiff: "orchestration.getTurnDiff",
2829 getFullThreadDiff: "orchestration.getFullThreadDiff",
30 searchThreads: "orchestration.searchThreads",
2931 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
3032 subscribeShell: "orchestration.subscribeShell",
3133 subscribeThread: "orchestration.subscribeThread",
......@@ -1394,6 +1396,31 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr
13941396export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;
13951397export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type;
13961398
1399export const OrchestrationThreadSearchSource = Schema.Literals(["user", "assistant"]);
1400export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSource.Type;
1401
1402// The server's SQLite client is synchronous and single-connection. Bound both
1403// scan input and response size so a search cannot monopolize that connection.
1404export const OrchestrationSearchThreadsInput = Schema.Struct({
1405 query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)),
1406 limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))),
1407});
1408export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsInput.Type;
1409
1410export const OrchestrationThreadSearchMatch = Schema.Struct({
1411 threadId: ThreadId,
1412 projectId: ProjectId,
1413 source: OrchestrationThreadSearchSource,
1414 snippet: Schema.String.check(Schema.isMaxLength(240)),
1415 messageCreatedAt: Schema.NullOr(IsoDateTime),
1416});
1417export type OrchestrationThreadSearchMatch = typeof OrchestrationThreadSearchMatch.Type;
1418
1419export const OrchestrationSearchThreadsResult = Schema.Struct({
1420 matches: Schema.Array(OrchestrationThreadSearchMatch),
1421});
1422export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type;
1423
13971424export const OrchestrationRpcSchemas = {
13981425 dispatchCommand: {
13991426 input: ClientOrchestrationCommand,
......@@ -1407,6 +1434,10 @@ export const OrchestrationRpcSchemas = {
14071434 input: OrchestrationGetFullThreadDiffInput,
14081435 output: OrchestrationGetFullThreadDiffResult,
14091436 },
1437 searchThreads: {
1438 input: OrchestrationSearchThreadsInput,
1439 output: OrchestrationSearchThreadsResult,
1440 },
14101441 getArchivedShellSnapshot: {
14111442 input: Schema.Struct({}),
14121443 output: OrchestrationShellSnapshot,
......@@ -1452,3 +1483,11 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass
14521483 cause: Schema.optional(Schema.Defect()),
14531484 },
14541485) {}
1486
1487export class OrchestrationSearchThreadsError extends Schema.TaggedErrorClass<OrchestrationSearchThreadsError>()(
1488 "OrchestrationSearchThreadsError",
1489 {
1490 message: TrimmedNonEmptyString,
1491 cause: Schema.optional(Schema.Defect()),
1492 },
1493) {}
packages/contracts/src/rpc.ts+9
......@@ -57,6 +57,8 @@ import {
5757 OrchestrationGetFullThreadDiffError,
5858 OrchestrationGetFullThreadDiffInput,
5959 OrchestrationGetSnapshotError,
60 OrchestrationSearchThreadsError,
61 OrchestrationSearchThreadsInput,
6062 OrchestrationGetTurnDiffError,
6163 OrchestrationGetTurnDiffInput,
6264 OrchestrationRpcSchemas,
......@@ -690,6 +692,12 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
690692 },
691693);
692694
695export const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, {
696 payload: OrchestrationSearchThreadsInput,
697 success: OrchestrationRpcSchemas.searchThreads.output,
698 error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]),
699});
700
693701export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
694702 ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
695703 {
......@@ -840,6 +848,7 @@ export const WsRpcGroup = RpcGroup.make(
840848 WsOrchestrationDispatchCommandRpc,
841849 WsOrchestrationGetTurnDiffRpc,
842850 WsOrchestrationGetFullThreadDiffRpc,
851 WsOrchestrationSearchThreadsRpc,
843852 WsOrchestrationGetArchivedShellSnapshotRpc,
844853 WsOrchestrationSubscribeShellRpc,
845854 WsOrchestrationSubscribeThreadRpc,