diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 625a3de8378..5d11e18e2a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -345,6 +345,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() + }) + + await enterSearchQuery('gmail') + const rows = Array.from(document.querySelectorAll('[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() + }) + + await enterSearchQuery('h') + const rows = Array.from(document.querySelectorAll('[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 } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index de345848b10..a5556ed8ed1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -970,6 +970,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 })), @@ -988,8 +991,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', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 55b51aee401..c5cf1859ee7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -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' } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 7403ae52c85..2600bf35593 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -578,19 +578,32 @@ export function scoreSectionItems( } /** - * 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[], @@ -606,10 +619,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 } + }) } /**