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(
@@ -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);
});
});
+47
View File
@@ -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;
}