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:  **After** — matching projects are promoted into a "Projects" group above Tasks, names visible:  ## 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:
parent
4a2447da3c
commit
ef6061a5e6
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue