feat: project typeahead in ⌘K command palette (#8773)

## 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 <noreply@paperclip.ing>
This commit is contained in:
scotttong 2026-06-29 17:55:14 -07:00 committed by GitHub
parent 4a2447da3c
commit ef6061a5e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 145 additions and 3 deletions

View File

@ -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(<CommandPalette />, 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(<CommandPalette />, container);

View File

@ -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 (
<CommandDialog open={open} onOpenChange={(v) => {
@ -182,6 +239,30 @@ export function CommandPalette() {
{showSearchAll ? <CommandSeparator /> : null}
{showPromotedProjects && (
<>
<CommandGroup heading="Projects">
{matchedProjects.map((project) => (
<CommandItem
key={project.id}
value={`${searchQuery} ${project.name}`}
onSelect={() => go(projectUrl(project))}
data-testid="command-project-match"
>
<Hexagon className="mr-2 h-4 w-4 shrink-0" />
<span className="min-w-0 truncate">{project.name}</span>
{project.description ? (
<span className="ml-2 hidden min-w-0 flex-1 truncate text-xs text-muted-foreground sm:inline">
{project.description}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
@ -261,7 +342,7 @@ export function CommandPalette() {
<>
<CommandSeparator />
<CommandGroup heading="Tasks">
{visibleIssues.slice(0, 10).map((issue) => (
{visibleIssues.slice(0, taskLimit).map((issue) => (
<CommandItem
key={issue.id}
value={
@ -301,7 +382,7 @@ export function CommandPalette() {
</>
)}
{projects.length > 0 && (
{projects.length > 0 && !showSearchAll && (
<>
<CommandSeparator />
<CommandGroup heading="Projects">