feat(app): rank combobox options by word-boundary match quality

Introduce a shared scoreMatch utility that tiers matches by
word-boundary vs mid-word offset, and use it to rank combobox
options so e.g. "py" prefers "a/py" over "happy".
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:51 +07:00
parent c4d84ff2a3
commit f411a2a383
4 changed files with 188 additions and 15 deletions
@@ -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", () => {
@@ -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(