Skip to content

feat(web): global search palette — find chats, workspaces and projects, run commands, toggle settings - #804

Open
danyaberezun wants to merge 18 commits into
mainfrom
global-search-research
Open

danyaberezun wants to merge 18 commits into
mainfrom
global-search-research

Conversation

@danyaberezun

@danyaberezun danyaberezun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Finding anything in ThinkRail took a different surface for each kind of thing: ⌘K opened a bare session switcher that only listed chats needing attention, Ctrl+R recalled prompts of the current chat, workspaces and projects had to be clicked through the tree, and every action — toggling an MCP server, opening a Settings section, settling a workspace — lived in the one menu or button that owned it. Nothing searched across open projects, and nothing ran a command by name.

This is the first PR of the global-search stack (research and design in the originating chat: a Search-Everywhere-style palette that finds, runs and later starts work). It ships the palette foundation with no protocol change: everything it lists is already known to the client.

Redesign (Oct 26). After a second research pass over shipped palettes (IntelliJ, VS Code, Zed, Raycast, Warp, Cursor, Conductor, Codex, T3, Antigravity) the stack was cut down to what the field keeps: a finder with explicit scopes, a bounded empty state, stated ranking and previews — not a kitchen sink. This PR lost its inline Theme / Extensions / MCP pages with their page stack and crumbs, the per-row ⌘K action panel, in-place toggles, and the is:/has: filter grammar (commit refactor(web): flatten the search palette to one finder level, −1 013 lines). Every setting row now opens its Settings section; a row is a destination or a command, nothing else.

