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