fix(search): stop cmd+k boosts from lifting weaker matches over stronger ones (#6668)

This commit is contained in:
Justin Blumencranz
2026-08-14 10:50:52 -07:00
committed by GitHub
parent 0650eab6e0
commit 41819124d7
4 changed files with 133 additions and 10 deletions
@@ -364,6 +364,74 @@ describe('SearchModal', () => {
}
})
it('keeps a block above its same-name trigger for the exact-name query', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
tools: [
{
id: 'gmail',
name: 'Gmail',
icon: Icon,
bgColor: '#E8453C',
type: 'gmail',
searchValue: 'gmail gmail',
},
],
triggers: [{ id: 'gmail', name: 'Gmail', icon: Icon, bgColor: '#E8453C', type: 'gmail' }],
}
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
})
await enterSearchQuery('gmail')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Gmail')
expect(rows[0]).not.toContain('Gmail Trigger')
expect(rows[1]).toContain('Gmail Trigger')
} finally {
mockSearchState.data = original
}
})
it('ranks prefix-matched rows above actions that only contain the letter mid-word', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
mockSearchState.data = {
...mockSearchState.data,
tools: [
{
id: 'hex',
name: 'Hex',
icon: Icon,
bgColor: '#111',
type: 'hex',
searchValue: 'hex hex',
},
],
}
try {
await act(async () => {
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
})
await enterSearchQuery('h')
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
(el) => el.textContent ?? ''
)
expect(rows[0]).toContain('Hex')
expect(rows.findIndex((row) => row.includes('New chat'))).toBeGreaterThan(0)
} finally {
mockSearchState.data = original
}
})
it('puts the workflow verb actions first for their bare-verb queries', async () => {
const Icon = () => null
const original = { ...mockSearchState.data }
@@ -1054,6 +1054,9 @@ function SearchModalContent({
...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []),
...rankActionGroup(actionsByGroup.sim, 'Sim'),
]
const blockNames = new Set(
[...availableBlocks, ...availableTools].map((item) => item.name.toLowerCase())
)
return {
actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })),
@@ -1072,8 +1075,14 @@ function SearchModalContent({
section: 'triggers',
item,
/* The display rename ("Start" → "Start Trigger") costs the exact-name
bonus, so a query that IS the trigger's name ranks it like a page row. */
score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score,
bonus, so a query that IS the trigger's name ranks it like a page row
— unless a block shares that name (Gmail, Slack). Then the query names
the block first, and the lift would leapfrog its exact-name match. */
score:
item.baseName.toLowerCase() === query.toLowerCase() &&
!blockNames.has(item.baseName.toLowerCase())
? PAGE_MATCH_TIER
: score,
})),
tools: rank(
'tools',
@@ -100,6 +100,34 @@ describe('getGlobalSearchResults', () => {
).toEqual(['new-chat-action', 'new-chat-result'])
})
it('keeps a mid-word-matched action below word-start entity matches', () => {
const action = {
id: 'create-folder',
name: 'Create folder',
icon: () => null,
context: 'global' as const,
run: () => {},
}
const [actionMatch] = scoreActions([action], 'a')
const [blockMatch] = scoreAndSort([{ name: 'Airtable' }], (item) => item.name, 'a')
expect(actionMatch.score).toBeLessThan(blockMatch.score)
})
it('still biases a word-start action match above entity name matches', () => {
const action = {
id: 'create-workflow',
name: 'Create workflow',
icon: () => null,
context: 'global' as const,
run: () => {},
}
const [actionMatch] = scoreActions([action], 'w')
const [blockMatch] = scoreAndSort([{ name: 'Webhook' }], (item) => item.name, 'w')
expect(actionMatch.score).toBeGreaterThan(blockMatch.score)
})
it('breaks identical visible-name matches by the original section order', () => {
const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' }
const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' }
@@ -581,19 +581,32 @@ export function scoreSectionItems<T>(
}
/**
* Rank offset added to every matched action. Actions are the palette's few
* Rank offset added to a matched action. Actions are the palette's few
* runnable verbs, so a matched action outranks entity rows of the same match
* quality — a name-matched action beats name-matched entities, a
* keyword-matched action beats other secondary-text matches — while the
* half-tier offset deliberately cannot bridge into the next tier up
* ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}).
* ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}). A name hit that
* starts mid-word ("h" in "New chat") is NOT the same quality as the
* word-start matches the offset would leapfrog, so it forgoes the bias.
*/
export const ACTION_MATCH_BIAS = 500_000
/**
* Whether a match begins where a word begins — the string start, right after a
* separator, or at a camelCase hump. The empty query (no positions) counts as
* a word start.
*/
function isWordStartMatch(text: string, positions: readonly number[]): boolean {
if (positions.length === 0) return true
return isHardBoundary(text.toLowerCase(), positions[0]) || isCamelBoundary(text, positions[0])
}
/**
* Scores actions by visible name before falling back to their keywords.
* Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the
* action's `exactQueries` ranks it like a page row instead.
* Word-start matches are lifted by {@link ACTION_MATCH_BIAS}; a mid-word name
* hit keeps its honest score so word-start entity matches outrank it; a query
* listed in the action's `exactQueries` ranks it like a page row instead.
*/
export function scoreActions(
actions: ActionItem[],
@@ -609,10 +622,15 @@ export function scoreActions(
search,
(action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`,
maxResults
).map(({ item, score }) => ({
item,
score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS,
}))
).map(({ item, score }) => {
if (item.exactQueries?.includes(query)) return { item, score: PAGE_MATCH_TIER }
/* Section-lifted rows (the query IS the group label) keep the bias
wholesale — only plain name-tier scores are quality-checked. */
const byName = fuzzyMatch(item.name, query)
const midWordNameMatch =
score < SECTION_MATCH_TIER && byName.matched && !isWordStartMatch(item.name, byName.positions)
return { item, score: midWordNameMatch ? score : score + ACTION_MATCH_BIAS }
})
}
/**