Skip to content

Commit 67a2f00

Browse files
authored
perf(search): cap Cmd-K result groups so typing isn't blocked by reshuffle (#5597)
* perf(search): cap Cmd-K result groups so typing isn't blocked by reshuffle Every result group re-rendered its full match set on each keystroke — the catalog alone is 1,000+ tool operations, plus all workflows/files in large workspaces — so the deferred re-render that reshuffles results stalls the input and drops the next character. Add a per-group cap (filterAndCap, MAX_RESULTS_PER_GROUP=50) applied to every variable-size group. Results are already score-sorted, so the cap only trims the low-relevance tail while keeping the DOM and per-keystroke reconciliation bounded. No UX changes. * perf(search): scope the result cap to active queries, never the browse list Keep the empty state byte-for-byte identical to before — capping applies only to the top-ranked matches of an active query (the reshuffling per-keystroke render that stalls input), never to the full browsable list. No browsable result a user could otherwise see is hidden. * 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. * fix(search): treat whitespace-only queries as browse A whitespace-only query (e.g. a single space) was truthy, so it both filtered (a space matches the spaces in multi-word labels) and capped large groups to 50 while the palette looked empty. Trim the query at the source in filterAndSort so every caller treats whitespace-only as browse, and decide the cap on the trimmed query — whitespace-only input now returns the full, unfiltered browse state. * fix(search): keep integrations catalog hidden on whitespace-only input The filteredIntegrations guard used the raw deferredSearch, so a whitespace-only value passed it while filterAndCap trimmed the same value to browse and returned the full catalog. Guard on deferredSearch.trim() to match the trimmed-emptiness semantics — the catalog stays hidden until the user types something meaningful.
1 parent eb12333 commit 67a2f00

3 files changed

Lines changed: 156 additions & 25 deletions

File tree

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

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import type {
7070
WorkflowItem,
7171
WorkspaceItem,
7272
} from './utils'
73-
import { filterAndSort } from './utils'
73+
import { filterAndCap, filterAndSort } from './utils'
7474

7575
const logger = createLogger('SearchModal')
7676

@@ -575,60 +575,69 @@ 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 filterAndSort(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 filterAndSort(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(() => {
594603
if (!isOnWorkflowPage) return []
595-
return filterAndSort(triggers, (t) => `${t.name} ${t.id}`, deferredSearch)
604+
return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch)
596605
}, [isOnWorkflowPage, triggers, deferredSearch])
597606

598607
const filteredToolOps = useMemo(() => {
599608
if (!isOnWorkflowPage) return []
600-
return filterAndSort(toolOperations, (op) => op.searchValue, deferredSearch)
609+
return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch)
601610
}, [isOnWorkflowPage, toolOperations, deferredSearch])
602611

603612
const filteredDocs = useMemo(() => {
604613
if (!isOnWorkflowPage) return []
605-
return filterAndSort(docs, (d) => `${d.name} docs documentation`, deferredSearch)
614+
return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch)
606615
}, [isOnWorkflowPage, docs, deferredSearch])
607616

608617
const filteredTables = useMemo(
609-
() => filterAndSort(tables, (t) => t.name, deferredSearch),
618+
() => filterAndCap(tables, (t) => t.name, deferredSearch),
610619
[tables, deferredSearch]
611620
)
612621
const filteredFiles = useMemo(
613-
() => filterAndSort(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
622+
() => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
614623
[files, deferredSearch]
615624
)
616625
const filteredKnowledgeBases = useMemo(
617-
() => filterAndSort(knowledgeBases, (kb) => kb.name, deferredSearch),
626+
() => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch),
618627
[knowledgeBases, deferredSearch]
619628
)
620629

