Skip to content

Commit 3f496a9

Browse files
committed
fix(search): stop cmd+k boosts from lifting weaker matches over stronger ones
1 parent bed25e2 commit 3f496a9

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
@@ -345,6 +345,74 @@ describe('SearchModal', () => {
345345
}
346346
})
347347

348+
it('keeps a block above its same-name trigger for the exact-name query', async () => {
349+
const Icon = () => null
350+
const original = { ...mockSearchState.data }
351+
mockSearchState.data = {
352+
...mockSearchState.data,
353+
tools: [
354+
{
355+
id: 'gmail',
356+
name: 'Gmail',
357+
icon: Icon,
358+
bgColor: '#E8453C',
359+
type: 'gmail',
360+
searchValue: 'gmail gmail',
361+
},
362+
],
363+
triggers: [{ id: 'gmail', name: 'Gmail', icon: Icon, bgColor: '#E8453C', type: 'gmail' }],
364+
}
365+
366+
try {
367+
await act(async () => {
368+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
369+
})
370+
371+
await enterSearchQuery('gmail')
372+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
373+
(el) => el.textContent ?? ''
374+
)
375+
expect(rows[0]).toContain('Gmail')
376+
expect(rows[0]).not.toContain('Gmail Trigger')
377+
expect(rows[1]).toContain('Gmail Trigger')
378+
} finally {
379+
mockSearchState.data = original
380+
}
381+
})
382+
383+
it('ranks prefix-matched rows above actions that only contain the letter mid-word', async () => {
384+
const Icon = () => null
385+
const original = { ...mockSearchState.data }
386+
mockSearchState.data = {
387+
...mockSearchState.data,
388+
tools: [
389+
{
390+
id: 'hex',
391+
name: 'Hex',
392+
icon: Icon,
393+
bgColor: '#111',
394+
type: 'hex',
395+
searchValue: 'hex hex',
396+
},
397+
],
398+
}
399+
400+
try {
401+
await act(async () => {
402+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
403+
})
404+
405+
await enterSearchQuery('h')
406+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
407+
(el) => el.textContent ?? ''
408+
)
409+
expect(rows[0]).toContain('Hex')
410+
expect(rows.findIndex((row) => row.includes('New chat'))).toBeGreaterThan(0)
411+
} finally {
412+
mockSearchState.data = original
413+
}
414+
})
415+
348416
it('puts the workflow verb actions first for their bare-verb queries', async () => {
349417
const Icon = () => null
350418
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
@@ -970,6 +970,9 @@ function SearchModalContent({
970970
...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []),
971971
...rankActionGroup(actionsByGroup.sim, 'Sim'),
972972
]
973+
const blockNames = new Set(
974+
[...availableBlocks, ...availableTools].map((item) => item.name.toLowerCase())
975+
)
973976

974977
return {
975978
actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })),
@@ -988,8 +991,14 @@ function SearchModalContent({
988991
section: 'triggers',
989992
item,
990993
/* The display rename ("Start" → "Start Trigger") costs the exact-name
991-
bonus, so a query that IS the trigger's name ranks it like a page row. */
992-
score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score,
994+
bonus, so a query that IS the trigger's name ranks it like a page row
995+
— unless a block shares that name (Gmail, Slack). Then the query names
996+
the block first, and the lift would leapfrog its exact-name match. */
997+
score:
998+
item.baseName.toLowerCase() === query.toLowerCase() &&
999+
!blockNames.has(item.baseName.toLowerCase())
1000+
? PAGE_MATCH_TIER
1001+
: score,
9931002
})),
9941003
tools: rank(
9951004
'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
@@ -578,19 +578,32 @@ export function scoreSectionItems<T>(
578578
}
579579

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

592+
/**
593+
* Whether a match begins where a word begins — the string start, right after a
594+
* separator, or at a camelCase hump. The empty query (no positions) counts as
595+
* a word start.
596+
*/
597+
function isWordStartMatch(text: string, positions: readonly number[]): boolean {
598+
if (positions.length === 0) return true
599+
return isHardBoundary(text.toLowerCase(), positions[0]) || isCamelBoundary(text, positions[0])
600+
}
601+
590602
/**
591603
* Scores actions by visible name before falling back to their keywords.
592-
* Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the
593-
* action's `exactQueries` ranks it like a page row instead.
604+
* Word-start matches are lifted by {@link ACTION_MATCH_BIAS}; a mid-word name
605+
* hit keeps its honest score so word-start entity matches outrank it; a query
606+
* listed in the action's `exactQueries` ranks it like a page row instead.
594607
*/
595608
export function scoreActions(
596609
actions: ActionItem[],
@@ -606,10 +619,15 @@ export function scoreActions(
606619
search,
607620
(action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`,
608621
maxResults
609-
).map(({ item, score }) => ({
610-
item,
611-
score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS,
612-
}))
622+
).map(({ item, score }) => {
623+
if (item.exactQueries?.includes(query)) return { item, score: PAGE_MATCH_TIER }
624+
/* Section-lifted rows (the query IS the group label) keep the bias
625+
wholesale — only plain name-tier scores are quality-checked. */
626+
const byName = fuzzyMatch(item.name, query)
627+
const midWordNameMatch =
628+
score < SECTION_MATCH_TIER && byName.matched && !isWordStartMatch(item.name, byName.positions)
629+
return { item, score: midWordNameMatch ? score : score + ACTION_MATCH_BIAS }
630+
})
613631
}
614632

615633
/**

0 commit comments

Comments
 (0)