mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(search): stop cmd+k boosts from lifting weaker matches over stronger ones (#6668)
This commit is contained in:
+68
@@ -364,6 +364,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 }
|
||||
|
||||
+11
-2
@@ -1054,6 +1054,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 })),
|
||||
@@ -1072,8 +1075,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',
|
||||
|
||||
+28
@@ -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' }
|
||||
|
||||
+26
-8
@@ -581,19 +581,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[],
|
||||
@@ -609,10 +622,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 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user