Files
PaperClipAI/ui/src/components/CommandPalette.tsx
T
scotttongandPaperclip ef6061a5e6 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>
2026-06-29 17:55:14 -07:00

402 lines
14 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
CircleDot,
Bot,
Hexagon,
Target,
LayoutDashboard,
Inbox,
DollarSign,
History,
SquarePen,
FileCode2,
Plus,
Search,
} from "lucide-react";
import { Identity } from "./Identity";
import { agentUrl, projectUrl } from "../lib/utils";
const SEARCH_ALL_VALUE = "__paperclip-search-all__";
export function buildFullSearchPath(query: string) {
const trimmed = query.trim();
return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`;
}
const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/;
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("");
const navigate = useNavigate();
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue, openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const searchQuery = query.trim();
const onIssueDetail = isOnIssueDetail(location.pathname);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true;
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(true);
if (isMobile) setSidebarOpen(false);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isMobile, setSidebarOpen]);
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const { data: issues = [] } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open && searchQuery.length === 0,
});
const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }),
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
});
const { data: agents = [] } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const { data: allProjects = [] } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const projects = useMemo(
() => allProjects.filter((p) => !p.archivedAt),
[allProjects],
);
function go(path: string) {
setOpen(false);
navigate(path);
}
function goFullSearch() {
go(buildFullSearchPath(searchQuery));
}
const agentName = (id: string | null) => {
if (!id) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
const visibleIssues = useMemo(
() => (searchQuery.length > 0 ? searchedIssues : issues),
[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 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) => {
setOpen(v);
if (v && isMobile) setSidebarOpen(false);
}}>
<CommandInput
placeholder="Search tasks, agents, projects..."
value={query}
onValueChange={setQuery}
onKeyDown={(event) => {
if (event.key === "Enter" && showEmptyHint) {
event.preventDefault();
goFullSearch();
}
}}
/>
<CommandList>
<CommandEmpty>
{showSearchAll ? (
<span>
No quick task matches. Press{" "}
<kbd className="rounded border border-border bg-muted px-1 py-0.5 text-[10px]">↵</kbd>{" "}
to <span className="font-medium">search all</span> or keep typing to refine.
</span>
) : (
"No results found."
)}
</CommandEmpty>
{showSearchAll ? (
<CommandGroup heading="Search">
<CommandItem
value={`${SEARCH_ALL_VALUE} ${searchQuery}`}
onSelect={goFullSearch}
className="bg-accent/40 border border-accent data-[selected=true]:bg-accent/60"
data-testid="command-search-all"
>
<Search className="mr-2 h-4 w-4" />
<span className="flex-1 truncate">
Search all for <span className="font-semibold">&ldquo;{searchQuery}&rdquo;</span>
</span>
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
<span>open full search</span>
<kbd className="rounded border border-border bg-background px-1 py-0.5 text-[10px]">↵</kbd>
</span>
</CommandItem>
</CommandGroup>
) : null}
{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={() => {
setOpen(false);
openNewIssue();
}}
>
<SquarePen className="mr-2 h-4 w-4" />
Create new task
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
{onIssueDetail && fileViewerEnabled && (
<CommandItem
onSelect={() => {
setOpen(false);
window.dispatchEvent(new CustomEvent("paperclip:open-file-viewer"));
}}
>
<FileCode2 className="mr-2 h-4 w-4" />
Open file in this issue...
<span className="ml-auto text-xs text-muted-foreground">g f</span>
</CommandItem>
)}
<CommandItem
onSelect={() => {
setOpen(false);
openNewAgent();
}}
>
<Plus className="mr-2 h-4 w-4" />
Create new agent
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Pages">
<CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => go("/inbox")}>
<Inbox className="mr-2 h-4 w-4" />
Inbox
</CommandItem>
<CommandItem onSelect={() => go("/issues")}>
<CircleDot className="mr-2 h-4 w-4" />
Tasks
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Hexagon className="mr-2 h-4 w-4" />
Projects
</CommandItem>
<CommandItem onSelect={() => go("/goals")}>
<Target className="mr-2 h-4 w-4" />
Goals
</CommandItem>
<CommandItem onSelect={() => go("/agents")}>
<Bot className="mr-2 h-4 w-4" />
Agents
</CommandItem>
<CommandItem onSelect={() => go("/costs")}>
<DollarSign className="mr-2 h-4 w-4" />
Costs
</CommandItem>
<CommandItem onSelect={() => go("/activity")}>
<History className="mr-2 h-4 w-4" />
Activity
</CommandItem>
</CommandGroup>
{visibleIssues.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Tasks">
{visibleIssues.slice(0, taskLimit).map((issue) => (
<CommandItem
key={issue.id}
value={
searchQuery.length > 0
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title}`
: undefined
}
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
>
<CircleDot className="mr-2 h-4 w-4" />
<span className="text-muted-foreground mr-2 font-mono text-xs">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? <Identity name={name} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
})()}
</CommandItem>
))}
</CommandGroup>
</>
)}
{agents.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Agents">
{agents.slice(0, 10).map((agent) => (
<CommandItem key={agent.id} onSelect={() => go(agentUrl(agent))}>
<Bot className="mr-2 h-4 w-4" />
{agent.name}
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{projects.length > 0 && !showSearchAll && (
<>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.slice(0, 10).map((project) => (
<CommandItem key={project.id} onSelect={() => go(projectUrl(project))}>
<Hexagon className="mr-2 h-4 w-4" />
{project.name}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
);
}