Skip to content

Commit ee6df29

Browse files
committed
fix(search): rank blocks/tools by name so exact name matches win
Blocks and tools were ranked against their full searchValue (name + type + every command-searchable option label), so an exact name match couldn't earn the exact-match bonus and paid a length penalty inflated by option text — e.g. "Agent" lost to "Pi Coding Agent" for the query "agent". Rank by name first via a new optional secondary accessor on filterAndSort/filterAndCap, falling back to searchValue only when the name doesn't match, so an exact name match always wins while a block stays findable by an option label.
1 parent 3107b1b commit ee6df29

3 files changed

Lines changed: 98 additions & 22 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -575,19 +575,28 @@ export function SearchModal({
575575
}, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch])
576576

577577
/**
578-
* Ranking matches against clean, human-meaningful text only (names, types,
579-
* aliases, folder paths) — never the structural `<type>-<id>`/uuid tokens used
580-
* for cmdk row identity. Those tokens carry letters (e.g. "block", "tool") that
581-
* would otherwise let short fuzzy queries scatter-match unrelated items.
578+
* Blocks and tools rank by name first, with `searchValue` (type + option
579+
* labels) as a lower-tier fallback, so an exact name match wins while a block
580+
* stays findable by an option label.
582581
*/
583582
const filteredBlocks = useMemo(() => {
584583
if (!isOnWorkflowPage) return []
585-
return filterAndCap(blocks, (b) => b.searchValue ?? b.name, deferredSearch)
584+
return filterAndCap(
585+
blocks,
586+
(b) => b.name,
587+
deferredSearch,
588+
(b) => b.searchValue
589+
)
586590
}, [isOnWorkflowPage, blocks, deferredSearch])
587591

588592
const filteredTools = useMemo(() => {
589593
if (!isOnWorkflowPage) return []
590-
return filterAndCap(tools, (t) => t.searchValue ?? t.name, deferredSearch)
594+
return filterAndCap(
595+
tools,
596+
(t) => t.name,
597+
deferredSearch,
598+
(t) => t.searchValue
599+
)
591600
}, [isOnWorkflowPage, tools, deferredSearch])
592601

593602
const filteredTriggers = useMemo(() => {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,47 @@ describe('fuzzyMatch — positions for highlighting', () => {
242242
})
243243
})
244244

