From cb4098f2bcf727a504c514a9aff2279082aa34cd Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:26:13 +0000 Subject: [PATCH 1/7] fix(search): a whole-word title match no longer drops below busy prefix matches Thread use multiplied the score of every word-start match, so a series of busy threads whose titles only start with the query ("Authentication cleanup N" for "auth") outranked an old thread whose title holds the word ("Share auth across worktrees"). The server's 15 older-settled title rows then cut it, and mobile search never showed it. Within a group, rankSearchGroups now puts whole-word matches above prefix matches after picks and before use. Picks keep their precedence, and use still orders whole words or prefixes among themselves. Commands and settings score by quality alone, so their order is unchanged. Fixes #31873 Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/paletteSearch.test.ts | 10 ++++++++++ packages/shared/src/paletteSearch.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index d30b3189ee42..ca40198dbbb4 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -40,6 +40,16 @@ describe("scoreThreadSearch", () => { thread("Share auth across worktrees"), ]), ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); + // Use never lifts a prefix above a whole word, so a busy series cannot bury an old thread. + expect( + rankTitles("auth", [ + thread("Authentication cleanup", { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread("Share auth across worktrees", { activityAt: NOW - 90 * DAY }), + ]), + ).toEqual(["Share auth across worktrees", "Authentication cleanup"]); }); 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..b1ea49ba77d2 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -1173,12 +1173,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.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( From 0c4858047feb547ca5c69b667f2ea286d4c6dd07 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:34:22 +0000 Subject: [PATCH 2/7] fix(search): classify whole words before phrase bonuses Carry whole-word matching through the field scorer so a multiword prefix phrase cannot gain the same ranking priority from its phrase bonus. Extend the existing regression with adjacent and separated whole-word queries. --- packages/shared/src/paletteSearch.test.ts | 26 ++++++++++++++--------- packages/shared/src/paletteSearch.ts | 10 +++++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index ca40198dbbb4..bc7c39884819 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -40,16 +40,22 @@ describe("scoreThreadSearch", () => { thread("Share auth across worktrees"), ]), ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); - // Use never lifts a prefix above a whole word, so a busy series cannot bury an old thread. - expect( - rankTitles("auth", [ - thread("Authentication cleanup", { - activityAt: NOW, - opens: { openedAt: NOW, score: 12, picks: [] }, - }), - thread("Share auth across worktrees", { activityAt: NOW - 90 * DAY }), - ]), - ).toEqual(["Share auth across worktrees", "Authentication cleanup"]); + for (const [query, prefix, wholeWords] of [ + ["auth", "Authentication cleanup", "Share auth across worktrees"], + ["share auth", "Share authentication cleanup", "Share auth across worktrees"], + ["share auth", "Share authentication cleanup", "Auth across worktrees to share"], + ] as const) { + expect( + rankTitles(query, [ + thread(prefix, { + activityAt: NOW, + opens: { openedAt: NOW, score: 12, picks: [] }, + }), + thread(wholeWords, { activityAt: NOW - 90 * DAY }), + ]), + `${query}: ${wholeWords}`, + ).toEqual([wholeWords, prefix]); + } }); 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 b1ea49ba77d2..c0a965b674ae 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -344,6 +344,8 @@ interface WeightedSearchField { interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; + /** Every counted token matched a whole word at full weight, before phrase bonuses. */ + 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. */ @@ -441,6 +443,7 @@ function matchSearchFields( } return { quality, + wholeWords: total >= counted * WHOLE_WORD, strong, primaryRanges: mergeRanges(primaryRanges), }; @@ -851,7 +854,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 +891,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 +1047,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 +1151,7 @@ export function rankSearchGroups< readonly pickedAt?: number; readonly pickedExactly?: boolean; readonly groupQuality?: number; + readonly wholeWords?: boolean; }, >( groups: ReadonlyArray<{ readonly group: G; readonly items: ReadonlyArray }>, @@ -1174,7 +1180,7 @@ export function rankSearchGroups< 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.quality >= 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) || From 3275d0e4395d06f3ae3555ac4f6c95008ae4b750 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:18:07 +0000 Subject: [PATCH 3/7] test(search): keep the one regression row that needs the new classification Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/paletteSearch.test.ts | 25 ++++++++--------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index bc7c39884819..96bd1194c4cc 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -40,22 +40,15 @@ describe("scoreThreadSearch", () => { thread("Share auth across worktrees"), ]), ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]); - for (const [query, prefix, wholeWords] of [ - ["auth", "Authentication cleanup", "Share auth across worktrees"], - ["share auth", "Share authentication cleanup", "Share auth across worktrees"], - ["share auth", "Share authentication cleanup", "Auth across worktrees to share"], - ] as const) { - expect( - rankTitles(query, [ - thread(prefix, { - activityAt: NOW, - opens: { openedAt: NOW, score: 12, picks: [] }, - }), - thread(wholeWords, { activityAt: NOW - 90 * DAY }), - ]), - `${query}: ${wholeWords}`, - ).toEqual([wholeWords, prefix]); - } + 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"]); }); it("treats camelCase, kebab, path, and digit boundaries as word starts", () => { From e650554b02dde617473627ce47316406556414bd Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:55:45 +0000 Subject: [PATCH 4/7] fix(search): keep busy threads first for queries under three letters Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/paletteSearch.test.ts | 6 ++++++ packages/shared/src/paletteSearch.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index 96bd1194c4cc..1e1ad5b8520b 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -49,6 +49,12 @@ describe("scoreThreadSearch", () => { 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"]); }); 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 c0a965b674ae..f4eb3f8b6f1e 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -344,7 +344,10 @@ interface WeightedSearchField { interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; - /** Every counted token matched a whole word at full weight, before phrase bonuses. */ + /** + * Every counted token matched a whole word at full weight, before phrase + * bonuses, and has three or more letters: "a" or "ui" is too little to go on. + */ readonly wholeWords: boolean; /** Every token matched at a word start, without a typo. */ readonly strong: boolean; @@ -409,6 +412,7 @@ function matchSearchFields( const primaryRanges: SearchRange[] = []; let total = 0; let counted = 0; + let shortToken = false; let strong = true; for (const token of tokens) { let best = 0; @@ -435,6 +439,7 @@ function matchSearchFields( if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); total += best; counted += 1; + shortToken ||= token.length < PICK_PREFIX_MIN_LENGTH; } let quality = total / counted; if (primary) { @@ -443,7 +448,7 @@ function matchSearchFields( } return { quality, - wholeWords: total >= counted * WHOLE_WORD, + wholeWords: !shortToken && total >= counted * WHOLE_WORD, strong, primaryRanges: mergeRanges(primaryRanges), }; From 0c43aa2c11b027981f47a1492b5a3133319d4a7d Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:07:49 +0000 Subject: [PATCH 5/7] fix(search): filler words don't count as short query words Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/paletteSearch.test.ts | 9 +++++++++ packages/shared/src/paletteSearch.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index 1e1ad5b8520b..141dad376d3e 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -55,6 +55,15 @@ describe("scoreThreadSearch", () => { 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: [] }, + }), + ]), + ).toEqual(["Fix crash in sidebar", "Fix sidebar"]); }); 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 f4eb3f8b6f1e..cd7e6e24a8c5 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -439,7 +439,8 @@ function matchSearchFields( if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); total += best; counted += 1; - shortToken ||= token.length < PICK_PREFIX_MIN_LENGTH; + shortToken ||= + token.length < PICK_PREFIX_MIN_LENGTH && !(fillerOptional && FILLER_WORDS.has(token)); } let quality = total / counted; if (primary) { From 79b9b0b0aa93b1254b2ac00d097783d246ef4b01 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:16:56 +0000 Subject: [PATCH 6/7] docs(search): clarify the optional filler-word exception --- packages/shared/src/paletteSearch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts index cd7e6e24a8c5..a6970def7b16 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -345,8 +345,8 @@ interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; /** - * Every counted token matched a whole word at full weight, before phrase - * bonuses, and has three or more letters: "a" or "ui" is too little to go on. + * Every counted token matched a whole word at full weight before phrase bonuses. + * Tokens have at least three characters, except optional filler words. */ readonly wholeWords: boolean; /** Every token matched at a word start, without a typo. */ From 44dea106b126c26f829526f1df06e50e9a3a239b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:23:18 +0000 Subject: [PATCH 7/7] fix(search): filler words never decide the whole-word tier Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/paletteSearch.test.ts | 15 ++++++++++++++- packages/shared/src/paletteSearch.ts | 13 +++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts index 141dad376d3e..4c00dd40571e 100644 --- a/packages/shared/src/paletteSearch.test.ts +++ b/packages/shared/src/paletteSearch.test.ts @@ -62,8 +62,21 @@ describe("scoreThreadSearch", () => { 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(["Fix crash in sidebar", "Fix sidebar"]); + ).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 a6970def7b16..c163482c3af7 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -345,8 +345,8 @@ interface SearchFieldsMatch { /** Roughly 0.3 (inside a word) to 1.5 (exact title). */ readonly quality: number; /** - * Every counted token matched a whole word at full weight before phrase bonuses. - * Tokens have at least three characters, except optional filler words. + * 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. */ @@ -412,7 +412,7 @@ function matchSearchFields( const primaryRanges: SearchRange[] = []; let total = 0; let counted = 0; - let shortToken = false; + let notWhole = false; let strong = true; for (const token of tokens) { let best = 0; @@ -439,8 +439,9 @@ function matchSearchFields( if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary])); total += best; counted += 1; - shortToken ||= - token.length < PICK_PREFIX_MIN_LENGTH && !(fillerOptional && FILLER_WORDS.has(token)); + // 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) { @@ -449,7 +450,7 @@ function matchSearchFields( } return { quality, - wholeWords: !shortToken && total >= counted * WHOLE_WORD, + wholeWords: !notWhole, strong, primaryRanges: mergeRanges(primaryRanges), };