diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts index 57396e163..34e076ced 100644 --- a/packages/app/src/components/ui/combobox-options.test.ts +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -88,6 +88,39 @@ describe("filterAndRankComboboxOptions", () => { it("returns empty when nothing matches", () => { expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]); }); + + it("ranks word-boundary matches above mid-word substring matches", () => { + const items = [ + { id: "happy", label: "happy" }, + { id: "a/py", label: "a/py" }, + ]; + const result = filterAndRankComboboxOptions(items, "py"); + expect(result.map((o) => o.id)).toEqual(["a/py", "happy"]); + }); + + it("interleaves mixed branches and PRs by match quality", () => { + const items = [ + { id: "branch:feat/api-login", label: "feat/api-login" }, + { id: "branch:feat/pi-direct-sdk", label: "feat/pi-direct-sdk" }, + { id: "github-pr:202", label: "#202 feat(server): replace Pi ACP with direct SDK provider" }, + { id: "github-pr:355", label: "#355 feat: add LaTeX math formula rendering" }, + ]; + const result = filterAndRankComboboxOptions(items, "pi"); + expect(result.map((o) => o.id)).toEqual([ + "branch:feat/pi-direct-sdk", + "github-pr:202", + "branch:feat/api-login", + ]); + }); + + it("ranks PR-number matches via word-boundary on #", () => { + const items = [ + { id: "github-pr:202", label: "#202 some title" }, + { id: "github-pr:1202", label: "#1202 another title" }, + ]; + const result = filterAndRankComboboxOptions(items, "202"); + expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]); + }); }); describe("combobox above-search ordering", () => { diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index 8fc41832d..407c5bc99 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -1,3 +1,5 @@ +import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match"; + export type ComboboxOptionKind = "directory" | "file"; export interface ComboboxOptionModel { @@ -7,6 +9,23 @@ export interface ComboboxOptionModel { kind?: ComboboxOptionKind; } +const DESCRIPTION_FALLBACK_TIER = 99; + +function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { + const labelScore = scoreMatch(search, opt.label); + const idScore = scoreMatch(search, opt.id); + let best: MatchScore | null = null; + if (labelScore) best = labelScore; + if (idScore && (!best || compareMatchScores(idScore, best) < 0)) { + best = idScore; + } + if (best) return best; + if (opt.description && opt.description.toLowerCase().includes(search)) { + return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 }; + } + return null; +} + export interface BuildVisibleComboboxOptionsInput { options: ComboboxOptionModel[]; searchQuery: string; @@ -40,21 +59,17 @@ export function filterAndRankComboboxOptions( search: string, ): ComboboxOptionModel[] { if (!search) return options; - return options - .filter( - (opt) => - opt.label.toLowerCase().includes(search) || - opt.id.toLowerCase().includes(search) || - opt.description?.toLowerCase().includes(search), - ) - .sort((a, b) => { - const aPrefix = - a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search); - const bPrefix = - b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search); - if (aPrefix !== bPrefix) return aPrefix ? -1 : 1; - return 0; - }); + const scored: { opt: ComboboxOptionModel; score: MatchScore }[] = []; + for (const opt of options) { + const score = scoreOption(opt, search); + if (score) scored.push({ opt, score }); + } + scored.sort((a, b) => { + const cmp = compareMatchScores(a.score, b.score); + if (cmp !== 0) return cmp; + return a.opt.label.localeCompare(b.opt.label); + }); + return scored.map((entry) => entry.opt); } export function buildVisibleComboboxOptions( diff --git a/packages/app/src/utils/score-match.test.ts b/packages/app/src/utils/score-match.test.ts new file mode 100644 index 000000000..88177a2e7 --- /dev/null +++ b/packages/app/src/utils/score-match.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { compareMatchScores, scoreMatch } from "./score-match"; + +describe("scoreMatch", () => { + it("returns tier 0 for empty query", () => { + expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 }); + }); + + it("returns tier 0 for exact match ignoring case", () => { + expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 }); + expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 }); + expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 }); + }); + + it("returns tier 1 for whole-word match at start", () => { + expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 }); + }); + + it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => { + expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 }); + expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 }); + }); + + it("returns tier 2 for string prefix that does not complete a word", () => { + expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 }); + }); + + it("returns tier 3 for word-boundary start that does not complete a word", () => { + expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 }); + }); + + it("returns tier 4 for substring inside a word", () => { + expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 }); + }); + + it("returns null when query is not found", () => { + expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull(); + }); + + it("picks the best tier across multiple occurrences", () => { + expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 }); + expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 }); + }); + + it("prefers earlier offset when tiers are equal", () => { + expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 }); + }); + + it("treats common separators as word boundaries", () => { + expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 }); + expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 }); + }); + + it("scores PR-title-shaped text realistically", () => { + const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider"; + expect(scoreMatch("pi", pr202)?.tier).toBe(1); + expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 }); + expect(scoreMatch("replace", pr202)?.tier).toBe(1); + }); +}); + +describe("compareMatchScores", () => { + it("sorts by tier ascending", () => { + expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0); + expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0); + }); + + it("tie-breaks by offset ascending at the same tier", () => { + expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0); + expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0); + }); +}); diff --git a/packages/app/src/utils/score-match.ts b/packages/app/src/utils/score-match.ts new file mode 100644 index 000000000..790f95cfe --- /dev/null +++ b/packages/app/src/utils/score-match.ts @@ -0,0 +1,47 @@ +export interface MatchScore { + tier: number; + offset: number; +} + +function isWordBoundaryChar(ch: string | undefined): boolean { + if (ch === undefined) return true; + return !/[a-z0-9]/.test(ch); +} + +export function scoreMatch(query: string, text: string): MatchScore | null { + if (!query) return { tier: 0, offset: 0 }; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (t === q) return { tier: 0, offset: 0 }; + + let best: MatchScore | null = null; + let pos = 0; + while (pos <= t.length - q.length) { + const found = t.indexOf(q, pos); + if (found === -1) break; + const before = found > 0 ? t[found - 1] : undefined; + const after = t[found + q.length]; + const startsAtBoundary = found === 0 || isWordBoundaryChar(before); + const endsAtBoundary = after === undefined || isWordBoundaryChar(after); + let tier: number; + if (startsAtBoundary && endsAtBoundary) { + tier = 1; + } else if (found === 0) { + tier = 2; + } else if (startsAtBoundary) { + tier = 3; + } else { + tier = 4; + } + if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) { + best = { tier, offset: found }; + } + pos = found + 1; + } + return best; +} + +export function compareMatchScores(a: MatchScore, b: MatchScore): number { + if (a.tier !== b.tier) return a.tier - b.tier; + return a.offset - b.offset; +}