diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index d30b3189ee42..4c00dd40571e 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -40,6 +40,43 @@ describe("scoreThreadSearch", () => { thread("Share auth across worktrees"), ]), ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); + expect( + rankTitles("share auth", [ + thread("Share authentication cleanup", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread("Auth across worktrees to share", { activityAt: NOW - 90 * DAY }), + ]), + ).toEqual(["Auth across worktrees to share", "Share authentication cleanup"]); + expect( + rankTitles("ui", [ + thread("Parse ui tokens", { activityAt: NOW - 200 * DAY }), + thread("Polish UIs", { activityAt: NOW, opens: { openedAt: NOW, score: 12, picks: [] } }), + ]), + ).toEqual(["Polish UIs", "Parse ui tokens"]); + expect( + rankTitles("fix in sidebar", [ + thread("Fix sidebar", { activityAt: NOW - 200 * DAY }), + thread("Fix crash in sidebar", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread("Fix sidebar input focus", { + activityAt: NOW, + opens: { openedAt: NOW, score: 6, picks: [] }, + }), + ]), + ).toEqual(["Fix crash in sidebar", "Fix sidebar input focus", "Fix sidebar"]); + expect( + rankTitles("and", [ + thread("Fix auth and sync", { activityAt: NOW - 200 * DAY }), + thread("Android build", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + ]), + ).toEqual(["Android build", "Fix auth and sync"]); }); it("treats camelCase, kebab, path, and digit boundaries as word starts", () => { diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts index af7d4ca48a0c..c163482c3af7 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -344,6 +344,11 @@ interface WeightedSearchField { interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; + /** + * Every non-filler token has three or more characters and matched a whole word at full + * weight, before phrase bonuses. Optional filler words don't count either way. + */ + readonly wholeWords: boolean; /** Every token matched at a word start, without a typo. */ readonly strong: boolean; /** Source ranges matched in the first field, for highlighting. */ @@ -407,6 +412,7 @@ function matchSearchFields( const primaryRanges: SearchRange[] = []; let total = 0; let counted = 0; + let notWhole = false; let strong = true; for (const token of tokens) { let best = 0; @@ -433,6 +439,9 @@ function matchSearchFields( if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); total += best; counted += 1; + // Optional filler never decides the whole-word tier; a query of only filler never earns it. + if (FILLER_WORDS.has(token)) notWhole ||= !fillerOptional; + else notWhole ||= token.length < PICK_PREFIX_MIN_LENGTH || best < WHOLE_WORD; } let quality = total / counted; if (primary) { @@ -441,6 +450,7 @@ function matchSearchFields( } return { quality, + wholeWords: !notWhole, strong, primaryRanges: mergeRanges(primaryRanges), }; @@ -851,7 +861,7 @@ function matchReference( }; return (thread.id && matches(thread.id)) || thread.pullRequests?.some((pullRequest) => pullRequest.references?.some(matches)) - ? { quality: 0.6, strong: true, primaryRanges: [] } + ? { quality: 0.6, wholeWords: false, strong: true, primaryRanges: [] } : null; } @@ -888,6 +898,7 @@ export interface ThreadSearchMatch { readonly score: number; /** Match quality alone; see `rankSearchGroups`. */ readonly quality: number; + readonly wholeWords: boolean; /** Every token matched at a word start; see `rankSearchGroups`. */ readonly strong: boolean; /** Matched ranges in the title. */ @@ -1043,6 +1054,7 @@ export function scoreThreadSearch( return { score: match.quality * threadUsageBoost(thread, now), quality: match.quality, + wholeWords: match.wholeWords, // A thread's whole title never lifts its group over a command or project // of that name: every new thread is titled "New thread" for a while. groupQuality: Math.min(match.quality, WHOLE_NAME - 0.01), @@ -1146,6 +1158,7 @@ export function rankSearchGroups< readonly pickedAt?: number; readonly pickedExactly?: boolean; readonly groupQuality?: number; + readonly wholeWords?: boolean; }, >( groups: ReadonlyArray<{ readonly group: G; readonly items: ReadonlyArray }>, @@ -1173,12 +1186,15 @@ export function rankSearchGroups< // A longer-query pick must not pass a whole-name match for the current query. const wordMatch = (match: M) => Math.min(matchClass(match.quality), 2); const exactPickAt = (match: M) => (match.pickedExactly ? (match.pickedAt ?? 0) : 0); + // Use reorders whole words or prefixes among themselves, never a prefix above a whole word. + const wholeWord = (match: M) => Number(match.wholeWords ?? match.quality >= WHOLE_WORD); const sorted = [...items].sort( (left, right) => wordMatch(right.match) - wordMatch(left.match) || exactPickAt(right.match) - exactPickAt(left.match) || matchClass(right.match.quality) - matchClass(left.match.quality) || (right.match.pickedAt ?? 0) - (left.match.pickedAt ?? 0) || + wholeWord(right.match) - wholeWord(left.match) || right.match.score - left.match.score, ); const best = Math.max(