Skip to content

feat(nav): global command palette with ranked cross-Space search - #115

Open
Ayush7614 wants to merge 1 commit into
CopilotKit:mainfrom
Ayush7614:feat/global-command-palette
Open

Ayush7614 wants to merge 1 commit into
CopilotKit:mainfrom
Ayush7614:feat/global-command-palette

Conversation

@Ayush7614

Copy link
Copy Markdown

Workflow enabled

The Space library only searches within one Space, and there is no keyboard-first way to jump between pages, Spaces, Dots, or actions. This PR adds a Ctrl/Command-K command palette over a new ranked search API:

  • Cross-Space search: tokenized scoring with title/prefix weighting, subsequence fallback for typos, and recency tiebreaks. Pages return bounded 160-char excerpts (no full-content dumps); Spaces and Dots are covered; conversations are intentionally excluded.
  • Palette UX: topbar Search button + global Ctrl/Command-K shortcut, debounced fetching with abort, grouped Pages/Spaces/Dots/Commands, full keyboard support (up/down/enter/escape), ARIA dialog/listbox roles, loading/empty/error states, and a narrow-screen layout.
  • Commands: new page in this Space (created via the pages API), new Space dialog, library navigation, and Dot chat switching.

No existing issue or PR covers this (verified: no palette/command/global-search text in the codebase or in open issues/PRs). Original work on a fresh branch from upstream main, independent of any other branch.

What changed

Server: new src/server/search.ts (pure ranking + excerpt helpers, dependency-free) and GET /api/search?q=&limit= in workspace routes (q 2-200 chars, limit capped at 50, inherits owner-auth + origin checks).
Client: new src/client/CommandPalette.tsx, topbar trigger + shortcut + palette mounting in App.tsx, palette styles in style.css, README Navigation row.
Tests: tests/search.test.ts (7 cases) + tests/command-palette.test.tsx (3 cases).

Verification evidence

All run locally on Node 24 with fixture-backed stores only:

  • prettier --check — pass
  • npm run lint — pass
  • tsc --noEmit — pass
  • npm test — 98 files / 614 tests passed, including 10 new search/palette cases
  • npm run build — pass (vite production build)
  • Manual paths exercised via component tests: closed render, command execution with Enter, debounced search + ArrowDown/Escape. Narrow-screen CSS collapses the palette trigger to an icon and stacks results.

No Intelligence/model/Slack/voice path touched; no credentials, databases, or planning notes committed.

Endpoint

  • GET /api/search?q=<2-200 chars>&limit=<1-50, default 20> -> { query, pages[{id, spaceId, spaceName, title, excerpt, updatedAt, score}], spaces, dots }

The Space library only searches within one Space and there is no
keyboard-first way to jump between pages, Spaces, Dots, or actions.
This adds a Ctrl/Command-K palette over a new ranked search API.

Server (src/server/search.ts, workspace-routes.ts):
- Tokenized scoring with title/prefix weighting, subsequence fallback,
  and recency tiebreaks; excerpts bounded to 160 chars.
- searchWorkspace covers every Space's pages plus Spaces and Dots
  (conversations intentionally excluded).
- GET /api/search?q=&limit= validates q (2-200 chars) and caps limit
  at 50; inherits the existing owner-auth and origin checks.

Client (CommandPalette.tsx, App.tsx, style.css):
- Palette button in the topbar plus a global Ctrl/Command-K shortcut.
- Debounced search with abort, grouped Pages/Spaces/Dots/Commands,
  full keyboard support (up/down/enter/escape), ARIA dialog/listbox
  roles, empty/loading/error states, and a narrow-screen layout.
- Commands: new page in this Space (created via the pages API),
  new Space dialog, library navigation, and Dot chat switching.

Tests:
- tests/search.test.ts (7 cases): tokenizing, title/prefix ranking,
  excerpt bounds, cross-Space ranking, multi-token overlap guard,
  API validation/limits, and owner-auth gating.
- tests/command-palette.test.tsx (3 cases): closed render, command
  execution via Enter, and debounced search with keyboard dismiss.

Verification: prettier, eslint, tsc --noEmit, vitest (98 files /
614 tests passed), vite production build. Fixture-backed tests only;
no Intelligence/model/voice path touched.

