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)