Skip to content

Commit 4181912

Browse files
authored
fix(search): stop cmd+k boosts from lifting weaker matches over stronger ones (#6668)
1 parent 0650eab commit 4181912

4 files changed

Lines changed: 133 additions & 10 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,74 @@ describe('SearchModal', () => {
364364
}
365365
})
366366

367+
it('keeps a block above its same-name trigger for the exact-name query', async () => {
368+
const Icon = () => null
369+
const original = { ...mockSearchState.data }
370+
mockSearchState.data = {
371+
...mockSearchState.data,
372+
tools: [
373+
{
374+
id: 'gmail',
375+
name: 'Gmail',
376+
icon: Icon,
377+
bgColor: '#E8453C',
378+
type: 'gmail',
379+
searchValue: 'gmail gmail',
380+
},
381+
],
382+
triggers: [{ id: 'gmail', name: 'Gmail', icon: Icon, bgColor: '#E8453C', type: 'gmail' }],
383+
}
384+
385+
try {
386+
await act(async () => {
387+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
388+
})
389+
390+
await enterSearchQuery('gmail')
391+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
392+
(el) => el.textContent ?? ''
393+
)
394+
expect(rows[0]).toContain('Gmail')
395+
expect(rows[0]).not.toContain('Gmail Trigger')
396+
expect(rows[1]).toContain('Gmail Trigger')
397+
} finally {
398+
mockSearchState.data = original
399+
}
400+
})
401+
402+
it('ranks prefix-matched rows above actions that only contain the letter mid-word', async () => {
403+
const Icon = () => null
404+
const original = { ...mockSearchState.data }
405+
mockSearchState.data = {
406+
...mockSearchState.data,
407+
tools: [
408+
{
409+
id: 'hex',
410+
name: 'Hex',
411+
icon: Icon,
412+
bgColor: '#111',
413+
type: 'hex',
414+
searchValue: 'hex hex',
415+
},
416+
],
417+
}
418+
419+
try {
420+
await act(async () => {
421+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
422+
})
423+
424+
await enterSearchQuery('h')
425+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
426+
(el) => el.textContent ?? ''
427+
)
428+
expect(rows[0]).toContain('Hex')
429+
expect(rows.findIndex((row) => row.includes('New chat'))).toBeGreaterThan(0)
430+
} finally {
431+
mockSearchState.data = original
432+
}
433+
})
434+
367435
it('puts the workflow verb actions first for their bare-verb queries', async () => {
368436
const Icon = () => null
369437
const original = { ...mockSearchState.data }

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,9 @@ function SearchModalContent({
10541054
...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []),
10551055
...rankActionGroup(actionsByGroup.sim, 'Sim'),
10561056
]
1057+
const blockNames = new Set(
1058+
[...availableBlocks, ...availableTools].map((item) => item.name.toLowerCase())
1059+
)
10571060

10581061
return {
10591062
actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })),
@@ -1072,8 +1075,14 @@ function SearchModalContent({
10721075
section: 'triggers',
10731076
item,
10741077
/* The display rename ("Start" → "Start Trigger") costs the exact-name
1075-
bonus, so a query that IS the trigger's name ranks it like a page row. */
1076-
score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score,
1078+
bonus, so a query that IS the trigger's name ranks it like a page row
1079+
— unless a block shares that name (Gmail, Slack). Then the query names
1080+
the block first, and the lift would leapfrog its exact-name match. */
1081+
score:
1082+
item.baseName.toLowerCase() === query.toLowerCase() &&
1083+
!blockNames.has(item.baseName.toLowerCase())
1084+
? PAGE_MATCH_TIER
1085+
: score,
10771086
})),
10781087
tools: rank(
10791088
'tools',

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ describe('getGlobalSearchResults', () => {
100100
).toEqual(['new-chat-action', 'new-chat-result'])
101101
})
102102

103+
it('keeps a mid-word-matched action below word-start entity matches', () => {
104+
const action = {
105+
id: 'create-folder',
106+
name: 'Create folder',
107+
icon: () => null,
108+
context: 'global' as const,
109+
run: () => {},
110+
}
111+
const [actionMatch] = scoreActions([action], 'a')
112+
const [blockMatch] = scoreAndSort([{ name: 'Airtable' }], (item) => item.name, 'a')
113+
114+
expect(actionMatch.score).toBeLessThan(blockMatch.score)
115+
})
116+
117+
it('still biases a word-start action match above entity name matches', () => {
118+
const action = {
119+
id: 'create-workflow',
120+
name: 'Create workflow',
121+
icon: () => null,
122+
context: 'global' as const,
123+
run: () => {},
124+
}
125+
const [actionMatch] = scoreActions([action], 'w')
126+
const [blockMatch] = scoreAndSort([{ name: 'Webhook' }], (item) => item.name, 'w')
127+
128+
expect(actionMatch.score).toBeGreaterThan(blockMatch.score)
129+
})
130+
103131
it('breaks identical visible-name matches by the original section order', () => {
104132
const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' }
105133
const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' }

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -581,19 +581,32 @@ export function scoreSectionItems<T>(
581581
}
582582

583583
/**
584-
* Rank offset added to every matched action. Actions are the palette's few
584+
* Rank offset added to a matched action. Actions are the palette's few
585585
* runnable verbs, so a matched action outranks entity rows of the same match
586586
* quality — a name-matched action beats name-matched entities, a
587587
* keyword-matched action beats other secondary-text matches — while the
588588
* half-tier offset deliberately cannot bridge into the next tier up
589-
* ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}).
589+
* ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}). A name hit that
590+
* starts mid-word ("h" in "New chat") is NOT the same quality as the
591+
* word-start matches the offset would leapfrog, so it forgoes the bias.
590592
*/
591593
export const ACTION_MATCH_BIAS = 500_000
592594

595+
/**
596+
* Whether a match begins where a word begins — the string start, right after a
597+
* separator, or at a camelCase hump. The empty query (no positions) counts as
598+
* a word start.
599+
*/
600+
function isWordStartMatch(text: string, positions: readonly number[]): boolean {
601+
if (positions.length === 0) return true
602+
return isHardBoundary(text.toLowerCase(), positions[0]) || isCamelBoundary(text, positions[0])
603+
}
604+
593605
/**
594606
* Scores actions by visible name before falling back to their keywords.
595-
* Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the
596-
* action's `exactQueries` ranks it like a page row instead.
607+
* Word-start matches are lifted by {@link ACTION_MATCH_BIAS}; a mid-word name
608+
* hit keeps its honest score so word-start entity matches outrank it; a query
609+
* listed in the action's `exactQueries` ranks it like a page row instead.
597610
*/
598611
export function scoreActions(
599612
actions: ActionItem[],
@@ -609,10 +622,15 @@ export function scoreActions(
609622
search,
610623
(action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`,
611624
maxResults
612-
).map(({ item, score }) => ({
613-
item,
614-
score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS,
615-
}))
625+
).map(({ item, score }) => {
626+
if (item.exactQueries?.includes(query)) return { item, score: PAGE_MATCH_TIER }
627+
/* Section-lifted rows (the query IS the group label) keep the bias
628+
wholesale — only plain name-tier scores are quality-checked. */
629+
const byName = fuzzyMatch(item.name, query)
630+
const midWordNameMatch =
631+
score < SECTION_MATCH_TIER && byName.matched && !isWordStartMatch(item.name, byName.positions)
632+
return { item, score: midWordNameMatch ? score : score + ACTION_MATCH_BIAS }
633+
})
616634
}
617635

618636
/**

0 commit comments

Comments
 (0)