245+
describe('filterAndSort — name ranked above secondary text', () => {
246+
interface Item {
247+
name: string
248+
searchValue: string
249+
}
250+
const toName = (i: Item) => i.name
251+
const toExtra = (i: Item) => i.searchValue
252+
253+
it('ranks an exact name match above a substring buried in another item’s option text', () => {
254+
const items: Item[] = [
255+
// Matches "agent" only inside a long secondary string (its model catalog).
256+
{ name: 'Pi Coding Agent', searchValue: `Pi Coding Agent pi ${'model-x '.repeat(60)}` },
257+
// Exact name match, but an even longer secondary string.
258+
{ name: 'Agent', searchValue: `Agent agent ${'claude-sonnet gpt-4o '.repeat(60)}` },
259+
]
260+
const sorted = filterAndSort(items, toName, 'agent', toExtra)
261+
expect(sorted[0].name).toBe('Agent')
262+
})
263+
264+
it('keeps every name match above every secondary-only match', () => {
265+
const items: Item[] = [
266+
{ name: 'Zeta', searchValue: 'Zeta agent agent agent' }, // strong secondary hit, no name hit
267+
{ name: 'Agent', searchValue: 'Agent agent' }, // name hit
268+
]
269+
const sorted = filterAndSort(items, toName, 'agent', toExtra)
270+
expect(sorted[0].name).toBe('Agent')
271+
})
272+
273+
it('still surfaces an item matched only by its secondary text', () => {
274+
const items: Item[] = [{ name: 'Agent', searchValue: 'Agent agent claude-sonnet gpt-4o' }]
275+
expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1)
276+
})
277+
278+
it('is byte-identical to single-field ranking when no secondary accessor is given', () => {
279+
const items = ['Slack message', 'Send message to Slack']
280+
expect(filterAndSort(items, (s) => s, 'slack')).toEqual(
281+
filterAndSort(items, (s) => s, 'slack', undefined)
282+
)
283+
})
284+
})
285+
245286
describe('filterAndCap', () => {
246287
const id = (s: string) => s
247288

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -243,38 +243,64 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult {
243243
return tokenFallback(lowerText, lowerQuery)
244244
}
245245

246+
/** Rank offset that lifts every name match above any secondary-text match. */
247+
const NAME_MATCH_TIER = 1_000_000
248+
249+
/**
250+
* Ranks an item by its name first, falling back to secondary text (ids, aliases,
251+
* option labels) only when the name doesn't match — a name match always wins, so
252+
* an exact name hit isn't diluted by a long secondary string ("Agent" beats
253+
* "Pi Coding Agent" for the query "agent").
254+
*/
255+
function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult {
256+
const byName = fuzzyMatch(name, search)
257+
if (!extra) return byName
258+
if (byName.matched) {
259+
return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions }
260+
}
261+
const byExtra = fuzzyMatch(extra, search)
262+
return byExtra.matched ? byExtra : NO_MATCH
263+
}
264+
246265
/**
247-
* Filters items whose value fuzzy-matches the search, ordered by descending
248-
* score. Returns the input untouched when the search is empty.
266+
* Filters and ranks items by fuzzy match, highest score first; returns the input
267+
* unchanged when the search is empty. Pass `toExtra` to rank the name first and
268+
* fall back to secondary text.
249269
*/
250-
export function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
270+
export function filterAndSort<T>(
271+
items: T[],
272+
toValue: (item: T) => string,
273+
search: string,
274+
toExtra?: (item: T) => string | undefined
275+
): T[] {
251276
if (!search) return items
252277
const scored: Array<{ item: T; score: number }> = []
253278
for (const item of items) {
254-
const { matched, score } = fuzzyMatch(toValue(item), search)
279+
const { matched, score } = scoreItem(toValue(item), toExtra?.(item), search)
255280
if (matched) scored.push({ item, score })
256281
}
257282
scored.sort((a, b) => b.score - a.score)
258283
return scored.map((entry) => entry.item)
259284
}
260285

261286
/**
262-
* Upper bound on rendered rows per result group while searching. Re-rendering an
263-
* unbounded, reshuffling match set on every keystroke — the catalog alone is
264-
* 1,000+ tool operations — is what stalls the input and drops the next
265-
* character. Results are score-sorted, so this only trims the low-relevance tail
266-
* of a query.
287+
* Max rows rendered per group while searching. Re-rendering an unbounded,
288+
* reshuffling match set every keystroke is what stalls typing; results are
289+
* score-sorted, so the cap only drops the low-relevance tail.
267290
*/
268291
export const MAX_RESULTS_PER_GROUP = 50
269292

270293
/**
271-
* {@link filterAndSort}, but bounded to {@link MAX_RESULTS_PER_GROUP} rows *while
272-
* searching* so the per-keystroke render can't flood the DOM and block typing.
273-
* The empty state is returned in full — capping applies only to the top-ranked
274-
* matches of an active query, never to the browsable list, so no result the user
275-
* could otherwise see is hidden.
294+
* {@link filterAndSort} bounded to {@link MAX_RESULTS_PER_GROUP} while searching,
295+
* so the per-keystroke render can't block typing. The empty browse state is
296+
* returned in full.
276297
*/
277-
export function filterAndCap<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
278-
const results = filterAndSort(items, toValue, search)
298+
export function filterAndCap<T>(
299+
items: T[],
300+
toValue: (item: T) => string,
301+
search: string,
302+
toExtra?: (item: T) => string | undefined
303+
): T[] {
304+
const results = filterAndSort(items, toValue, search, toExtra)
279305
return search ? results.slice(0, MAX_RESULTS_PER_GROUP) : results
280306
}

0 commit comments

Comments
 (0)