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() {...@@ -25,7 +25,7 @@ export function HomeRouteScreen() {
25 const { layout } = useAdaptiveWorkspaceLayout();25 const { layout } = useAdaptiveWorkspaceLayout();
26 const projects = useProjects();26 const projects = useProjects();
27 const threads = useThreadShells();27 const threads = useThreadShells();
28 const { state: catalogState } = useWorkspaceState();28 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
29 const { savedConnectionsById } = useSavedRemoteConnections();29 const { savedConnectionsById } = useSavedRemoteConnections();
30 const navigation = useNavigation();30 const navigation = useNavigation();
31 const [searchQuery, setSearchQuery] = useState("");31 const [searchQuery, setSearchQuery] = useState("");
...@@ -33,20 +33,22 @@ export function HomeRouteScreen() {...@@ -33,20 +33,22 @@ export function HomeRouteScreen() {
33 useThreadListActions();33 useThreadListActions();
34 const pendingTasks = usePendingNewTasks();34 const pendingTasks = usePendingNewTasks();
35 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();35 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
36 const environments = useMemo(36 const environments = useMemo(() => {
37 () =>37 const connectionStateByEnvironmentId = new Map(
38 Arr.sort(38 workspaceEnvironments.map(
39 Object.values(savedConnectionsById).map((connection) => ({39 (environment) => [environment.environmentId, environment.connectionState] as const,
40 environmentId: connection.environmentId,
41 label: connection.environmentLabel,
42 })),
43 Order.mapInput(
44 Order.String,
45 (environment: { readonly label: string }) => environment.label,
46 ),
47 ),40 ),
48 [savedConnectionsById],41 );
49 );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]);
50 const availableEnvironmentIds = useMemo(52 const availableEnvironmentIds = useMemo(
51 () => new Set(environments.map((environment) => environment.environmentId)),53 () => new Set(environments.map((environment) => environment.environmentId)),
52 [environments],54 [environments],
apps/mobile/src/features/home/HomeScreen.tsx+97-27
...@@ -7,6 +7,10 @@ import {...@@ -7,6 +7,10 @@ import {
7 type EnvironmentProject,7 type EnvironmentProject,
8 type EnvironmentThreadShell,8 type EnvironmentThreadShell,
9} from "@t3tools/client-runtime/state/shell";9} from "@t3tools/client-runtime/state/shell";
10import {
11 threadSearchMatchKey,
12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";
10import type {14import type {
11 EnvironmentId,15 EnvironmentId,
12 SidebarProjectGroupingMode,16 SidebarProjectGroupingMode,
...@@ -22,11 +26,12 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -22,11 +26,12 @@ import { useThemeColor } from "../../lib/useThemeColor";
2226
23import { AppText as Text } from "../../components/AppText";27import { AppText as Text } from "../../components/AppText";
24import { EmptyState } from "../../components/EmptyState";28import { EmptyState } from "../../components/EmptyState";
25import type { WorkspaceState } from "../../state/workspaceModel";29import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
26import type { SavedRemoteConnection } from "../../lib/connection";30import type { SavedRemoteConnection } from "../../lib/connection";
27import { scopedProjectKey } from "../../lib/scopedEntities";31import { scopedProjectKey } from "../../lib/scopedEntities";
28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";32import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";33import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
34import { useThreadSearch } from "../../state/queries";
30import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";35import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
31import { environmentServerConfigsAtom } from "../../state/server";36import { environmentServerConfigsAtom } from "../../state/server";
32import type { PendingNewTask } from "../../state/use-pending-new-tasks";37import type { PendingNewTask } from "../../state/use-pending-new-tasks";
...@@ -72,7 +77,9 @@ interface HomeScreenProps {...@@ -72,7 +77,9 @@ interface HomeScreenProps {
72 readonly pendingTasks: ReadonlyArray<PendingNewTask>;77 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
73 readonly catalogState: WorkspaceState;78 readonly catalogState: WorkspaceState;
74 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;79 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
75 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;80 readonly environments: ReadonlyArray<
81 HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState">
82 >;
76 readonly searchQuery: string;83 readonly searchQuery: string;
77 readonly selectedEnvironmentId: EnvironmentId | null;84 readonly selectedEnvironmentId: EnvironmentId | null;
78 readonly selectedProjectKey: string | null;85 readonly selectedProjectKey: string | null;
...@@ -189,6 +196,35 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -189,6 +196,35 @@ export function HomeScreen(props: HomeScreenProps) {
189 const listRef = useRef<LegendListRef | null>(null);196 const listRef = useRef<LegendListRef | null>(null);
190 const insets = useSafeAreaInsets();197 const insets = useSafeAreaInsets();
191 const accentColor = useThemeColor("--color-icon-muted");198 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 );
192 const effectiveGroupDisplayStates = useMemo(() => {228 const effectiveGroupDisplayStates = useMemo(() => {
193 const next = new Map(groupDisplayStates);229 const next = new Map(groupDisplayStates);
194 if (!AsyncResult.isSuccess(preferencesResult)) {230 if (!AsyncResult.isSuccess(preferencesResult)) {
...@@ -318,6 +354,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -318,6 +354,7 @@ export function HomeScreen(props: HomeScreenProps) {
318 pendingTasks: scopedPendingTasks,354 pendingTasks: scopedPendingTasks,
319 environmentId: props.selectedEnvironmentId,355 environmentId: props.selectedEnvironmentId,
320 searchQuery: props.searchQuery,356 searchQuery: props.searchQuery,
357 matchedThreadKeys,
321 projectSortOrder: props.projectSortOrder,358 projectSortOrder: props.projectSortOrder,
322 threadSortOrder: props.threadSortOrder,359 threadSortOrder: props.threadSortOrder,
323 projectGroupingMode: props.projectGroupingMode,360 projectGroupingMode: props.projectGroupingMode,
...@@ -328,6 +365,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -328,6 +365,7 @@ export function HomeScreen(props: HomeScreenProps) {
328 props.searchQuery,365 props.searchQuery,
329 props.selectedEnvironmentId,366 props.selectedEnvironmentId,
330 props.threadSortOrder,367 props.threadSortOrder,
368 matchedThreadKeys,
331 scopedPendingTasks,369 scopedPendingTasks,
332 scopedProjects,370 scopedProjects,
333 scopedThreads,371 scopedThreads,
...@@ -516,6 +554,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -516,6 +554,7 @@ export function HomeScreen(props: HomeScreenProps) {
516 environmentId: props.selectedEnvironmentId,554 environmentId: props.selectedEnvironmentId,
517 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,555 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
518 searchQuery: props.searchQuery,556 searchQuery: props.searchQuery,
557 matchedThreadKeys,
519 changeRequestStateByKey,558 changeRequestStateByKey,
520 settlementEnvironmentIds,559 settlementEnvironmentIds,
521 snoozeEnvironmentIds,560 snoozeEnvironmentIds,
...@@ -533,6 +572,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -533,6 +572,7 @@ export function HomeScreen(props: HomeScreenProps) {
533 props.searchQuery,572 props.searchQuery,
534 props.selectedEnvironmentId,573 props.selectedEnvironmentId,
535 props.threads,574 props.threads,
575 matchedThreadKeys,
536 threadListV2Enabled,576 threadListV2Enabled,
537 v2ScopedProjectGroup,577 v2ScopedProjectGroup,
538 ]);578 ]);
...@@ -629,6 +669,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -629,6 +669,13 @@ export function HomeScreen(props: HomeScreenProps) {
629 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)669 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
630 : null670 : null
631 }671 }
672 searchMatch={threadSearchMatchByKey.get(
673 threadSearchMatchKey({
674 environmentId: thread.environmentId,
675 threadId: thread.id,
676 }),
677 )}
678 searchQuery={props.searchQuery}
632 onSelectThread={props.onSelectThread}679 onSelectThread={props.onSelectThread}
633 onDeleteThread={handleDeleteThread}680 onDeleteThread={handleDeleteThread}
634 onArchiveThread={props.onArchiveThread}681 onArchiveThread={props.onArchiveThread}
...@@ -660,7 +707,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -660,7 +707,9 @@ export function HomeScreen(props: HomeScreenProps) {
660 props.savedConnectionsById,707 props.savedConnectionsById,
661 serverConfigs,708 serverConfigs,
662 settlementEnvironmentIds,709 settlementEnvironmentIds,
710 threadSearchMatchByKey,
663 v2ProjectTitleByProjectKey,711 v2ProjectTitleByProjectKey,
712 props.searchQuery,
664 ],713 ],
665 );714 );
666 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);715 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
...@@ -675,19 +724,28 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -675,19 +724,28 @@ export function HomeScreen(props: HomeScreenProps) {
675 projectTitleByProjectKey: v2ProjectTitleByProjectKey,724 projectTitleByProjectKey: v2ProjectTitleByProjectKey,
676 serverConfigs,725 serverConfigs,
677 savedConnectionsById: props.savedConnectionsById,726 savedConnectionsById: props.savedConnectionsById,
727 searchQuery: props.searchQuery,
728 threadSearchMatchByKey,
678 }),729 }),
679 [730 [
680 projectByKey,731 projectByKey,
681 projectCwdByKey,732 projectCwdByKey,
733 props.searchQuery,
682 props.savedConnectionsById,734 props.savedConnectionsById,
683 serverConfigs,735 serverConfigs,
736 threadSearchMatchByKey,
684 v2ProjectTitleByProjectKey,737 v2ProjectTitleByProjectKey,
685 ],738 ],
686 );739 );
687740
688 const extraData = useMemo(741 const extraData = useMemo(
689 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),742 () => ({
690 [props.savedConnectionsById, projectCwdByKey],743 projectCwdByKey,
744 savedConnectionsById: props.savedConnectionsById,
745 searchQuery: props.searchQuery,
746 threadSearchMatchByKey,
747 }),
748 [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
691 );749 );
692750
693 const renderItem = useCallback(751 const renderItem = useCallback(
...@@ -740,6 +798,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -740,6 +798,13 @@ export function HomeScreen(props: HomeScreenProps) {
740 null798 null
741 }799 }
742 isLast={item.isLast}800 isLast={item.isLast}
801 searchMatch={threadSearchMatchByKey.get(
802 threadSearchMatchKey({
803 environmentId: thread.environmentId,
804 threadId: thread.id,
805 }),
806 )}
807 searchQuery={props.searchQuery}
743 onArchiveThread={props.onArchiveThread}808 onArchiveThread={props.onArchiveThread}
744 onDeleteThread={props.onDeleteThread}809 onDeleteThread={props.onDeleteThread}
745 onSelectThread={props.onSelectThread}810 onSelectThread={props.onSelectThread}
...@@ -770,7 +835,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -770,7 +835,9 @@ export function HomeScreen(props: HomeScreenProps) {
770 props.onNewThreadInProject,835 props.onNewThreadInProject,
771 props.onSelectPendingTask,836 props.onSelectPendingTask,
772 props.onSelectThread,837 props.onSelectThread,
838 props.searchQuery,
773 props.savedConnectionsById,839 props.savedConnectionsById,
840 threadSearchMatchByKey,
774 updateGroupDisplay,841 updateGroupDisplay,
775 ],842 ],
776 );843 );
...@@ -863,7 +930,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -863,7 +930,7 @@ export function HomeScreen(props: HomeScreenProps) {
863 const v2ListHeader = listHeader;930 const v2ListHeader = listHeader;
864931
865 const listEmpty = !hasResults ? (932 const listEmpty = !hasResults ? (
866 hasSearchQuery ? (933 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
867 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />934 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
868 ) : selectedProjectScope !== null ? (935 ) : selectedProjectScope !== null ? (
869 <EmptyState936 <EmptyState
...@@ -886,31 +953,34 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -886,31 +953,34 @@ export function HomeScreen(props: HomeScreenProps) {
886 // threads yet" over an inbox that is merely all-snoozed reads as data953 // threads yet" over an inbox that is merely all-snoozed reads as data
887 // loss.954 // loss.
888 const v2SnoozedCount = threadListV2Layout.snoozedCount;955 const v2SnoozedCount = threadListV2Layout.snoozedCount;
889 const v2ListEmpty = hasSearchQuery ? (956 const v2ListEmpty =
890 v2SnoozedCount > 0 ? (957 hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? (
891 // The snoozed threads already passed this search filter: "No958 v2SnoozedCount > 0 ? (
892 // results" would claim nothing matched when matches are merely959 // The snoozed threads already passed this search filter: "No
893 // parked.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 ? (
894 <EmptyState977 <EmptyState
895 title={v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`}978 title={`No threads in ${v2ScopedProjectGroup.title}`}
896 detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}979 detail="Choose another project or create a new task."
897 />980 />
898 ) : (981 ) : (
899 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />982 listEmpty
900 )983 );
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 );
914984
915 if (threadListV2Enabled) {985 if (threadListV2Enabled) {
916 return (986 return (
apps/mobile/src/features/home/homeThreadList.test.ts+28
...@@ -2,6 +2,7 @@ import type {...@@ -2,6 +2,7 @@ import type {
2 EnvironmentProject,2 EnvironmentProject,
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";6import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
78
...@@ -661,6 +662,33 @@ describe("buildHomeThreadGroups", () => {...@@ -661,6 +662,33 @@ describe("buildHomeThreadGroups", () => {
661 );662 );
662 });663 });
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
664 it("targets quick new threads at the group member with the newest thread", () => {692 it("targets quick new threads at the group member with the newest thread", () => {
665 const laptopEnv = EnvironmentId.make("environment-laptop");693 const laptopEnv = EnvironmentId.make("environment-laptop");
666 const desktopEnv = EnvironmentId.make("environment-desktop");694 const desktopEnv = EnvironmentId.make("environment-desktop");
apps/mobile/src/features/home/homeThreadList.ts+12-1
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 sortThreads,12 sortThreads,
13 toSortableTimestamp,13 toSortableTimestamp,
14} from "@t3tools/client-runtime/state/thread-sort";14} from "@t3tools/client-runtime/state/thread-sort";
15import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
15import type {16import type {
16 EnvironmentId,17 EnvironmentId,
17 ScopedProjectRef,18 ScopedProjectRef,
...@@ -254,6 +255,7 @@ export function buildHomeThreadGroups(input: {...@@ -254,6 +255,7 @@ export function buildHomeThreadGroups(input: {
254 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;255 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
255 readonly environmentId: EnvironmentId | null;256 readonly environmentId: EnvironmentId | null;
256 readonly searchQuery: string;257 readonly searchQuery: string;
258 readonly matchedThreadKeys?: ReadonlySet<string>;
257 readonly projectSortOrder: HomeProjectSortOrder;259 readonly projectSortOrder: HomeProjectSortOrder;
258 readonly threadSortOrder: SidebarThreadSortOrder;260 readonly threadSortOrder: SidebarThreadSortOrder;
259 readonly projectGroupingMode: SidebarProjectGroupingMode;261 readonly projectGroupingMode: SidebarProjectGroupingMode;
...@@ -350,7 +352,16 @@ export function buildHomeThreadGroups(input: {...@@ -350,7 +352,16 @@ export function buildHomeThreadGroups(input: {
350 group.projects.some((project) => project.title.toLocaleLowerCase().includes(query));352 group.projects.some((project) => project.title.toLocaleLowerCase().includes(query));
351 const matchingThreads = groupMatches353 const matchingThreads = groupMatches
352 ? group.threads354 ? 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 );
354 const matchingPendingTasks = groupMatches365 const matchingPendingTasks = groupMatches
355 ? group.pendingTasks366 ? group.pendingTasks
356 : group.pendingTasks.filter((pendingTask) =>367 : group.pendingTasks.filter((pendingTask) =>
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+73-9
...@@ -3,6 +3,10 @@ import type {...@@ -3,6 +3,10 @@ import type {
3 EnvironmentProject,3 EnvironmentProject,
4 EnvironmentThreadShell,4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import {
7 threadSearchMatchKey,
8 type EnvironmentThreadSearchMatch,
9} from "@t3tools/client-runtime/state/thread-search";
6import { LegendList } from "@legendapp/list/react-native";10import { LegendList } from "@legendapp/list/react-native";
7import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
8import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
...@@ -24,6 +28,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -24,6 +28,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
24import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";28import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
25import { useThemeColor } from "../../lib/useThemeColor";29import { useThemeColor } from "../../lib/useThemeColor";
26import { useProjects, useThreadShells } from "../../state/entities";30import { useProjects, useThreadShells } from "../../state/entities";
31import { useThreadSearch } from "../../state/queries";
27import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";32import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
28import { environmentServerConfigsAtom } from "../../state/server";33import { environmentServerConfigsAtom } from "../../state/server";
29import { usePendingNewTasks } from "../../state/use-pending-new-tasks";34import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
...@@ -180,7 +185,7 @@ function ThreadNavigationSidebarPane(...@@ -180,7 +185,7 @@ function ThreadNavigationSidebarPane(
180 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";185 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
181 const projects = useProjects();186 const projects = useProjects();
182 const threads = useThreadShells();187 const threads = useThreadShells();
183 const { state: catalogState } = useWorkspaceState();188 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
184 const { savedConnectionsById } = useSavedRemoteConnections();189 const { savedConnectionsById } = useSavedRemoteConnections();
185 const [headerIsOverContent, setHeaderIsOverContent] = useState(false);190 const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
186 const searchInputRef = useRef<TextInput>(null);191 const searchInputRef = useRef<TextInput>(null);
...@@ -209,6 +214,35 @@ function ThreadNavigationSidebarPane(...@@ -209,6 +214,35 @@ function ThreadNavigationSidebarPane(
209 );214 );
210 const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } =215 const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } =
211 useHomeListOptions(availableEnvironmentIds);216 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 );
212 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);246 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
213 const projectScopes = useMemo(247 const projectScopes = useMemo(
214 () =>248 () =>
...@@ -305,11 +339,19 @@ function ThreadNavigationSidebarPane(...@@ -305,11 +339,19 @@ function ThreadNavigationSidebarPane(
305 pendingTasks: scopedPendingTasks,339 pendingTasks: scopedPendingTasks,
306 environmentId: options.selectedEnvironmentId,340 environmentId: options.selectedEnvironmentId,
307 searchQuery: props.searchQuery,341 searchQuery: props.searchQuery,
342 matchedThreadKeys,
308 projectSortOrder: options.projectSortOrder,343 projectSortOrder: options.projectSortOrder,
309 threadSortOrder: options.threadSortOrder,344 threadSortOrder: options.threadSortOrder,
310 projectGroupingMode: options.projectGroupingMode,345 projectGroupingMode: options.projectGroupingMode,
311 }),346 }),
312 [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads],347 [
348 matchedThreadKeys,
349 options,
350 props.searchQuery,
351 scopedPendingTasks,
352 scopedProjects,
353 scopedThreads,
354 ],
313 );355 );
314 const [groupDisplayStates, setGroupDisplayStates] = useState<356 const [groupDisplayStates, setGroupDisplayStates] = useState<
315 ReadonlyMap<string, HomeGroupDisplayState>357 ReadonlyMap<string, HomeGroupDisplayState>
...@@ -432,6 +474,7 @@ function ThreadNavigationSidebarPane(...@@ -432,6 +474,7 @@ function ThreadNavigationSidebarPane(
432 environmentId: options.selectedEnvironmentId,474 environmentId: options.selectedEnvironmentId,
433 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,475 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
434 searchQuery: props.searchQuery,476 searchQuery: props.searchQuery,
477 matchedThreadKeys,
435 changeRequestStateByKey,478 changeRequestStateByKey,
436 settlementEnvironmentIds,479 settlementEnvironmentIds,
437 snoozeEnvironmentIds,480 snoozeEnvironmentIds,
...@@ -445,6 +488,7 @@ function ThreadNavigationSidebarPane(...@@ -445,6 +488,7 @@ function ThreadNavigationSidebarPane(
445 snoozeWakeTick,488 snoozeWakeTick,
446 options.selectedEnvironmentId,489 options.selectedEnvironmentId,
447 props.searchQuery,490 props.searchQuery,
491 matchedThreadKeys,
448 settledVisibleCount,492 settledVisibleCount,
449 settlementEnvironmentIds,493 settlementEnvironmentIds,
450 snoozeEnvironmentIds,494 snoozeEnvironmentIds,
...@@ -687,6 +731,7 @@ function ThreadNavigationSidebarPane(...@@ -687,6 +731,7 @@ function ThreadNavigationSidebarPane(
687 projectTitleByProjectKey,731 projectTitleByProjectKey,
688 savedConnectionsById,732 savedConnectionsById,
689 serverConfigs,733 serverConfigs,
734 threadSearchMatchByKey,
690 }),735 }),
691 [736 [
692 props.selectedThreadKey,737 props.selectedThreadKey,
...@@ -695,6 +740,7 @@ function ThreadNavigationSidebarPane(...@@ -695,6 +740,7 @@ function ThreadNavigationSidebarPane(
695 projectTitleByProjectKey,740 projectTitleByProjectKey,
696 savedConnectionsById,741 savedConnectionsById,
697 serverConfigs,742 serverConfigs,
743 threadSearchMatchByKey,
698 ],744 ],
699 );745 );
700 const sidebarItemsAreEqual = useCallback(746 const sidebarItemsAreEqual = useCallback(
...@@ -797,6 +843,13 @@ function ThreadNavigationSidebarPane(...@@ -797,6 +843,13 @@ function ThreadNavigationSidebarPane(
797 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)843 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
798 : null844 : null
799 }845 }
846 searchMatch={threadSearchMatchByKey.get(
847 threadSearchMatchKey({
848 environmentId: thread.environmentId,
849 threadId: thread.id,
850 }),
851 )}
852 searchQuery={props.searchQuery}
800 pane="sidebar"853 pane="sidebar"
801 selected={854 selected={
802 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey855 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
...@@ -876,6 +929,13 @@ function ThreadNavigationSidebarPane(...@@ -876,6 +929,13 @@ function ThreadNavigationSidebarPane(
876 null929 null
877 }930 }
878 isLast={item.isLast}931 isLast={item.isLast}
932 searchMatch={threadSearchMatchByKey.get(
933 threadSearchMatchKey({
934 environmentId: thread.environmentId,
935 threadId: thread.id,
936 }),
937 )}
938 searchQuery={props.searchQuery}
879 selected={939 selected={
880 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey940 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
881 }941 }
...@@ -914,10 +974,12 @@ function ThreadNavigationSidebarPane(...@@ -914,10 +974,12 @@ function ThreadNavigationSidebarPane(
914 projectCwdByKey,974 projectCwdByKey,
915 projectTitleByProjectKey,975 projectTitleByProjectKey,
916 props.onNewThreadInProject,976 props.onNewThreadInProject,
977 props.searchQuery,
917 props.selectedThreadKey,978 props.selectedThreadKey,
918 props.width,979 props.width,
919 savedConnectionsById,980 savedConnectionsById,
920 serverConfigs,981 serverConfigs,
982 threadSearchMatchByKey,
921 settleThread,983 settleThread,
922 settlementEnvironmentIds,984 settlementEnvironmentIds,
923 showMoreSettled,985 showMoreSettled,
...@@ -977,13 +1039,15 @@ function ThreadNavigationSidebarPane(...@@ -977,13 +1039,15 @@ function ThreadNavigationSidebarPane(
977 {catalogState.isLoadingConnections1039 {catalogState.isLoadingConnections
978 ? "Loading threads…"1040 ? "Loading threads…"
979 : props.searchQuery.trim().length > 01041 : props.searchQuery.trim().length > 0
980 ? snoozedCount > 01042 ? threadSearch.isPending && snoozedCount === 0
981 ? // Snoozed matches passed this same search filter — "No1043 ? "Searching thread messages…"
982 // matching threads" would misreport them as nonexistent.1044 : snoozedCount > 0
983 snoozedCount === 11045 ? // Snoozed matches passed this same search filter — "No
984 ? "1 matching thread snoozed"1046 // matching threads" would misreport them as nonexistent.
985 : "All matching threads snoozed"1047 snoozedCount === 1
986 : "No matching threads"1048 ? "1 matching thread snoozed"
1049 : "All matching threads snoozed"
1050 : "No matching threads"
987 : snoozedCount > 01051 : snoozedCount > 0
988 ? snoozedCount === 11052 ? snoozedCount === 1
989 ? "1 thread snoozed"1053 ? "1 thread snoozed"
apps/mobile/src/features/threads/thread-list-items.tsx+18
...@@ -3,6 +3,7 @@ import type {...@@ -3,6 +3,7 @@ import type {
3 EnvironmentProject,3 EnvironmentProject,
4 EnvironmentThreadShell,4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
7import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
8import { memo, useCallback, useMemo, type ComponentProps } from "react";9import { memo, useCallback, useMemo, type ComponentProps } from "react";
...@@ -21,6 +22,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";...@@ -21,6 +22,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
21import type { HomeGroupDisplayAction } from "../home/homeListItems";22import type { HomeGroupDisplayAction } from "../home/homeListItems";
22import { ThreadSwipeable } from "../home/thread-swipe-actions";23import { ThreadSwipeable } from "../home/thread-swipe-actions";
23import { resolveThreadStatus } from "./threadPresentation";24import { resolveThreadStatus } from "./threadPresentation";
25import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2426
25/**27/**
26 * Shared presentation for the thread lists: the compact (phone) Home list and28 * Shared presentation for the thread lists: the compact (phone) Home list and
...@@ -416,6 +418,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -416,6 +418,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
416 readonly thread: EnvironmentThreadShell;418 readonly thread: EnvironmentThreadShell;
417 readonly environmentLabel: string | null;419 readonly environmentLabel: string | null;
418 readonly projectCwd: string | null;420 readonly projectCwd: string | null;
421 readonly searchMatch?: EnvironmentThreadSearchMatch;
422 readonly searchQuery?: string;
419 readonly isLast: boolean;423 readonly isLast: boolean;
420 /** Sidebar only: the thread currently open in the detail pane. */424 /** Sidebar only: the thread currently open in the detail pane. */
421 readonly selected?: boolean;425 readonly selected?: boolean;
...@@ -569,6 +573,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -569,6 +573,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
569 />573 />
570 </View>574 </View>
571 </View>575 </View>
576 {props.searchMatch ? (
577 <ThreadSearchMatchExcerpt
578 compact
579 match={props.searchMatch}
580 query={props.searchQuery ?? ""}
581 />
582 ) : null}
572 {subtitleRow}583 {subtitleRow}
573 </View>584 </View>
574 </View>585 </View>
...@@ -623,6 +634,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -623,6 +634,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
623 </Text>634 </Text>
624 </View>635 </View>
625 </View>636 </View>
637 {props.searchMatch ? (
638 <ThreadSearchMatchExcerpt
639 match={props.searchMatch}
640 query={props.searchQuery ?? ""}
641 selected={selected}
642 />
643 ) : null}
626 {subtitleRow}644 {subtitleRow}
627 </View>645 </View>
628 </Pressable>646 </Pressable>
apps/mobile/src/features/threads/thread-list-v2-items.tsx+31-9
...@@ -2,6 +2,7 @@ import type {...@@ -2,6 +2,7 @@ import type {
2 EnvironmentProject,2 EnvironmentProject,
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
5import type { MenuAction } from "@react-native-menu/menu";6import type { MenuAction } from "@react-native-menu/menu";
6import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";7import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
7import { Platform, Pressable, useWindowDimensions, View } from "react-native";8import { Platform, Pressable, useWindowDimensions, View } from "react-native";
...@@ -18,6 +19,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";...@@ -18,6 +19,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
18import { useThreadPr } from "../../state/use-thread-pr";19import { useThreadPr } from "../../state/use-thread-pr";
19import { ThreadSwipeable } from "../home/thread-swipe-actions";20import { ThreadSwipeable } from "../home/thread-swipe-actions";
20import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";21import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
22import { ThreadSearchMatchExcerpt } from "./thread-search-match";
2123
22/**24/**
23 * Thread List v2 renders one flat native list: rich edge-to-edge rows for25 * 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: {...@@ -243,6 +245,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
243 state: "open" | "closed" | "merged" | null,245 state: "open" | "closed" | "merged" | null,
244 ) => void;246 ) => void;
245 readonly projectCwd?: string | null;247 readonly projectCwd?: string | null;
248 readonly searchMatch?: EnvironmentThreadSearchMatch;
249 readonly searchQuery?: string;
246 readonly simultaneousSwipeGesture?: ComponentProps<250 readonly simultaneousSwipeGesture?: ComponentProps<
247 typeof ThreadSwipeable251 typeof ThreadSwipeable
248 >["simultaneousWithExternalGesture"];252 >["simultaneousWithExternalGesture"];
...@@ -369,6 +373,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -369,6 +373,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
369 >373 >
370 {thread.title}374 {thread.title}
371 </Text>375 </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}
372 <View className="mt-1 flex-row items-center gap-2">385 <View className="mt-1 flex-row items-center gap-2">
373 {status === "failed" && thread.session?.lastError ? (386 {status === "failed" && thread.session?.lastError ? (
374 <Text387 <Text
...@@ -517,15 +530,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -517,15 +530,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
517 />530 />
518 </View>531 </View>
519 ) : null}532 ) : null}
520 <Text533 <View className="min-w-0 flex-1">
521 className={cn(534 <Text
522 "flex-1 text-base",535 className={cn(
523 selected ? "text-user-bubble-foreground" : "text-foreground-muted",536 "text-base",
524 )}537 selected ? "text-user-bubble-foreground" : "text-foreground-muted",
525 numberOfLines={1}538 )}
526 >539 numberOfLines={1}
527 {thread.title}540 >
528 </Text>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>
529 <Text551 <Text
530 className={cn(552 className={cn(
531 "text-sm tabular-nums",553 "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 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
2import {3import {
3 CommandId,4 CommandId,
4 EnvironmentId,5 EnvironmentId,
...@@ -263,6 +264,27 @@ describe("buildThreadListV2Items", () => {...@@ -263,6 +264,27 @@ describe("buildThreadListV2Items", () => {
263 ]);264 ]);
264 });265 });
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
266 it("scopes the flat list to one project", () => {288 it("scopes the flat list to one project", () => {
267 const otherProjectId = ProjectId.make("project-2");289 const otherProjectId = ProjectId.make("project-2");
268 const { items } = buildThreadListV2Items({290 const { items } = buildThreadListV2Items({
apps/mobile/src/features/threads/threadListV2.ts+14-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";1import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
45
5import type { PendingNewTask } from "../../state/use-pending-new-tasks";6import type { PendingNewTask } from "../../state/use-pending-new-tasks";
...@@ -183,6 +184,7 @@ export function buildThreadListV2Items(input: {...@@ -183,6 +184,7 @@ export function buildThreadListV2Items(input: {
183 readonly projectId: ProjectId;184 readonly projectId: ProjectId;
184 }> | null;185 }> | null;
185 readonly searchQuery: string;186 readonly searchQuery: string;
187 readonly matchedThreadKeys?: ReadonlySet<string>;
186 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */188 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
187 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;189 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
188 /** Environments whose server supports thread.settle/unsettle. Threads on190 /** Environments whose server supports thread.settle/unsettle. Threads on
...@@ -222,7 +224,18 @@ export function buildThreadListV2Items(input: {...@@ -222,7 +224,18 @@ export function buildThreadListV2Items(input: {
222 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {224 if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) {
223 continue;225 continue;
224 }226 }
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 }
226 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;239 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
227 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;240 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
228 const changeRequestState =241 const changeRequestState =
apps/mobile/src/state/queries.ts+47
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";1import 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";
2import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import { Atom } from "effect/unstable/reactivity";
3import { useEffect, useMemo, useState } from "react";10import { useEffect, useMemo, useState } from "react";
411
5import { orchestrationEnvironment } from "./orchestration";12import { orchestrationEnvironment } from "./orchestration";
...@@ -15,7 +22,22 @@ import {...@@ -15,7 +22,22 @@ import {
1522
16const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;23const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
17const COMPOSER_PATH_SEARCH_LIMIT = 20;24const COMPOSER_PATH_SEARCH_LIMIT = 20;
25const THREAD_SEARCH_DEBOUNCE_MS = 200;
18const VCS_REF_LIST_LIMIT = 100;26const 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
20export interface ThreadDetailView {42export interface ThreadDetailView {
21 readonly data: OrchestrationThread | null;43 readonly data: OrchestrationThread | null;
...@@ -45,6 +67,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {...@@ -45,6 +67,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {
45 return debounced;67 return debounced;
46}68}
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
48export function useThreadDetail(95export function useThreadDetail(
49 environmentId: EnvironmentId | null,96 environmentId: EnvironmentId | null,
50 threadId: ThreadId | null,97 threadId: ThreadId | null,
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -24,6 +24,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -24,6 +24,7 @@ export const RPC_REQUIRED_SCOPES = {
24 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,24 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
25 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,25 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
26 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,26 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
27 [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
27 [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,28 [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
28 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,29 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
29 [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,30 [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
...@@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => {
108 getThreadShellById: () => Effect.succeed(Option.none()),108 getThreadShellById: () => Effect.succeed(Option.none()),
109 getThreadDetailById: () => Effect.succeed(Option.none()),109 getThreadDetailById: () => Effect.succeed(Option.none()),
110 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),110 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
111 searchThreads: () => Effect.succeed({ matches: [] }),
111 }),112 }),
112 ),113 ),
113 );114 );
...@@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => {
201 getThreadShellById: () => Effect.succeed(Option.none()),202 getThreadShellById: () => Effect.succeed(Option.none()),
202 getThreadDetailById: () => Effect.succeed(Option.none()),203 getThreadDetailById: () => Effect.succeed(Option.none()),
203 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),204 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
205 searchThreads: () => Effect.succeed({ matches: [] }),
204 }),206 }),
205 ),207 ),
206 );208 );
...@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => {
284 getThreadShellById: () => Effect.succeed(Option.none()),286 getThreadShellById: () => Effect.succeed(Option.none()),
285 getThreadDetailById: () => Effect.succeed(Option.none()),287 getThreadDetailById: () => Effect.succeed(Option.none()),
286 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),288 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
289 searchThreads: () => Effect.succeed({ matches: [] }),
287 }),290 }),
288 ),291 ),
289 );292 );
...@@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => {
352 getThreadShellById: () => Effect.succeed(Option.none()),355 getThreadShellById: () => Effect.succeed(Option.none()),
353 getThreadDetailById: () => Effect.succeed(Option.none()),356 getThreadDetailById: () => Effect.succeed(Option.none()),
354 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),357 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
358 searchThreads: () => Effect.succeed({ matches: [] }),
355 }),359 }),
356 ),360 ),
357 );361 );
...@@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => {
405 getThreadShellById: () => Effect.succeed(Option.none()),409 getThreadShellById: () => Effect.succeed(Option.none()),
406 getThreadDetailById: () => Effect.succeed(Option.none()),410 getThreadDetailById: () => Effect.succeed(Option.none()),
407 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),411 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
412 searchThreads: () => Effect.succeed({ matches: [] }),
408 }),413 }),
409 ),414 ),
410 );415 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
...@@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => {...@@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => {
203 getThreadShellById: () => Effect.succeed(Option.none()),203 getThreadShellById: () => Effect.succeed(Option.none()),
204 getThreadDetailById: () => Effect.succeed(Option.none()),204 getThreadDetailById: () => Effect.succeed(Option.none()),
205 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),205 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
206 searchThreads: () => Effect.succeed({ matches: [] }),
206 }),207 }),
207 ),208 ),
208 Layer.provide(209 Layer.provide(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+265
...@@ -1551,6 +1551,271 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -1551,6 +1551,271 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
1551 assert.equal(shellSnapshot.threads.length, 0);1551 assert.equal(shellSnapshot.threads.length, 0);
1552 }),1552 }),
1553 );1553 );
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 );
1554});1819});
15551820
1556it.effect(1821it.effect(
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+132
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 OrchestrationCheckpointFile,7 OrchestrationCheckpointFile,
8 OrchestrationProposedPlanId,8 OrchestrationProposedPlanId,
9 OrchestrationReadModel,9 OrchestrationReadModel,
10 OrchestrationThreadSearchSource,
10 OrchestrationShellSnapshot,11 OrchestrationShellSnapshot,
11 OrchestrationThread,12 OrchestrationThread,
12 OrchestrationThreadDetailSnapshot,13 OrchestrationThreadDetailSnapshot,
...@@ -108,6 +109,17 @@ const ProjectionCountsRowSchema = Schema.Struct({...@@ -108,6 +109,17 @@ const ProjectionCountsRowSchema = Schema.Struct({
108 projectCount: Schema.Number,109 projectCount: Schema.Number,
109 threadCount: Schema.Number,110 threadCount: Schema.Number,
110});111});
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});
111const WorkspaceRootLookupInput = Schema.Struct({123const WorkspaceRootLookupInput = Schema.Struct({
112 workspaceRoot: Schema.String,124 workspaceRoot: Schema.String,
113});125});
...@@ -157,6 +169,31 @@ function maxIso(left: string | null, right: string): string {...@@ -157,6 +169,31 @@ function maxIso(left: string | null, right: string): string {
157 return left > right ? left : right;169 return left > right ? left : right;
158}170}
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
160function computeSnapshotSequence(197function computeSnapshotSequence(
161 stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>,198 stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>,
162): number {199): number {
...@@ -685,6 +722,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -685,6 +722,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
685 `,722 `,
686 });723 });
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
688 const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({793 const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({
689 Request: WorkspaceRootLookupInput,794 Request: WorkspaceRootLookupInput,
690 Result: ProjectionProjectLookupRowSchema,795 Result: ProjectionProjectLookupRowSchema,
...@@ -1758,6 +1863,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1758,6 +1863,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1758 ),1863 ),
1759 );1864 );
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
1761 const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] =1892 const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] =
1762 (workspaceRoot) =>1893 (workspaceRoot) =>
1763 getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe(1894 getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe(
...@@ -2131,6 +2262,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2131,6 +2262,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2131 getSnapshot,2262 getSnapshot,
2132 getShellSnapshot,2263 getShellSnapshot,
2133 getArchivedShellSnapshot,2264 getArchivedShellSnapshot,
2265 searchThreads,
2134 getSnapshotSequence,2266 getSnapshotSequence,
2135 getCounts,2267 getCounts,
2136 getActiveProjectByWorkspaceRoot,2268 getActiveProjectByWorkspaceRoot,
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10
...@@ -12,6 +12,8 @@ import type {...@@ -12,6 +12,8 @@ import type {
12 OrchestrationProject,12 OrchestrationProject,
13 OrchestrationProjectShell,13 OrchestrationProjectShell,
14 OrchestrationReadModel,14 OrchestrationReadModel,
15 OrchestrationSearchThreadsInput,
16 OrchestrationSearchThreadsResult,
15 OrchestrationShellSnapshot,17 OrchestrationShellSnapshot,
16 OrchestrationThread,18 OrchestrationThread,
17 OrchestrationThreadDetailSnapshot,19 OrchestrationThreadDetailSnapshot,
...@@ -94,6 +96,14 @@ export interface ProjectionSnapshotQueryShape {...@@ -94,6 +96,14 @@ export interface ProjectionSnapshotQueryShape {
94 ProjectionRepositoryError96 ProjectionRepositoryError
95 >;97 >;
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
97 /**107 /**
98 * Read the latest projection snapshot sequence without hydrating read-model108 * Read the latest projection snapshot sequence without hydrating read-model
99 * entities.109 * entities.
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
...@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
44 getThreadShellById: () => Effect.die("unused"),44 getThreadShellById: () => Effect.die("unused"),
45 getThreadDetailById: () => Effect.die("unused"),45 getThreadDetailById: () => Effect.die("unused"),
46 getThreadDetailSnapshot: () => Effect.die("unused"),46 getThreadDetailSnapshot: () => Effect.die("unused"),
47 searchThreads: () => Effect.succeed({ matches: [] }),
47 });48 });
4849
49const makeTerminalManagerLayer = (50const makeTerminalManagerLayer = (
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -212,6 +212,7 @@ describe("ProviderSessionReaper", () => {...@@ -212,6 +212,7 @@ describe("ProviderSessionReaper", () => {
212 ),212 ),
213 getThreadDetailById: () => Effect.die("unused"),213 getThreadDetailById: () => Effect.die("unused"),
214 getThreadDetailSnapshot: () => Effect.die("unused"),214 getThreadDetailSnapshot: () => Effect.die("unused"),
215 searchThreads: () => Effect.succeed({ matches: [] }),
215 }),216 }),
216 ),217 ),
217 Layer.provideMerge(NodeServices.layer),218 Layer.provideMerge(NodeServices.layer),
apps/server/src/server.test.ts+30
...@@ -729,6 +729,7 @@ const buildAppUnderTest = (options?: {...@@ -729,6 +729,7 @@ const buildAppUnderTest = (options?: {
729 threads: [],729 threads: [],
730 updatedAt: "1970-01-01T00:00:00.000Z",730 updatedAt: "1970-01-01T00:00:00.000Z",
731 }),731 }),
732 searchThreads: () => Effect.succeed({ matches: [] }),
732 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),733 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
733 getProjectShellById: () => Effect.succeed(Option.none()),734 getProjectShellById: () => Effect.succeed(Option.none()),
734 getThreadShellById: () => Effect.succeed(Option.none()),735 getThreadShellById: () => Effect.succeed(Option.none()),
...@@ -5723,6 +5724,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5723,6 +5724,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5723 layers: {5724 layers: {
5724 projectionSnapshotQuery: {5725 projectionSnapshotQuery: {
5725 getSnapshot: () => Effect.succeed(snapshot),5726 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 }),
5726 },5739 },
5727 orchestrationEngine: {5740 orchestrationEngine: {
5728 dispatch: () => Effect.succeed({ sequence: 7 }),5741 dispatch: () => Effect.succeed({ sequence: 7 }),
...@@ -5780,6 +5793,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5780,6 +5793,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5780 ),5793 ),
5781 );5794 );
5782 assert.equal(fullDiffResult.diff, "full-diff");5795 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 ]);
5783 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5813 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5784 );5814 );
57855815
apps/server/src/serverRuntimeStartup.test.ts+4
...@@ -97,6 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -97,6 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
97 getThreadShellById: () => Effect.succeed(Option.none()),97 getThreadShellById: () => Effect.succeed(Option.none()),
98 getThreadDetailById: () => Effect.succeed(Option.none()),98 getThreadDetailById: () => Effect.succeed(Option.none()),
99 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),99 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
100 searchThreads: () => Effect.succeed({ matches: [] }),
100 }),101 }),
101 Effect.provideService(AnalyticsService.AnalyticsService, {102 Effect.provideService(AnalyticsService.AnalyticsService, {
102 record: () => Effect.void,103 record: () => Effect.void,
...@@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
160 getThreadShellById: () => Effect.die("unused"),161 getThreadShellById: () => Effect.die("unused"),
161 getThreadDetailById: () => Effect.die("unused"),162 getThreadDetailById: () => Effect.die("unused"),
162 getThreadDetailSnapshot: () => Effect.die("unused"),163 getThreadDetailSnapshot: () => Effect.die("unused"),
164 searchThreads: () => Effect.succeed({ matches: [] }),
163 }),165 }),
164 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {166 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
165 readEvents: () => Stream.empty,167 readEvents: () => Stream.empty,
...@@ -204,6 +206,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -204,6 +206,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
204 getThreadShellById: () => Effect.die("unused"),206 getThreadShellById: () => Effect.die("unused"),
205 getThreadDetailById: () => Effect.die("unused"),207 getThreadDetailById: () => Effect.die("unused"),
206 getThreadDetailSnapshot: () => Effect.die("unused"),208 getThreadDetailSnapshot: () => Effect.die("unused"),
209 searchThreads: () => Effect.succeed({ matches: [] }),
207 }),210 }),
208 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {211 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
209 readEvents: () => Stream.empty,212 readEvents: () => Stream.empty,
...@@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
254 getThreadShellById: () => Effect.die("unused"),257 getThreadShellById: () => Effect.die("unused"),
255 getThreadDetailById: () => Effect.die("unused"),258 getThreadDetailById: () => Effect.die("unused"),
256 getThreadDetailSnapshot: () => Effect.die("unused"),259 getThreadDetailSnapshot: () => Effect.die("unused"),
260 searchThreads: () => Effect.succeed({ matches: [] }),
257 }),261 }),
258 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {262 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
259 readEvents: () => Stream.empty,263 readEvents: () => Stream.empty,
apps/server/src/ws.ts+15
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 type OrchestrationThreadStreamItem,28 type OrchestrationThreadStreamItem,
29 OrchestrationGetFullThreadDiffError,29 OrchestrationGetFullThreadDiffError,
30 OrchestrationGetSnapshotError,30 OrchestrationGetSnapshotError,
31 OrchestrationSearchThreadsError,
31 OrchestrationGetTurnDiffError,32 OrchestrationGetTurnDiffError,
32 ORCHESTRATION_WS_METHODS,33 ORCHESTRATION_WS_METHODS,
33 type ProjectId,34 type ProjectId,
...@@ -1103,6 +1104,20 @@ const makeWsRpcLayer = (...@@ -1103,6 +1104,20 @@ const makeWsRpcLayer = (
1103 ),1104 ),
1104 { "rpc.aggregate": "orchestration" },1105 { "rpc.aggregate": "orchestration" },
1105 ),1106 ),
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 ),
1106 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>1121 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>
1107 observeRpcStreamEffect(1122 observeRpcStreamEffect(
1108 ORCHESTRATION_WS_METHODS.subscribeShell,1123 ORCHESTRATION_WS_METHODS.subscribeShell,
apps/web/src/components/CommandPalette.logic.test.ts+23
...@@ -169,6 +169,29 @@ describe("buildThreadActionItems", () => {...@@ -169,6 +169,29 @@ describe("buildThreadActionItems", () => {
169 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);169 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
170 });170 });
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
172 it("filters archived threads out of thread search items", () => {195 it("filters archived threads out of thread search items", () => {
173 const items = buildThreadActionItems({196 const items = buildThreadActionItems({
174 threads: [197 threads: [
apps/web/src/components/CommandPalette.logic.ts+16-1
...@@ -15,12 +15,19 @@ export const RECENT_THREAD_LIMIT = 12;...@@ -15,12 +15,19 @@ export const RECENT_THREAD_LIMIT = 12;
15export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";15export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
16export const ADDON_ICON_CLASS = "size-4";16export 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
18export interface CommandPaletteItem {24export interface CommandPaletteItem {
19 readonly kind: "action" | "submenu";25 readonly kind: "action" | "submenu";
20 readonly value: string;26 readonly value: string;
21 readonly searchTerms: ReadonlyArray<string>;27 readonly searchTerms: ReadonlyArray<string>;
22 readonly title: ReactNode;28 readonly title: ReactNode;
23 readonly description?: string;29 readonly description?: string;
30 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
24 readonly timestamp?: string;31 readonly timestamp?: string;
25 readonly icon: ReactNode;32 readonly icon: ReactNode;
26 readonly disabled?: boolean;33 readonly disabled?: boolean;
...@@ -114,6 +121,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -114,6 +121,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
114 renderLeadingContent?: (thread: TThread) => ReactNode;121 renderLeadingContent?: (thread: TThread) => ReactNode;
115 /** Optional content rendered inline after the title text per-thread. */122 /** Optional content rendered inline after the title text per-thread. */
116 renderTrailingContent?: (thread: TThread) => ReactNode;123 renderTrailingContent?: (thread: TThread) => ReactNode;
124 getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined;
117 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;125 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
118 limit?: number;126 limit?: number;
119}): CommandPaletteActionItem[] {127}): CommandPaletteActionItem[] {
...@@ -140,12 +148,18 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -140,12 +148,18 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
140148
141 const leadingContent = input.renderLeadingContent?.(thread);149 const leadingContent = input.renderLeadingContent?.(thread);
142 const trailingContent = input.renderTrailingContent?.(thread);150 const trailingContent = input.renderTrailingContent?.(thread);
151 const contentMatch = input.getContentMatch?.(thread);
143152
144 return Object.assign(153 return Object.assign(
145 {154 {
146 kind: "action" as const,155 kind: "action" as const,
147 value: `thread:${thread.id}`,156 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 ],
149 title: thread.title,163 title: thread.title,
150 description: descriptionParts.join(` · `),164 description: descriptionParts.join(` · `),
151 timestamp: formatRelativeTimeLabel(165 timestamp: formatRelativeTimeLabel(
...@@ -155,6 +169,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr...@@ -155,6 +169,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
155 },169 },
156 leadingContent ? { titleLeadingContent: leadingContent } : {},170 leadingContent ? { titleLeadingContent: leadingContent } : {},
157 trailingContent ? { titleTrailingContent: trailingContent } : {},171 trailingContent ? { titleTrailingContent: trailingContent } : {},
172 contentMatch ? { threadContentMatch: contentMatch } : {},
158 {173 {
159 run: async () => {174 run: async () => {
160 await input.runThread(thread);175 await input.runThread(thread);
apps/web/src/components/CommandPalette.tsx+49-2
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";4import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
5import { connectionStatusText } from "@t3tools/client-runtime/connection";5import { connectionStatusText } from "@t3tools/client-runtime/connection";
6import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
6import {7import {
7 canPreloadBrowsePath,8 canPreloadBrowsePath,
8 createBrowseNavigationCoordinator,9 createBrowseNavigationCoordinator,
...@@ -66,6 +67,7 @@ import { useAtomCommand } from "../state/use-atom-command";...@@ -66,6 +67,7 @@ import { useAtomCommand } from "../state/use-atom-command";
66import { useAtomQueryRunner } from "../state/use-atom-query-runner";67import { useAtomQueryRunner } from "../state/use-atom-query-runner";
67import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";68import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
68import { useProjects, useThreadShells } from "../state/entities";69import { useProjects, useThreadShells } from "../state/entities";
70import { useThreadSearch } from "../state/queries";
69import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";71import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
70import {72import {
71 appendBrowsePathSegment,73 appendBrowsePathSegment,
...@@ -510,6 +512,26 @@ function OpenCommandPaletteDialog(props: {...@@ -510,6 +512,26 @@ function OpenCommandPaletteDialog(props: {
510 const providers = useAtomValue(primaryServerProvidersAtom);512 const providers = useAtomValue(primaryServerProvidersAtom);
511 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);513 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
512 const currentView = viewStack.at(-1) ?? null;514 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 );
513 const [browseGeneration, setBrowseGeneration] = useState(0);535 const [browseGeneration, setBrowseGeneration] = useState(0);
514 const browseNavigationRef = useRef<ReturnType<typeof createBrowseNavigationCoordinator> | null>(536 const browseNavigationRef = useRef<ReturnType<typeof createBrowseNavigationCoordinator> | null>(
515 null,537 null,
...@@ -916,6 +938,21 @@ function OpenCommandPaletteDialog(props: {...@@ -916,6 +938,21 @@ function OpenCommandPaletteDialog(props: {
916 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,938 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
917 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,939 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
918 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,940 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 },
919 runThread: async (thread) => {956 runThread: async (thread) => {
920 await navigate({957 await navigate({
921 to: "/$environmentId/$threadId",958 to: "/$environmentId/$threadId",
...@@ -923,7 +960,15 @@ function OpenCommandPaletteDialog(props: {...@@ -923,7 +960,15 @@ function OpenCommandPaletteDialog(props: {
923 });960 });
924 },961 },
925 }),962 }),
926 [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads],963 [
964 activeThreadId,
965 clientSettings.sidebarThreadSortOrder,
966 navigate,
967 projectTitleById,
968 threadContentMatchByKey,
969 threadSearchQuery,
970 threads,
971 ],
927 );972 );
928 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);973 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
929974
...@@ -2194,7 +2239,9 @@ function OpenCommandPaletteDialog(props: {...@@ -2194,7 +2239,9 @@ function OpenCommandPaletteDialog(props: {
2194 emptyStateMessage:2239 emptyStateMessage:
2195 "Press Enter to create this folder and add it as a project.",2240 "Press Enter to create this folder and add it as a project.",
2196 }2241 }
2197 : {})}2242 : threadSearch.isPending
2243 ? { emptyStateMessage: "Searching thread messages…" }
2244 : {})}
2198 />2245 />
2199 </CommandPanel>2246 </CommandPanel>
2200 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">2247 <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 {...@@ -16,6 +16,69 @@ import {
16} from "./ui/command";16} from "./ui/command";
17import { cn } from "~/lib/utils";17import { 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
19interface CommandPaletteResultsProps {82interface CommandPaletteResultsProps {
20 emptyStateMessage?: string;83 emptyStateMessage?: string;
21 groups: ReadonlyArray<CommandPaletteGroup>;84 groups: ReadonlyArray<CommandPaletteGroup>;
...@@ -69,15 +132,20 @@ function DisabledCommandPaletteResultRow(props: {...@@ -69,15 +132,20 @@ function DisabledCommandPaletteResultRow(props: {
69 return (132 return (
70 <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">133 <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">
71 {props.item.icon}134 {props.item.icon}
72 {props.item.description ? (135 {props.item.description || props.item.threadContentMatch ? (
73 <span className="flex min-w-0 flex-1 flex-col">136 <span className="flex min-w-0 flex-1 flex-col">
74 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">137 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
75 {props.item.titleLeadingContent}138 {props.item.titleLeadingContent}
76 <span className="truncate">{props.item.title}</span>139 <span className="truncate">{props.item.title}</span>
77 </span>140 </span>
78 <span className="truncate text-muted-foreground/85 text-xs">141 {props.item.threadContentMatch ? (
79 {props.item.description}142 <ThreadContentMatch match={props.item.threadContentMatch} />
80 </span>143 ) : null}
144 {props.item.description ? (
145 <span className="truncate text-muted-foreground/70 text-xs">
146 {props.item.description}
147 </span>
148 ) : null}
81 </span>149 </span>
82 ) : (150 ) : (
83 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">151 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
...@@ -115,15 +183,20 @@ function CommandPaletteResultRow(props: {...@@ -115,15 +183,20 @@ function CommandPaletteResultRow(props: {
115 }}183 }}
116 >184 >
117 {props.item.icon}185 {props.item.icon}
118 {props.item.description ? (186 {props.item.description || props.item.threadContentMatch ? (
119 <span className="flex min-w-0 flex-1 flex-col">187 <span className="flex min-w-0 flex-1 flex-col">
120 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">188 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
121 {props.item.titleLeadingContent}189 {props.item.titleLeadingContent}
122 <span className="truncate">{props.item.title}</span>190 <span className="truncate">{props.item.title}</span>
123 </span>191 </span>
124 <span className="truncate text-muted-foreground/85 text-xs">192 {props.item.threadContentMatch ? (
125 {props.item.description}193 <ThreadContentMatch match={props.item.threadContentMatch} />
126 </span>194 ) : null}
195 {props.item.description ? (
196 <span className="truncate text-muted-foreground/70 text-xs">
197 {props.item.description}
198 </span>
199 ) : null}
127 </span>200 </span>
128 ) : (201 ) : (
129 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">202 <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 {...@@ -3,6 +3,11 @@ import {
3 type CheckpointDiffTarget,3 type CheckpointDiffTarget,
4 type ComposerPathSearchTarget,4 type ComposerPathSearchTarget,
5} from "@t3tools/client-runtime/state/threads";5} from "@t3tools/client-runtime/state/threads";
6import {
7 createThreadSearchResultsAtomFamily,
8 makeThreadSearchKey,
9 type EnvironmentThreadSearchMatch,
10} from "@t3tools/client-runtime/state/thread-search";
6import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";11import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
7import type {12import type {
8 EnvironmentId,13 EnvironmentId,
...@@ -26,9 +31,24 @@ import { vcsEnvironment } from "./vcs";...@@ -26,9 +31,24 @@ import { vcsEnvironment } from "./vcs";
2631
27const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;32const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
28const COMPOSER_PATH_SEARCH_LIMIT = 80;33const COMPOSER_PATH_SEARCH_LIMIT = 80;
34const THREAD_SEARCH_DEBOUNCE_MS = 200;
29const VCS_REF_LIST_LIMIT = 100;35const VCS_REF_LIST_LIMIT = 100;
30const EMPTY_REFS: ReadonlyArray<VcsRef> = [];36const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
31const INITIAL_BRANCH_CURSORS = [undefined] as const;37const 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
33export interface ThreadDetailView {53export interface ThreadDetailView {
34 readonly data: OrchestrationThread | null;54 readonly data: OrchestrationThread | null;
...@@ -52,6 +72,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {...@@ -52,6 +72,31 @@ function useDebouncedValue<A>(value: A, delayMs: number): A {
52 return debounced;72 return debounced;
53}73}
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
55export function useThreadDetail(100export function useThreadDetail(
56 environmentId: EnvironmentId | null,101 environmentId: EnvironmentId | null,
57 threadId: ThreadId | null,102 threadId: ThreadId | null,
docs/user/keybindings.md+5
...@@ -69,6 +69,11 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged...@@ -69,6 +69,11 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
69- `editor.openFavorite`: open current project/worktree in the last-used editor69- `editor.openFavorite`: open current project/worktree in the last-used editor
70- `script.{id}.run`: run a project script by id (for example `script.test.run`)70- `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
72### Key Syntax77### Key Syntax
7378
74Supported modifiers:79Supported modifiers:
packages/client-runtime/package.json+4
...@@ -131,6 +131,10 @@...@@ -131,6 +131,10 @@
131 "types": "./src/state/threadSettled.ts",131 "types": "./src/state/threadSettled.ts",
132 "default": "./src/state/threadSettled.ts"132 "default": "./src/state/threadSettled.ts"
133 },133 },
134 "./state/thread-search": {
135 "types": "./src/state/threadSearch.ts",
136 "default": "./src/state/threadSearch.ts"
137 },
134 "./state/vcs": {138 "./state/vcs": {
135 "types": "./src/state/vcs.ts",139 "types": "./src/state/vcs.ts",
136 "default": "./src/state/vcs.ts"140 "default": "./src/state/vcs.ts"
packages/client-runtime/src/state/orchestration.ts+6
...@@ -16,6 +16,12 @@ export function createOrchestrationEnvironmentAtoms<R, E>(...@@ -16,6 +16,12 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
16 label: "environment-data:orchestration:full-thread-diff",16 label: "environment-data:orchestration:full-thread-diff",
17 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,17 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,
18 }),18 }),
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 }),
19 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {25 archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {
20 label: "environment-data:orchestration:archived-shell-snapshot",26 label: "environment-data:orchestration:archived-shell-snapshot",
21 tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,27 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 {...@@ -18,6 +18,7 @@ import {
18 ProviderItemId,18 ProviderItemId,
19 ThreadId,19 ThreadId,
20 TrimmedNonEmptyString,20 TrimmedNonEmptyString,
21 TrimmedString,
21 TurnId,22 TurnId,
22} from "./baseSchemas.ts";23} from "./baseSchemas.ts";
23import { ProviderInstanceId } from "./providerInstance.ts";24import { ProviderInstanceId } from "./providerInstance.ts";
...@@ -26,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = {...@@ -26,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = {
26 dispatchCommand: "orchestration.dispatchCommand",27 dispatchCommand: "orchestration.dispatchCommand",
27 getTurnDiff: "orchestration.getTurnDiff",28 getTurnDiff: "orchestration.getTurnDiff",
28 getFullThreadDiff: "orchestration.getFullThreadDiff",29 getFullThreadDiff: "orchestration.getFullThreadDiff",
30 searchThreads: "orchestration.searchThreads",
29 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",31 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
30 subscribeShell: "orchestration.subscribeShell",32 subscribeShell: "orchestration.subscribeShell",
31 subscribeThread: "orchestration.subscribeThread",33 subscribeThread: "orchestration.subscribeThread",
...@@ -1394,6 +1396,31 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr...@@ -1394,6 +1396,31 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr
1394export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;1396export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;
1395export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type;1397export 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
1397export const OrchestrationRpcSchemas = {1424export const OrchestrationRpcSchemas = {
1398 dispatchCommand: {1425 dispatchCommand: {
1399 input: ClientOrchestrationCommand,1426 input: ClientOrchestrationCommand,
...@@ -1407,6 +1434,10 @@ export const OrchestrationRpcSchemas = {...@@ -1407,6 +1434,10 @@ export const OrchestrationRpcSchemas = {
1407 input: OrchestrationGetFullThreadDiffInput,1434 input: OrchestrationGetFullThreadDiffInput,
1408 output: OrchestrationGetFullThreadDiffResult,1435 output: OrchestrationGetFullThreadDiffResult,
1409 },1436 },
1437 searchThreads: {
1438 input: OrchestrationSearchThreadsInput,
1439 output: OrchestrationSearchThreadsResult,
1440 },
1410 getArchivedShellSnapshot: {1441 getArchivedShellSnapshot: {
1411 input: Schema.Struct({}),1442 input: Schema.Struct({}),
1412 output: OrchestrationShellSnapshot,1443 output: OrchestrationShellSnapshot,
...@@ -1452,3 +1483,11 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass...@@ -1452,3 +1483,11 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass
1452 cause: Schema.optional(Schema.Defect()),1483 cause: Schema.optional(Schema.Defect()),
1453 },1484 },
1454) {}1485) {}
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 {...@@ -57,6 +57,8 @@ import {
57 OrchestrationGetFullThreadDiffError,57 OrchestrationGetFullThreadDiffError,
58 OrchestrationGetFullThreadDiffInput,58 OrchestrationGetFullThreadDiffInput,
59 OrchestrationGetSnapshotError,59 OrchestrationGetSnapshotError,
60 OrchestrationSearchThreadsError,
61 OrchestrationSearchThreadsInput,
60 OrchestrationGetTurnDiffError,62 OrchestrationGetTurnDiffError,
61 OrchestrationGetTurnDiffInput,63 OrchestrationGetTurnDiffInput,
62 OrchestrationRpcSchemas,64 OrchestrationRpcSchemas,
...@@ -690,6 +692,12 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(...@@ -690,6 +692,12 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
690 },692 },
691);693);
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
693export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(701export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
694 ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,702 ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
695 {703 {
...@@ -840,6 +848,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -840,6 +848,7 @@ export const WsRpcGroup = RpcGroup.make(
840 WsOrchestrationDispatchCommandRpc,848 WsOrchestrationDispatchCommandRpc,
841 WsOrchestrationGetTurnDiffRpc,849 WsOrchestrationGetTurnDiffRpc,
842 WsOrchestrationGetFullThreadDiffRpc,850 WsOrchestrationGetFullThreadDiffRpc,
851 WsOrchestrationSearchThreadsRpc,
843 WsOrchestrationGetArchivedShellSnapshotRpc,852 WsOrchestrationGetArchivedShellSnapshotRpc,
844 WsOrchestrationSubscribeShellRpc,853 WsOrchestrationSubscribeShellRpc,
845 WsOrchestrationSubscribeThreadRpc,854 WsOrchestrationSubscribeThreadRpc,