From f84a5248ef0eda73f988a7137370a7ff6b3f510a Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 7 Oct 2026 16:49:21 +0200 Subject: [PATCH] feat(search): filter threads with GitHub-style qualifiers Thread search understands project:/in:, env:, branch:, provider:, status:, since:/before:, and is:archived, with - negation and comma OR lists. Free text keeps searching titles, PR terms, and message content. The command palette gets a filter menu that writes the same qualifiers into the query. --- .../src/features/home/HomeRouteScreen.tsx | 15 +- apps/mobile/src/features/home/HomeScreen.tsx | 67 +++- .../src/features/keyboard/CommandPalette.tsx | 74 +++- .../keyboard/commandPaletteItems.test.ts | 36 ++ .../features/keyboard/commandPaletteItems.ts | 7 +- .../layout/AdaptiveWorkspaceLayout.tsx | 7 + .../threads/ThreadNavigationSidebar.tsx | 70 +++- .../src/features/threads/threadListV2.test.ts | 39 ++ .../src/features/threads/threadListV2.ts | 76 +++- apps/mobile/src/state/queries.ts | 10 +- .../src/orchestration-v2/ThreadSearch.test.ts | 6 + .../src/orchestration-v2/ThreadSearch.ts | 112 +++--- .../components/CommandPalette.logic.test.ts | 28 +- .../src/components/CommandPalette.logic.ts | 32 +- apps/web/src/components/CommandPalette.tsx | 119 +++++- .../src/components/CommandPaletteContent.tsx | 18 +- .../CommandPaletteThreadFilters.tsx | 218 ++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 56 +++ apps/web/src/components/Sidebar.logic.ts | 73 +++- apps/web/src/components/Sidebar.tsx | 96 ++++- apps/web/src/state/queries.ts | 10 +- docs/user/thread-sidebar.md | 14 +- packages/client-runtime/package.json | 4 + .../src/state/threadSearch.test.ts | 6 + .../client-runtime/src/state/threadSearch.ts | 21 +- .../src/state/threadSearchQuery.test.ts | 186 +++++++++ .../src/state/threadSearchQuery.ts | 375 ++++++++++++++++++ packages/contracts/src/threadSearch.ts | 1 + 28 files changed, 1635 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/components/CommandPaletteThreadFilters.tsx create mode 100644 packages/client-runtime/src/state/threadSearchQuery.test.ts create mode 100644 packages/client-runtime/src/state/threadSearchQuery.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7a660ef1aadf3..d0c3776ac6301 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -36,7 +36,20 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const handleSelectThread = useHomeThreadSelection(); + const selectThread = useHomeThreadSelection(); + const handleSelectThread = useCallback( + (thread: EnvironmentThreadShell) => { + if (thread.archivedAt !== null) { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsArchive" }, + }); + return; + } + selectThread(thread); + }, + [navigation, selectThread], + ); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { navigation.navigate("NewTaskSheet", { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da2975..aaef1b08f1269 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -5,6 +5,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, + presentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey, @@ -33,6 +34,8 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThreadSearch } from "../../state/queries"; +import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -49,6 +52,7 @@ import { import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, + createMobileThreadSearchContext, getThreadListV2OrderedSection, buildThreadListV2ListItems, threadListV2ListItemsAreEqual, @@ -56,6 +60,7 @@ import { THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, + sortThreadsByActivity, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; @@ -222,6 +227,10 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const parsedSearch = useMemo( + () => parseThreadSearchQuery(props.searchQuery, { now: new Date() }), + [props.searchQuery], + ); const queuedThreadKeys = useQueuedThreadKeys(); const openSwipeableRef = useRef(null); const insets = useSafeAreaInsets(); @@ -245,7 +254,25 @@ export function HomeScreen(props: HomeScreenProps) { : [], [props.environments, props.selectedEnvironmentId], ); - const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const archivedSnapshots = useArchivedThreadSnapshots( + parsedSearch.filters.archived ? searchEnvironmentIds : [], + ); + const searchableThreads = useMemo( + () => + parsedSearch.filters.archived + ? sortThreadsByActivity( + archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), + ), + ) + : props.threads, + [archivedSnapshots.snapshots, parsedSearch.filters.archived, props.threads], + ); + const threadSearch = useThreadSearch( + searchEnvironmentIds, + parsedSearch.text, + parsedSearch.filters.archived, + ); const threadSearchMatchByKey = useMemo(() => { const matches = new Map(); for (const match of threadSearch.matches) { @@ -504,6 +531,22 @@ export function HomeScreen(props: HomeScreenProps) { } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadSearchContext = useMemo( + () => + createMobileThreadSearchContext({ + projects: projectByKey, + providers: providersByEnvironmentId, + environmentNames: (thread) => + [ + props.environments.find( + (environment) => environment.environmentId === thread.environmentId, + )?.label, + props.savedConnectionsById[thread.environmentId]?.environmentLabel, + thread.environmentId, + ].filter((name): name is string => name !== undefined), + }), + [projectByKey, props.environments, props.savedConnectionsById, providersByEnvironmentId], + ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. @@ -544,14 +587,18 @@ export function HomeScreen(props: HomeScreenProps) { ]); const threadListV2Layout = useMemo(() => { threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); - // Settled threads are live shells; archived threads keep their original - // "hidden from lists" meaning. + // Archive searches use the snapshot instead of the live shells. return buildThreadListV2Items({ pendingOrder, - threads: props.threads.filter((thread) => thread.archivedAt === null), + threads: searchableThreads.filter( + (thread) => parsedSearch.filters.archived || thread.archivedAt === null, + ), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, - searchQuery: props.searchQuery, + searchQuery: parsedSearch.text, + searchFilters: parsedSearch.filters, + searchFilterContext: threadSearchContext, + searchNow: new Date(), matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -577,11 +624,13 @@ export function HomeScreen(props: HomeScreenProps) { settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, - props.searchQuery, + parsedSearch, props.selectedEnvironmentId, props.threads, + searchableThreads, matchedThreadKeys, v2ScopedProjectGroup, + threadSearchContext, ]); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). @@ -601,7 +650,7 @@ export function HomeScreen(props: HomeScreenProps) { // they are spliced in below the active block and stay visible and deletable // while their environment is offline. Same environment scope and search // filter as the list itself. - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2SearchQuery = parsedSearch.text.trim().toLocaleLowerCase(); const v2PendingTasks = useMemo( () => props.pendingTasks.filter( @@ -745,7 +794,7 @@ export function HomeScreen(props: HomeScreenProps) { threadId: thread.id, }), )} - searchQuery={props.searchQuery} + searchQuery={parsedSearch.text} onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} @@ -830,7 +879,7 @@ export function HomeScreen(props: HomeScreenProps) { projectTitleByProjectKey: v2ProjectTitleByProjectKey, listEnvironments, savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, + searchQuery: parsedSearch.text, threadSearchMatchByKey, // Rows read it for their reorder menu items. workingShelfEnabled, diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 5c5126865e554..98d0839867f75 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -26,8 +26,17 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; +import { + matchesThreadSearchFilters, + parseThreadSearchQuery, +} from "@t3tools/client-runtime/state/threadSearchQuery"; +import { createMobileThreadSearchContext, sortThreadsByActivity } from "../threads/threadListV2"; +import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { threadListEnvironmentsAtom } from "../../state/server"; +import { useAtomValue } from "@effect/atom-react"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; @@ -143,11 +152,20 @@ export function CommandPalette(props: { const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); + const projectByKey = useMemo( + () => + new Map( + projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), + ), + [projects], + ); const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); const activeThread = useThreadShell(activeThreadRef); const environments = useWorkspaceEnvironments(); const { savedConnectionsById } = useSavedRemoteConnections(); + const { providersByEnvironmentId } = useAtomValue(threadListEnvironmentsAtom); const [query, setQuery] = useState(""); + const parsedSearch = useMemo(() => parseThreadSearchQuery(query, { now: new Date() }), [query]); const [selection, setSelection] = useState(null); const [visible, setVisible] = useState(true); const pendingAction = useRef<(() => void) | null>(null); @@ -162,7 +180,38 @@ export function CommandPalette(props: { .map((environment) => environment.environmentId), [environments], ); - const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query); + const archivedSnapshots = useArchivedThreadSnapshots( + parsedSearch.filters.archived ? searchEnvironmentIds : [], + ); + const searchableThreads = useMemo( + () => + parsedSearch.filters.archived + ? archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), + ) + : threads, + [archivedSnapshots.snapshots, parsedSearch.filters.archived, threads], + ); + const threadSearchContext = useMemo( + () => + createMobileThreadSearchContext({ + projects: projectByKey, + providers: providersByEnvironmentId, + environmentNames: (thread) => + [ + environments.find((environment) => environment.environmentId === thread.environmentId) + ?.environmentLabel, + savedConnectionsById[thread.environmentId]?.environmentLabel, + thread.environmentId, + ].filter((name): name is string => name !== undefined), + }), + [environments, projectByKey, providersByEnvironmentId, savedConnectionsById], + ); + const search = useThreadSearch( + searchEnvironmentIds, + query.startsWith(">") ? "" : parsedSearch.text, + parsedSearch.filters.archived, + ); const matchedThreadKeys = useMemo( () => new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))), @@ -260,9 +309,6 @@ export function CommandPalette(props: { }), }, ]; - const projectByKey = new Map( - projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]), - ); const activeProject = activeThread ? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId)) : null; @@ -316,13 +362,8 @@ export function CommandPalette(props: { }, }), })); - const threadItems: CommandPaletteItem[] = threads - .filter((thread) => thread.archivedAt === null) - .sort((left, right) => - (right.latestUserMessageAt ?? right.updatedAt).localeCompare( - left.latestUserMessageAt ?? left.updatedAt, - ), - ) + const threadItems: CommandPaletteItem[] = sortThreadsByActivity(searchableThreads) + .filter((thread) => parsedSearch.filters.archived || thread.archivedAt === null) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = @@ -338,6 +379,10 @@ export function CommandPalette(props: { thread.branch ?? "", ...threadPullRequestSearchTerms(thread), ], + matchesSearch: () => + matchesThreadSearchFilters(thread, parsedSearch.filters, threadSearchContext, { + now: new Date(), + }), run: () => selectThread(thread), }; }); @@ -346,11 +391,14 @@ export function CommandPalette(props: { activeThread, activeThreadRef, navigation, + projectByKey, projects, + parsedSearch, runCommand, savedConnectionsById, selectThread, - threads, + searchableThreads, + threadSearchContext, ]); const results = useMemo( () => filterCommandPaletteItems(items, query, matchedThreadKeys), @@ -497,7 +545,7 @@ export function CommandPalette(props: { searchMatch={ item.kind === "thread" ? contentMatchByKey.get(item.key) : undefined } - searchQuery={query} + searchQuery={parsedSearch.text} onSelect={() => close(item.run)} /> )} diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts index 2a383b80369d8..be810912d19d6 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { + matchesThreadSearchFilters, + parseThreadSearchQuery, +} from "@t3tools/client-runtime/state/threadSearchQuery"; import { filterCommandPaletteItems, @@ -63,6 +67,38 @@ describe("filterCommandPaletteItems", () => { ).toEqual(["siva:one"]); expect(filterCommandPaletteItems(items, "> message content", matches)).toEqual([]); }); + + it("applies qualifiers before including a server content match", () => { + const now = new Date("2026-10-07T12:00:00.000Z"); + const context = { + projectName: () => "Mobile", + environmentNames: () => ["Local"], + providerNames: () => ["Codex"], + status: () => "ready", + activityAt: (thread: { updatedAt: string }) => thread.updatedAt, + }; + const candidates = ["2026-10-06T12:00:00.000Z", "2026-09-01T12:00:00.000Z"].map( + (updatedAt, index) => ({ + ...item(`thread:${index}`, "Unrelated title", "thread"), + matchesSearch: (query: string) => + matchesThreadSearchFilters( + { updatedAt, branch: null }, + parseThreadSearchQuery(query, { now }).filters, + context, + { now }, + ), + }), + ); + const matches = new Set(candidates.map((candidate) => candidate.key)); + expect( + filterCommandPaletteItems(candidates, "since:7d", matches).map((candidate) => candidate.key), + ).toEqual(["thread:0"]); + expect( + filterCommandPaletteItems(candidates, "since:7d message", matches).map( + (candidate) => candidate.key, + ), + ).toEqual(["thread:0"]); + }); }); describe("nextPaletteIndex", () => { diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 6a06fd4e3387b..cce1a87868665 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -1,3 +1,5 @@ +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; + export interface CommandPaletteItem { readonly key: string; readonly kind: "action" | "project" | "thread"; @@ -5,6 +7,7 @@ export interface CommandPaletteItem { readonly detail?: string; readonly searchTerms: ReadonlyArray; readonly run: () => void; + readonly matchesSearch?: (query: string) => boolean; } /** `>` narrows to actions, matching the desktop palette. Stable ties retain recent-thread order. */ @@ -14,11 +17,13 @@ export function filterCommandPaletteItems( matchedThreadKeys: ReadonlySet, ) { const actionsOnly = query.startsWith(">"); - const normalized = (actionsOnly ? query.slice(1) : query).trim().toLocaleLowerCase(); + const parsed = parseThreadSearchQuery(actionsOnly ? query.slice(1) : query, { now: new Date() }); + const normalized = parsed.text.trim().toLocaleLowerCase(); const tokens = normalized.split(/\s+/); return items .flatMap((item, index) => { if (actionsOnly && item.kind !== "action") return []; + if (item.kind === "thread" && item.matchesSearch && !item.matchesSearch(query)) return []; if (!normalized) return item.kind === "project" ? [] : [{ item, rank: 0, index }]; const title = item.title.toLocaleLowerCase(); const haystack = [title, ...item.searchTerms].join(" ").toLocaleLowerCase(); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index caaed841ce01d..44907323970a6 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -496,6 +496,13 @@ function AdaptiveWorkspaceLayoutContent( const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { + if (thread.archivedAt !== null) { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsArchive" }, + }); + return; + } const params = { environmentId: String(thread.environmentId), threadId: String(thread.id), diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 27d23b0a0b152..cd7ff3e9ed383 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,9 +1,10 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { computeThreadMoveAvailability } from "./threadOrder"; -import type { - EnvironmentProject, - EnvironmentThreadShell, +import { + presentThreadShell, + type EnvironmentProject, + type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey, @@ -29,6 +30,8 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; +import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -66,6 +69,7 @@ import { import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import { buildThreadListV2Items, + createMobileThreadSearchContext, getThreadListV2OrderedSection, buildThreadListV2ListItems, isThreadListV2ListItem, @@ -74,6 +78,7 @@ import { THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, + sortThreadsByActivity, } from "./threadListV2"; /** The sidebar list: flat v2 rows with queued tasks spliced in, plus a @@ -131,6 +136,10 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { + const parsedSearch = useMemo( + () => parseThreadSearchQuery(props.searchQuery, { now: new Date() }), + [props.searchQuery], + ); const { themeVariables: materialTheme } = useAppearancePreferences(); const drawerColor = materialTheme["--color-drawer"]; @@ -191,7 +200,25 @@ function ThreadNavigationSidebarPane( : [], [options.selectedEnvironmentId, workspaceEnvironments], ); - const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + const archivedSnapshots = useArchivedThreadSnapshots( + parsedSearch.filters.archived ? searchEnvironmentIds : [], + ); + const searchableThreads = useMemo( + () => + parsedSearch.filters.archived + ? sortThreadsByActivity( + archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), + ), + ) + : threads, + [archivedSnapshots.snapshots, parsedSearch.filters.archived, threads], + ); + const threadSearch = useThreadSearch( + searchEnvironmentIds, + parsedSearch.text, + parsedSearch.filters.archived, + ); const threadSearchMatchByKey = useMemo(() => { const matches = new Map(); for (const match of threadSearch.matches) { @@ -327,6 +354,22 @@ function ThreadNavigationSidebarPane( } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadSearchContext = useMemo( + () => + createMobileThreadSearchContext({ + projects: projectByKey, + providers: providersByEnvironmentId, + environmentNames: (thread) => + [ + workspaceEnvironments.find( + (environment) => environment.environmentId === thread.environmentId, + )?.environmentLabel, + savedConnectionsById[thread.environmentId]?.environmentLabel, + thread.environmentId, + ].filter((name): name is string => name !== undefined), + }), + [projectByKey, providersByEnvironmentId, savedConnectionsById, workspaceEnvironments], + ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. @@ -369,10 +412,15 @@ function ThreadNavigationSidebarPane( threadListInboxReturns.observe(workingShelfEnabled ? threads : null); return buildThreadListV2Items({ pendingOrder, - threads: threads.filter((thread) => thread.archivedAt === null), + threads: searchableThreads.filter( + (thread) => parsedSearch.filters.archived || thread.archivedAt === null, + ), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, - searchQuery: props.searchQuery, + searchQuery: parsedSearch.text, + searchFilters: parsedSearch.filters, + searchFilterContext: threadSearchContext, + searchNow: new Date(), matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -397,13 +445,15 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, props.selectedThreadKey, options.selectedEnvironmentId, - props.searchQuery, + parsedSearch, matchedThreadKeys, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, threads, + searchableThreads, selectedProjectScope, + threadSearchContext, ]); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). @@ -425,7 +475,7 @@ function ThreadNavigationSidebarPane( // (mirrors the compact Home v2 list) where they stay visible and // deletable while their environment is offline. Same environment scope // and search filter as the list. - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2SearchQuery = parsedSearch.text.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => (options.selectedEnvironmentId === null || @@ -715,7 +765,7 @@ function ThreadNavigationSidebarPane( threadId: thread.id, }), )} - searchQuery={props.searchQuery} + searchQuery={parsedSearch.text} pane="sidebar" selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey @@ -814,7 +864,7 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, renameThread, props.onNewThreadOnBranch, - props.searchQuery, + parsedSearch, props.selectedThreadKey, props.width, savedConnectionsById, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7459b17672f9b..4499de2d57d98 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -29,6 +29,7 @@ import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures" import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; import { buildThreadListV2Items, + createMobileThreadSearchContext, buildThreadListV2ListItems, getThreadListV2OrderedSection, isThreadListV2ListItem, @@ -417,6 +418,44 @@ describe("getThreadListV2OrderedSection", () => { }); describe("buildThreadListV2Items", () => { + it("filters archive searches by user activity and orders newest activity first", () => { + const recent = makeThread({ + id: ThreadId.make("recent"), + title: "Recent", + archivedAt: NOW, + latestUserMessageAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-02T00:00:00.000Z", + }); + const older = makeThread({ + id: ThreadId.make("older"), + title: "Older", + archivedAt: NOW, + latestUserMessageAt: "2026-05-28T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }); + const stale = makeThread({ + id: ThreadId.make("stale"), + title: "Stale", + archivedAt: NOW, + latestUserMessageAt: "2026-05-01T00:00:00.000Z", + updatedAt: NOW, + }); + const context = createMobileThreadSearchContext({ + projects: new Map(), + providers: new Map(), + environmentNames: () => ["Local"], + }); + const layout = buildThreadListV2Items({ + threads: [older, stale, recent], + environmentId: null, + searchQuery: "is:archived since:7d env:local", + searchFilterContext: context, + searchNow: new Date(NOW), + now: NOW, + }); + expect(layout.items.map((item) => item.thread.id)).toEqual(["recent", "older"]); + }); + it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f7327706ef575..f54e9d12be76f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -11,6 +11,12 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + matchesThreadSearchFilters, + parseThreadSearchQuery, + type ThreadSearchFilters, + type ThreadSearchMatchContext, +} from "@t3tools/client-runtime/state/threadSearchQuery"; import { createInboxReturnTracker, isThreadWorking, @@ -84,6 +90,42 @@ export type ThreadListV2Status = | "failed" | "limited" | "ready"; + +export function createMobileThreadSearchContext(input: { + readonly projects: ReadonlyMap< + string, + { readonly title: string; readonly workspaceRoot: string } + >; + readonly environmentNames: (thread: EnvironmentThreadShell) => ReadonlyArray; + readonly providers: ReadonlyMap>; +}): ThreadSearchMatchContext { + return { + projectName: (thread) => { + const project = input.projects.get(`${thread.environmentId}:${thread.projectId}`); + return project?.title || project?.workspaceRoot.split(/[\\/]/).pop() || ""; + }, + environmentNames: input.environmentNames, + providerNames: (thread) => { + const providers = input.providers.get(thread.environmentId) ?? []; + return resolveThreadProviderStack(thread).flatMap((instanceId) => { + const provider = providers.find((entry) => entry.instanceId === instanceId); + return provider ? [provider.driver, instanceId, provider.displayName ?? ""] : [instanceId]; + }); + }, + status: resolveThreadListV2Status, + activityAt: (thread) => thread.latestUserMessageAt ?? thread.updatedAt, + }; +} + +export function sortThreadsByActivity< + T extends { readonly updatedAt: string; readonly latestUserMessageAt?: string | null }, +>(threads: ReadonlyArray): T[] { + return [...threads].sort((left, right) => + (right.latestUserMessageAt ?? right.updatedAt).localeCompare( + left.latestUserMessageAt ?? left.updatedAt, + ), + ); +} export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { @@ -618,6 +660,9 @@ export function buildThreadListV2Items(input: { readonly projectId: ProjectId; }> | null; readonly searchQuery: string; + readonly searchFilters?: ThreadSearchFilters; + readonly searchFilterContext?: ThreadSearchMatchContext; + readonly searchNow?: Date; readonly matchedThreadKeys?: ReadonlySet; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither @@ -663,7 +708,10 @@ export function buildThreadListV2Items(input: { pendingOrder: null, }), ); - const query = input.searchQuery.trim().toLocaleLowerCase(); + const parsedSearch = input.searchFilters + ? { text: input.searchQuery, filters: input.searchFilters, hasFilters: true } + : parseThreadSearchQuery(input.searchQuery, { now: input.searchNow ?? new Date() }); + const query = parsedSearch.text.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; @@ -676,12 +724,24 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue; + if ( + (!parsedSearch.filters.archived && thread.archivedAt !== null) || + thread.lineage.relationshipToParent === "subagent" + ) + continue; // The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } + if ( + parsedSearch.hasFilters && + input.searchFilterContext !== undefined && + !matchesThreadSearchFilters(thread, parsedSearch.filters, input.searchFilterContext, { + now: input.searchNow ?? new Date(), + }) + ) + continue; if ( query.length > 0 && !thread.title.toLocaleLowerCase().includes(query) && @@ -697,6 +757,10 @@ export function buildThreadListV2Items(input: { ) { continue; } + if (parsedSearch.filters.archived) { + active.push(thread); + continue; + } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; // Snooze outranks settlement and pinning until the thread wakes. @@ -726,9 +790,11 @@ export function buildThreadListV2Items(input: { // The beta inbox is time-ordered, so the saved arrangement (and any move in // flight) is kept but not applied until the beta is off again. - const orderedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, input.inboxReturnAt) - : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + const orderedActive = parsedSearch.filters.archived + ? sortThreadsByActivity(active) + : workingShelfEnabled + ? sortInboxThreadsByReturn(active, input.inboxReturnAt) + : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); // Newest send first; finishing and waking again do not move a row. const orderedWorking = sortWorkingThreadsBySend(working); const orderedSnoozed = [...snoozed].sort( diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 41aa35ed43674..2e6512a04c700 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -48,10 +48,10 @@ const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ }).pipe(Atom.withLabel("mobile:thread-search:empty")); const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ - getSearchAtom: (environmentId, query) => + getSearchAtom: (environmentId, query, archived) => orchestrationEnvironment.threadSearch({ environmentId, - input: { query }, + input: { query, ...(archived ? { archived: true } : {}) }, }), labelPrefix: "mobile:thread-search", }); @@ -153,6 +153,7 @@ export function useComposerPullRequestSearch(input: { export function useThreadSearch( environmentIds: ReadonlyArray, query: string, + archived = false, ): { readonly matches: ReadonlyArray; readonly isPending: boolean; @@ -162,8 +163,9 @@ export function useThreadSearch( 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], + () => + settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery, archived), + [archived, environmentIds, settledQuery], ); const result = useAtomValue( searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 6847221113846..51349b558c1b9 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -172,6 +172,12 @@ it.layer(layerTest)("ThreadSearch", (it) => { assert.lengthOf((yield* search.search({ query: "needle", limit: 1 })).matches, 1); // LIKE wildcards in the query match literally. assert.deepEqual((yield* search.search({ query: "ne%le" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "needle", archived: true })).matches.map( + (match) => match.threadId, + ), + [ThreadId.make("thread:archived")], + ); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 855556dd3d19e..c1472af90ed2a 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -26,7 +26,11 @@ export class ThreadSearchError extends Schema.TaggedError()( } } -const SearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int }); +const SearchRequest = Schema.Struct({ + pattern: Schema.String, + limit: Schema.Int, + archived: Schema.Boolean, +}); const SearchRow = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -83,57 +87,62 @@ export const make = Effect.gen(function* () { const searchRows = SqlSchema.findAll({ Request: SearchRequest, Result: SearchRow, - execute: ({ pattern, limit }) => sql` - WITH candidate AS ( - SELECT - threads.thread_id, - threads.project_id, - messages.role, - json_extract(messages.payload_json, '$.text') AS match_text, - messages.created_at AS message_created_at, - messages.message_id, - threads.updated_at AS thread_updated_at - FROM orchestration_v2_projection_messages AS messages - INNER JOIN orchestration_v2_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.streaming = 0 - AND messages.role IN ('user', 'assistant') - AND json_extract(messages.payload_json, '$.text') LIKE ${pattern} ESCAPE '!' - ), - ranked AS ( + execute: ({ pattern, limit, archived }) => { + const archivedPredicate = archived + ? sql`threads.archived_at IS NOT NULL` + : sql`threads.archived_at IS NULL`; + return sql` + WITH candidate AS ( + SELECT + threads.thread_id, + threads.project_id, + messages.role, + json_extract(messages.payload_json, '$.text') AS match_text, + messages.created_at AS message_created_at, + messages.message_id, + threads.updated_at AS thread_updated_at + FROM orchestration_v2_projection_messages AS messages + INNER JOIN orchestration_v2_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 ${archivedPredicate} + AND projects.deleted_at IS NULL + AND messages.streaming = 0 + AND messages.role IN ('user', 'assistant') + AND json_extract(messages.payload_json, '$.text') LIKE ${pattern} ESCAPE '!' + ), + ranked AS ( + SELECT + thread_id, + project_id, + role AS source, + match_text, + message_created_at, + CASE role WHEN 'user' THEN 0 ELSE 1 END AS match_rank, + thread_updated_at, + ROW_NUMBER() OVER ( + PARTITION BY thread_id + ORDER BY + CASE role WHEN 'user' THEN 0 ELSE 1 END ASC, + message_created_at DESC, + message_id ASC + ) AS thread_match_rank + FROM candidate + ) SELECT - thread_id, - project_id, - role AS source, - match_text, - message_created_at, - CASE role WHEN 'user' THEN 0 ELSE 1 END AS match_rank, - thread_updated_at, - ROW_NUMBER() OVER ( - PARTITION BY thread_id - ORDER BY - CASE role WHEN 'user' THEN 0 ELSE 1 END ASC, - message_created_at DESC, - message_id ASC - ) AS thread_match_rank - FROM candidate - ) - 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} - `, + 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 search: ThreadSearch["Service"]["search"] = Effect.fn("ThreadSearch.search")( @@ -141,6 +150,7 @@ export const make = Effect.gen(function* () { const rows = yield* searchRows({ pattern: `%${escapeLikePattern(input.query)}%`, limit: input.limit ?? 50, + archived: input.archived === true, }).pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69fb..faa10f2d319d5 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -56,7 +56,11 @@ describe("linked pull request thread navigation", () => { expect(groups.flatMap((group) => group.items)).toEqual(items); expect(items[0]?.description).toBe("Archived thread"); await items[0]?.run(); - expect(runThread).toHaveBeenCalledWith({ environmentId, id }); + expect(runThread).toHaveBeenCalledWith({ + environmentId, + id, + archivedAt: "2026-09-01T00:00:00.000Z", + }); }); }); @@ -823,6 +827,28 @@ it.each([ }); describe("filterCommandPaletteGroups", () => { + it("shows all filtered threads for qualifier-only queries and keeps empty-query recent groups", () => { + const threadItem: CommandPaletteActionItem = { + kind: "action", + value: "thread:one", + title: "Fix search", + searchTerms: ["Fix search"], + icon: null, + run: async () => {}, + }; + const recent = { value: "recent-threads", label: "Recent threads", items: [threadItem] }; + const input = { + activeGroups: [recent], + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [threadItem], + }; + expect(filterCommandPaletteGroups({ ...input, query: "status:failed" })).toEqual([ + { value: "threads-search", label: "Threads", items: [threadItem] }, + ]); + expect(filterCommandPaletteGroups({ ...input, query: "" })).toEqual([recent]); + }); + it("sorts secondary settings results after other matches", () => { const item = (value: string, title: string, secondary?: boolean) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313a..edeb71d778276 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -13,6 +13,7 @@ import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import { normalizeSearchText } from "../lib/utils"; +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; @@ -25,7 +26,9 @@ export function buildLinkedThreadActionItems( input: CommandPaletteLinkedThreads & { query: string; icon: ReactNode; - runThread: (thread: Pick) => Promise; + runThread: ( + thread: Pick, + ) => Promise; }, ): CommandPaletteActionItem[] { return input.threads.map((thread) => ({ @@ -35,7 +38,12 @@ export function buildLinkedThreadActionItems( description: thread.archivedAt === null ? "Linked thread" : "Archived thread", searchTerms: [input.query, thread.title], icon: input.icon, - run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }), + run: () => + input.runThread({ + environmentId: input.environmentId, + id: thread.id, + archivedAt: thread.archivedAt, + }), })); } @@ -326,11 +334,19 @@ export function buildThreadActionItems ReactNode; getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; - runThread: (thread: Pick) => Promise; + filterThread?: (thread: TThread) => boolean; + includeArchived?: boolean; + runThread: ( + thread: Pick, + ) => Promise; limit?: number; }): CommandPaletteActionItem[] { const sortedThreads = sortThreads( - input.threads.filter((thread) => thread.archivedAt === null), + input.threads.filter( + (thread) => + (input.includeArchived === true || thread.archivedAt === null) && + (input.filterThread?.(thread) ?? true), + ), input.sortOrder, ); const visibleThreads = @@ -448,9 +464,15 @@ export function filterCommandPaletteGroups(input: { }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; - const normalizedQuery = normalizeSearchText(searchQuery); + const parsedQuery = parseThreadSearchQuery(searchQuery, { now: new Date() }); + const normalizedQuery = normalizeSearchText(parsedQuery.text); if (normalizedQuery.length === 0) { + if (parsedQuery.hasFilters && !isActionsFilter) { + return input.threadSearchItems.length > 0 + ? [{ value: "threads-search", label: "Threads", items: input.threadSearchItems }] + : []; + } if (isActionsFilter) { return input.activeGroups.filter((group) => group.value === "actions"); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 023eafa9397a9..69450ec96303c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -116,6 +116,7 @@ import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -179,9 +180,18 @@ import { reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; -import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; +import { + createWebThreadSearchContext, + getThreadSearchEnvironmentNames, + orderItemsByPreferredIds, + sortLogicalProjectsForSidebar, +} from "./Sidebar.logic"; +import { matchesThreadSearchFilters } from "@t3tools/client-runtime/state/threadSearchQuery"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { CommandPaletteContent } from "./CommandPaletteContent"; +import { CommandPaletteThreadFilters } from "./CommandPaletteThreadFilters"; import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, @@ -699,10 +709,15 @@ function OpenCommandPaletteDialog(props: { const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); + const paletteInputRef = useRef(null); const [linkedThreadSearch, setLinkedThreadSearch] = useState( openIntent?.kind === "search" ? openIntent : null, ); const deferredQuery = useDeferredValue(query); + const parsedThreadSearch = useMemo( + () => parseThreadSearchQuery(deferredQuery, { now: new Date() }), + [deferredQuery], + ); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const resultListRef = useRef(null); @@ -847,8 +862,24 @@ function OpenCommandPaletteDialog(props: { .map((environment) => environment.environmentId), [environments], ); - const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; - const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); + const archivedSnapshots = useArchivedThreadSnapshots( + parsedThreadSearch.filters.archived ? environmentIds : [], + ); + const searchableThreads = useMemo( + () => + parsedThreadSearch.filters.archived + ? archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), + ) + : threads, + [archivedSnapshots.snapshots, parsedThreadSearch.filters.archived, threads], + ); + const threadSearchQuery = currentView === null && !isActionsOnly ? parsedThreadSearch.text : ""; + const threadSearch = useThreadSearch( + environmentIds, + threadSearchQuery, + parsedThreadSearch.filters.archived, + ); const threadContentMatchByKey = useMemo( () => new Map( @@ -921,6 +952,43 @@ function OpenCommandPaletteDialog(props: { ), [environments], ); + const environmentNamesById = useMemo( + () => + new Map( + environments.map((environment) => [ + environment.environmentId, + getThreadSearchEnvironmentNames(environment.entry.target), + ]), + ), + [environments], + ); + const threadFilterProjects = useMemo(() => { + const seen = new Set(); + return projects.flatMap((project) => { + const title = project.title || project.workspaceRoot.split(/[\\/]/).pop() || ""; + if (!title) return []; + const key = title.toLocaleLowerCase(); + if (seen.has(key)) return []; + seen.add(key); + return [{ label: title, value: title }]; + }); + }, [projects]); + const threadFilterProviders = useMemo(() => { + const seen = new Set(); + return [...providerEntryByEnvironmentAndInstanceId.values()].flatMap((entry) => { + const key = entry.displayName.toLocaleLowerCase(); + if (seen.has(key)) return []; + seen.add(key); + return [{ label: entry.displayName, value: entry.displayName }]; + }); + }, [providerEntryByEnvironmentAndInstanceId]); + const threadFilterEnvironments = useMemo(() => { + if (environmentIds.length <= 1) return []; + const labels = new Set( + environmentIds.map((id) => projectEnvironmentLocationById.get(id)?.label ?? id), + ); + return [...labels].map((label) => ({ label, value: label })); + }, [environmentIds, projectEnvironmentLocationById]); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1137,6 +1205,16 @@ function OpenCommandPaletteDialog(props: { () => new Map(projects.map((project) => [project.id, project.title])), [projects], ); + const threadSearchContext = useMemo( + () => + createWebThreadSearchContext({ + projects: projectByKey, + environmentNames: environmentNamesById, + providerEntry: (thread, instanceId) => + providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${instanceId}`), + }), + [environmentNamesById, projectByKey, providerEntryByEnvironmentAndInstanceId], + ); const activeThreadId = activeThread?.id; const currentProjectEnvironmentId = @@ -1417,11 +1495,17 @@ function OpenCommandPaletteDialog(props: { const allThreadItems = useMemo( () => buildThreadActionItems({ - threads, + threads: searchableThreads, + includeArchived: parsedThreadSearch.filters.archived, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, sortOrder: clientSettings.sidebarThreadSortOrder, icon: , + filterThread: (thread) => + !parsedThreadSearch.hasFilters || + matchesThreadSearchFilters(thread, parsedThreadSearch.filters, threadSearchContext, { + now: new Date(), + }), renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , renderDescription: (thread, { projectTitle }) => { @@ -1466,6 +1550,10 @@ function OpenCommandPaletteDialog(props: { : undefined; }, runThread: async (thread) => { + if (thread.archivedAt !== null) { + await navigate({ to: "/settings/archived" }); + return; + } await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), @@ -1476,14 +1564,15 @@ function OpenCommandPaletteDialog(props: { activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, - projectCwdById, projectByKey, projectEnvironmentLocationById, projectTitleById, providerEntryByEnvironmentAndInstanceId, + parsedThreadSearch, + searchableThreads, + threadSearchContext, threadContentMatchByKey, threadSearch.query, - threads, ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -2340,6 +2429,10 @@ function OpenCommandPaletteDialog(props: { query: linkedThreadSearch.query, icon: , runThread: async (thread) => { + if (thread.archivedAt !== null) { + await navigate({ to: "/settings/archived" }); + return; + } await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), @@ -3411,6 +3504,15 @@ function OpenCommandPaletteDialog(props: { : "This connection cannot add projects."} + ) : currentView === null && !query.startsWith(">") ? ( + ) : null; const footerActionLabel = @@ -3444,6 +3546,7 @@ function OpenCommandPaletteDialog(props: { autoHighlight={autoHighlightsFirstRow ? "always" : false} footerActionLabel={footerActionLabel} footerTrailing={footerTrailing} + inputRef={paletteInputRef} inputAccessory={inputAccessory} inputProps={{ // The submit button is absolutely positioned over the field, so the @@ -3456,7 +3559,9 @@ function OpenCommandPaletteDialog(props: { willCreateProjectPath, hasHighlightedBrowseItem, }) - : undefined, + : currentView === null && !query.startsWith(">") + ? "*:data-[slot=autocomplete-input]:pe-12!" + : undefined, placeholder: inputPlaceholder, ...(isSubmenu ? { diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 8732c6ef926f7..0aee59cfd6937 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,5 +1,11 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; +import { + type ComponentProps, + type ReactNode, + type RefObject, + useLayoutEffect, + useRef, +} from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -10,6 +16,7 @@ type CommandPaletteContentProps = Omit, "children readonly footerActionLabel?: ReactNode; readonly footerTrailing?: ReactNode; readonly inputAccessory?: ReactNode; + readonly inputRef?: RefObject; readonly inputProps: ComponentProps; /** * How tall the results panel may grow: the palette's list, a taller file list, or the whole @@ -31,6 +38,7 @@ export function CommandPaletteContent({ footerActionLabel, footerTrailing, inputAccessory, + inputRef: externalInputRef, inputProps, panelSize = "list", showBackHint, @@ -50,7 +58,13 @@ export function CommandPaletteContent({
- + { + inputRef.current = node; + if (externalInputRef) externalInputRef.current = node; + }} + /> {inputAccessory}
= [ + { label: "Working", value: "working" }, + { label: "Needs approval", value: "approval" }, + { label: "Needs input", value: "input" }, + { label: "Failed", value: "failed" }, + { label: "Ready", value: "ready" }, +]; + +const UPDATED_OPTIONS: ReadonlyArray = [ + { label: "Today", value: "today" }, + { label: "Last 7 days", value: "7d" }, + { label: "Last 30 days", value: "30d" }, +]; + +function hasSelectedValue( + parsedQuery: ParsedThreadSearchQuery, + key: Exclude, + value: string, +): boolean { + return parsedQuery.filters[key].some( + (clause) => + !clause.negated && + clause.values.some((candidate) => candidate.toLowerCase() === value.toLowerCase()), + ); +} + +function countRecognizedQualifiers(parsedQuery: ParsedThreadSearchQuery): number { + const keys = ["project", "env", "branch", "provider", "status", "since", "before"] as const; + return ( + keys.reduce((count, key) => count + parsedQuery.filters[key].length, 0) + + (parsedQuery.filters.archived ? 1 : 0) + ); +} + +export function CommandPaletteThreadFilters({ + query, + projects, + providers, + environments, + inputRef, + onQueryChange, +}: { + readonly query: string; + readonly projects: ReadonlyArray; + readonly providers: ReadonlyArray; + readonly environments: ReadonlyArray; + readonly inputRef: RefObject; + readonly onQueryChange: (query: string) => void; +}) { + const parsedQuery = useMemo(() => parseThreadSearchQuery(query, { now: new Date() }), [query]); + const filterCount = countRecognizedQualifiers(parsedQuery); + const selected = (key: Exclude, value: string) => + hasSelectedValue(parsedQuery, key, value); + const toggle = (key: ThreadSearchQualifierToggleKey, value: string, multi: boolean) => { + onQueryChange(toggleThreadSearchQualifier(query, key, value, { multi })); + }; + + return ( + + + } + > + + {filterCount > 0 ? ( + + {filterCount} + + ) : null} + + + + Status + {STATUS_OPTIONS.map((option) => ( + toggle("status", option.value, true)} + > + {option.label} + + ))} + + + + Project + + Project + {projects.length === 0 ? ( + No projects + ) : ( + projects.map((option) => ( + toggle("project", option.value, true)} + > + {option.label} + + )) + )} + + + + Provider + + Provider + {providers.length === 0 ? ( + No providers + ) : ( + providers.map((option) => ( + toggle("provider", option.value, true)} + > + {option.label} + + )) + )} + + + {environments.length > 0 ? ( + + Environment + + Environment + {environments.map((option) => ( + toggle("env", option.value, true)} + > + {option.label} + + ))} + + + ) : null} + + + Updated + {UPDATED_OPTIONS.map((option) => ( + toggle("since", option.value, false)} + > + {option.label} + + ))} + + + toggle("archived", "archived", false)} + > + Archived + + {filterCount > 0 ? ( + <> + + { + onQueryChange(clearThreadSearchQualifiers(query)); + }} + > + Clear filters + + + ) : null} + + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf2a0c0cbd6f4..71a63284f3d76 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,4 +1,8 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + BearerConnectionTarget, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -18,6 +22,7 @@ import { formatWorkingDurationLabel, getFallbackThreadIdAfterDelete, getProjectSortTimestamp, + getThreadSearchEnvironmentNames, getSidebarForkParentThreadId, getSidebarThreadIdsToPrewarm, hasUnseenCompletion, @@ -995,11 +1000,44 @@ describe("resolveSidebarThreadStatus", () => { }); describe("searchSidebarThreads", () => { + it("accepts the environment names displayed in the sidebar and palette", () => { + expect( + getThreadSearchEnvironmentNames( + new PrimaryConnectionTarget({ + environmentId: localEnvironmentId, + label: "This device", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + }), + ), + ).toEqual(["This device", "Local"]); + expect( + getThreadSearchEnvironmentNames( + new BearerConnectionTarget({ + environmentId: localEnvironmentId, + connectionId: "local:wsl:Ubuntu", + label: "Ubuntu", + }), + ), + ).toEqual(["Ubuntu", "Ubuntu (Local)"]); + expect( + getThreadSearchEnvironmentNames( + new BearerConnectionTarget({ + environmentId: localEnvironmentId, + connectionId: "remote", + label: "Office Mac", + }), + ), + ).toEqual(["Office Mac"]); + }); + const searchThread = (id: string, title: string, project: string) => ({ environmentId: localEnvironmentId, id: ThreadId.make(id), title, project, + updatedAt: "2026-10-07T00:00:00.000Z", + branch: null, }); const threads = [ searchThread("thread-1", "Fix workspace search", "Alpha"), @@ -1025,6 +1063,24 @@ describe("searchSidebarThreads", () => { expect(searchSidebarThreads(threads, " ")).toEqual([]); }); + it("applies project qualifiers to both title and content matches", () => { + const filterContext = { + projectName: (thread: (typeof threads)[number]) => thread.project, + environmentNames: () => ["Local"], + providerNames: () => ["Codex"], + status: () => "ready", + activityAt: (thread: (typeof threads)[number]) => thread.updatedAt, + }; + expect( + searchSidebarThreads(threads, "project:Workspace", new Set(), { filterContext }), + ).toEqual([threads[1]]); + expect( + searchSidebarThreads(threads, "work project:Alpha", contentKeys("thread-2"), { + filterContext, + }), + ).toEqual([threads[0]]); + }); + it("appends content-only matches after every title match", () => { expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([ threads[0], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a8743024832f1..217436b68bef9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,7 @@ -import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { + resolveThreadProviderStack, + resolveThreadWorkingStartedAt, +} from "@t3tools/client-runtime/state/models"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; @@ -8,6 +11,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + matchesThreadSearchFilters, + parseThreadSearchQuery, + type ThreadSearchMatchContext, +} from "@t3tools/client-runtime/state/threadSearchQuery"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; @@ -23,6 +31,9 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; +import type { ProviderInstanceEntry } from "../providerInstances"; +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { cn } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; @@ -1071,6 +1082,41 @@ export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3t const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); +export function getThreadSearchEnvironmentNames(target: ConnectionTarget): ReadonlyArray { + if (target._tag === "PrimaryConnectionTarget") return [target.label, "Local"]; + if (isDesktopLocalConnectionTarget(target)) return [target.label, `${target.label} (Local)`]; + return [target.label]; +} + +export function createWebThreadSearchContext(input: { + readonly projects: ReadonlyMap< + string, + { readonly title: string; readonly workspaceRoot: string } + >; + readonly environmentNames: ReadonlyMap>; + readonly providerEntry: ( + thread: SidebarThreadSummary, + instanceId: string, + ) => ProviderInstanceEntry | undefined; +}): ThreadSearchMatchContext { + return { + projectName: (thread) => { + const project = input.projects.get(`${thread.environmentId}:${thread.projectId}`); + return project?.title || project?.workspaceRoot.split(/[\\/]/).pop() || ""; + }, + environmentNames: (thread) => + input.environmentNames.get(thread.environmentId) ?? [thread.environmentId], + providerNames: (thread) => { + return resolveThreadProviderStack(thread).flatMap((instanceId) => { + const entry = input.providerEntry(thread, instanceId); + return entry ? [entry.driverKind, instanceId, entry.displayName] : [instanceId]; + }); + }, + status: resolveSidebarThreadStatus, + activityAt: (thread) => thread.latestUserMessageAt ?? thread.updatedAt, + }; +} + /** * Search the already-ordered sidebar thread collection by title or linked PR, * plus any thread whose messages the server matched (`contentMatchKeys`, keyed @@ -1082,20 +1128,35 @@ export function searchSidebarThreads< readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly title: string; + readonly updatedAt: string; + readonly branch: string | null; } & Parameters[0], >( threads: readonly T[], query: string, contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS, + options?: { readonly now?: Date; readonly filterContext?: ThreadSearchMatchContext }, ): T[] { - const normalizedQuery = query.trim().toLowerCase(); - if (normalizedQuery.length === 0) return []; + const parsed = parseThreadSearchQuery(query, { now: options?.now ?? new Date() }); + const normalizedQuery = parsed.text.trim().toLowerCase(); + if (normalizedQuery.length === 0 && !parsed.hasFilters) return []; const titleMatches: T[] = []; const contentMatches: T[] = []; for (const thread of threads) { - const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => - term.toLowerCase().includes(normalizedQuery), - ); + if (parsed.hasFilters && options?.filterContext === undefined) continue; + if ( + options?.filterContext && + !matchesThreadSearchFilters(thread, parsed.filters, options.filterContext, { + now: options.now ?? new Date(), + }) + ) { + continue; + } + const matchesTitle = + normalizedQuery.length === 0 || + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ); if (matchesTitle) { titleMatches.push(thread); } else if ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb1..2ca5b592323d4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,8 +41,11 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery"; +import { environmentCatalog } from "../connection/catalog"; import { resolveThreadProviderStack, + presentThreadShell, threadRuntimeCanArchive, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/models"; @@ -160,6 +163,7 @@ import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useThreadSearch } from "../state/queries"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { @@ -168,6 +172,7 @@ import { resolveThreadRouteTarget, } from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; +import { getThreadSortTimestamp } from "../lib/threadSort"; import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; @@ -191,6 +196,8 @@ import { hasUnseenCompletion, isSidebarNestedLinkClick, isSidebarThreadWorking, + createWebThreadSearchContext, + getThreadSearchEnvironmentNames, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -2493,6 +2500,17 @@ export default function Sidebar() { ), [environments], ); + const connectionCatalog = useAtomValue(environmentCatalog.catalogValueAtom); + const environmentNamesById = useMemo( + () => + new Map( + [...connectionCatalog.entries].map(([environmentId, entry]) => [ + environmentId, + getThreadSearchEnvironmentNames(entry.target), + ]), + ), + [connectionCatalog.entries], + ); const environmentMachineById = useEnvironmentMachines(); const orderedProjects = useMemo( () => @@ -2864,21 +2882,67 @@ export default function Sidebar() { const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const parsedThreadSearch = useMemo( + () => parseThreadSearchQuery(threadSearchQuery, { now: new Date() }), + [threadSearchQuery], + ); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; + const searchEnvironmentIds = useConnectedEnvironmentIds(); + const archivedSnapshots = useArchivedThreadSnapshots( + parsedThreadSearch.filters.archived ? searchEnvironmentIds : [], + ); const searchableThreads = useMemo( - () => [ - ...pinnedThreads, - ...activeThreads, - ...workingThreads, - ...snoozedThreads, - ...settledThreads, + () => + parsedThreadSearch.filters.archived + ? archivedSnapshots.snapshots + .flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)), + ) + .filter( + (thread) => + scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), + ) + .sort( + (left, right) => + getThreadSortTimestamp(right, "updated_at") - + getThreadSortTimestamp(left, "updated_at"), + ) + : [ + ...pinnedThreads, + ...activeThreads, + ...workingThreads, + ...snoozedThreads, + ...settledThreads, + ], + [ + activeThreads, + archivedSnapshots.snapshots, + parsedThreadSearch.filters.archived, + pinnedThreads, + settledThreads, + snoozedThreads, + workingThreads, + scopedProjectKeys, ], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads, workingThreads], ); - const searchEnvironmentIds = useConnectedEnvironmentIds(); + const threadSearchContext = useMemo( + () => + createWebThreadSearchContext({ + projects: projectByKey, + environmentNames: environmentNamesById, + providerEntry: (thread, instanceId) => + providerEntriesByEnvironment.get(thread.environmentId)?.get(instanceId), + }), + [environmentNamesById, projectByKey, providerEntriesByEnvironment], + ); // useThreadSearch owns the debounce and the two-character floor. - const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); + const threadSearch = useThreadSearch( + searchEnvironmentIds, + parsedThreadSearch.text, + parsedThreadSearch.filters.archived, + ); const threadSearchMatchByKey = useMemo( () => new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)), @@ -2890,8 +2954,12 @@ export default function Sidebar() { searchableThreads, threadSearchQuery, new Set(threadSearchMatchByKey.keys()), + { + now: new Date(), + filterContext: threadSearchContext, + }, ), - [searchableThreads, threadSearchQuery, threadSearchMatchByKey], + [searchableThreads, threadSearchQuery, threadSearchContext, threadSearchMatchByKey], ); const threadSearchResultOrderKey = threadSearchResults .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) @@ -3179,9 +3247,13 @@ export default function Sidebar() { const selectThreadSearchResult = useCallback( (thread: EnvironmentThreadShell) => { clearThreadSearch(); + if (thread.archivedAt !== null) { + void router.navigate({ to: "/settings/archived" }); + return; + } navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); }, - [clearThreadSearch, navigateToThread], + [clearThreadSearch, navigateToThread, router], ); const handleThreadSearchKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -5162,7 +5234,7 @@ export default function Sidebar() { }), ) ?? null } - searchQuery={threadSearchQuery} + searchQuery={parsedThreadSearch.text} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} onFileDropThreads={handleThreadFileDrop} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2128c547c339e..6a5bef5e0413c 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -49,10 +49,10 @@ const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ }).pipe(Atom.withLabel("web:thread-search:empty")); const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ - getSearchAtom: (environmentId, query) => + getSearchAtom: (environmentId, query, archived) => orchestrationEnvironment.threadSearch({ environmentId, - input: { query }, + input: { query, ...(archived ? { archived: true } : {}) }, }), labelPrefix: "web:thread-search", }); @@ -75,6 +75,7 @@ export function useDebouncedValue(value: A, delayMs: number): A { export function useThreadSearch( environmentIds: ReadonlyArray, query: string, + archived = false, ): { readonly matches: ReadonlyArray; /** The settled query `matches` came from; it only changes when results do. */ @@ -86,8 +87,9 @@ export function useThreadSearch( 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], + () => + settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery, archived), + [archived, environmentIds, settledQuery], ); const result = useAtomValue( searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b676..18fff85a73395 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -180,9 +180,17 @@ The linked pull request participates in automatic settlement. ## Find and reference work -On web and desktop, open the command palette with `Cmd/Ctrl+K` to search threads -across connected environments. Message search starts after two characters and -includes your messages and final agent responses. +Search the sidebar, command palette, or mobile thread list for titles, linked pull +requests, and message content. Add `project:` (or `in:`), `env:`, `branch:`, +`provider:`, `status:`, `since:`, `before:`, or `is:archived`; comma-separated +values mean "any", and `-` excludes a value. For example, `status:failed +provider:claude` finds failed Claude work, and `project:"my app" since:7d` +finds that project's work active in the last week. `since:` and `before:` use +last activity; dates are `YYYY-MM-DD` or relative values such as `7d`, `12h`, +`2w`, `today`, and `yesterday`. + +Open the command palette with `Cmd/Ctrl+K`; its filter button adds the same +qualifiers for you. Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's pull request diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 3b72e948454ad..33bdb1d7efa10 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -319,6 +319,10 @@ "types": "./src/state/threadSearch.ts", "default": "./src/state/threadSearch.ts" }, + "./state/threadSearchQuery": { + "types": "./src/state/threadSearchQuery.ts", + "default": "./src/state/threadSearchQuery.ts" + }, "./state/turn-item-presentation": { "types": "./src/state/turnItemPresentation.ts", "default": "./src/state/turnItemPresentation.ts" diff --git a/packages/client-runtime/src/state/threadSearch.test.ts b/packages/client-runtime/src/state/threadSearch.test.ts index e907ea94f0515..82feb321d6128 100644 --- a/packages/client-runtime/src/state/threadSearch.test.ts +++ b/packages/client-runtime/src/state/threadSearch.test.ts @@ -23,6 +23,12 @@ it("creates stable keys regardless of environment order", () => { ); }); +it("separates archived content searches from active searches", () => { + expect(makeThreadSearchKey([envA], "needle")).not.toBe( + makeThreadSearchKey([envA], "needle", true), + ); +}); + it("creates keys without array methods unavailable in Hermes", () => { const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); Reflect.deleteProperty(Array.prototype, "toSorted"); diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index ff7874d77bb6a..c31888ac65d2e 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -18,18 +18,24 @@ export interface ThreadSearchResultsState { } const ThreadSearchKey = Schema.fromJsonString( - Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]), + Schema.Tuple([ + Schema.Array(EnvironmentId), + OrchestrationSearchThreadsInput.fields.query, + Schema.optional(Schema.Boolean), + ]), ); const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey); export function makeThreadSearchKey( environmentIds: ReadonlyArray, query: string, + archived = false, ): string { - return JSON.stringify([ - [...environmentIds].sort((left, right) => left.localeCompare(right)), - query, - ]); + const sortedEnvironmentIds = [...environmentIds].sort((left, right) => left.localeCompare(right)); + const key = archived + ? ([sortedEnvironmentIds, query, true] as const) + : ([sortedEnvironmentIds, query] as const); + return JSON.stringify(key); } function parseThreadSearchKey(key: string) { @@ -51,6 +57,7 @@ export function createThreadSearchResultsAtomFamily(options: { readonly getSearchAtom: ( environmentId: EnvironmentId, query: string, + archived?: boolean, ) => Atom.Atom>; readonly labelPrefix: string; }) { @@ -61,12 +68,12 @@ export function createThreadSearchResultsAtomFamily(options: { return { matches: [], isLoading: false }; } - const [environmentIds, query] = parsedKey.value; + const [environmentIds, query, archived] = parsedKey.value; const matches: EnvironmentThreadSearchMatch[] = []; let isLoading = false; for (const environmentId of environmentIds) { - const result = get(options.getSearchAtom(environmentId, query)); + const result = get(options.getSearchAtom(environmentId, query, archived)); isLoading ||= result.waiting; const value = Option.getOrNull(AsyncResult.value(result)); if (value !== null) { diff --git a/packages/client-runtime/src/state/threadSearchQuery.test.ts b/packages/client-runtime/src/state/threadSearchQuery.test.ts new file mode 100644 index 0000000000000..92c9bd70ec27d --- /dev/null +++ b/packages/client-runtime/src/state/threadSearchQuery.test.ts @@ -0,0 +1,186 @@ +// @effect-diagnostics globalDate:off -- Tests inject local-calendar boundaries. + +import { describe, expect, it } from "vite-plus/test"; + +import { + clearThreadSearchQualifiers, + matchesThreadSearchFilters, + parseThreadSearchQuery, + toggleThreadSearchQualifier, +} from "./threadSearchQuery.ts"; + +const now = new Date("2026-10-07T15:30:00"); +const thread = { updatedAt: "2026-10-07T10:00:00", branch: "feature/search" }; +const context = { + projectName: () => "My App", + environmentNames: () => ["Office Mac", "Local"], + providerNames: () => ["claudeAgent", "Claude"], + status: () => "failed", + activityAt: (value: typeof thread) => value.updatedAt, +}; + +describe("thread search query", () => { + it("parses quoted values, aliases, comma OR, and negation", () => { + const parsed = parseThreadSearchQuery( + 'project:"my app" provider:codex,claude -status:waiting hello', + { now }, + ); + expect(parsed.text).toBe("hello"); + expect(parsed.filters.project[0]?.values).toEqual(["my app"]); + expect(parsed.filters.provider[0]?.values).toEqual(["codex", "claude"]); + expect(parsed.filters.status[0]?.negated).toBe(true); + expect(parsed.hasFilters).toBe(true); + }); + + it("keeps unknown and invalid qualifiers as text", () => { + expect(parseThreadSearchQuery("foo:bar since:banana", { now })).toEqual({ + text: "foo:bar since:banana", + filters: { + project: [], + env: [], + branch: [], + provider: [], + status: [], + since: [], + before: [], + archived: false, + }, + hasFilters: false, + }); + }); + + it("keeps apostrophes and backslashes literal", () => { + expect(parseThreadSearchQuery("don't fix C:\\repo", { now }).text).toBe("don't fix C:\\repo"); + }); + + it("does not let an unbalanced double quote swallow later tokens", () => { + const parsed = parseThreadSearchQuery('project:"my app status:failed', { now }); + expect(parsed.text).toBe('project:"my app'); + expect(parsed.filters.status[0]?.values).toEqual(["failed"]); + }); + + it("matches all qualifier families and local day boundaries", () => { + const parsed = parseThreadSearchQuery( + "in:app env:office branch:feature provider:claude status:error since:today before:2026-10-08", + { now }, + ); + expect(matchesThreadSearchFilters(thread, parsed.filters, context, { now })).toBe(true); + expect( + matchesThreadSearchFilters( + thread, + parseThreadSearchQuery("-branch:feature", { now }).filters, + context, + { now }, + ), + ).toBe(false); + expect(parseThreadSearchQuery("is:archived", { now }).filters.archived).toBe(true); + }); + + it("matches dates against the resolved activity timestamp and all environment names", () => { + const activityThread = { + updatedAt: "2026-10-07T10:00:00", + latestUserMessageAt: "2026-09-20T10:00:00", + branch: null, + }; + const activityContext = { + ...context, + activityAt: (value: typeof activityThread) => value.latestUserMessageAt, + }; + expect( + matchesThreadSearchFilters( + activityThread, + parseThreadSearchQuery("since:7d env:local", { now }).filters, + activityContext, + { now }, + ), + ).toBe(false); + expect( + matchesThreadSearchFilters( + { ...activityThread, latestUserMessageAt: "2026-10-06T10:00:00" }, + parseThreadSearchQuery("since:7d env:local", { now }).filters, + activityContext, + { now }, + ), + ).toBe(true); + }); + + it("edits multi-value qualifiers and quotes project names", () => { + expect( + toggleThreadSearchQualifier("hello status:failed", "status", "input", { multi: true }), + ).toBe("hello status:failed,input"); + expect( + toggleThreadSearchQualifier("status:failed,input", "status", "failed", { multi: true }), + ).toBe("status:input"); + expect(toggleThreadSearchQualifier("hello", "project", "My App", { multi: true })).toBe( + 'hello project:"My App"', + ); + }); + + it("replaces single-value qualifiers and toggles archived", () => { + expect(toggleThreadSearchQualifier("since:7d hello", "since", "today", { multi: false })).toBe( + "since:today hello", + ); + expect( + toggleThreadSearchQualifier("is:archived hello", "archived", "archived", { multi: false }), + ).toBe("hello"); + }); + + it("leaves negated qualifiers alone and clears recognized qualifiers", () => { + expect( + toggleThreadSearchQualifier("-status:failed status:ready", "status", "failed", { + multi: true, + }), + ).toBe("-status:failed status:ready,failed"); + expect(clearThreadSearchQualifiers('hello -status:failed project:"My App" unknown:value')).toBe( + "hello unknown:value", + ); + }); + + it("merges repeated positive clauses at the first qualifier's position", () => { + const query = 'first in:"My App" middle project:Docs last'; + const next = toggleThreadSearchQualifier(query, "project", "Website", { multi: true }); + expect(next).toBe('first project:"My App",Docs,Website middle last'); + expect(parseThreadSearchQuery(next, { now }).filters.project).toEqual([ + { values: ["My App", "Docs", "Website"], negated: false }, + ]); + }); + + it("removes the final value without moving or rewriting free text", () => { + const query = '"fix this" status:FAILED don\'t C:\\repo unknown:"my thing"'; + expect(toggleThreadSearchQualifier(query, "status", "failed", { multi: true })).toBe( + '"fix this" don\'t C:\\repo unknown:"my thing"', + ); + }); + + it("adds and removes single-value filters while retaining negated filters", () => { + expect(toggleThreadSearchQualifier("-since:7d fix", "since", "today", { multi: false })).toBe( + "-since:7d fix since:today", + ); + expect(toggleThreadSearchQualifier("since:today fix", "since", "today", { multi: false })).toBe( + "fix", + ); + expect( + toggleThreadSearchQualifier("-is:archived fix", "archived", "archived", { multi: false }), + ).toBe("-is:archived fix is:archived"); + expect( + toggleThreadSearchQualifier("since:7d text since:30d", "since", "today", { multi: false }), + ).toBe("since:today text"); + }); + + it("clears all qualifier families and preserves invalid and unknown tokens verbatim", () => { + const query = + '"fix this" in:"My App" environment:Local branch:main provider:Claude -status:error since:7d before:2026-10-08 is:archived -is:archived foo:"my thing" since:banana since:2026-02-30 status:unknown ""'; + expect(clearThreadSearchQualifiers(query)).toBe( + '"fix this" foo:"my thing" since:banana since:2026-02-30 status:unknown ""', + ); + }); + + it("normalizes status aliases when merging and removing values", () => { + expect( + toggleThreadSearchQualifier("status:error,awaiting", "status", "working", { multi: true }), + ).toBe("status:failed,input,working"); + expect( + toggleThreadSearchQualifier("status:error,awaiting", "status", "failed", { multi: true }), + ).toBe("status:input"); + }); +}); diff --git a/packages/client-runtime/src/state/threadSearchQuery.ts b/packages/client-runtime/src/state/threadSearchQuery.ts new file mode 100644 index 0000000000000..19b3e6b745c99 --- /dev/null +++ b/packages/client-runtime/src/state/threadSearchQuery.ts @@ -0,0 +1,375 @@ +// @effect-diagnostics globalDate:off -- Qualifier dates use the user's local calendar and test-injected Date. + +export type ThreadSearchQualifierKey = + | "project" + | "env" + | "branch" + | "provider" + | "status" + | "since" + | "before"; + +export type ThreadSearchQualifierToggleKey = ThreadSearchQualifierKey | "archived"; + +export interface ThreadSearchClause { + readonly values: ReadonlyArray; + readonly negated: boolean; +} + +export interface ThreadSearchFilters { + readonly project: ReadonlyArray; + readonly env: ReadonlyArray; + readonly branch: ReadonlyArray; + readonly provider: ReadonlyArray; + readonly status: ReadonlyArray; + readonly since: ReadonlyArray; + readonly before: ReadonlyArray; + readonly archived: boolean; +} + +export interface ParsedThreadSearchQuery { + readonly text: string; + readonly filters: ThreadSearchFilters; + readonly hasFilters: boolean; +} + +export interface ThreadSearchMatchContext { + readonly projectName: (thread: T) => string; + readonly environmentNames: (thread: T) => ReadonlyArray; + readonly providerNames: (thread: T) => ReadonlyArray; + readonly status: (thread: T) => string; + readonly activityAt: (thread: T) => string; +} + +const STATUS_VALUES = new Set([ + "working", + "approval", + "pending", + "input", + "awaiting", + "waiting", + "failed", + "error", + "limited", + "ready", + "done", + "completed", +]); +const STATUS_ALIASES: Readonly> = { + pending: "approval", + awaiting: "input", + error: "failed", + done: "ready", + completed: "ready", +}; +const DATE_PATTERN = /^(?:\d{4}-\d{2}-\d{2}|\d+[hdw]|today|yesterday)$/i; +const KEY_ALIASES: Readonly> = { + project: "project", + in: "project", + env: "env", + environment: "env", + branch: "branch", + provider: "provider", + status: "status", + since: "since", + before: "before", +}; + +interface QueryToken { + readonly raw: string; + readonly value: string; +} + +function tokenizeWithRaw(raw: string): QueryToken[] { + const tokens: QueryToken[] = []; + const input = raw.trim(); + let index = 0; + while (index < input.length) { + while (index < input.length && /\s/.test(input[index]!)) index += 1; + if (index >= input.length) break; + + const start = index; + let token = ""; + let quoted = false; + while (index < input.length) { + const character = input[index]!; + if (character === '"' && !quoted) { + if (input.indexOf('"', index + 1) !== -1) { + quoted = true; + index += 1; + continue; + } + // An unmatched quote is ordinary text. Backslashes and apostrophes + // are ordinary text in every mode. + token += character; + index += 1; + continue; + } + if (character === '"' && quoted) { + quoted = false; + index += 1; + continue; + } + if (!quoted && /\s/.test(character)) break; + token += character; + index += 1; + } + tokens.push({ raw: input.slice(start, index), value: token }); + while (index < input.length && /\s/.test(input[index]!)) index += 1; + } + return tokens; +} + +function tokenize(raw: string): string[] { + return tokenizeWithRaw(raw) + .map((token) => token.value) + .filter(Boolean); +} + +function startOfLocalDay(date: Date): Date { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +} + +function parseDate(value: string, now: Date): number | null { + const lower = value.toLowerCase(); + if (lower === "today") return startOfLocalDay(now).getTime(); + if (lower === "yesterday") { + const result = startOfLocalDay(now); + result.setDate(result.getDate() - 1); + return result.getTime(); + } + const relative = /^(\d+)([hdw])$/i.exec(lower); + if (relative) { + const amount = Number(relative[1]); + const unit = relative[2]!.toLowerCase(); + const milliseconds = + unit === "h" ? amount * 3_600_000 : unit === "d" ? amount * 86_400_000 : amount * 604_800_000; + return now.getTime() - milliseconds; + } + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date(now); + date.setFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + date.setHours(0, 0, 0, 0); + return Number.isNaN(date.getTime()) || + date.getFullYear() !== Number(match[1]) || + date.getMonth() !== Number(match[2]) - 1 || + date.getDate() !== Number(match[3]) + ? null + : date.getTime(); +} + +function validValues(key: ThreadSearchQualifierKey, value: string, now: Date): string[] | null { + if (value.includes('"')) return null; + const values = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (values.length === 0) return null; + if (key === "status") { + if (values.some((part) => !STATUS_VALUES.has(part.toLowerCase()))) return null; + return values.map((part) => STATUS_ALIASES[part.toLowerCase()] ?? part.toLowerCase()); + } + if ( + (key === "since" || key === "before") && + values.some((part) => !DATE_PATTERN.test(part) || parseDate(part, now) === null) + ) + return null; + return values; +} + +export function parseThreadSearchQuery( + raw: string, + options: { readonly now: Date }, +): ParsedThreadSearchQuery { + const filters: Record = { + project: [], + env: [], + branch: [], + provider: [], + status: [], + since: [], + before: [], + }; + const text: string[] = []; + let archived = false; + let hasFilters = false; + for (const token of tokenize(raw)) { + const negated = token.startsWith("-"); + const candidate = negated ? token.slice(1) : token; + const separator = candidate.indexOf(":"); + const key = + separator > 0 ? KEY_ALIASES[candidate.slice(0, separator).toLowerCase()] : undefined; + const value = separator > 0 ? candidate.slice(separator + 1) : ""; + if (candidate.toLowerCase() === "is:archived") { + if (!negated) { + archived = true; + hasFilters = true; + } + continue; + } + if (key !== undefined) { + const values = validValues(key, value, options.now); + if (values !== null) { + filters[key].push({ values, negated }); + hasFilters = true; + continue; + } + } + text.push(token); + } + return { text: text.join(" "), filters: { ...filters, archived }, hasFilters }; +} + +interface RecognizedQueryToken { + readonly key: ThreadSearchQualifierToggleKey; + readonly values: ReadonlyArray; + readonly negated: boolean; +} + +function recognizedQueryToken(token: QueryToken): RecognizedQueryToken | null { + const negated = token.value.startsWith("-"); + const candidate = negated ? token.value.slice(1) : token.value; + if (candidate.toLowerCase() === "is:archived") { + return { key: "archived", values: ["archived"], negated }; + } + const separator = candidate.indexOf(":"); + if (separator <= 0) return null; + const key = KEY_ALIASES[candidate.slice(0, separator).toLowerCase()]; + if (key === undefined) return null; + // Editing validates date syntax, without resolving boundaries against the current time. + const values = validValues(key, candidate.slice(separator + 1), new Date(0)); + return values === null ? null : { key, values, negated }; +} + +function formatQualifierValue(value: string): string { + return /\s/.test(value) ? `"${value}"` : value; +} + +function formatQualifier( + key: ThreadSearchQualifierToggleKey, + values: ReadonlyArray, +): string { + if (key === "archived") return "is:archived"; + return `${key}:${values.map(formatQualifierValue).join(",")}`; +} + +/** Toggle one positive qualifier while preserving free text and negated tokens. */ +export function toggleThreadSearchQualifier( + query: string, + key: ThreadSearchQualifierToggleKey, + value: string, + options: { readonly multi: boolean }, +): string { + const tokens = tokenizeWithRaw(query); + const recognized = tokens.map(recognizedQueryToken); + const positiveIndexes = recognized.flatMap((token, index) => + token?.key === key && !token.negated ? [index] : [], + ); + const existingValues = positiveIndexes.flatMap((index) => recognized[index]?.values ?? []); + const hasValue = existingValues.some( + (candidate) => candidate.toLowerCase() === value.toLowerCase(), + ); + const nextValues = hasValue + ? existingValues.filter((candidate) => candidate.toLowerCase() !== value.toLowerCase()) + : options.multi + ? [...existingValues, value] + : [value]; + const output: string[] = []; + const firstPositiveIndex = positiveIndexes[0]; + for (let index = 0; index < tokens.length; index += 1) { + const token = recognized[index]; + if (token?.key !== key || token.negated) { + output.push(tokens[index]!.raw); + continue; + } + if (index !== firstPositiveIndex) continue; + if (nextValues.length > 0) output.push(formatQualifier(key, nextValues)); + } + if (positiveIndexes.length === 0 && !hasValue) output.push(formatQualifier(key, [value])); + return output.join(" "); +} + +/** Remove all recognized qualifiers, leaving unknown tokens and free text intact. */ +export function clearThreadSearchQualifiers(query: string): string { + return tokenizeWithRaw(query) + .filter((token) => recognizedQueryToken(token) === null) + .map((token) => token.raw) + .join(" "); +} + +function matchesClause(value: string, clause: ThreadSearchClause): boolean { + const matched = clause.values.some((candidate) => + value.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), + ); + return clause.negated ? !matched : matched; +} + +function matchesDateClause( + activityAt: string, + clause: ThreadSearchClause, + now: Date, + before: boolean, +): boolean { + const timestamp = Date.parse(activityAt); + if (Number.isNaN(timestamp)) return false; + const matched = clause.values.some((value) => { + const boundary = parseDate(value, now); + if (boundary === null) return false; + return before ? timestamp < boundary : timestamp >= boundary; + }); + return clause.negated ? !matched : matched; +} + +export function matchesThreadSearchFilters< + T extends { readonly updatedAt: string; readonly branch: string | null }, +>( + thread: T, + filters: ThreadSearchFilters, + context: ThreadSearchMatchContext, + options: { readonly now: Date }, +): boolean { + const match = (clauses: ReadonlyArray, value: string) => + clauses.every((clause) => matchesClause(value, clause)); + if (filters.project.length > 0 && !match(filters.project, context.projectName(thread))) + return false; + const environmentNames = filters.env.length > 0 ? context.environmentNames(thread) : []; + if ( + !filters.env.every((clause) => { + const matched = clause.values.some((value) => + environmentNames.some((name) => + name.toLocaleLowerCase().includes(value.toLocaleLowerCase()), + ), + ); + return clause.negated ? !matched : matched; + }) + ) + return false; + if (!match(filters.branch, thread.branch ?? "")) return false; + const providerNames = filters.provider.length > 0 ? context.providerNames(thread) : []; + if ( + !filters.provider.every((clause) => { + const matched = clause.values.some((value) => + providerNames.some((name) => name.toLocaleLowerCase().includes(value.toLocaleLowerCase())), + ); + return clause.negated ? !matched : matched; + }) + ) + return false; + if (filters.status.length > 0 && !match(filters.status, context.status(thread))) return false; + if ( + !filters.since.every((clause) => + matchesDateClause(context.activityAt(thread), clause, options.now, false), + ) + ) + return false; + if ( + !filters.before.every((clause) => + matchesDateClause(context.activityAt(thread), clause, options.now, true), + ) + ) + return false; + return true; +} diff --git a/packages/contracts/src/threadSearch.ts b/packages/contracts/src/threadSearch.ts index d3b8f6badee09..90f33fd3abb66 100644 --- a/packages/contracts/src/threadSearch.ts +++ b/packages/contracts/src/threadSearch.ts @@ -16,6 +16,7 @@ export type OrchestrationThreadSearchSource = typeof OrchestrationThreadSearchSo 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 }))), + archived: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSearchThreadsInput = typeof OrchestrationSearchThreadsInput.Type;