From 8b9c3c3e07367fc9a53938d883b8ff4848a1159b Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Thu, 17 Sep 2026 01:37:58 +0000 Subject: [PATCH 01/10] feat(web): Discord-style search operators in the command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the command-palette search work from the stale map-sidebar-project-search branch onto current main: - in: scopes results by project name/path, with an inline 'Filter by project' autocomplete group — Enter commits the filter into the query and keeps the palette open - agent: matches the session provider name / model instance id - before:/after:/on: bound updatedAt with ISO days, relative values (7d, 2w), today, and yesterday; unparseable dates degrade to plain text - residual text keeps ranking against titles, project names, and server content-search snippets; operator tokens are stripped from the content-search query - archived threads join the search corpus (snapshot fetched only while a root query is active) with an Archived badge replacing the live status decorations - recognized operator tokens wear send-button-colored pills via a metric-identical backdrop behind the palette input The operator engine lives in threadSearchQuery.logic.ts, pure and unit tested. The sidebar's inline thread search is unchanged. Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/CommandPalette.logic.ts | 10 +- apps/web/src/components/CommandPalette.tsx | 297 ++++++++++++-- .../src/components/CommandPaletteContent.tsx | 14 +- .../threadSearchQuery.logic.test.ts | 296 ++++++++++++++ .../src/components/threadSearchQuery.logic.ts | 385 ++++++++++++++++++ 5 files changed, 972 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/threadSearchQuery.logic.test.ts create mode 100644 apps/web/src/components/threadSearchQuery.logic.ts diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 57c1711de158..5cf5a329afc8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -263,9 +263,14 @@ export function buildThreadActionItems CommandPaletteThreadContentMatch | undefined; runThread: (thread: Pick) => Promise; limit?: number; + /** Keep archived threads (search corpora); the default drops them so the + recents list never surfaces archived rows. */ + includeArchived?: boolean; }): CommandPaletteActionItem[] { const sortedThreads = sortThreads( - input.threads.filter((thread) => thread.archivedAt === null), + input.includeArchived === true + ? [...input.threads] + : input.threads.filter((thread) => thread.archivedAt === null), input.sortOrder, ); const visibleThreads = @@ -284,6 +289,9 @@ export function buildThreadActionItems environment.environmentId), [environments], ); - const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; + // Discord-style operators (in:, agent:, before:/after:/on:) apply only to + // the root search — submenus and > queries keep their own semantics. + // Non-null only when the query actually carries operator criteria. + const parsedOperatorQuery = useMemo(() => { + if (currentView !== null || isActionsOnly) return null; + const parsed = parseThreadSearchQuery(deferredQuery, new Date()); + return hasThreadSearchOperators(parsed) ? parsed : null; + }, [currentView, deferredQuery, isActionsOnly]); + // The server content search must see only the residual text — operator + // tokens would pollute the message LIKE query. + const threadSearchQuery = + currentView === null && !isActionsOnly ? (parsedOperatorQuery?.text ?? deferredQuery) : ""; const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); + // Archived threads live in a separate query-style snapshot, not the live + // shell stream; search subscribes only while a root query is active so the + // palette otherwise never pays for archive data. Archive/unarchive actions + // refresh these atoms (useThreadActions), so hits stay current in-session. + const archivedSearchEnvironmentIds = useMemo( + () => + currentView === null && !isActionsOnly && deferredQuery.trim().length > 0 + ? environmentIds + : [], + [currentView, deferredQuery, environmentIds, isActionsOnly], + ); + const archivedThreadSnapshots = useArchivedThreadSnapshots(archivedSearchEnvironmentIds); const threadContentMatchByKey = useMemo( () => new Map( @@ -1233,16 +1272,30 @@ function OpenCommandPaletteDialog(props: { ], ); - const allThreadItems = useMemo( - () => + const buildPaletteThreadItems = useCallback( + ( + threadList: ReadonlyArray, + options?: { readonly includeArchived?: boolean }, + ) => buildThreadActionItems({ - threads, + threads: threadList, ...(activeThreadId ? { activeThreadId } : {}), + ...(options?.includeArchived === true ? { includeArchived: true } : {}), projectTitleById, sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => , + renderTrailingContent: (thread) => + // An archived row's marker replaces the live status decorations — + // whatever session state the shell froze with is history, not + // status. + thread.archivedAt !== null ? ( + + Archived + + ) : ( + + ), renderDescription: (thread, { projectTitle }) => { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; @@ -1299,9 +1352,63 @@ function OpenCommandPaletteDialog(props: { providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, threadSearchQuery, - threads, ], ); + const allThreadItems = useMemo( + () => buildPaletteThreadItems(threads), + [buildPaletteThreadItems, threads], + ); + const archivedSearchThreads = useMemo(() => { + if (archivedThreadSnapshots.snapshots.length === 0) return []; + // A shell can linger in the live stream with archivedAt freshly set (or + // appear in both after an unarchive races the snapshot refresh); the + // live stream wins so a thread never renders twice. + const liveThreadKeys = new Set(threads.map((thread) => `${thread.environmentId}:${thread.id}`)); + return archivedThreadSnapshots.snapshots.flatMap((entry) => + entry.snapshot.threads + .filter((thread) => !liveThreadKeys.has(`${entry.environmentId}:${thread.id}`)) + .map((thread) => scopeThreadShell(entry.environmentId, thread)), + ); + }, [archivedThreadSnapshots.snapshots, threads]); + const archivedThreadItems = useMemo( + () => buildPaletteThreadItems(archivedSearchThreads, { includeArchived: true }), + [archivedSearchThreads, buildPaletteThreadItems], + ); + // The search corpus: live threads first, archived history after. With + // operator criteria the corpus is narrowed thread-by-thread (project keys + // for in:, agent/date via the matcher) BEFORE items are built; residual + // text then ranks against titles, project names, and content snippets in + // filterCommandPaletteGroups like any other query. + const threadSearchItems = useMemo(() => { + if (parsedOperatorQuery === null) { + return [...allThreadItems, ...archivedThreadItems]; + } + const projectFilterKeys = resolveProjectFilterKeys( + projectGroups, + parsedOperatorQuery.projectQueries, + ); + const operatorOnlyQuery = { ...parsedOperatorQuery, text: "" }; + const passesOperators = (thread: EnvironmentThreadShell) => + (projectFilterKeys === null || + projectFilterKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + matchesParsedThreadSearch(thread, operatorOnlyQuery); + return [ + ...buildPaletteThreadItems( + threads.filter((thread) => thread.archivedAt === null && passesOperators(thread)), + ), + ...buildPaletteThreadItems(archivedSearchThreads.filter(passesOperators), { + includeArchived: true, + }), + ]; + }, [ + allThreadItems, + archivedSearchThreads, + archivedThreadItems, + buildPaletteThreadItems, + parsedOperatorQuery, + projectGroups, + threads, + ]); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); const pushPaletteView = useCallback( @@ -1900,27 +2007,92 @@ function OpenCommandPaletteDialog(props: { ) : (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)), - }); - }, - }) - : allThreadItems, - }); + // With operator criteria the palette becomes a pure thread search: actions + // and project rows would degenerate to matching the residual text (or, with + // no text, matching everything), so only the operator-narrowed Threads + // group renders. Residual text still ranks within it. + const operatorFilteredGroups: CommandPaletteGroup[] | null = + parsedOperatorQuery === null + ? null + : parsedOperatorQuery.text.trim().length === 0 + ? threadSearchItems.length > 0 + ? [{ value: "threads-search", label: "Threads", items: threadSearchItems }] + : [] + : filterCommandPaletteGroups({ + activeGroups: [], + query: parsedOperatorQuery.text, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems, + }); + + const baseFilteredGroups = + operatorFilteredGroups ?? + 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)), + }); + }, + }) + : threadSearchItems, + }); + + // While the caret sits inside an in: token, project completions lead the + // list: Enter commits the filter into the query (palette stays open) + // instead of navigating. + const trailingProjectOperatorToken = + currentView === null && !isActionsOnly ? getTrailingProjectOperatorToken(query) : null; + const projectFilterSuggestionItems = useMemo((): CommandPaletteActionItem[] => { + if (trailingProjectOperatorToken === null) return []; + return filterProjectSuggestions(projectGroups, trailingProjectOperatorToken.partialValue) + .slice(0, 8) + .map((group) => ({ + kind: "action" as const, + value: `filter-project:${group.projectKey}`, + searchTerms: [], + title: group.displayName, + description: group.workspaceRoot, + icon: projectFavicon(group), + keepOpen: true, + run: async () => { + handleQueryChange( + applyProjectSuggestionToQuery( + query, + getTrailingProjectOperatorToken(query), + group.displayName, + ), + ); + }, + })); + // handleQueryChange (a plain function declaration) is intentionally not + // a dependency: listing it would churn this memo every render for no + // observable difference. + }, [projectGroups, query, trailingProjectOperatorToken]); + + const filteredGroups = + projectFilterSuggestionItems.length > 0 + ? [ + { + value: "project-filter-suggestions", + label: "Filter by project", + items: projectFilterSuggestionItems, + }, + ...baseFilteredGroups, + ] + : baseFilteredGroups; const handleAddProjectForEnvironment = useCallback( async (input: { @@ -2375,6 +2547,65 @@ function OpenCommandPaletteDialog(props: { remoteProjectInputPlaceholder(addProjectCloneFlow) ?? getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + + // Discord-style operator pills: the input's own text goes transparent (the + // caret stays visible) and a metric-identical backdrop renders the same + // string with a pill behind each recognized operator token, translating + // with the input's scrollLeft. Root command mode only — browse and + // submenu queries have no operators. + const showOperatorPills = !isSubmenu && !isBrowsing && addProjectCloneFlow === null; + const searchInputRef = useRef(null); + const operatorPillHighlightRef = useRef(null); + const syncOperatorPillScroll = useCallback(() => { + const highlight = operatorPillHighlightRef.current; + const input = searchInputRef.current; + if (highlight === null || input === null) return; + highlight.style.transform = `translateX(${-input.scrollLeft}px)`; + }, []); + useLayoutEffect(() => { + // Typing at the end of an overflowing query scrolls the input after the + // change event; re-sync once the new value has laid out. + syncOperatorPillScroll(); + }, [query, syncOperatorPillScroll]); + const operatorQuerySegments = useMemo( + () => (showOperatorPills ? segmentThreadSearchQuery(query, new Date()) : []), + [query, showOperatorPills], + ); + const operatorPillBackdrop = + showOperatorPills && operatorQuerySegments.length > 0 ? ( + // Mirrors CommandInput's metrics exactly: the outer div reproduces the + // shell inset, the row reproduces the lg input's height and start + // padding, and the text run carries no padding of its own — the pill + // look comes from a layout-neutral shadow halo. + + ) : null; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = isBrowsing && @@ -2738,10 +2969,15 @@ function OpenCommandPaletteDialog(props: { footerActionLabel={footerActionLabel} footerTrailing={footerTrailing} inputAccessory={inputAccessory} + inputBackdrop={operatorPillBackdrop} inputProps={{ + ref: (node: HTMLInputElement | null) => { + searchInputRef.current = node; + }, + onScroll: syncOperatorPillScroll, // The submit button is absolutely positioned over the field, so the // inner input must reserve enough room for the full action label. - className: + className: cn( addProjectCloneFlow?.step === "repository" ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing @@ -2750,6 +2986,11 @@ function OpenCommandPaletteDialog(props: { hasHighlightedBrowseItem, }) : undefined, + // Glyphs render in the pill backdrop; the input keeps the caret + // and selection. Only while the backdrop is actually mounted. + operatorPillBackdrop !== null && + "*:data-[slot=autocomplete-input]:text-transparent! *:data-[slot=autocomplete-input]:caret-foreground", + ), placeholder: inputPlaceholder, wrapperClassName: isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 8c1a5b0e3c83..0373ae37bafd 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -10,6 +10,8 @@ type CommandPaletteContentProps = Omit, "children readonly footerActionLabel?: ReactNode; readonly footerTrailing?: ReactNode; readonly inputAccessory?: ReactNode; + /** Layer rendered behind the input's text (operator pills). */ + readonly inputBackdrop?: ReactNode; readonly inputProps: ComponentProps; readonly panelClassName?: string; readonly showBackHint?: boolean; @@ -27,6 +29,7 @@ export function CommandPaletteContent({ footerActionLabel, footerTrailing, inputAccessory, + inputBackdrop, inputProps, panelClassName, showBackHint, @@ -46,7 +49,16 @@ export function CommandPaletteContent({
- + {inputBackdrop} + { + inputRef.current = node; + if (typeof inputProps.ref === "function") { + inputProps.ref(node); + } + }} + /> {inputAccessory}
{children} diff --git a/apps/web/src/components/threadSearchQuery.logic.test.ts b/apps/web/src/components/threadSearchQuery.logic.test.ts new file mode 100644 index 000000000000..17dfb5cc5733 --- /dev/null +++ b/apps/web/src/components/threadSearchQuery.logic.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + applyProjectSuggestionToQuery, + filterProjectSuggestions, + getTrailingProjectOperatorToken, + hasThreadSearchOperators, + matchesParsedThreadSearch, + parseThreadSearchQuery, + resolveProjectFilterKeys, + segmentThreadSearchQuery, +} from "./threadSearchQuery.logic"; + +// Local-time construction on purpose: the day operators resolve against the +// user's calendar, not UTC. +const now = new Date(2026, 7, 9, 12, 30, 0); +const DAY_MS = 86_400_000; + +describe("parseThreadSearchQuery", () => { + it("treats a plain query as lowercased text with no filters", () => { + const parsed = parseThreadSearchQuery(" Fix Bug ", now); + expect(parsed).toEqual({ + text: "fix bug", + projectQueries: [], + agentQueries: [], + updatedStartMs: null, + updatedEndMs: null, + }); + expect(hasThreadSearchOperators(parsed)).toBe(false); + }); + + it("extracts in: values, including quoted multi-word names", () => { + const parsed = parseThreadSearchQuery('in:Icarus in:"My Project" fix', now); + expect(parsed.projectQueries).toEqual(["icarus", "my project"]); + expect(parsed.text).toBe("fix"); + expect(hasThreadSearchOperators(parsed)).toBe(true); + }); + + it("extracts agent: values", () => { + const parsed = parseThreadSearchQuery("agent:Claude retry", now); + expect(parsed.agentQueries).toEqual(["claude"]); + expect(parsed.text).toBe("retry"); + }); + + it("resolves relative before:/after: values against now", () => { + const parsed = parseThreadSearchQuery("after:7d before:2d", now); + expect(parsed.updatedStartMs).toBe(now.getTime() - 7 * DAY_MS); + expect(parsed.updatedEndMs).toBe(now.getTime() - 2 * DAY_MS); + }); + + it("resolves week-relative values", () => { + const parsed = parseThreadSearchQuery("after:2w", now); + expect(parsed.updatedStartMs).toBe(now.getTime() - 14 * DAY_MS); + }); + + it("resolves on: to a local calendar day", () => { + const parsed = parseThreadSearchQuery("on:2026-08-01", now); + expect(parsed.updatedStartMs).toBe(new Date(2026, 7, 1).getTime()); + expect(parsed.updatedEndMs).toBe(new Date(2026, 7, 2).getTime()); + }); + + it("resolves on:today and on:yesterday", () => { + const today = parseThreadSearchQuery("on:today", now); + expect(today.updatedStartMs).toBe(new Date(2026, 7, 9).getTime()); + expect(today.updatedEndMs).toBe(new Date(2026, 7, 10).getTime()); + const yesterday = parseThreadSearchQuery("on:yesterday", now); + expect(yesterday.updatedStartMs).toBe(new Date(2026, 7, 8).getTime()); + expect(yesterday.updatedEndMs).toBe(new Date(2026, 7, 9).getTime()); + }); + + it("excludes the named day from before: and after: bounds", () => { + const parsed = parseThreadSearchQuery("after:2026-08-01 before:2026-08-05", now); + expect(parsed.updatedStartMs).toBe(new Date(2026, 7, 2).getTime()); + expect(parsed.updatedEndMs).toBe(new Date(2026, 7, 5).getTime()); + }); + + it("degrades unparseable and rolled-over dates to plain text", () => { + expect(parseThreadSearchQuery("before:banana", now).text).toBe("before:banana"); + expect(parseThreadSearchQuery("on:2026-02-31", now).text).toBe("on:2026-02-31"); + expect(parseThreadSearchQuery("on:7d", now).text).toBe("on:7d"); + }); + + it("ignores a half-typed operator with no value", () => { + const parsed = parseThreadSearchQuery("in:", now); + expect(parsed.text).toBe(""); + expect(hasThreadSearchOperators(parsed)).toBe(false); + }); + + it("leaves unknown operator-shaped tokens as text", () => { + expect(parseThreadSearchQuery("re: meeting notes", now).text).toBe("re: meeting notes"); + }); +}); + +describe("matchesParsedThreadSearch", () => { + const makeThread = (input: { + title?: string; + updatedAt?: string; + providerName?: string | null; + instanceId?: string; + }) => ({ + title: input.title ?? "Anything", + updatedAt: input.updatedAt ?? "2026-08-09T10:00:00.000Z", + modelSelection: { instanceId: input.instanceId ?? "claude-code" }, + session: input.providerName === undefined ? null : { providerName: input.providerName }, + }); + + it("matches titles case-insensitively", () => { + const parsed = parseThreadSearchQuery("work", now); + expect(matchesParsedThreadSearch(makeThread({ title: "WORKTREE cleanup" }), parsed)).toBe(true); + expect(matchesParsedThreadSearch(makeThread({ title: "Review providers" }), parsed)).toBe( + false, + ); + }); + + it("filters by agent against provider name and instance id", () => { + const parsed = parseThreadSearchQuery("agent:claude", now); + expect( + matchesParsedThreadSearch(makeThread({ providerName: "claude", instanceId: "cc" }), parsed), + ).toBe(true); + expect( + matchesParsedThreadSearch( + makeThread({ providerName: "codex", instanceId: "codex-cli" }), + parsed, + ), + ).toBe(false); + expect(matchesParsedThreadSearch(makeThread({ instanceId: "claude-code" }), parsed)).toBe(true); + }); + + it("filters by the updatedAt window and rejects malformed timestamps", () => { + const recent = makeThread({ updatedAt: "2026-08-08T00:00:00.000Z" }); + const old = makeThread({ updatedAt: "2026-07-01T00:00:00.000Z" }); + const after = parseThreadSearchQuery("after:7d", now); + expect(matchesParsedThreadSearch(recent, after)).toBe(true); + expect(matchesParsedThreadSearch(old, after)).toBe(false); + const before = parseThreadSearchQuery("before:7d", now); + expect(matchesParsedThreadSearch(recent, before)).toBe(false); + expect(matchesParsedThreadSearch(old, before)).toBe(true); + expect(matchesParsedThreadSearch(makeThread({ updatedAt: "not-a-date" }), after)).toBe(false); + }); + + it("ANDs operators together", () => { + const parsed = parseThreadSearchQuery("agent:claude fix", now); + expect( + matchesParsedThreadSearch( + makeThread({ title: "Fix search", providerName: "claude", instanceId: "cc" }), + parsed, + ), + ).toBe(true); + expect( + matchesParsedThreadSearch( + makeThread({ title: "Fix search", providerName: "codex", instanceId: "codex-cli" }), + parsed, + ), + ).toBe(false); + }); +}); + +describe("resolveProjectFilterKeys", () => { + const groups = [ + { + projectKey: "group-icarus", + displayName: "Icarus", + workspaceRoot: "/Users/dev/icarus", + memberProjectRefs: [ + { environmentId: "env-1", projectId: "proj-1" }, + { environmentId: "env-2", projectId: "proj-9" }, + ], + }, + { + projectKey: "group-daedalus", + displayName: "Daedalus", + workspaceRoot: "/Users/dev/daedalus", + memberProjectRefs: [{ environmentId: "env-1", projectId: "proj-2" }], + }, + ]; + + it("returns null when there is no in: filter", () => { + expect(resolveProjectFilterKeys(groups, [])).toBeNull(); + }); + + it("expands matching groups into every member project key", () => { + expect(resolveProjectFilterKeys(groups, ["icarus"])).toEqual( + new Set(["env-1:proj-1", "env-2:proj-9"]), + ); + }); + + it("matches by workspace path and ORs multiple values", () => { + expect(resolveProjectFilterKeys(groups, ["dev/daedalus", "icarus"])).toEqual( + new Set(["env-1:proj-1", "env-2:proj-9", "env-1:proj-2"]), + ); + }); + + it("returns an empty set (zero results, not all) when nothing matches", () => { + expect(resolveProjectFilterKeys(groups, ["zeus"])).toEqual(new Set()); + }); +}); + +describe("thread search project autocomplete", () => { + it("detects a trailing partial in: token", () => { + expect(getTrailingProjectOperatorToken("fix in:ica")).toEqual({ + start: 4, + partialValue: "ica", + }); + expect(getTrailingProjectOperatorToken("in:")).toEqual({ start: 0, partialValue: "" }); + }); + + it("detects an unterminated quoted value", () => { + expect(getTrailingProjectOperatorToken('in:"my pro')).toEqual({ + start: 0, + partialValue: "my pro", + }); + }); + + it("returns null once the operator is committed or absent", () => { + expect(getTrailingProjectOperatorToken("in:icarus ")).toBeNull(); + expect(getTrailingProjectOperatorToken("fix in:alpha beta")).toBeNull(); + expect(getTrailingProjectOperatorToken("fix")).toBeNull(); + }); + + it("replaces the trailing token with a quoted committed filter", () => { + const token = getTrailingProjectOperatorToken("fix in:ica"); + expect(applyProjectSuggestionToQuery("fix in:ica", token, "Icarus")).toBe('fix in:"Icarus" '); + }); + + it("replaces the whole query when no trailing token exists", () => { + expect(applyProjectSuggestionToQuery("icarus", null, "Icarus")).toBe('in:"Icarus" '); + }); + + it("ranks suggestions: name prefix, then name substring, then path", () => { + const groups = [ + { displayName: "Tools", workspaceRoot: "/dev/icarus-tools" }, + { displayName: "My Icarus Fork", workspaceRoot: "/dev/fork" }, + { displayName: "Icarus", workspaceRoot: "/dev/icarus" }, + { displayName: "Unrelated", workspaceRoot: "/dev/other" }, + ]; + expect(filterProjectSuggestions(groups, "ica").map((group) => group.displayName)).toEqual([ + "Icarus", + "My Icarus Fork", + "Tools", + ]); + }); + + it("skips workspace-path matches when matchWorkspaceRoot is false", () => { + const groups = [ + { displayName: "Icarus", workspaceRoot: "/dev/icarus" }, + { displayName: "Tools", workspaceRoot: "/dev/icarus-tools" }, + ]; + expect( + filterProjectSuggestions(groups, "ica", { matchWorkspaceRoot: false }).map( + (group) => group.displayName, + ), + ).toEqual(["Icarus"]); + }); + + it("lists every project for an empty partial value", () => { + const groups = [ + { displayName: "Alpha", workspaceRoot: "/a" }, + { displayName: "Beta", workspaceRoot: "/b" }, + ]; + expect(filterProjectSuggestions(groups, "")).toEqual(groups); + }); +}); + +describe("segmentThreadSearchQuery", () => { + it("returns the whole query as one plain segment when there are no operators", () => { + expect(segmentThreadSearchQuery("fix wing telemetry", now)).toEqual([ + { text: "fix wing telemetry", isOperator: false }, + ]); + }); + + it("highlights operator tokens and reproduces the input verbatim", () => { + const query = 'fix in:"My Project" after:7d tail'; + const segments = segmentThreadSearchQuery(query, now); + expect(segments).toEqual([ + { text: "fix ", isOperator: false }, + { text: 'in:"My Project"', isOperator: true }, + { text: " ", isOperator: false }, + { text: "after:7d", isOperator: true }, + { text: " tail", isOperator: false }, + ]); + expect(segments.map((segment) => segment.text).join("")).toBe(query); + }); + + it("highlights a bare operator keyword while its value is being typed", () => { + expect(segmentThreadSearchQuery("in:", now)).toEqual([{ text: "in:", isOperator: true }]); + }); + + it("does not highlight tokens the parser degrades to plain text", () => { + expect(segmentThreadSearchQuery("before:banana", now)).toEqual([ + { text: "before:banana", isOperator: false }, + ]); + expect(segmentThreadSearchQuery("on:7d", now)).toEqual([{ text: "on:7d", isOperator: false }]); + expect(segmentThreadSearchQuery("re: meeting", now)).toEqual([ + { text: "re: meeting", isOperator: false }, + ]); + }); +}); diff --git a/apps/web/src/components/threadSearchQuery.logic.ts b/apps/web/src/components/threadSearchQuery.logic.ts new file mode 100644 index 000000000000..9cad03a92bd4 --- /dev/null +++ b/apps/web/src/components/threadSearchQuery.logic.ts @@ -0,0 +1,385 @@ +// ── Thread search query language ───────────────────────────────────── +// Discord-style operators over the already-synced shell data, used by the +// command palette's thread search: +// in: scope to projects whose name or path contains the value +// agent: provider name / instance id substring +// before: after: updatedAt bounds — ISO day, or relative like 7d / 2w +// on: one calendar day — ISO day, today, yesterday +// Everything else remains free text. Operators AND together; repeated values +// of the same operator OR within it. Values with spaces are quoted: +// in:"My Project". An operator token whose value doesn't parse (bad date) +// degrades to plain text instead of silently filtering everything out. + +export interface ParsedThreadSearchQuery { + /** Lowercased free text left after operator extraction. */ + readonly text: string; + /** Lowercased in: values. Project-key resolution happens in the caller + (via resolveProjectFilterKeys) because group names live outside the + thread shells. */ + readonly projectQueries: readonly string[]; + /** Lowercased agent: values. */ + readonly agentQueries: readonly string[]; + /** Half-open [start, end) bounds on updatedAt, merged across all date + operators; null side = unbounded. */ + readonly updatedStartMs: number | null; + readonly updatedEndMs: number | null; +} + +const SEARCH_OPERATOR_PATTERN = /^(in|agent|before|after|on):(.*)$/i; +const DAY_MS = 86_400_000; + +/** Splits on whitespace, except inside double quotes (an unterminated quote + runs to the end — that's just a user mid-typing). */ +function tokenizeSearchQuery(query: string): string[] { + const tokens: string[] = []; + let current = ""; + let inQuotes = false; + for (const char of query) { + if (char === '"') { + inQuotes = !inQuotes; + current += char; + continue; + } + if (!inQuotes && /\s/.test(char)) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + current += char; + } + if (current.length > 0) tokens.push(current); + return tokens; +} + +function unquoteSearchValue(value: string): string { + return value.replace(/^"/, "").replace(/"$/, ""); +} + +type SearchDateToken = + | { readonly kind: "instant"; readonly ms: number } + | { readonly kind: "day"; readonly startMs: number; readonly endMs: number }; + +/** Local-calendar day math goes through the Date constructor (day ± 1) so + month rollover and DST transitions resolve correctly. */ +function localDayRange(year: number, monthIndex: number, day: number): SearchDateToken { + return { + kind: "day", + startMs: new Date(year, monthIndex, day).getTime(), + endMs: new Date(year, monthIndex, day + 1).getTime(), + }; +} + +export function resolveSearchDateToken(rawValue: string, now: Date): SearchDateToken | null { + const value = rawValue.toLowerCase(); + const relative = /^(\d{1,4})([dw])$/.exec(value); + if (relative) { + const amount = Number(relative[1]); + const unitMs = relative[2] === "w" ? 7 * DAY_MS : DAY_MS; + return { kind: "instant", ms: now.getTime() - amount * unitMs }; + } + if (value === "today" || value === "yesterday") { + const offset = value === "yesterday" ? 1 : 0; + return localDayRange(now.getFullYear(), now.getMonth(), now.getDate() - offset); + } + const isoDay = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (isoDay) { + const year = Number(isoDay[1]); + const monthIndex = Number(isoDay[2]) - 1; + const day = Number(isoDay[3]); + const start = new Date(year, monthIndex, day); + // The Date constructor rolls invalid components over (2026-02-31 → + // March 3rd); a rolled-over date is a typo, not a filter. + if ( + start.getFullYear() !== year || + start.getMonth() !== monthIndex || + start.getDate() !== day + ) { + return null; + } + return localDayRange(year, monthIndex, day); + } + return null; +} + +export function parseThreadSearchQuery(query: string, now: Date): ParsedThreadSearchQuery { + const textTokens: string[] = []; + const projectQueries: string[] = []; + const agentQueries: string[] = []; + let updatedStartMs: number | null = null; + let updatedEndMs: number | null = null; + const tightenStart = (ms: number) => { + updatedStartMs = updatedStartMs === null ? ms : Math.max(updatedStartMs, ms); + }; + const tightenEnd = (ms: number) => { + updatedEndMs = updatedEndMs === null ? ms : Math.min(updatedEndMs, ms); + }; + + for (const token of tokenizeSearchQuery(query)) { + const operator = SEARCH_OPERATOR_PATTERN.exec(token); + if (!operator) { + textTokens.push(token); + continue; + } + const keyword = operator[1]!.toLowerCase(); + const value = unquoteSearchValue(operator[2]!).trim().toLowerCase(); + // A bare `in:` is someone mid-typing (the autocomplete is open); an + // empty value must not filter anything. + if (value.length === 0) continue; + if (keyword === "in") { + projectQueries.push(value); + continue; + } + if (keyword === "agent") { + agentQueries.push(value); + continue; + } + const dateToken = resolveSearchDateToken(value, now); + if (dateToken === null) { + textTokens.push(token); + continue; + } + // before: excludes the named day/instant; after: starts past it; on: + // pins both bounds to the day. All merge by intersection. + if (keyword === "before") { + tightenEnd(dateToken.kind === "instant" ? dateToken.ms : dateToken.startMs); + } else if (keyword === "after") { + tightenStart(dateToken.kind === "instant" ? dateToken.ms : dateToken.endMs); + } else if (dateToken.kind === "day") { + tightenStart(dateToken.startMs); + tightenEnd(dateToken.endMs); + } else { + // on:7d is a point, not a day — degrade to text like other bad dates. + textTokens.push(token); + } + } + + return { + text: textTokens.join(" ").toLowerCase(), + projectQueries, + agentQueries, + updatedStartMs, + updatedEndMs, + }; +} + +/** Whether the parsed query carries any OPERATOR criteria (project, agent, + or date bounds) — free text alone doesn't count. */ +export function hasThreadSearchOperators(parsed: ParsedThreadSearchQuery): boolean { + return ( + parsed.projectQueries.length > 0 || + parsed.agentQueries.length > 0 || + parsed.updatedStartMs !== null || + parsed.updatedEndMs !== null + ); +} + +export interface OperatorSearchableThread { + readonly title: string; + readonly updatedAt: string; + readonly modelSelection: { readonly instanceId: string }; + readonly session: { + readonly providerName: string | null; + readonly providerInstanceId?: string | undefined; + } | null; +} + +/** + * Applies the parsed query's OPERATOR criteria (agent, date bounds) plus the + * free text against the thread title. Callers that rank text against richer + * haystacks (project title, content snippets) pass `{ ...parsed, text: "" }` + * and match the text themselves. The in: operator is applied separately as a + * project-key filter — thread shells don't carry project names. + */ +export function matchesParsedThreadSearch( + thread: OperatorSearchableThread, + parsed: ParsedThreadSearchQuery, +): boolean { + if (parsed.text.length > 0 && !thread.title.toLowerCase().includes(parsed.text)) { + return false; + } + if (parsed.agentQueries.length > 0) { + const agentHaystack = [ + thread.session?.providerName, + thread.session?.providerInstanceId, + thread.modelSelection.instanceId, + ] + .filter((candidate): candidate is string => typeof candidate === "string") + .join(" ") + .toLowerCase(); + if (!parsed.agentQueries.some((query) => agentHaystack.includes(query))) { + return false; + } + } + if (parsed.updatedStartMs !== null || parsed.updatedEndMs !== null) { + const updatedMs = Date.parse(thread.updatedAt); + if (Number.isNaN(updatedMs)) return false; + if (parsed.updatedStartMs !== null && updatedMs < parsed.updatedStartMs) return false; + if (parsed.updatedEndMs !== null && updatedMs >= parsed.updatedEndMs) return false; + } + return true; +} + +export interface ThreadSearchProjectGroup { + readonly projectKey: string; + readonly displayName: string; + readonly workspaceRoot: string; + readonly memberProjectRefs: readonly { + readonly environmentId: string; + readonly projectId: string; + }[]; +} + +/** Expands in: values into the `${environmentId}:${projectId}` key set the + clients already filter by. null = no in: filter; an empty set means the + values matched no project (zero results, not all results). */ +export function resolveProjectFilterKeys( + groups: readonly ThreadSearchProjectGroup[], + projectQueries: readonly string[], +): ReadonlySet | null { + if (projectQueries.length === 0) return null; + const keys = new Set(); + for (const group of groups) { + const name = group.displayName.toLowerCase(); + const root = group.workspaceRoot.toLowerCase(); + if (projectQueries.some((query) => name.includes(query) || root.includes(query))) { + for (const ref of group.memberProjectRefs) { + keys.add(`${ref.environmentId}:${ref.projectId}`); + } + } + } + return keys; +} + +export interface TrailingProjectOperatorToken { + /** Index in the query where the trailing in: token starts. */ + readonly start: number; + /** The unquoted partial value typed so far (may be empty). */ + readonly partialValue: string; +} + +/** Detects an in-progress trailing `in:` token — the caret is still inside + it, so the project autocomplete should be open. Trailing whitespace (or a + different trailing token) means the operator is committed and this + returns null. */ +export function getTrailingProjectOperatorToken( + query: string, +): TrailingProjectOperatorToken | null { + const match = /(^|\s)(in:("[^"]*"?|[^\s"]*))$/i.exec(query); + if (!match) return null; + return { + start: match.index + match[1]!.length, + partialValue: unquoteSearchValue(match[2]!.slice("in:".length)), + }; +} + +/** Ranks project groups for the in: autocomplete: name prefix, then name + substring, then workspace-path substring. An empty partial lists all. + Path matching is opt-out for surfaces where a folder path coincidentally + containing the typed words would be noise. */ +export function filterProjectSuggestions< + T extends { readonly displayName: string; readonly workspaceRoot: string }, +>( + groups: readonly T[], + partialValue: string, + options?: { readonly matchWorkspaceRoot?: boolean }, +): T[] { + const matchWorkspaceRoot = options?.matchWorkspaceRoot ?? true; + const query = partialValue.trim().toLowerCase(); + if (query.length === 0) return [...groups]; + const ranked: { group: T; rank: number }[] = []; + for (const group of groups) { + const name = group.displayName.toLowerCase(); + const rank = name.startsWith(query) + ? 0 + : name.includes(query) + ? 1 + : matchWorkspaceRoot && group.workspaceRoot.toLowerCase().includes(query) + ? 2 + : null; + if (rank !== null) ranked.push({ group, rank }); + } + // toSorted is stable: groups keep their input order within each rank. + return ranked.toSorted((left, right) => left.rank - right.rank).map((entry) => entry.group); +} + +/** Applies a picked project suggestion: replaces the in-progress trailing + in: token — or, absent one, the whole query — with a quoted, committed + filter plus a trailing space so typing continues naturally. */ +export function applyProjectSuggestionToQuery( + query: string, + token: TrailingProjectOperatorToken | null, + projectName: string, +): string { + const filter = `in:"${projectName.replaceAll('"', "")}" `; + if (token === null) return filter; + return `${query.slice(0, token.start)}${filter}`; +} + +export interface ThreadSearchQuerySegment { + readonly text: string; + readonly isOperator: boolean; +} + +/** + * Splits the raw query into alternating plain/operator segments covering the + * exact input string (including whitespace), for the Discord-style operator + * pills rendered behind the search input. A segment is highlighted only when + * the parser would honor it: in:/agent: tokens always (an empty value means + * the autocomplete is open), date tokens only while empty or parseable — a + * token the parser degrades to plain text must not wear a pill. + */ +export function segmentThreadSearchQuery(query: string, now: Date): ThreadSearchQuerySegment[] { + const operatorRanges: [number, number][] = []; + const classifyToken = (start: number, end: number) => { + const operator = SEARCH_OPERATOR_PATTERN.exec(query.slice(start, end)); + if (!operator) return; + const keyword = operator[1]!.toLowerCase(); + const value = unquoteSearchValue(operator[2]!).trim(); + if (keyword === "in" || keyword === "agent") { + operatorRanges.push([start, end]); + return; + } + if (value.length === 0) { + operatorRanges.push([start, end]); + return; + } + const dateToken = resolveSearchDateToken(value, now); + if (dateToken !== null && (keyword !== "on" || dateToken.kind === "day")) { + operatorRanges.push([start, end]); + } + }; + + // Same walk as tokenizeSearchQuery, but tracking positions so segments + // reproduce the input verbatim. + let tokenStart = -1; + let inQuotes = false; + for (let index = 0; index < query.length; index += 1) { + const char = query[index]!; + if (char === '"') { + inQuotes = !inQuotes; + if (tokenStart === -1) tokenStart = index; + continue; + } + if (!inQuotes && /\s/.test(char)) { + if (tokenStart !== -1) { + classifyToken(tokenStart, index); + tokenStart = -1; + } + continue; + } + if (tokenStart === -1) tokenStart = index; + } + if (tokenStart !== -1) classifyToken(tokenStart, query.length); + + const segments: ThreadSearchQuerySegment[] = []; + let cursor = 0; + for (const [start, end] of operatorRanges) { + if (start > cursor) segments.push({ text: query.slice(cursor, start), isOperator: false }); + segments.push({ text: query.slice(start, end), isOperator: true }); + cursor = end; + } + if (cursor < query.length) segments.push({ text: query.slice(cursor), isOperator: false }); + return segments; +} From 30e45678eacbb815064fa393bc3689e901509fd8 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Thu, 17 Sep 2026 01:54:27 +0000 Subject: [PATCH 02/10] fix(web): open archived palette hits and hide operator pills in action mode Three fixes from browser testing of the operator search work: - Selecting an archived thread hit fell through to a new-thread draft because archived threads aren't in the live shell store. runThread now unarchives first (useThreadActions.unarchiveThread, which also refreshes the archived snapshots) and only navigates on success; failures surface a toast via the existing squashAtomCommandFailure pattern. - Operator pills rendered in actions-only mode (> queries). showOperatorPills now requires the live query to not start with '>'. - Scrolling an overflowing query let the pill backdrop text translate into the start padding and paint over the search icon. Clipping now happens in an inner min-w-0 flex-1 overflow-hidden wrapper that starts exactly where the input text starts. Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/CommandPalette.logic.test.ts | 6 +- .../src/components/CommandPalette.logic.ts | 15 +++- apps/web/src/components/CommandPalette.tsx | 72 +++++++++++++------ 3 files changed, 67 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 25896baf7555..f849f2193b86 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -52,7 +52,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({ + archivedAt: "2026-09-01T00:00:00.000Z", + environmentId, + id, + }); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 5cf5a329afc8..d5e9b80d7dc3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -25,7 +25,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 +37,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({ + archivedAt: thread.archivedAt, + environmentId: input.environmentId, + id: thread.id, + }), })); } @@ -261,7 +268,9 @@ export function buildThreadActionItems ReactNode; getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; - runThread: (thread: Pick) => Promise; + runThread: ( + thread: Pick, + ) => Promise; limit?: number; /** Keep archived threads (search corpora); the default drops them so the recents list never surfaces archived rows. */ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b1fe905d6d22..3b1e2cf47c1a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -75,6 +75,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; @@ -668,6 +669,7 @@ function OpenCommandPaletteDialog(props: { const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); + const { unarchiveThread } = useThreadActions(); const projects = useProjects(); const referenceThreadRef = pathname === "/pull-requests" @@ -1336,6 +1338,25 @@ function OpenCommandPaletteDialog(props: { : undefined; }, runThread: async (thread) => { + // Archived hits aren't in the live shell store, so navigating + // directly falls through to a new-thread draft. Restore the thread + // first, then open it like any live hit. + if (thread.archivedAt !== null) { + const unarchiveResult = await unarchiveThread( + scopeThreadRef(thread.environmentId, thread.id), + ); + if (unarchiveResult._tag !== "Success") { + const error = squashAtomCommandFailure(unarchiveResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to restore archived thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), @@ -1346,6 +1367,7 @@ function OpenCommandPaletteDialog(props: { activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, + unarchiveThread, projectByKey, projectEnvironmentLocationById, projectTitleById, @@ -2553,7 +2575,8 @@ function OpenCommandPaletteDialog(props: { // string with a pill behind each recognized operator token, translating // with the input's scrollLeft. Root command mode only — browse and // submenu queries have no operators. - const showOperatorPills = !isSubmenu && !isBrowsing && addProjectCloneFlow === null; + const showOperatorPills = + !isSubmenu && !isBrowsing && addProjectCloneFlow === null && !query.startsWith(">"); const searchInputRef = useRef(null); const operatorPillHighlightRef = useRef(null); const syncOperatorPillScroll = useCallback(() => { @@ -2581,27 +2604,32 @@ function OpenCommandPaletteDialog(props: { aria-hidden="true" className="pointer-events-none absolute inset-0 px-[var(--command-shell-inset)] py-1.5" > -
-
- {(() => { - // Segments tile the query, so each one's character offset is a - // stable, data-derived key even when texts repeat. - let offset = 0; - return operatorQuerySegments.map((segment) => { - const key = `${offset}:${segment.text}`; - offset += segment.text.length; - return segment.isOperator ? ( - - {segment.text} - - ) : ( - {segment.text} - ); - }); - })()} +
+ {/* Clip starts where the input text starts: the row's ps-9 (search + icon zone) stays outside the scroll translation window so + scrolled-back text can't paint over the icon. */} +
+
+ {(() => { + // Segments tile the query, so each one's character offset is a + // stable, data-derived key even when texts repeat. + let offset = 0; + return operatorQuerySegments.map((segment) => { + const key = `${offset}:${segment.text}`; + offset += segment.text.length; + return segment.isOperator ? ( + + {segment.text} + + ) : ( + {segment.text} + ); + }); + })()} +
From 45915804c99d3c026143c1f25441268ea30ab116 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Thu, 17 Sep 2026 02:00:24 +0000 Subject: [PATCH 03/10] fix(web): wait for restored thread before opening archived palette hit Unarchive succeeded but navigate fired before the unarchived shell reached the live store, so the thread route saw a "missing" render state and redirected to /. Adds waitForThreadShell(ref) to apps/web/src/state/entities.ts, modelled on waitForProject but subscribing to environmentThreadShells.threadShellAtom; the predicate requires shell.archivedAt === null because an archived thread can already have a stale shell in the live store. The palette's archived-hit path awaits it after a successful unarchive and toasts + returns on timeout instead of navigating. Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/CommandPalette.tsx | 28 ++++++++++++++++++--- apps/web/src/state/entities.ts | 29 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3b1e2cf47c1a..1f59ffb0824d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -89,7 +89,13 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; +import { + useProjects, + useServerConfigs, + useThreadShells, + waitForProject, + waitForThreadShell, +} from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -1342,9 +1348,8 @@ function OpenCommandPaletteDialog(props: { // directly falls through to a new-thread draft. Restore the thread // first, then open it like any live hit. if (thread.archivedAt !== null) { - const unarchiveResult = await unarchiveThread( - scopeThreadRef(thread.environmentId, thread.id), - ); + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const unarchiveResult = await unarchiveThread(threadRef); if (unarchiveResult._tag !== "Success") { const error = squashAtomCommandFailure(unarchiveResult); toastManager.add( @@ -1356,6 +1361,21 @@ function OpenCommandPaletteDialog(props: { ); return; } + // The restore round-trips through the shell stream before the + // thread page can resolve it; navigating early lands on a + // "missing" render state that redirects away. + try { + await waitForThreadShell(threadRef); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to restore archived thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } await navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index d9610e20717f..aeb30bc17c86 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -183,6 +183,35 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** Resolves when the thread's unarchived shell reaches the live client store. + An archived thread can already have a stale shell (archivedAt set), so a + non-null shell alone is not enough. */ +export function waitForThreadShell( + ref: ScopedThreadRef, + timeoutMs = 10_000, +): Promise { + const isRestored = (shell: EnvironmentThreadShell | null): shell is EnvironmentThreadShell => + shell !== null && shell.archivedAt === null; + const current = readThreadShell(ref); + if (isRestored(current)) return Promise.resolve(current); + + return new Promise((resolve, reject) => { + let unsubscribe: (() => void) | null = null; + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("The thread did not appear after being restored.")); + }, timeoutMs); + const finish = (shell: EnvironmentThreadShell | null) => { + if (!isRestored(shell)) return; + clearTimeout(timeout); + unsubscribe?.(); + resolve(shell); + }; + unsubscribe = appAtomRegistry.subscribe(environmentThreadShells.threadShellAtom(ref), finish); + finish(readThreadShell(ref)); + }); +} + /** Whether the environment's server understands thread.settle/unsettle. False for pre-settlement servers (capability defaults false on decode), so clients under version skew fall back instead of erroring. */ From ad3274a18c28b4df89d52a09e5a193bbd4ca76c4 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Thu, 17 Sep 2026 04:58:32 +0000 Subject: [PATCH 04/10] chore(web): drop unused export flagged by knip Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/threadSearchQuery.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/threadSearchQuery.logic.ts b/apps/web/src/components/threadSearchQuery.logic.ts index 9cad03a92bd4..e7f029d6545e 100644 --- a/apps/web/src/components/threadSearchQuery.logic.ts +++ b/apps/web/src/components/threadSearchQuery.logic.ts @@ -71,7 +71,7 @@ function localDayRange(year: number, monthIndex: number, day: number): SearchDat }; } -export function resolveSearchDateToken(rawValue: string, now: Date): SearchDateToken | null { +function resolveSearchDateToken(rawValue: string, now: Date): SearchDateToken | null { const value = rawValue.toLowerCase(); const relative = /^(\d{1,4})([dw])$/.exec(value); if (relative) { From 78b6eec6dad6e1fa435b1229d177a4144176fde8 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Fri, 18 Sep 2026 05:14:41 +0000 Subject: [PATCH 05/10] style(web): neutral chip look for palette operator pills Filled foreground/8 chip with muted keyword and foreground value, replacing the primary-colored highlight. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/CommandPalette.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1f59ffb0824d..b3b7e57e5978 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2628,7 +2628,7 @@ function OpenCommandPaletteDialog(props: { {/* Clip starts where the input text starts: the row's ps-9 (search icon zone) stays outside the scroll translation window so scrolled-back text can't paint over the icon. */} -
+
{(() => { // Segments tile the query, so each one's character offset is a @@ -2637,15 +2637,18 @@ function OpenCommandPaletteDialog(props: { return operatorQuerySegments.map((segment) => { const key = `${offset}:${segment.text}`; offset += segment.text.length; - return segment.isOperator ? ( + if (!segment.isOperator) return {segment.text}; + const keywordEnd = segment.text.indexOf(":") + 1; + return ( - {segment.text} + + {segment.text.slice(0, keywordEnd)} + + {segment.text.slice(keywordEnd)} - ) : ( - {segment.text} ); }); })()} From 05385bbf9da25210e3cbce5e41c4e3d2722163d0 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Sat, 19 Sep 2026 17:42:19 +0000 Subject: [PATCH 06/10] style(web): stronger fill for palette operator chips Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/CommandPalette.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b3b7e57e5978..075404f4282c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2642,7 +2642,7 @@ function OpenCommandPaletteDialog(props: { return ( {segment.text.slice(0, keywordEnd)} From d17938837a33cbc2b0b7fa074cc0c995ace4e2d0 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Sat, 19 Sep 2026 19:13:01 +0000 Subject: [PATCH 07/10] refactor(web): render palette search operators as inline chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the metric-identical backdrop behind a transparent input with real DOM chips rendered before the input via a new CommandInput leading prop — a tokenized input like Discord/Gmail. The canonical query string still prefixes committed operator tokens, so parsing, filtering, and the in: autocomplete are untouched. - CommandInput gains a leading slot; when present, the search icon moves into a fixed-width zone with the same left edge (shell inset + 2px) so it does not shift when chips appear, and the input's start padding collapses to zero - Committed operators render as removable muted chips (favicon for in: hits); Backspace at caret 0 pops the last chip, and the × button keeps input focus - Backdrop, scroll-sync refs/effect, and the text-transparent trick are deleted; CommandPaletteContent drops inputBackdrop Devin Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/CommandPalette.tsx | 161 +++++++++--------- .../src/components/CommandPaletteContent.tsx | 4 - .../threadSearchQuery.logic.test.ts | 59 +++++++ .../src/components/threadSearchQuery.logic.ts | 50 ++++++ apps/web/src/components/ui/command.tsx | 63 +++++-- 5 files changed, 239 insertions(+), 98 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 075404f4282c..3cb6ad5e18af 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -57,6 +57,7 @@ import { SettingsIcon, SquarePenIcon, TextSearchIcon, + XIcon, } from "lucide-react"; import { useCallback, @@ -113,13 +114,15 @@ import { onOpenCommandPalette } from "../commandPaletteBus"; import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { applyProjectSuggestionToQuery, + composeThreadSearchQuery, + describeSearchOperator, filterProjectSuggestions, getTrailingProjectOperatorToken, hasThreadSearchOperators, matchesParsedThreadSearch, parseThreadSearchQuery, resolveProjectFilterKeys, - segmentThreadSearchQuery, + tokenizeThreadSearchQuery, } from "./threadSearchQuery.logic"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -2590,73 +2593,67 @@ function OpenCommandPaletteDialog(props: { getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; - // Discord-style operator pills: the input's own text goes transparent (the - // caret stays visible) and a metric-identical backdrop renders the same - // string with a pill behind each recognized operator token, translating - // with the input's scrollLeft. Root command mode only — browse and - // submenu queries have no operators. - const showOperatorPills = + // Discord-style operator chips: committed operator tokens render as + // removable chips ahead of the input while the canonical query string + // keeps them prefixed, so every downstream consumer stays unchanged. + // Root command mode only — browse, submenu, and > queries have no + // operators. + const showOperatorChips = !isSubmenu && !isBrowsing && addProjectCloneFlow === null && !query.startsWith(">"); - const searchInputRef = useRef(null); - const operatorPillHighlightRef = useRef(null); - const syncOperatorPillScroll = useCallback(() => { - const highlight = operatorPillHighlightRef.current; - const input = searchInputRef.current; - if (highlight === null || input === null) return; - highlight.style.transform = `translateX(${-input.scrollLeft}px)`; - }, []); - useLayoutEffect(() => { - // Typing at the end of an overflowing query scrolls the input after the - // change event; re-sync once the new value has laid out. - syncOperatorPillScroll(); - }, [query, syncOperatorPillScroll]); - const operatorQuerySegments = useMemo( - () => (showOperatorPills ? segmentThreadSearchQuery(query, new Date()) : []), - [query, showOperatorPills], + const tokenizedQuery = useMemo( + () => + showOperatorChips + ? tokenizeThreadSearchQuery(query, new Date()) + : { operators: [], text: query }, + [query, showOperatorChips], ); - const operatorPillBackdrop = - showOperatorPills && operatorQuerySegments.length > 0 ? ( - // Mirrors CommandInput's metrics exactly: the outer div reproduces the - // shell inset, the row reproduces the lg input's height and start - // padding, and the text run carries no padding of its own — the pill - // look comes from a layout-neutral shadow halo. -