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 && (
<>