@paoValle paoValle left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Read the whole diff and ran the branch locally (Node 22.23.3): npm run typecheck, npm run lint, npm run check-format clean, npm test → 50 files / 312 tests pass (two consecutive full runs; one earlier full run failed in tests/telemetry.test.ts, which passes in isolation and is the subprocess test #121 raises to a 15 s timeout — a load flake, not this PR).

1. Accented and non-Latin queries return nothing. tokenize() splits on [^a-z0-9]+, which treats every letter outside ASCII as a separator, so the tokens can come out empty and searchWorkspace bails at if (!tokens.length). Probe on this branch, with a page titled "Configuração do café" whose content is "A ação do agente e a sessão.":

tokenize(ação) -> []
tokenize(café) -> ["caf"]
tokenize(não) -> []
tokenize(агенты) -> []
search ação -> 0 hit(s)
search café -> 1 hit(s)
search configuração -> 1 hit(s)

Searching a common Portuguese word silently returns "No matches" in a UI that ships pt-BR/es/fr. split(/[^\p{L}\p{N}]+/u) (plus the existing toLocaleLowerCase()) fixes it; tests/search.test.ts only feeds ASCII, which is why nothing caught it.

2. A stale response can be displayed and acted on. In CommandPalette.tsx the previous request is aborted inside the next debounce tick (line 78), not when the query changes, so a response for ab can land while the input already reads abc, and Enter then opens the older query's hit. Aborting in the effect cleanup (line 99) and not clearing the loading state from an aborted request closes the window.

3. Accessibility of the new dialog. role="dialog" aria-modal="true" is declared, but Tab still walks into the page behind the backdrop and focus is not returned to the trigger when the palette closes. The listbox also owns group divs, not just options. The repo labels everything else carefully, so this is the one place I would finish before merge.

Measured cost, for the record: 400 pages × 9,750 chars (≈3.9 MB) → 30.3 ms for q="keyword", 27.6 ms for q="lorem ipsum". Fine for a personal workspace, and the slice(0, 20000) body cap is what keeps it bounded — worth one comment in the code, since a page whose only match sits past character 20,000 is invisible to search.

Good: /search validates q/limit and caps results, trashed pages are excluded through pages.list(), the fuzzy subsequence path is clearly marked cheap, and the tests cover ranking plus the keyboard flow.

Comment thread src/server/search.ts
export function tokenize(query: string): string[] {
return query
.toLocaleLowerCase()
.split(/[^a-z0-9]+/i)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[^a-z0-9] treats every non-ASCII letter as a separator, so tokenize('ação') and tokenize('não') return [] and the endpoint answers with no results (verified with a throwaway probe on this branch: search ação -> 0 hit(s) against a page containing "A ação do agente"; tokenize('агенты') -> [] too). In a UI that ships pt-BR/es/fr that is a silent dead end. split(/[^\p{L}\p{N}]+/u) keeps the accented words; tests/search.test.ts only uses ASCII today.

Comment thread src/server/search.ts
tokens: string[],
): { score: number; matched: number } {
const lowerTitle = title.toLocaleLowerCase();
const lowerBody = body.toLocaleLowerCase().slice(0, 20000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The 20,000-character body cap is invisible in the API: a page whose only match sits past character 20,000 is simply not found. Measured on this branch, the scan costs ~30 ms for 400 pages × 9,750 chars, so the cap is doing real work — a one-line comment here would say why it exists and what it costs the user.

})
.finally(() => setLoading(false));
}, 150);
return () => clearTimeout(timer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The in-flight request is only aborted when the next debounce tick fires (line 78), so a response for ab can arrive while the input already reads abc: stale rows are rendered and Enter opens the previous query's hit. Aborting here in the cleanup (abortRef.current?.abort()) closes the window; the aborted request's .finally(() => setLoading(false)) should then also be skipped so the spinner does not flicker off during the newer request.

<div
className="palette"
role="dialog"
aria-modal="true"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

aria-modal="true" promises the rest of the page is inert, but Tab still moves focus into the page behind the backdrop and closing does not return focus to the button that opened the palette. A focus trap plus restoring focus on close is what makes the dialog usable by keyboard alone.

</div>
<div
id="palette-results"
role="listbox"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The listbox owns group divs and header ps, not only role="option" children, so screen readers may not announce the options as a set. Either flatten the options into the listbox and mark the headers role="presentation", or use role="group" with an accessible name per group.

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.

2 participants