diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..c172694b6c5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -25,7 +25,7 @@ export function HomeRouteScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); + const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); @@ -33,20 +33,22 @@ export function HomeRouteScreen() { useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); - const environments = useMemo( - () => - Arr.sort( - Object.values(savedConnectionsById).map((connection) => ({ - environmentId: connection.environmentId, - label: connection.environmentLabel, - })), - Order.mapInput( - Order.String, - (environment: { readonly label: string }) => environment.label, - ), + const environments = useMemo(() => { + const connectionStateByEnvironmentId = new Map( + workspaceEnvironments.map( + (environment) => [environment.environmentId, environment.connectionState] as const, ), - [savedConnectionsById], - ); + ); + return Arr.sort( + Object.values(savedConnectionsById).map((connection) => ({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionState: + connectionStateByEnvironmentId.get(connection.environmentId) ?? "available", + })), + Order.mapInput(Order.String, (environment: { readonly label: string }) => environment.label), + ); + }, [savedConnectionsById, workspaceEnvironments]); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b96cc862b27..3c2ee4f7b92 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -7,6 +7,10 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -22,11 +26,12 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import type { WorkspaceState } from "../../state/workspaceModel"; +import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -72,7 +77,9 @@ interface HomeScreenProps { readonly pendingTasks: ReadonlyArray; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; - readonly environments: ReadonlyArray; + readonly environments: ReadonlyArray< + HomeListFilterMenuEnvironment & Pick + >; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -189,6 +196,35 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); + const searchEnvironmentIds = useMemo( + () => + props.selectedEnvironmentId === null + ? props.environments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId) + : props.environments.some( + (environment) => + environment.environmentId === props.selectedEnvironmentId && + environment.connectionState === "connected", + ) + ? [props.selectedEnvironmentId] + : [], + [props.environments, props.selectedEnvironmentId], + ); + const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const threadSearchMatchByKey = useMemo(() => { + const matches = new Map(); + for (const match of threadSearch.matches) { + if (match.source === "user" || match.source === "assistant") { + matches.set(threadSearchMatchKey(match), match); + } + } + return matches; + }, [threadSearch.matches]); + const matchedThreadKeys = useMemo( + () => new Set(threadSearch.matches.map(threadSearchMatchKey)), + [threadSearch.matches], + ); const effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { @@ -318,6 +354,7 @@ export function HomeScreen(props: HomeScreenProps) { pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, + matchedThreadKeys, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, @@ -328,6 +365,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, + matchedThreadKeys, scopedPendingTasks, scopedProjects, scopedThreads, @@ -516,6 +554,7 @@ export function HomeScreen(props: HomeScreenProps) { environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, + matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -533,6 +572,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.selectedEnvironmentId, props.threads, + matchedThreadKeys, threadListV2Enabled, v2ScopedProjectGroup, ]); @@ -629,6 +669,13 @@ export function HomeScreen(props: HomeScreenProps) { ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -660,7 +707,9 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, + threadSearchMatchByKey, v2ProjectTitleByProjectKey, + props.searchQuery, ], ); const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); @@ -675,19 +724,28 @@ export function HomeScreen(props: HomeScreenProps) { projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + threadSearchMatchByKey, }), [ projectByKey, projectCwdByKey, + props.searchQuery, props.savedConnectionsById, serverConfigs, + threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], ); const extraData = useMemo( - () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), - [props.savedConnectionsById, projectCwdByKey], + () => ({ + projectCwdByKey, + savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + threadSearchMatchByKey, + }), + [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], ); const renderItem = useCallback( @@ -740,6 +798,13 @@ export function HomeScreen(props: HomeScreenProps) { null } isLast={item.isLast} + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} onSelectThread={props.onSelectThread} @@ -770,7 +835,9 @@ export function HomeScreen(props: HomeScreenProps) { props.onNewThreadInProject, props.onSelectPendingTask, props.onSelectThread, + props.searchQuery, props.savedConnectionsById, + threadSearchMatchByKey, updateGroupDisplay, ], ); @@ -863,7 +930,7 @@ export function HomeScreen(props: HomeScreenProps) { const v2ListHeader = listHeader; const listEmpty = !hasResults ? ( - hasSearchQuery ? ( + hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( ) : selectedProjectScope !== null ? ( 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. + const v2ListEmpty = + hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. + + ) : ( + + ) + ) : v2SnoozedCount > 0 ? ( + + ) : v2ScopedProjectGroup !== null ? ( ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( - - ) : v2ScopedProjectGroup !== null ? ( - - ) : ( - listEmpty - ); + listEmpty + ); if (threadListV2Enabled) { return ( diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e791cd3b36a..75964aa23d2 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -661,6 +662,33 @@ describe("buildHomeThreadGroups", () => { ); }); + it("includes a thread matched by message content", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const thread = makeThread({ + environmentId, + id: ThreadId.make("thread-content"), + projectId: project.id, + title: "Unrelated title", + }); + + const groups = buildGroups([project], [thread], { + searchQuery: "relay reconnect", + matchedThreadKeys: new Set([ + threadSearchMatchKey({ + environmentId, + threadId: thread.id, + }), + ]), + }); + + expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); + }); + it("targets quick new threads at the group member with the newest thread", () => { const laptopEnv = EnvironmentId.make("environment-laptop"); const desktopEnv = EnvironmentId.make("environment-desktop"); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe..5bd14086e29 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -12,6 +12,7 @@ import { sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, @@ -254,6 +255,7 @@ export function buildHomeThreadGroups(input: { readonly pendingTasks?: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; + readonly matchedThreadKeys?: ReadonlySet; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; @@ -350,7 +352,16 @@ export function buildHomeThreadGroups(input: { group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); const matchingThreads = groupMatches ? group.threads - : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + : group.threads.filter( + (thread) => + thread.title.toLocaleLowerCase().includes(query) || + input.matchedThreadKeys?.has( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) === true, + ); const matchingPendingTasks = groupMatches ? group.pendingTasks : group.pendingTasks.filter((pendingTask) => diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 3d413f9c487..36a86ceb1e3 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -3,6 +3,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; @@ -24,6 +28,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -180,7 +185,7 @@ function ThreadNavigationSidebarPane( const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; const projects = useProjects(); const threads = useThreadShells(); - const { state: catalogState } = useWorkspaceState(); + const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef(null); @@ -209,6 +214,35 @@ function ThreadNavigationSidebarPane( ); const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = useHomeListOptions(availableEnvironmentIds); + const searchEnvironmentIds = useMemo( + () => + options.selectedEnvironmentId === null + ? workspaceEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId) + : workspaceEnvironments.some( + (environment) => + environment.environmentId === options.selectedEnvironmentId && + environment.connectionState === "connected", + ) + ? [options.selectedEnvironmentId] + : [], + [options.selectedEnvironmentId, workspaceEnvironments], + ); + const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const threadSearchMatchByKey = useMemo(() => { + const matches = new Map(); + for (const match of threadSearch.matches) { + if (match.source === "user" || match.source === "assistant") { + matches.set(threadSearchMatchKey(match), match); + } + } + return matches; + }, [threadSearch.matches]); + const matchedThreadKeys = useMemo( + () => new Set(threadSearch.matches.map(threadSearchMatchKey)), + [threadSearch.matches], + ); const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectScopes = useMemo( () => @@ -305,11 +339,19 @@ function ThreadNavigationSidebarPane( pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, + matchedThreadKeys, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), - [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], + [ + matchedThreadKeys, + options, + props.searchQuery, + scopedPendingTasks, + scopedProjects, + scopedThreads, + ], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap @@ -432,6 +474,7 @@ function ThreadNavigationSidebarPane( environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, + matchedThreadKeys, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -445,6 +488,7 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, + matchedThreadKeys, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -687,6 +731,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, }), [ props.selectedThreadKey, @@ -695,6 +740,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, ], ); const sidebarItemsAreEqual = useCallback( @@ -797,6 +843,13 @@ function ThreadNavigationSidebarPane( ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} pane="sidebar" selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey @@ -876,6 +929,13 @@ function ThreadNavigationSidebarPane( null } isLast={item.isLast} + searchMatch={threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + )} + searchQuery={props.searchQuery} selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } @@ -914,10 +974,12 @@ function ThreadNavigationSidebarPane( projectCwdByKey, projectTitleByProjectKey, props.onNewThreadInProject, + props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, serverConfigs, + threadSearchMatchByKey, settleThread, settlementEnvironmentIds, showMoreSettled, @@ -977,13 +1039,15 @@ function ThreadNavigationSidebarPane( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? snoozedCount > 0 - ? // Snoozed matches passed this same search filter — "No - // matching threads" would misreport them as nonexistent. - snoozedCount === 1 - ? "1 matching thread snoozed" - : "All matching threads snoozed" - : "No matching threads" + ? threadSearch.isPending && snoozedCount === 0 + ? "Searching thread messages…" + : snoozedCount > 0 + ? // Snoozed matches passed this same search filter — "No + // matching threads" would misreport them as nonexistent. + snoozedCount === 1 + ? "1 matching thread snoozed" + : "All matching threads snoozed" + : "No matching threads" : snoozedCount > 0 ? snoozedCount === 1 ? "1 thread snoozed" diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index c2eccc725ae..9ac4002a9b0 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -3,6 +3,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; @@ -21,6 +22,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadStatus } from "./threadPresentation"; +import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Shared presentation for the thread lists: the compact (phone) Home list and @@ -416,6 +418,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly projectCwd: string | null; + readonly searchMatch?: EnvironmentThreadSearchMatch; + readonly searchQuery?: string; readonly isLast: boolean; /** Sidebar only: the thread currently open in the detail pane. */ readonly selected?: boolean; @@ -569,6 +573,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { /> + {props.searchMatch ? ( + + ) : null} {subtitleRow} @@ -623,6 +634,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { + {props.searchMatch ? ( + + ) : null} {subtitleRow} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2ab7e6cf9f4..6af5795a94a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -2,6 +2,7 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -18,6 +19,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; +import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * 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: { state: "open" | "closed" | "merged" | null, ) => void; readonly projectCwd?: string | null; + readonly searchMatch?: EnvironmentThreadSearchMatch; + readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; @@ -369,6 +373,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {thread.title} + {props.searchMatch ? ( + + + + ) : null} {status === "failed" && thread.session?.lastError ? ( ) : null} - - {thread.title} - + + + {thread.title} + + {props.searchMatch ? ( + + ) : null} + character.toLowerCase()); +} + +function splitHighlightParts(text: string, query: string) { + const normalizedText = foldAsciiCase(text); + const normalizedQuery = foldAsciiCase(query.trim()); + if (normalizedQuery.length === 0) { + return [{ text, highlighted: false, start: 0 }]; + } + + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + while (cursor < text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: text.slice(matchIndex, matchIndex + normalizedQuery.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + normalizedQuery.length; + } + return parts; +} + +export function ThreadSearchMatchExcerpt(props: { + readonly match: EnvironmentThreadSearchMatch; + readonly query: string; + readonly selected?: boolean; + readonly compact?: boolean; +}) { + const isUser = props.match.source === "user"; + const parts = splitHighlightParts(props.match.snippet, props.query); + return ( + + + {isUser ? "You:" : "Agent:"}{" "} + + {parts.map((part) => ( + + {part.text} + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1b15905ef7b..90b5897f18e 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { CommandId, EnvironmentId, @@ -263,6 +264,27 @@ describe("buildThreadListV2Items", () => { ]); }); + it("includes a thread matched by message content", () => { + const thread = makeThread({ + id: ThreadId.make("content-match"), + title: "Unrelated title", + }); + const { items } = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "relay reconnect", + matchedThreadKeys: new Set([ + threadSearchMatchKey({ + environmentId, + threadId: thread.id, + }), + ]), + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["content-match"]); + }); + it("scopes the flat list to one project", () => { const otherProjectId = ProjectId.make("project-2"); const { items } = buildThreadListV2Items({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ab955d16d4d..920b7f0b53a 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,6 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -183,6 +184,7 @@ export function buildThreadListV2Items(input: { readonly projectId: ProjectId; }> | null; readonly searchQuery: string; + readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ readonly changeRequestStateByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on @@ -222,7 +224,18 @@ export function buildThreadListV2Items(input: { if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } - if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + if ( + query.length > 0 && + !thread.title.toLocaleLowerCase().includes(query) && + input.matchedThreadKeys?.has( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) !== true + ) { + continue; + } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index ea625995928..b02b190db25 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,5 +1,12 @@ import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; +import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; import { useEffect, useMemo, useState } from "react"; import { orchestrationEnvironment } from "./orchestration"; @@ -15,7 +22,22 @@ import { const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; +const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; +const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ + matches: EMPTY_THREAD_SEARCH_MATCHES, + isLoading: false, +}).pipe(Atom.withLabel("mobile:thread-search:empty")); + +const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, query) => + orchestrationEnvironment.threadSearch({ + environmentId, + input: { query }, + }), + labelPrefix: "mobile:thread-search", +}); export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -45,6 +67,31 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +export function useThreadSearch( + environmentIds: ReadonlyArray, + query: string, +): { + readonly matches: ReadonlyArray; + readonly isPending: boolean; +} { + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); + const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; + const searchKey = useMemo( + () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)), + [environmentIds, settledQuery], + ); + const result = useAtomValue( + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + ); + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + return { + matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + isPending: canSearch && (isDebouncing || result.isLoading), + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc9..ba43c95e359 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -24,6 +24,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..fe093c451e2 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); @@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..1d1e0d8462e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..c06684196eb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1549,6 +1549,271 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(shellSnapshot.threads.length, 0); }), ); + + it.effect("searches active user messages and canonical assistant outputs", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_projects`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-search', + 'Project Needle', + '/tmp/project-search', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-05-01T00:00:00.000Z', + '2026-05-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-active', + 'project-search', + 'Literal 100% fix', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + 'search-branch', + NULL, + 'turn-active', + '2026-05-01T00:00:02.000Z', + 0, + 0, + 0, + '2026-05-01T00:00:02.000Z', + '2026-05-01T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-percent-decoy', + 'project-search', + 'Literal 100x fix', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-05-01T00:00:04.000Z', + '2026-05-01T00:00:05.000Z', + NULL, + NULL + ), + ( + 'thread-hidden', + 'project-search', + 'Archived search', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-05-01T00:00:06.000Z', + '2026-05-01T00:00:07.000Z', + '2026-05-01T00:00:08.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES + ( + 'message-user', + 'thread-active', + 'turn-active', + 'user', + 'Please find this USER needle in an old prompt.', + 0, + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:12.000Z' + ), + ( + 'message-percent', + 'thread-active', + NULL, + 'user', + 'Literal 100% fix in a prompt.', + 0, + '2026-05-01T00:00:11.000Z', + '2026-05-01T00:00:11.000Z' + ), + ( + 'message-percent-decoy', + 'thread-percent-decoy', + NULL, + 'user', + 'Literal 100x fix in a prompt.', + 0, + '2026-05-01T00:00:11.000Z', + '2026-05-01T00:00:11.000Z' + ), + ( + 'message-final', + 'thread-active', + 'turn-active', + 'assistant', + 'The canonical final needle appears in this completed answer.', + 0, + '2026-05-01T00:00:13.000Z', + '2026-05-01T00:00:13.000Z' + ), + ( + 'message-interim', + 'thread-active', + 'turn-active', + 'assistant', + 'Interim needle must not be searchable.', + 0, + '2026-05-01T00:00:14.000Z', + '2026-05-01T00:00:14.000Z' + ), + ( + 'message-system', + 'thread-active', + NULL, + 'system', + 'System needle must not be searchable.', + 0, + '2026-05-01T00:00:15.000Z', + '2026-05-01T00:00:15.000Z' + ), + ( + 'message-hidden', + 'thread-hidden', + NULL, + 'user', + 'Hidden needle in archive.', + 0, + '2026-05-01T00:00:16.000Z', + '2026-05-01T00:00:16.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_files_json + ) + VALUES ( + 'thread-active', + 'turn-active', + 'message-user', + 'message-final', + 'completed', + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:12.000Z', + '2026-05-01T00:00:13.000Z', + '[]' + ) + `; + + const literalPercent = yield* snapshotQuery.searchThreads({ query: "100%" }); + assert.deepStrictEqual( + literalPercent.matches.map((match) => [match.threadId, match.source]), + [[ThreadId.make("thread-active"), "user"]], + ); + + const user = yield* snapshotQuery.searchThreads({ query: "user needle" }); + assert.equal(user.matches[0]?.source, "user"); + assert.match(user.matches[0]?.snippet ?? "", /USER needle/); + + const assistant = yield* snapshotQuery.searchThreads({ query: "FINAL NEEDLE" }); + assert.equal(assistant.matches[0]?.source, "assistant"); + + const deduped = yield* snapshotQuery.searchThreads({ query: "needle" }); + assert.deepStrictEqual( + deduped.matches.map((match) => [match.threadId, match.source]), + [[ThreadId.make("thread-active"), "user"]], + ); + + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "interim needle" })).matches, + [], + ); + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "system needle" })).matches, + [], + ); + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "hidden needle" })).matches, + [], + ); + yield* sql` + UPDATE projection_threads + SET deleted_at = '2026-05-01T00:00:20.000Z' + WHERE thread_id = 'thread-active' + `; + assert.deepStrictEqual( + (yield* snapshotQuery.searchThreads({ query: "user needle" })).matches, + [], + ); + }), + ); }); it.effect( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..9bf4b4e416b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ import { OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationThreadSearchSource, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -108,6 +109,17 @@ const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, }); +const ProjectionThreadSearchRequest = Schema.Struct({ + pattern: Schema.String, + limit: Schema.Int, +}); +const ProjectionThreadSearchRow = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + source: OrchestrationThreadSearchSource, + matchText: Schema.String, + messageCreatedAt: Schema.NullOr(IsoDateTime), +}); const WorkspaceRootLookupInput = Schema.Struct({ workspaceRoot: Schema.String, }); @@ -157,6 +169,31 @@ function maxIso(left: string | null, right: string): string { return left > right ? left : right; } +function escapeLikePattern(value: string): string { + return value.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_"); +} + +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function buildSearchSnippet(text: string, query: string): string { + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (normalizedText.length <= 240) { + return normalizedText; + } + + const normalizedQuery = foldAsciiCase(query.replace(/\s+/g, " ").trim()); + const matchIndex = foldAsciiCase(normalizedText).indexOf(normalizedQuery); + const bodyLength = 236; + const idealStart = Math.max(0, matchIndex - 72); + const start = Math.min(idealStart, normalizedText.length - bodyLength); + const end = Math.min(normalizedText.length, start + bodyLength); + return `${start > 0 ? "…" : ""}${normalizedText.slice(start, end)}${ + end < normalizedText.length ? "…" : "" + }`; +} + function computeSnapshotSequence( stateRows: ReadonlyArray>, ): number { @@ -670,6 +707,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const searchActiveThreadRows = SqlSchema.findAll({ + Request: ProjectionThreadSearchRequest, + Result: ProjectionThreadSearchRow, + execute: ({ pattern, limit }) => + sql` + WITH ranked AS ( + SELECT + threads.thread_id AS thread_id, + threads.project_id AS project_id, + CASE messages.role + WHEN 'user' THEN 'user' + ELSE 'assistant' + END AS source, + messages.text AS match_text, + messages.created_at AS message_created_at, + CASE messages.role + WHEN 'user' THEN 0 + ELSE 1 + END AS match_rank, + threads.updated_at AS thread_updated_at, + ROW_NUMBER() OVER ( + PARTITION BY threads.thread_id + ORDER BY + CASE messages.role + WHEN 'user' THEN 0 + ELSE 1 + END ASC, + messages.created_at DESC, + messages.message_id ASC + ) AS thread_match_rank + FROM projection_thread_messages AS messages + INNER JOIN projection_threads AS threads + ON threads.thread_id = messages.thread_id + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND messages.is_streaming = 0 + AND ( + messages.role = 'user' + OR ( + messages.role = 'assistant' + AND messages.message_id IN ( + SELECT turns.assistant_message_id + FROM projection_turns AS turns + WHERE turns.assistant_message_id IS NOT NULL + ) + ) + ) + AND messages.text LIKE ${pattern} ESCAPE '!' + ) + SELECT + thread_id AS "threadId", + project_id AS "projectId", + source, + match_text AS "matchText", + message_created_at AS "messageCreatedAt" + FROM ranked + WHERE thread_match_rank = 1 + ORDER BY + match_rank ASC, + thread_updated_at DESC, + thread_id ASC + LIMIT ${limit} + `, + }); + const getActiveProjectRowByWorkspaceRoot = SqlSchema.findOneOption({ Request: WorkspaceRootLookupInput, Result: ProjectionProjectLookupRowSchema, @@ -1737,6 +1842,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn( + "ProjectionSnapshotQuery.searchThreads", + )(function* (input) { + const escapedQuery = escapeLikePattern(input.query); + const rows = yield* searchActiveThreadRows({ + pattern: `%${escapedQuery}%`, + limit: input.limit ?? 50, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.searchThreads:query", + "ProjectionSnapshotQuery.searchThreads:decodeRows", + ), + ), + ); + return { + matches: rows.map((row) => ({ + threadId: row.threadId, + projectId: row.projectId, + source: row.source, + snippet: buildSearchSnippet(row.matchText, input.query), + messageCreatedAt: row.messageCreatedAt, + })), + }; + }); + const getActiveProjectByWorkspaceRoot: ProjectionSnapshotQueryShape["getActiveProjectByWorkspaceRoot"] = (workspaceRoot) => getActiveProjectRowByWorkspaceRoot({ workspaceRoot }).pipe( @@ -2108,6 +2239,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getSnapshot, getShellSnapshot, getArchivedShellSnapshot, + searchThreads, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..64138fb7559 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -12,6 +12,8 @@ import type { OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationSearchThreadsInput, + OrchestrationSearchThreadsResult, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, @@ -94,6 +96,14 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Search active thread navigation metadata, user messages, and canonical + * assistant outputs without hydrating thread detail snapshots. + */ + readonly searchThreads: ( + input: OrchestrationSearchThreadsInput, + ) => Effect.Effect; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..5c5da4666b0 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd..f3f4ca39d47 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -212,6 +212,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0b233d7d9f7..fff71dbb4e7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -729,6 +729,7 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + searchThreads: () => Effect.succeed({ matches: [] }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), @@ -5723,6 +5724,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { projectionSnapshotQuery: { getSnapshot: () => Effect.succeed(snapshot), + searchThreads: () => + Effect.succeed({ + matches: [ + { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), + source: "assistant", + snippet: "Search reached the final response.", + messageCreatedAt: now, + }, + ], + }), }, orchestrationEngine: { dispatch: () => Effect.succeed({ sequence: 7 }), @@ -5780,6 +5793,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(fullDiffResult.diff, "full-diff"); + + const searchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.searchThreads]({ + query: "final response", + }), + ), + ); + assert.deepEqual(searchResult.matches, [ + { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-a"), + source: "assistant", + snippet: "Search reached the final response.", + messageCreatedAt: now, + }, + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..e3f7e482b2e 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -97,6 +97,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -204,6 +206,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af80..2c3b388c11f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,7 @@ import { type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, @@ -1101,6 +1102,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.searchThreads]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.searchThreads, + projectionSnapshotQuery.searchThreads(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadsError({ + message: "Failed to search threads", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 04de1784715..5b4accc9fc7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -169,6 +169,29 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("keeps message excerpts searchable without replacing thread metadata", () => { + const [item] = buildThreadActionItems({ + threads: [makeThread({ branch: "feat/search" })], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + getContentMatch: () => ({ + source: "assistant", + snippet: "The relay reconnect is now bounded.", + query: "reconnect", + }), + runThread: async (_thread) => undefined, + }); + + expect(item?.searchTerms).toContain("The relay reconnect is now bounded."); + expect(item?.threadContentMatch).toEqual({ + source: "assistant", + snippet: "The relay reconnect is now bounded.", + query: "reconnect", + }); + expect(item?.description).toBe("T3 Code · #feat/search"); + }); + it("filters archived threads out of thread search items", () => { const items = buildThreadActionItems({ threads: [ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 058322744bb..7a07cc48481 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,12 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export interface CommandPaletteThreadContentMatch { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; readonly description?: string; + readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; readonly disabled?: boolean; @@ -114,6 +121,7 @@ export function buildThreadActionItems ReactNode; /** Optional content rendered inline after the title text per-thread. */ renderTrailingContent?: (thread: TThread) => ReactNode; + getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -140,12 +148,18 @@ export function buildThreadActionItems { await input.runThread(thread); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 45b5e117600..94870fef3eb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, @@ -66,6 +67,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; +import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -510,6 +512,26 @@ function OpenCommandPaletteDialog(props: { const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; + const environmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; + const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); + const threadContentMatchByKey = useMemo( + () => + new Map( + threadSearch.matches.flatMap((match) => + match.source === "user" || match.source === "assistant" + ? [[threadSearchMatchKey(match), match] as const] + : [], + ), + ), + [threadSearch.matches], + ); const [browseGeneration, setBrowseGeneration] = useState(0); const browseNavigationRef = useRef | null>( null, @@ -916,6 +938,21 @@ function OpenCommandPaletteDialog(props: { icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , + getContentMatch: (thread) => { + const match = threadContentMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ); + return match && (match.source === "user" || match.source === "assistant") + ? { + source: match.source, + snippet: match.snippet, + query: threadSearchQuery, + } + : undefined; + }, runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", @@ -923,7 +960,15 @@ function OpenCommandPaletteDialog(props: { }); }, }), - [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], + [ + activeThreadId, + clientSettings.sidebarThreadSortOrder, + navigate, + projectTitleById, + threadContentMatchByKey, + threadSearchQuery, + threads, + ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -2194,7 +2239,9 @@ function OpenCommandPaletteDialog(props: { emptyStateMessage: "Press Enter to create this folder and add it as a project.", } - : {})} + : threadSearch.isPending + ? { emptyStateMessage: "Searching thread messages…" } + : {})} /> diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 532d546df9a..2ab4ef8f3f8 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -16,6 +16,69 @@ import { } from "./ui/command"; import { cn } from "~/lib/utils"; +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function HighlightedSearchText(props: { text: string; query: string }) { + const query = props.query.trim(); + if (query.length === 0) return props.text; + + const normalizedText = foldAsciiCase(props.text); + const normalizedQuery = foldAsciiCase(query); + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + + while (cursor < props.text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: props.text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: props.text.slice(matchIndex, matchIndex + query.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + query.length; + } + + return parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +function ThreadContentMatch(props: { + match: NonNullable; +}) { + const isUser = props.match.source === "user"; + return ( + + + {isUser ? "You:" : "Agent:"} + {" "} + + + ); +} + interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -69,15 +132,20 @@ function DisabledCommandPaletteResultRow(props: { return (
{props.item.icon} - {props.item.description ? ( + {props.item.description || props.item.threadContentMatch ? ( {props.item.titleLeadingContent} {props.item.title} - - {props.item.description} - + {props.item.threadContentMatch ? ( + + ) : null} + {props.item.description ? ( + + {props.item.description} + + ) : null} ) : ( @@ -115,15 +183,20 @@ function CommandPaletteResultRow(props: { }} > {props.item.icon} - {props.item.description ? ( + {props.item.description || props.item.threadContentMatch ? ( {props.item.titleLeadingContent} {props.item.title} - - {props.item.description} - + {props.item.threadContentMatch ? ( + + ) : null} + {props.item.description ? ( + + {props.item.description} + + ) : null} ) : ( diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 745c9e700b3..a9564c2fd64 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -3,6 +3,11 @@ import { type CheckpointDiffTarget, type ComposerPathSearchTarget, } from "@t3tools/client-runtime/state/threads"; +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -26,9 +31,24 @@ import { vcsEnvironment } from "./vcs"; const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; +const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ + matches: EMPTY_THREAD_SEARCH_MATCHES, + isLoading: false, +}).pipe(Atom.withLabel("web:thread-search:empty")); + +const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId, query) => + orchestrationEnvironment.threadSearch({ + environmentId, + input: { query }, + }), + labelPrefix: "web:thread-search", +}); export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -52,6 +72,31 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +export function useThreadSearch( + environmentIds: ReadonlyArray, + query: string, +): { + readonly matches: ReadonlyArray; + readonly isPending: boolean; +} { + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); + const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; + const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; + const searchKey = useMemo( + () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)), + [environmentIds, settledQuery], + ); + const result = useAtomValue( + searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), + ); + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + return { + matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + isPending: canSearch && (isDebouncing || result.isLoading), + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a0..0746272633f 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -69,6 +69,11 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +The command palette searches active thread titles, projects, branches, user messages, and final +agent responses across connected environments. Message matches show one labeled excerpt while +keeping the thread's project, branch, and machine context visible. Message search begins after two +characters and uses SQLite's ASCII case-insensitive matching. + ### Key Syntax Supported modifiers: diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e5..0b7b078a522 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,10 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/thread-search": { + "types": "./src/state/threadSearch.ts", + "default": "./src/state/threadSearch.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f8faa49ea38..666b3b94c45 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -16,6 +16,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, }), + threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-search", + tag: ORCHESTRATION_WS_METHODS.searchThreads, + staleTimeMs: 30_000, + idleTtlMs: 60_000, + }), archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:archived-shell-snapshot", tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts new file mode 100644 index 00000000000..2f1430a51b6 --- /dev/null +++ b/packages/client-runtime/src/state/threadSearch.test.ts @@ -0,0 +1,72 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type OrchestrationSearchThreadsResult, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { expect, it } from "vite-plus/test"; + +import { + createThreadSearchResultsAtomFamily, + makeThreadSearchKey, + threadSearchMatchKey, +} from "./threadSearch.ts"; + +const envA = EnvironmentId.make("env-a"); +const envB = EnvironmentId.make("env-b"); + +it("creates stable keys regardless of environment order", () => { + expect(makeThreadSearchKey([envB, envA], "needle")).toBe( + makeThreadSearchKey([envA, envB], "needle"), + ); +}); + +it("encodes scoped thread keys without delimiter collisions", () => { + const first = threadSearchMatchKey({ + environmentId: EnvironmentId.make("env\u0000thread"), + threadId: ThreadId.make("id"), + }); + const second = threadSearchMatchKey({ + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread\u0000id"), + }); + + expect(first).not.toBe(second); +}); + +it("merges successful environments and silently ignores failures", () => { + const result: OrchestrationSearchThreadsResult = { + matches: [ + { + threadId: ThreadId.make("thread-a"), + projectId: ProjectId.make("project-a"), + source: "user", + snippet: "needle", + messageCreatedAt: "2026-07-30T00:00:00.000Z", + }, + ], + }; + const searchAtom = createThreadSearchResultsAtomFamily({ + getSearchAtom: (environmentId) => + environmentId === envA + ? Atom.make(AsyncResult.success(result)) + : Atom.make( + AsyncResult.failure( + Cause.fail(new Error("unsupported rpc")), + ), + ), + labelPrefix: "test:thread-search", + }); + const registry = AtomRegistry.make(); + + const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle"))); + expect(state).toEqual({ + matches: [{ ...result.matches[0], environmentId: envA }], + isLoading: false, + }); + expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]'); + + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts new file mode 100644 index 00000000000..4011a91cd58 --- /dev/null +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -0,0 +1,81 @@ +import { + EnvironmentId, + OrchestrationSearchThreadsInput, + type OrchestrationSearchThreadsResult, + type OrchestrationThreadSearchMatch, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch { + readonly environmentId: EnvironmentId; +} + +export interface ThreadSearchResultsState { + readonly matches: ReadonlyArray; + readonly isLoading: boolean; +} + +const ThreadSearchKey = Schema.Tuple([ + Schema.Array(EnvironmentId), + OrchestrationSearchThreadsInput.fields.query, +]); +const decodeThreadSearchKey = Schema.decodeUnknownSync(ThreadSearchKey); + +export function makeThreadSearchKey( + environmentIds: ReadonlyArray, + query: string, +): string { + return JSON.stringify([ + [...environmentIds].toSorted((left, right) => left.localeCompare(right)), + query, + ]); +} + +function parseThreadSearchKey(key: string) { + return decodeThreadSearchKey(JSON.parse(key)); +} + +export function threadSearchMatchKey( + match: Pick, +): string { + return JSON.stringify([match.environmentId, match.threadId]); +} + +/** + * Combines one search query atom per environment. Failed and disconnected + * environments contribute no content matches, preserving local title search + * as the compatibility fallback. + */ +export function createThreadSearchResultsAtomFamily(options: { + readonly getSearchAtom: ( + environmentId: EnvironmentId, + query: string, + ) => Atom.Atom>; + readonly labelPrefix: string; +}) { + return Atom.family((key: string) => + Atom.make((get): ThreadSearchResultsState => { + const [environmentIds, query] = parseThreadSearchKey(key); + const matches: EnvironmentThreadSearchMatch[] = []; + let isLoading = false; + + for (const environmentId of environmentIds) { + const result = get(options.getSearchAtom(environmentId, query)); + isLoading ||= result.waiting; + const value = Option.getOrNull(AsyncResult.value(result)); + if (value !== null) { + matches.push( + ...value.matches.map((match) => ({ + ...match, + environmentId, + })), + ); + } + } + + return { matches, isLoading }; + }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)), + ); +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4c..9fdf58e8953 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -18,6 +18,7 @@ import { ProviderItemId, ThreadId, TrimmedNonEmptyString, + TrimmedString, TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -26,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", + searchThreads: "orchestration.searchThreads", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -1362,6 +1364,31 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; +export const OrchestrationThreadSearchSource = Schema.Literals(["user", "assistant"]); +export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSource.Type; + +// The server's SQLite client is synchronous and single-connection. Bound both +// scan input and response size so a search cannot monopolize that connection. +export const OrchestrationSearchThreadsInput = Schema.Struct({ + query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)), + limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))), +}); +export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsInput.Type; + +export const OrchestrationThreadSearchMatch = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + source: OrchestrationThreadSearchSource, + snippet: Schema.String.check(Schema.isMaxLength(240)), + messageCreatedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationThreadSearchMatch = typeof OrchestrationThreadSearchMatch.Type; + +export const OrchestrationSearchThreadsResult = Schema.Struct({ + matches: Schema.Array(OrchestrationThreadSearchMatch), +}); +export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; + export const OrchestrationRpcSchemas = { dispatchCommand: { input: ClientOrchestrationCommand, @@ -1375,6 +1402,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, + searchThreads: { + input: OrchestrationSearchThreadsInput, + output: OrchestrationSearchThreadsResult, + }, getArchivedShellSnapshot: { input: Schema.Struct({}), output: OrchestrationShellSnapshot, @@ -1420,3 +1451,11 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass cause: Schema.optional(Schema.Defect()), }, ) {} + +export class OrchestrationSearchThreadsError extends Schema.TaggedErrorClass()( + "OrchestrationSearchThreadsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0701e15a668..26b295ca19c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -57,6 +57,8 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, + OrchestrationSearchThreadsInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationRpcSchemas, @@ -678,6 +680,12 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( }, ); +export const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { + payload: OrchestrationSearchThreadsInput, + success: OrchestrationRpcSchemas.searchThreads.output, + error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), +}); + export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { @@ -827,6 +835,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationSearchThreadsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc,