Repository navigation
Conversation
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
left a comment
There was a problem hiding this comment.
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.
| export function tokenize(query: string): string[] { | ||
| return query | ||
| .toLocaleLowerCase() | ||
| .split(/[^a-z0-9]+/i) |
There was a problem hiding this comment.
[^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.
| tokens: string[], | ||
| ): { score: number; matched: number } { | ||
| const lowerTitle = title.toLocaleLowerCase(); | ||
| const lowerBody = body.toLocaleLowerCase().slice(0, 20000); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
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:
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) andGET /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 inApp.tsx, palette styles instyle.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— passnpm run lint— passtsc --noEmit— passnpm test— 98 files / 614 tests passed, including 10 new search/palette casesnpm run build— pass (vite production build)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 }