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 }
+ })
}
/**