621630
const filteredWorkflows = useMemo(
622631
() =>
623-
filterAndSort(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
632+
filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
624633
[workflows, deferredSearch]
625634
)
626635
const filteredChats = useMemo(
627-
() => filterAndSort(chats, (t) => t.name, deferredSearch),
636+
() => filterAndCap(chats, (t) => t.name, deferredSearch),
628637
[chats, deferredSearch]
629638
)
630639
const filteredWorkspaces = useMemo(
631-
() => filterAndSort(workspaces, (w) => w.name, deferredSearch),
640+
() => filterAndCap(workspaces, (w) => w.name, deferredSearch),
632641
[workspaces, deferredSearch]
633642
)
634643
const filteredPages = useMemo(
@@ -639,13 +648,13 @@ export function SearchModal({
639648
/** Connected accounts: visible on the integrations page even with empty input. */
640649
const filteredConnectedAccounts = useMemo(() => {
641650
if (!isOnIntegrationsPage) return []
642-
return filterAndSort(connectedAccounts, (a) => a.name, deferredSearch)
651+
return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch)
643652
}, [isOnIntegrationsPage, connectedAccounts, deferredSearch])
644653

645654
/** Catalog integrations: only shown once the user has typed something. */
646655
const filteredIntegrations = useMemo(() => {
647-
if (!isOnIntegrationsPage || !deferredSearch) return []
648-
return filterAndSort(integrations, (i) => i.name, deferredSearch)
656+
if (!isOnIntegrationsPage || !deferredSearch.trim()) return []
657+
return filterAndCap(integrations, (i) => i.name, deferredSearch)
649658
}, [isOnIntegrationsPage, deferredSearch, integrations])
650659

651660
if (!mounted) return null

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { filterAndSort, fuzzyMatch } from './utils'
5+
import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils'
66

77
/**
88
* The matcher that shipped before fuzzy matching was introduced. Re-implemented
@@ -241,3 +241,77 @@ describe('fuzzyMatch — positions for highlighting', () => {
241241
expect(result.matched).toBe(true)
242242
})
243243
})
244+
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+
286+
describe('filterAndCap', () => {
287+
const id = (s: string) => s
288+
289+
it('caps an active search to MAX_RESULTS_PER_GROUP', () => {
290+
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
291+
expect(filterAndCap(items, id, 'item')).toHaveLength(MAX_RESULTS_PER_GROUP)
292+
})
293+
294+
it('never caps the empty (browse) state, even above the cap', () => {
295+
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
296+
const result = filterAndCap(items, id, '')
297+
expect(result).toHaveLength(items.length)
298+
expect(result).toBe(items)
299+
})
300+
301+
it('treats whitespace-only input as browse: unfiltered and uncapped', () => {
302+
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
303+
const result = filterAndCap(items, id, ' ')
304+
expect(result).toBe(items)
305+
})
306+
307+
it('returns every match untrimmed when under the cap', () => {
308+
const items = ['Slack', 'Slate', 'Slalom']
309+
expect(filterAndCap(items, id, 'sl')).toHaveLength(3)
310+
})
311+
312+
it('caps to the top-ranked matches, preserving filterAndSort order', () => {
313+
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 5 }, (_, i) => `item ${i}`)
314+
const capped = filterAndCap(items, id, 'item')
315+
expect(capped).toEqual(filterAndSort(items, id, 'item').slice(0, MAX_RESULTS_PER_GROUP))
316+
})
317+
})

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

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,65 @@ 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+
246249
/**
247-
* Filters items whose value fuzzy-matches the search, ordered by descending
248-
* score. Returns the input untouched when the search is empty.
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").
249254
*/
250-
export function filterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
251-
if (!search) return items
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+
265+
/**
266+
* Filters and ranks items by fuzzy match, highest score first; returns the input
267+
* unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank
268+
* the name first and fall back to secondary text.
269+
*/
270+
export function filterAndSort<T>(
271+
items: T[],
272+
toValue: (item: T) => string,
273+
search: string,
274+
toExtra?: (item: T) => string | undefined
275+
): T[] {
276+
const query = search.trim()
277+
if (!query) return items
252278
const scored: Array<{ item: T; score: number }> = []
253279
for (const item of items) {
254-
const { matched, score } = fuzzyMatch(toValue(item), search)
280+
const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query)
255281
if (matched) scored.push({ item, score })
256282
}
257283
scored.sort((a, b) => b.score - a.score)
258284
return scored.map((entry) => entry.item)
259285
}
286+
287+
/**
288+
* Max rows rendered per group while searching. Re-rendering an unbounded,
289+
* reshuffling match set every keystroke is what stalls typing; results are
290+
* score-sorted, so the cap only drops the low-relevance tail.
291+
*/
292+
export const MAX_RESULTS_PER_GROUP = 50
293+
294+
/**
295+
* {@link filterAndSort} bounded to {@link MAX_RESULTS_PER_GROUP} while searching,
296+
* so the per-keystroke render can't block typing. The empty browse state is
297+
* returned in full.
298+
*/
299+
export function filterAndCap<T>(
300+
items: T[],
301+
toValue: (item: T) => string,
302+
search: string,
303+
toExtra?: (item: T) => string | undefined
304+
): T[] {
305+
const results = filterAndSort(items, toValue, search, toExtra)
306+
return search.trim() ? results.slice(0, MAX_RESULTS_PER_GROUP) : results
307+
}

0 commit comments

Comments
 (0)