diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0825c5cb92cc..fa4b97f1597d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -118,7 +118,7 @@ import { selectActiveRightPanel, useRightPanelStore, } from "../rightPanelStore"; -import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; +import { getLatestThreadForProject, getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import { cn, getLocalFileManagerName, @@ -146,6 +146,7 @@ import { buildLinkedThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteGroup, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -158,6 +159,12 @@ import { reduceCommandPaletteUiState, type SearchOverlayMode, } from "./CommandPalette.logic"; +import { + applyThreadSearchOperatorCompletion, + getThreadSearchOperatorCompletion, + matchesThreadSearchOperators, + parseThreadSearchOperators, +} from "./threadSearchOperators.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; @@ -176,6 +183,7 @@ import { ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; @@ -823,7 +831,17 @@ function OpenCommandPaletteDialog(props: { .map((environment) => environment.environmentId), [environments], ); - const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; + // Operators (in:, provider:, before:/after:/on:) only apply to the root search. + const searchOperators = useMemo( + () => + currentView === null && !isActionsOnly + ? parseThreadSearchOperators(deferredQuery, new Date()) + : null, + [currentView, deferredQuery, isActionsOnly], + ); + // Message search only sees the free text left after the operators. + const threadSearchQuery = + currentView === null && !isActionsOnly ? (searchOperators?.text ?? deferredQuery) : ""; const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); const threadContentMatchByKey = useMemo( () => @@ -1331,10 +1349,41 @@ function OpenCommandPaletteDialog(props: { ], ); + // Recent threads only render for an empty query, so operators can narrow the shared list. + const searchThreads = useMemo(() => { + if (searchOperators === null) return threads; + const projectLabelsByKey = new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (project) => + [ + `${project.environmentId}:${project.id}`, + [group.displayName, project.title, project.workspaceRoot], + ] as const, + ), + ), + ); + return threads.filter((thread) => { + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const provider = providerEntryByEnvironmentAndInstanceId.get( + `${thread.environmentId}:${instanceId}`, + ); + return matchesThreadSearchOperators(searchOperators, { + projectLabels: projectLabelsByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? [], + providerLabels: [ + instanceId, + thread.session?.providerName, + provider?.displayName, + provider?.driverKind, + ], + activityMs: getThreadSortTimestamp(thread, "updated_at"), + }); + }); + }, [projectGroups, providerEntryByEnvironmentAndInstanceId, searchOperators, threads]); const allThreadItems = useMemo( () => buildThreadActionItems({ - threads, + threads: searchThreads, ...(activeThreadId ? { activeThreadId } : {}), projectTitleById, sortOrder: clientSettings.sidebarThreadSortOrder, @@ -1395,9 +1444,9 @@ function OpenCommandPaletteDialog(props: { projectEnvironmentLocationById, projectTitleById, providerEntryByEnvironmentAndInstanceId, + searchThreads, threadContentMatchByKey, threadSearchQuery, - threads, ], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); @@ -2125,27 +2174,111 @@ function OpenCommandPaletteDialog(props: { ? changeAppearanceItem.groups : (currentView?.groups ?? rootGroups); - const filteredGroups = filterCommandPaletteGroups({ - activeGroups, - query: deferredQuery, - isInSubmenu: currentView !== null, - projectSearchItems: projectSearchItems, - settingsSearchItems, - threadSearchItems: - linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query - ? buildLinkedThreadActionItems({ - ...linkedThreadSearch.linkedThreads, - query: linkedThreadSearch.query, - icon: , - runThread: async (thread) => { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), - }); + // With operators the palette only searches threads; the free text ranks within them. + const searchGroups = + searchOperators === null + ? filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectSearchItems, + settingsSearchItems, + threadSearchItems: + linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query + ? buildLinkedThreadActionItems({ + ...linkedThreadSearch.linkedThreads, + query: linkedThreadSearch.query, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(thread.environmentId, thread.id), + ), + }); + }, + }) + : allThreadItems, + }) + : filterCommandPaletteGroups({ + activeGroups: + allThreadItems.length > 0 + ? [{ value: "threads-search", label: "Threads", items: allThreadItems }] + : [], + query: searchOperators.text, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + + // While an in: or provider: value is being typed, its completions lead the + // list so Enter commits the value and keeps the palette open. + const operatorCompletion = + currentView === null && !isActionsOnly ? getThreadSearchOperatorCompletion(query) : null; + const operatorCompletionGroups: CommandPaletteGroup[] = []; + if (operatorCompletion !== null) { + const options = + operatorCompletion.keyword === "in" + ? projectGroups.map((group) => ({ + name: group.displayName, + searchTerms: [group.displayName, group.workspaceRoot], + description: group.workspaceRoot, + icon: projectFavicon(group), + })) + : [ + // One option per name; the same provider is usually set up on every environment. + ...new Map( + [...providerEntryByEnvironmentAndInstanceId.values()].map((entry) => [ + entry.displayName, + entry, + ]), + ).values(), + ].map((entry) => ({ + name: entry.displayName, + searchTerms: [entry.displayName, entry.instanceId, entry.driverKind], + description: undefined, + icon: ( + + ), + })); + const items = options.map((option, index): CommandPaletteActionItem => ({ + kind: "action", + value: `search-operator:${index}`, + searchTerms: option.searchTerms, + title: option.name, + ...(option.description ? { description: option.description } : {}), + icon: option.icon, + keepOpen: true, + run: async () => { + handleQueryChange( + applyThreadSearchOperatorCompletion(query, operatorCompletion, option.name), + ); + }, + })); + if (items.length > 0) { + operatorCompletionGroups.push( + ...filterCommandPaletteGroups({ + activeGroups: [ + { + value: "search-operator-completions", + label: + operatorCompletion.keyword === "in" ? "Filter by project" : "Filter by provider", + items, }, - }) - : allThreadItems, - }); + ], + query: operatorCompletion.partial, + isInSubmenu: true, + projectSearchItems: [], + threadSearchItems: [], + }), + ); + } + } + const filteredGroups = [...operatorCompletionGroups, ...searchGroups]; const handleAddProjectForEnvironment = useCallback( async (input: { diff --git a/apps/web/src/components/threadSearchOperators.logic.test.ts b/apps/web/src/components/threadSearchOperators.logic.test.ts new file mode 100644 index 000000000000..522b1054c6c7 --- /dev/null +++ b/apps/web/src/components/threadSearchOperators.logic.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + applyThreadSearchOperatorCompletion, + getThreadSearchOperatorCompletion, + matchesThreadSearchOperators, + parseThreadSearchOperators, +} from "./threadSearchOperators.logic"; + +// Local time on purpose: day operators follow the user's calendar, not UTC. +const now = new Date(2026, 7, 9, 12, 30); +const day = (month: number, date: number) => new Date(2026, month, date).getTime(); + +describe("parseThreadSearchOperators", () => { + it("leaves plain queries to the regular search", () => { + expect(parseThreadSearchOperators("fix bug", now)).toBeNull(); + expect(parseThreadSearchOperators("re: meeting notes", now)).toBeNull(); + }); + + it("keeps an operator still being typed out of the free text", () => { + expect(parseThreadSearchOperators("mobile in:", now)).toEqual({ + text: "mobile", + projects: [], + providers: [], + startMs: null, + endMs: null, + }); + expect(parseThreadSearchOperators("provider:", now)).toMatchObject({ text: "" }); + }); + + it("extracts project and provider values, including quoted names", () => { + expect(parseThreadSearchOperators('fix in:Atlas in:"My Project" provider:Claude', now)).toEqual( + { + text: "fix", + projects: ["atlas", "my project"], + providers: ["claude"], + startMs: null, + endMs: null, + }, + ); + }); + + it("resolves on:, today, and yesterday to local calendar days", () => { + expect(parseThreadSearchOperators("on:2026-08-01", now)).toMatchObject({ + startMs: day(7, 1), + endMs: day(7, 2), + }); + expect(parseThreadSearchOperators("on:today", now)).toMatchObject({ + startMs: day(7, 9), + endMs: day(7, 10), + }); + expect(parseThreadSearchOperators("on:yesterday", now)).toMatchObject({ + startMs: day(7, 8), + endMs: day(7, 9), + }); + }); + + it("excludes the named day from before: and after:, intersecting repeats", () => { + expect( + parseThreadSearchOperators("after:2026-07-01 after:2026-08-01 before:2026-08-05", now), + ).toMatchObject({ startMs: day(7, 2), endMs: day(7, 5) }); + }); + + it("keeps unparseable dates as text", () => { + expect(parseThreadSearchOperators("before:banana", now)).toBeNull(); + expect(parseThreadSearchOperators("on:2026-02-31 in:atlas", now)).toMatchObject({ + text: "on:2026-02-31", + startMs: null, + }); + }); +}); + +describe("matchesThreadSearchOperators", () => { + const thread = { + projectLabels: ["Atlas", "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/work/atlas"], + providerLabels: ["claudeAgent", "Claude"], + activityMs: day(7, 8) + 1_000, + }; + const matches = (query: string) => + matchesThreadSearchOperators(parseThreadSearchOperators(query, now)!, thread); + + it("matches project and provider values by substring, ORing repeats", () => { + expect(matches("in:atl")).toBe(true); + expect(matches("in:work/atlas")).toBe(true); + expect(matches("in:beacon in:atlas")).toBe(true); + expect(matches("in:beacon")).toBe(false); + expect(matches("provider:claude")).toBe(true); + expect(matches("provider:codex")).toBe(false); + }); + + it("ANDs different operators and applies day bounds", () => { + expect(matches("in:atlas on:yesterday")).toBe(true); + expect(matches("in:atlas on:today")).toBe(false); + expect(matches("after:2026-08-07 before:today")).toBe(true); + expect(matches("after:yesterday")).toBe(false); + }); +}); + +describe("thread search operator completion", () => { + it("finds the operator being typed at the end of the query", () => { + expect(getThreadSearchOperatorCompletion("fix in:atl")).toEqual({ + keyword: "in", + start: 4, + partial: "atl", + }); + expect(getThreadSearchOperatorCompletion('Provider:"claude co')).toEqual({ + keyword: "provider", + start: 0, + partial: "claude co", + }); + expect(getThreadSearchOperatorCompletion("in:atlas ")).toBeNull(); + expect(getThreadSearchOperatorCompletion('in:"My Project"')).toBeNull(); + expect(getThreadSearchOperatorCompletion("fix on:")).toBeNull(); + }); + + it("commits the picked value with a trailing space, quoting names with spaces", () => { + const completion = getThreadSearchOperatorCompletion("fix in:my")!; + expect(applyThreadSearchOperatorCompletion("fix in:my", completion, "Atlas")).toBe( + "fix in:Atlas ", + ); + expect(applyThreadSearchOperatorCompletion("fix in:my", completion, "My Project")).toBe( + 'fix in:"My Project" ', + ); + }); + + it("round-trips names containing quotes", () => { + const query = applyThreadSearchOperatorCompletion( + "in:my", + getThreadSearchOperatorCompletion("in:my")!, + 'My "Quoted" Project', + ); + expect(query).toBe('in:"My \\"Quoted\\" Project" '); + expect(parseThreadSearchOperators(query, now)?.projects).toEqual(['my "quoted" project']); + expect(getThreadSearchOperatorCompletion('in:"My \\"Quo')?.partial).toBe('My "Quo'); + }); +}); diff --git a/apps/web/src/components/threadSearchOperators.logic.ts b/apps/web/src/components/threadSearchOperators.logic.ts new file mode 100644 index 000000000000..4b53bf28127f --- /dev/null +++ b/apps/web/src/components/threadSearchOperators.logic.ts @@ -0,0 +1,147 @@ +// Discord-style operators for the command palette's thread search: +// in: project name or path contains the value +// provider: provider name, instance id, or driver contains the value +// before:/after:/on: a local calendar day: YYYY-MM-DD, today, or yesterday +// Values with spaces are quoted (in:"My Project"), with \" for a literal quote. +// Different operators AND together; repeating one ORs its values. Everything +// else stays free text, and a date that doesn't parse stays free text rather +// than filtering everything. + +interface ThreadSearchOperators { + /** The free text left once operator tokens are removed. */ + readonly text: string; + /** Lowercased in: values. */ + readonly projects: ReadonlyArray; + /** Lowercased provider: values. */ + readonly providers: ReadonlyArray; + /** Inclusive lower and exclusive upper activity bounds; null is unbounded. */ + readonly startMs: number | null; + readonly endMs: number | null; +} + +// A run of non-space characters, where a quoted section may contain spaces. +// An unterminated quote runs to the end: the user is still typing it. +const TOKEN_PATTERN = /(?:[^\s"]|"(?:[^"\\]|\\.)*"?)+/g; +const OPERATOR_PATTERN = /^(in|provider|before|after|on):(.*)$/i; + +function parseLocalDay(value: string, now: Date): { start: number; end: number } | null { + let year = now.getFullYear(); + let month = now.getMonth(); + let day = now.getDate(); + if (value === "yesterday") { + day -= 1; + } else if (value !== "today") { + const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!iso) return null; + year = Number(iso[1]); + month = Number(iso[2]) - 1; + day = Number(iso[3]); + const date = new Date(year, month, day); + // The Date constructor rolls typos over (2026-02-31 becomes March 3rd). + if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) { + return null; + } + } + return { + start: new Date(year, month, day).getTime(), + end: new Date(year, month, day + 1).getTime(), + }; +} + +/** Drops unescaped quotes and unescapes \". */ +function unquote(value: string): string { + return value.replace(/\\"|"/g, (match) => (match === '"' ? "" : '"')); +} + +/** Returns null when the query has no operators, so callers keep plain search. */ +export function parseThreadSearchOperators(query: string, now: Date): ThreadSearchOperators | null { + const text: string[] = []; + const projects: string[] = []; + const providers: string[] = []; + let startMs: number | null = null; + let endMs: number | null = null; + let hasOperator = false; + + for (const token of query.match(TOKEN_PATTERN) ?? []) { + const operator = OPERATOR_PATTERN.exec(token); + if (!operator) { + text.push(token); + continue; + } + const keyword = operator[1]!.toLowerCase(); + const value = unquote(operator[2]!).trim().toLowerCase(); + // A bare `in:` or `on:` is still being typed; it filters nothing and searches nothing. + if (keyword === "in" || keyword === "provider" || value.length === 0) { + if (value.length > 0) (keyword === "in" ? projects : providers).push(value); + hasOperator = true; + continue; + } + const day = parseLocalDay(value, now); + if (day === null) { + text.push(token); + continue; + } + hasOperator = true; + // Like Discord, before: and after: exclude the named day. Repeats intersect. + const start = keyword === "on" ? day.start : keyword === "after" ? day.end : null; + const end = keyword === "on" ? day.end : keyword === "before" ? day.start : null; + if (start !== null) startMs = Math.max(startMs ?? start, start); + if (end !== null) endMs = Math.min(endMs ?? end, end); + } + + if (!hasOperator) return null; + return { text: text.join(" "), projects, providers, startMs, endMs }; +} + +export function matchesThreadSearchOperators( + operators: ThreadSearchOperators, + thread: { + readonly projectLabels: ReadonlyArray; + readonly providerLabels: ReadonlyArray; + readonly activityMs: number; + }, +): boolean { + const matchesAny = ( + values: ReadonlyArray, + labels: ReadonlyArray, + ) => + values.length === 0 || + values.some((value) => labels.some((label) => label?.toLowerCase().includes(value))); + return ( + matchesAny(operators.projects, thread.projectLabels) && + matchesAny(operators.providers, thread.providerLabels) && + (operators.startMs === null || thread.activityMs >= operators.startMs) && + (operators.endMs === null || thread.activityMs < operators.endMs) + ); +} + +interface ThreadSearchOperatorCompletion { + readonly keyword: "in" | "provider"; + /** Index where the operator token starts in the query. */ + readonly start: number; + /** The unquoted value typed so far. */ + readonly partial: string; +} + +/** The `in:` or `provider:` token still being typed at the end of the query. */ +export function getThreadSearchOperatorCompletion( + query: string, +): ThreadSearchOperatorCompletion | null { + const match = /(?:^|\s)((in|provider):("(?:[^"\\]|\\.)*|[^\s"]*))$/i.exec(query); + if (!match) return null; + return { + keyword: match[2]!.toLowerCase() === "in" ? "in" : "provider", + start: query.length - match[1]!.length, + partial: unquote(match[3]!), + }; +} + +/** Replaces the token being typed with the picked value, quoted if it has spaces or quotes. */ +export function applyThreadSearchOperatorCompletion( + query: string, + completion: ThreadSearchOperatorCompletion, + value: string, +): string { + const rendered = /[\s"]/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value; + return `${query.slice(0, completion.start)}${completion.keyword}:${rendered} `; +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4a19bac401b9..022a541ce7d6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -131,6 +131,12 @@ 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. +Narrow thread results with filters in the same search: `in:` for a project, +`provider:` for a provider, and `before:`, `after:`, or `on:` with a day +(`2026-08-01`, `today`, or `yesterday`) to check each thread's last activity. +Typing `in:` or `provider:` suggests values. Quote names with spaces, like +`in:"My Project"`. + 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 link when available, otherwise its thread ID. See [keybindings](./keybindings.md)