Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -578,19 +578,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[],
Expand All @@ -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 }
})
}

/**
Expand Down
Loading