From ef6061a5e6e7e5ccac6cbf5bfea73aac554a95d1 Mon Sep 17 00:00:00 2001 From: scotttong Date: Mon, 29 Jun 2026 17:55:14 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20project=20typeahead=20in=20=E2=8C=98K?= =?UTF-8?q?=20command=20palette=20(#8773)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The ⌘K command palette is the primary fast-navigation surface across tasks and projects > - When searching, projects were ranked below tasks, so typing a project name (e.g. "Paperclip") surfaced tasks first and buried matching projects — often only one project was visible even when several matched > - Users expect name-matching projects to appear at the top, like other quick-switchers > - This pull request promotes matching projects above tasks in the palette (with a sensible cap) and ensures the project name is always visible in the row > - The benefit is faster, more predictable project navigation from ⌘K ## Linked Issues or Issue Description No public GitHub issue exists for this, so the underlying problem is described inline below following the [feature request template](.github/ISSUE_TEMPLATE/feature_request.yml). **Subsystem affected** UI — the ⌘K command palette (`ui/src/components/CommandPalette.tsx`). **Problem or motivation** When typing in the ⌘K palette, projects were ranked *below* tasks. A query matching several projects (e.g. "paperclip") surfaced mostly tasks above the fold and pushed matching projects to the bottom of the list, so often only one — or zero — matching projects were visible. Users expect name-matching projects to appear at the top, the way other quick-switchers behave. **Proposed solution** Compute matching projects from the typed query client-side (exact > prefix > substring > description > fuzzy-subsequence ranking), promote them into a dedicated "Projects" group above the Tasks group, and cap both groups so neither crowds the other out. Keep the project name always visible in each row. **Alternatives considered** A dedicated "Projects" search mode/scope toggle — rejected as heavier and more UI for what is really a ranking problem. A backend search-ranking change — unnecessary since the project list is already loaded client-side. **Roadmap alignment** Small, self-contained UX improvement to an existing surface; not roadmap-level core feature work. ## What Changed - Added a `scoreProjectMatch` + `matchedProjects` memo in `CommandPalette` that ranks already-loaded projects against the typed query (exact > prefix > substring > description > fuzzy subsequence, capped at 5). - Promoted the matched "Projects" group above the Tasks group, and reduced the task cap from 10 → 6 when projects are promoted so neither group crowds the other out. Empty-query preview order is unchanged. - Fixed the project-name span so the name stays visible in the typeahead row (the name was being starved to 0px width by a flexible description span). ## Verification - `cd ui && npx vitest run CommandPalette` — added test seeds react-query project data and asserts matching projects are promoted to the top of the palette, non-matching projects are excluded, and selecting a promoted project navigates correctly (6/6 passing). - `pnpm --filter @paperclipai/ui typecheck` — passes. - Manual (screenshots below): open ⌘K, type "paperclip"; matching projects appear at the top with names visible. **Before** — typing "paperclip" surfaces Tasks first; matching projects are buried below the fold: ![before](https://azure-ponder-ry48.here.now/before.png) **After** — matching projects are promoted into a "Projects" group above Tasks, names visible: ![after](https://azure-ponder-ry48.here.now/after.png) ## Risks Low risk. UI-only change scoped to the command palette; no backend or data changes. Worst case is palette ordering for project-name queries. Ranking is pure and unit-tested; the prefix-score length penalty is clamped so the prefix > substring > description > fuzzy invariant holds even for pathologically long project names. ## Model Used Claude — claude-opus-4-8 (Opus 4.8), extended thinking, tool use (Claude Code agent). ## Checklist - [x] Thinking path included - [x] Model used specified - [x] Checked ROADMAP.md; small self-contained UX change - [x] Searched GitHub for duplicate/related PRs - [x] Described the issue in-PR (no public issue exists) - [x] No internal/instance-local references - [x] Branch name descriptive, no internal ticket id - [x] Tests run locally and pass - [x] Added/updated tests - [x] Screenshots included for the visual change - [ ] Docs updated (n/a — no user-facing docs for this) - [x] Risks documented - [ ] All CI gates green (verify after push) - [ ] Greptile 5/5 (drive after push) --------- Co-authored-by: Paperclip --- ui/src/components/CommandPalette.test.tsx | 61 ++++++++++++++++ ui/src/components/CommandPalette.tsx | 87 ++++++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index bdb71c67ff..3c4db32370 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -320,6 +320,67 @@ describe("CommandPalette", () => { }); }); + it("promotes matching projects above the Tasks group when typing", async () => { + const projects = [ + { id: "p1", urlKey: "mobile", name: "Mobile App", description: "iOS client", archivedAt: null }, + { id: "p2", urlKey: "billing", name: "Billing Service", description: null, archivedAt: null }, + ]; + mockProjectsApi.list.mockResolvedValue(projects); + mockIssuesApi.list.mockImplementation((_companyId: string, opts?: { q?: string }) => + Promise.resolve(opts?.q ? [{ id: "i1", identifier: "ENG-9", title: "Fix login" }] : []), + ); + + const { root } = renderWithQueryClient(, container, (queryClient) => { + // Seed the caches so the already-loaded data is available synchronously — + // this harness's flush model doesn't reliably propagate fresh async fetches. + queryClient.setQueryData(queryKeys.projects.list("company-1"), projects); + queryClient.setQueryData(queryKeys.issues.search("company-1", "mob", undefined, 10), [ + { id: "i1", identifier: "ENG-9", title: "Fix login" }, + ]); + }); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement; + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeSetter.call(input, "mob"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + const match = container.querySelector('button[data-testid="command-project-match"]'); + expect(match).not.toBeNull(); + expect(match!.textContent).toContain("Mobile App"); + }); + + // Non-matching project is excluded from the typeahead results. + expect(container.textContent).not.toContain("Billing Service"); + + // The promoted project renders above the fold — before the Tasks group. + await waitForAssertion(() => { + const text = container.textContent ?? ""; + expect(text).toContain("Fix login"); + expect(text.indexOf("Mobile App")).toBeLessThan(text.indexOf("Fix login")); + }); + + // Selecting the promoted project navigates to its URL. + act(() => { + container + .querySelector('button[data-testid="command-project-match"]')! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await waitForAssertion(() => { + expect(navigateState.navigate).toHaveBeenCalledWith("/projects/mobile"); + }); + + act(() => { + root.unmount(); + }); + }); + it("navigates to /search when the user clicks the Search-all command", async () => { mockIssuesApi.list.mockResolvedValue([]); const { root } = renderWithQueryClient(, container); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 42d6af17f9..384e522bb1 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -48,6 +48,39 @@ function isOnIssueDetail(pathname: string): boolean { return ISSUE_DETAIL_PATH_RE.test(pathname); } +/** Max promoted project matches kept when typing in the palette. */ +const MAX_MATCHED_PROJECTS = 5; +/** Task cap when projects are also promoted, so Tasks can't crowd them out. */ +const TASK_LIMIT_WITH_PROJECTS = 6; +const TASK_LIMIT = 10; + +/** True when every char of `needle` appears in `haystack` in order (fuzzy). */ +function isSubsequence(needle: string, haystack: string): boolean { + let i = 0; + for (let j = 0; j < haystack.length && i < needle.length; j += 1) { + if (haystack[j] === needle[i]) i += 1; + } + return i === needle.length; +} + +/** + * Score a project against a lowercased query. Higher is a better match; + * `null` means no match. Prefers name hits (exact > prefix > substring) over + * description hits, with fuzzy subsequence as a last resort. + */ +function scoreProjectMatch(name: string, description: string, q: string): number | null { + if (name === q) return 1000; + // Shorter names rank first, but clamp the length penalty so a prefix match can + // never sink below the substring band (max 699) for unusually long names — + // keeps the prefix > substring > description > fuzzy ordering invariant. + if (name.startsWith(q)) return Math.max(700, 900 - name.length); + const nameIdx = name.indexOf(q); + if (nameIdx >= 0) return 700 - nameIdx; + if (description.includes(q)) return 400; + if (isSubsequence(q, name)) return 200; + return null; +} + export function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -128,8 +161,32 @@ export function CommandPalette() { [issues, searchedIssues, searchQuery], ); + // Client-side typeahead ranking over the already-loaded projects. cmdk ranks + // items by their `value` (which defaults to the rendered name) and would bury + // or drop description-only matches, so we rank in JS and force-match below. + const matchedProjects = useMemo(() => { + if (searchQuery.length === 0) return []; + const q = searchQuery.toLowerCase(); + return projects + .map((project) => ({ + project, + score: scoreProjectMatch( + project.name.toLowerCase(), + (project.description ?? "").toLowerCase(), + q, + ), + })) + .filter((entry): entry is { project: (typeof projects)[number]; score: number } => entry.score !== null) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_MATCHED_PROJECTS) + .map((entry) => entry.project); + }, [projects, searchQuery]); + const showSearchAll = searchQuery.length > 0; - const showEmptyHint = showSearchAll && visibleIssues.length === 0; + const showPromotedProjects = showSearchAll && matchedProjects.length > 0; + const taskLimit = showPromotedProjects ? TASK_LIMIT_WITH_PROJECTS : TASK_LIMIT; + const showEmptyHint = + showSearchAll && visibleIssues.length === 0 && matchedProjects.length === 0; return ( { @@ -182,6 +239,30 @@ export function CommandPalette() { {showSearchAll ? : null} + {showPromotedProjects && ( + <> + + {matchedProjects.map((project) => ( + go(projectUrl(project))} + data-testid="command-project-match" + > + + {project.name} + {project.description ? ( + + {project.description} + + ) : null} + + ))} + + + + )} + { @@ -261,7 +342,7 @@ export function CommandPalette() { <> - {visibleIssues.slice(0, 10).map((issue) => ( + {visibleIssues.slice(0, taskLimit).map((issue) => ( )} - {projects.length > 0 && ( + {projects.length > 0 && !showSearchAll && ( <>