Rebased on main (Oct 28, twice). main gained the shared button system (#817), first-run onboarding (#808), the ask_user_question extension (#869), repository cloning (#975) and protocol v92–v95 (#937, #869, #808, #975), so the stack's versions moved up by four: search v96 (#805), chats v97 (#887), continue v98 (#888), Find in Files v99 (#933), spec sections v100 (#939). The topbar search button is now the shared IconButton, the palette's scope chips and Preview toggle use the shared Button, and the palette offers main's new Start a new project… and Open a path on the host…. The first-prompt label for unnamed chats (#911) still carries into the chat rows. The second rebase, onto 7b07d5c5f (#965 web access, #975 cloning, #978/#979 onboarding fixes), had one conflict, in the store SPEC's list of Settings sections.

Review (Oct 28). Two design reviews and eight rounds of code review (GPT-6 Astra, Claude Fable 5.1) changed:

  • Keys. The palette's keys live on the dialog frame, so a click on a header, the footer or the preview no longer leaves Escape, ⌘K and the arrows dead. An active IME owns every key. Ctrl+Shift+R, ⌘⇧I and ⌘⌥I reach the browser.
  • Host load. The title backfill lists a workspace once per session set and welcome generation, records the chats into the history projection (so another window's rename reaches them), drops a reply a rename overtook and never diffs a worktree. Learning is computed once per query.
  • Navigation. A chat in a project whose workspace list was never loaded loads that list before it opens, and only if no later pick or navigation overtook it. Work in project folder uses the same guard, so a late reply never pulls you back.
  • Matcher. A quoted term is a literal. Case and Latin accents fold without moving highlights.
  • Waiting for you shows eight rows before "Show N more" and leaves running chats out.
  • Settle is disabled while an agent works or a chat needs you, as in the Projects tree.
  • Notification click resets an already-open palette.
  • Accessibility. Sections are named groups.

Approach

  • One box, one hotkey. ⌘K, a double tap of Shift, or the topbar search button open the palette. The empty query keeps the switcher's job as the Waiting for you group (chats that need input, failed or finished unread, across every open project, eight before "Show N more"), followed by Recent and Actions. Typing searches chats, workspaces and projects, commands and settings at once; the top hit shows only when it is clearly ahead; groups are capped with an "N more" row that narrows the scope.
  • Grammar mirrors the chips. @ work, > commands and settings; a chip writes its prefix into the query, so the prefix is the one source of scope (Tab / Alt+1…n cycle it). / files and # specs are reserved for the host search in PR2. Quoted text is a contiguous literal (no acronym or subsequence match), and an unterminated quote runs to the end. There is no filter grammar: triage happens through the empty state's grouping and the status dots every row carries.
  • Ranking. The shared tiered matcher (new in contracts, a named runtime exception in architecture Decision 3 so the host's providers rank with the same copy in PR2) decides first; within a tier a decay-free frecency (Firefox's stored form), the learned query→selection count (adaptive history), and current-project/workspace and attention boosts reorder. Learning is client-local and bounded (ids and query prefixes only, under the native adapter's cap), keyed on the text without its scope prefix and computed once per query.
  • Commands are a contract, not a second keymap. The new commands sub-module describes an invocable action (visibility when, mutates, display-only shortcut, a required run); every chord keeps its single owner and the palette calls the same handler. shell composes the catalog and the Settings section rows (each opens its section — the palette never flips a setting in place) and injects them; panels import neither new module, and search never imports panels/chat/shell.
  • Topmost chord layer. The dialog frame owns the keys, so they work wherever a click left focus. Inside the open palette ⌘K and double Shift close it, Escape clears then closes (a synthetic Escape from the desktop close-item command closes outright), Tab never leaves the input, plain Ctrl+R is swallowed, and an active IME owns every key. Double Shift is IntelliJ's gesture (300 ms steps, reset by any other key, IME, repeat, modifiers, pointer, blur) with a per-client off switch in Settings › Keyboard.
  • Offline. Local results stay usable; every host mutation (commands marked mutates) is disabled with an "Offline" reason until the current welcome, so nothing is issued blind for the transport to replay.
  • i18n. Every string is a catalog key; the translator is threaded through the item builders so rows are matched on the language the user reads and recompute on a switch.

Rejected: an all-host palette (commands and view state are browser-owned) and an all-client one (PR2's paths and transcripts cannot ship to remote clients); a fuzzy library (Fuse/cmdk's own scoring rank paths poorly); learning in host config (the conflicts the double-Shift switch solves are per machine).

Changes

  • packages/contracts: matchFields / parseMatchTerms / compareMatches / MATCH_TIER / MatchTerm — fixed tiers exact > prefix > word start/camel hump/acronym > substring > bounded subsequence, every term required, quoted terms literal, case and Latin accents folded without moving offsets, path-aware, field-keyed highlight ranges.
  • apps/web/src/commands (new barrel): Command (with an optional disabledReason), CommandContext, isCommandVisible/isCommandEnabled/visibleCommands, shortcutKeys/shortcutLabel; its SPEC carries the coverage table of every existing action with the PR it is wired in.
  • apps/web/src/search (new barrel): props-driven SearchPalette (WAI-ARIA combobox/listbox, ⌘I preview pane, Alt+1…n chips, one level — no pages), query grammar, sections/ranking/learning, client-local preferences, createDoubleShiftDetector, the Keyboard settings section.
  • shell: SearchPaletteHost + paletteCatalog.ts + useSearchPalette replace SessionSwitcher/useSessionSwitcher; paletteBackfill.ts + backfillQueue.ts hydrate missing workspace lists and chat titles (four requests in flight, history projection, no diff stats); useGlobalHotkeys routes Mod+K to the palette, adds the double-Shift listeners and keeps Ctrl+R inert under it; a topbar search IconButton.
  • store: searchOpen + searchSession + openSearch/closeSearch and the client-local doubleShiftSearch replace sessionSwitcherOpen; SettingsSection.Keyboard.
  • notifications: the aggregate attention notification opens the palette.
  • panels: goToProjectHome exported so the palette and the existing surfaces share one implementation; SettingsDialog takes the injected Keyboard section.
  • packages/locales/en.json: search.*, commands.*, settings.keyboard.*, shell.search.*; the orphaned shell.sessionSwitcher.* keys removed from every catalog (en, es, ja, zh-CN).
  • e2e: session-switcher.spec.ts → search-palette.spec.ts (empty state, title search, commands with shortcuts, a Settings section row + ⌘K closes, double Shift on/off, the double-Shift row → Settings › Keyboard + inert Ctrl+R, IME-owned keys, keys after a click outside the input, Tab/Alt+n/chips and the preview chord).
  • Specs: architecture Decision 3, module-contracts, module-web (table + edges), new submodule-web-search / submodule-web-commands, submodule-web-shell, submodule-web-store, submodule-web-notifications; README gains the capability bullet.

Not in this PR (the rest of the stack): host search over files, specs, plans and transcripts with the preview body (PR2), start work from the typed text (PR3), chats without a project (PR4), continue in workspace (PR5).

Screenshots

Same fixture (two chats needing attention in sample-project), 1280×800.

Before — ⌘K session switcher (main) After — ⌘K palette, empty query (rebased build)
The former Mod+K session switcher: a "Switch session" dialog listing only the two chats needing attention The search palette's empty state: Waiting for you (all projects) with the two chats, then Actions with shortcuts
After — typing auth After — the > commands scope
Typing "auth": a Top hit row with the match highlighted, then the Chats group The ">" prefix selects the Commands chip and lists commands and Settings sections grouped, with their shortcuts

Testing

All on the final head (4806a8a25, the review head 8e0a3631b rebased onto main 7b07d5c5f) unless noted:

  • bun run lint, check:deps, check:boundaries, check:seams, check:spec-surface, check:i18n, check:compiler → green
  • bunx turbo run typecheck --filter='!@thinkrail/desktop' → 24/24 green
  • apps/web bun test → 2011 pass; bun run test (all 25 packages) → green
  • bun run e2e (full no-agent suite, 8 shards) → all passed on the final head, and on every review head before it (e2aaf2f2b, 11f6fb767, 9fa0cd0ba, e8847d5dd, 4deabef3f, 4515f6b10, 8e0a3631b)
  • bun run e2e e2e/search-palette.spec.ts e2e/chat-title-fallback.spec.ts --repeat-each=5 → 55 passed (review head); the new e2e cases (IME keys, keys after a click outside the input, scope keys and the preview chord) fail with their fix removed
  • New unit coverage fails with each guard removed: matcher literals and folding, learning, Waiting cap, backfill queue (concurrency, rerun), backfill against deferred replies (rename in flight, reconnect, stale reply, refusal vs timeout, a session joining a workspace), project membership merge, navigation supersede

Related issues

Supersedes the "type-to-filter command palette" deferral in #613.

Checklist

  • Fast gates pass: bun run lint, bun run typecheck, bun run test
  • E2E suite passes for app-affecting changes (bun run e2e, or bun run e2e:full when touching agent behavior)
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline findings.

Comment thread apps/web/src/shell/useSearchPalette.ts Outdated
Comment thread apps/web/src/shell/useSearchPalette.ts
Comment thread apps/web/src/search/SearchPalette.tsx Outdated
Comment thread apps/web/src/shell/paletteCatalog.ts Outdated
Comment thread apps/web/src/search/query.ts Outdated

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

Comment thread apps/web/src/shell/useSearchPalette.ts Outdated
Comment thread apps/web/src/shell/useSearchPalette.ts Outdated
@danyaberezun

Copy link
Copy Markdown
Collaborator Author

CI note: the only failing job is E2E (no-agent) shard 1/2, always on e2e/ask-user-question.spec.ts:129 ("a persisted tall questionnaire … without hidden review focus" — firstOption not focused). That test is unrelated to the palette: it fails the same way on main's own #769 run (Desktop shard 1/4), passes locally 6/6 with --repeat-each 6 on this branch, and the failure's page snapshot shows no palette or search input mounted (the focus never lands on the option after openPersistedChat on the Linux runner). Every other job is green, including the full no-agent suite locally (613/614 before the fix commit, the one failure being the pre-existing visualize.spec.ts:581 huge-diff timing that fails identically on main under 8-way load).

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

Comment thread apps/web/src/shell/useSearchPalette.ts Outdated

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — ready to merge.

`Mod+K`, double Shift and a topbar button open one palette that finds chats, workspaces and projects
across every open project, runs commands with their shortcuts shown, deep-links Settings sections and
toggles MCP servers and extensions in place. The empty query keeps the former switcher's role as the
"Waiting for you" group next to Recent and Actions.

Two new web sub-modules: `commands` (the typed contract an invocable action is described with;
handlers stay with their owners) and `search` (props-driven palette, prefix/filter grammar, tiered
ranking over the shared matcher with decay-free frecency and learned query→pick, client-local
preferences, the double-Shift state machine). Shell composes the catalog, pages and row actions and
injects them; `panels` import neither module.
…ions and module specs

Also the README capability line for the palette.
…ne terminal, IME, protocol gates, quoted filters

- Chat-title backfill now covers every untitled workspace (active and context-project first) four
  requests at a time, re-asks a workspace at most once a minute and keeps the titles across opens,
  instead of stopping at the first 24 forever.
- `terminal.new` is a host mutation (placement reserves on the host), so it is disabled offline.
- The action panel's Enter ignores an IME composition like the main input does.
- `settings.extensions` / `settings.mcp` follow the same protocol gates as their Settings sections,
  and the extension catalog / MCP list are requested only when the host serves them.
- Quoted spans are tokenized before state filters, so `"is:running"` stays literal text.
Drop the inline Theme, Extensions and MCP pages with their page stack, the
per-row action panel, in-place toggles and the is:/has: filter grammar.
Every setting row now opens its Settings section, the double-Shift row
included; Mod+K inside the palette closes it; a command always runs.
… keep the title backfill current

The palette's backfill now loads the workspace list of every open project
it has not seen — not only projects that already have session-state
records — so a collapsed project's workspaces are searchable, and it
re-reads its queue whenever the normalized snapshot gains or loses a
workspace, so a palette opened during cold-start hydration catches up.
…eys, IME, bounded backfill and learning, literal quotes, Settle gate

- Keys live on the dialog frame, not the input: a click on a header, the footer or the preview no longer
  leaves Escape, Mod+K and the arrows dead; Escape comes through Radix's `onEscapeKeyDown`, so a
  synthetic close-item Escape still closes and a nested layer keeps its own.
- An active IME owns every key (`isComposing` or the 229 sentinel, also used by double Shift); plain
  Ctrl+R only is swallowed, so Ctrl+Shift+R reloads.
- Title backfill: one `session.list` per workspace until its session set changes (a failed read
  included), workspaces whose chats are all titled are skipped, `workspace.list` skips diff stats, and both
  queues keep four requests in flight across palette openings (`backfillQueue`, unit-tested); the queues
  re-read on welcome, open-project and workspace changes.
- Learning: adaptive counts are computed once per query and keyed on the text without its scope prefix;
  a query keeps its 32 most recent picks.
- Matcher: a quoted term is a contiguous literal (no acronym or subsequence), an unterminated quote runs
  to the end, and case plus Latin accents fold without moving highlight offsets (İ, final sigma).
- Waiting for you leaves running chats out and shows eight rows before an in-place "Show N more";
  a notification click resets an already-open palette (`searchSession`).
- Settle is disabled with the tree's reason while an agent works or a chat needs you
  (`Command.disabledReason`); the palette offers Start a new project and Open a path on the host, shows
  the native Settings shortcut, and says Connecting rather than Disconnected before the first welcome.
- Sections are named groups; the chips and the Preview toggle use the shared Button (`aria-pressed`).
- Tests: matcher, learning, sections, backfill queue, catalog gates and store session units; e2e for
  IME keys, focus outside the input and scope switching; double Shift is dispatched deterministically and
  the bottom-panel case waits for its known initial state; fixtures follow #869's input state.
…sh a project list that misses a reported workspace

- The palette's title backfill no longer keeps a private title cache: `session.list` results go through
  `noteClosedChats` into the workspace's history projection (the same rows reconciliation writes), so
  the rename broadcast every client receives updates a chat this client never opened; a reply from an
  earlier connection is dropped and retried on the next schedule.
- A project whose list exists but lacks a workspace the session snapshot reports (created by a peer
  while this client was away) is listed again, and the reply is merged with `addWorkspace` so rows that
  arrived meanwhile are never replaced.
- `paletteBackfill.ts` holds the ordering, membership and install rules, covered with the real store:
  rename after backfill, missed membership, no clobbering, backfill order.
…leave Mod+Shift+I and Mod+Alt+I to the browser

- A workspace's listing is keyed on the welcome generation too, so a reconnect lists it again; a timeout
  or a dropped connection no longer marks it listed, only a host-refused read does.
- `Mod+I` toggles the preview only without Shift or Alt, so the DevTools chords pass through.
- search/SPEC lists `@thinkrail/locales` among the allowed dependencies.
… reconnects, and merge listed workspaces in the host's order

- A `session.list` reply is installed only if no rename landed while it was in flight
  (`sessionTitleRevision`, bumped on every `session_info_changed`) and the connection is the same;
  otherwise it is dropped and asked again.
- After a reconnect, workspaces the palette listed before are listed once more, so a rename missed
  while away reaches their rows; the effect also re-reads when a workspace gains or loses a session.
- The queue runs a key once more after it lands when it was asked for while in flight (a reply
  dropped across a reconnect no longer waits for the palette to reopen).
- A refreshed project list keeps the host's creation order, holds on to rows that arrived meanwhile
  and never replaces a row the client already has.
- `selectKnownChatTitle` lets an unnamed layout tab yield to the history row's title.
- `createPaletteBackfill` owns the queues and listing keys with injected sources; deferred-reply tests
  cover a rename in flight, a reconnect, a stale reply and refusal vs timeout.
…for another trigger

The backfill now watches the store itself while the palette is open (`watch()`), keyed on the welcome
generation, the open projects, each workspace's session count and `sessionTitleRevision`, so a listing
dropped because a rename landed in flight is asked again on its own; the hook only mounts the watch.
Tests drive the store instead of calling `schedule()` by hand, including a session joining an existing
workspace.
…chat in it

A chat row can point at a workspace whose list was never loaded (the palette lists chats from the
session snapshot), and picking it unmounts the palette, dropping its queued listings. Opening it now
loads the project's list first when the row is missing, and still opens if that fails, so the
location bar, the URL and the tree highlight see the workspace.
…ation, loading the list without diff stats

A chat whose workspace row is missing first loads its project's list, now without diff stats and
merged in the host's order (the backfill's merge), and opens only if no later pick or navigation
(workspace, Project Home or tab) happened while it loaded (`paletteNavigation.ts`, unit-tested with
deferred lists).
…s overtaken

The overtaken check compared the active tab, which a route target from an earlier pick changes on its
own when its chat finishes opening; it now compares the workspace's navigation tick, which only user
navigation advances, so a legitimate pick is no longer dropped while a user tab switch still is.
…s as later picks

Work in project folder entered the Default workspace without a stillWanted guard, so a late
workspace.list reply could pull the user back after they chose another workspace; it now passes the
navigator's pick guard. A project pick also supersedes a chat pick still loading its project, so
picking the Project Home already shown is not undone by an older pick landing.
@danyaberezun
danyaberezun force-pushed the global-search-research branch from 8e0a363 to 4806a8a Compare October 28, 2026 13:23
@danyaberezun

Copy link
Copy Markdown
Collaborator Author

@jetbrains-air please review — rebased onto main 7b07d5c5f (#965, #975, #978, #979); the only conflict was the store SPEC's list of Settings sections, and the stack's protocol versions moved up by one above main's v95. Full no-agent e2e (8 shards) and bun run test pass on 4806a8a25.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant