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 fc60cd431ba..beac3a100cb 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 @@ -70,7 +70,7 @@ import type { WorkflowItem, WorkspaceItem, } from './utils' -import { filterAndSort } from './utils' +import { filterAndCap, filterAndSort } from './utils' const logger = createLogger('SearchModal') @@ -575,60 +575,69 @@ export function SearchModal({ }, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch]) /** - * Ranking matches against clean, human-meaningful text only (names, types, - * aliases, folder paths) — never the structural `-`/uuid tokens used - * for cmdk row identity. Those tokens carry letters (e.g. "block", "tool") that - * would otherwise let short fuzzy queries scatter-match unrelated items. + * Blocks and tools rank by name first, with `searchValue` (type + option + * labels) as a lower-tier fallback, so an exact name match wins while a block + * stays findable by an option label. */ const filteredBlocks = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(blocks, (b) => b.searchValue ?? b.name, deferredSearch) + return filterAndCap( + blocks, + (b) => b.name, + deferredSearch, + (b) => b.searchValue + ) }, [isOnWorkflowPage, blocks, deferredSearch]) const filteredTools = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(tools, (t) => t.searchValue ?? t.name, deferredSearch) + return filterAndCap( + tools, + (t) => t.name, + deferredSearch, + (t) => t.searchValue + ) }, [isOnWorkflowPage, tools, deferredSearch]) const filteredTriggers = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) + return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) }, [isOnWorkflowPage, triggers, deferredSearch]) const filteredToolOps = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(toolOperations, (op) => op.searchValue, deferredSearch) + return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch) }, [isOnWorkflowPage, toolOperations, deferredSearch]) const filteredDocs = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(docs, (d) => `${d.name} docs documentation`, deferredSearch) + return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch) }, [isOnWorkflowPage, docs, deferredSearch]) const filteredTables = useMemo( - () => filterAndSort(tables, (t) => t.name, deferredSearch), + () => filterAndCap(tables, (t) => t.name, deferredSearch), [tables, deferredSearch] ) const filteredFiles = useMemo( - () => filterAndSort(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch), + () => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch), [files, deferredSearch] ) const filteredKnowledgeBases = useMemo( - () => filterAndSort(knowledgeBases, (kb) => kb.name, deferredSearch), + () => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch), [knowledgeBases, deferredSearch] ) const filteredWorkflows = useMemo( () => - filterAndSort(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch), + filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch), [workflows, deferredSearch] ) const filteredChats = useMemo( - () => filterAndSort(chats, (t) => t.name, deferredSearch), + () => filterAndCap(chats, (t) => t.name, deferredSearch), [chats, deferredSearch] ) const filteredWorkspaces = useMemo( - () => filterAndSort(workspaces, (w) => w.name, deferredSearch), + () => filterAndCap(workspaces, (w) => w.name, deferredSearch), [workspaces, deferredSearch] ) const filteredPages = useMemo( @@ -639,13 +648,13 @@ export function SearchModal({ /** Connected accounts: visible on the integrations page even with empty input. */ const filteredConnectedAccounts = useMemo(() => { if (!isOnIntegrationsPage) return [] - return filterAndSort(connectedAccounts, (a) => a.name, deferredSearch) + return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch) }, [isOnIntegrationsPage, connectedAccounts, deferredSearch]) /** Catalog integrations: only shown once the user has typed something. */ const filteredIntegrations = useMemo(() => { - if (!isOnIntegrationsPage || !deferredSearch) return [] - return filterAndSort(integrations, (i) => i.name, deferredSearch) + if (!isOnIntegrationsPage || !deferredSearch.trim()) return [] + return filterAndCap(integrations, (i) => i.name, deferredSearch) }, [isOnIntegrationsPage, deferredSearch, integrations]) if (!mounted) return null 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 eb9175abd28..a82b79fd5f9 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 @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { filterAndSort, fuzzyMatch } from './utils' +import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils' /** * The matcher that shipped before fuzzy matching was introduced. Re-implemented @@ -241,3 +241,77 @@ describe('fuzzyMatch — positions for highlighting', () => { expect(result.matched).toBe(true) }) }) + +describe('filterAndSort — name ranked above secondary text', () => { + interface Item { + name: string + searchValue: string + } + const toName = (i: Item) => i.name + const toExtra = (i: Item) => i.searchValue + + it('ranks an exact name match above a substring buried in another item’s option text', () => { + const items: Item[] = [ + // Matches "agent" only inside a long secondary string (its model catalog). + { name: 'Pi Coding Agent', searchValue: `Pi Coding Agent pi ${'model-x '.repeat(60)}` }, + // Exact name match, but an even longer secondary string. + { name: 'Agent', searchValue: `Agent agent ${'claude-sonnet gpt-4o '.repeat(60)}` }, + ] + const sorted = filterAndSort(items, toName, 'agent', toExtra) + expect(sorted[0].name).toBe('Agent') + }) + + it('keeps every name match above every secondary-only match', () => { + const items: Item[] = [ + { name: 'Zeta', searchValue: 'Zeta agent agent agent' }, // strong secondary hit, no name hit + { name: 'Agent', searchValue: 'Agent agent' }, // name hit + ] + const sorted = filterAndSort(items, toName, 'agent', toExtra) + expect(sorted[0].name).toBe('Agent') + }) + + it('still surfaces an item matched only by its secondary text', () => { + const items: Item[] = [{ name: 'Agent', searchValue: 'Agent agent claude-sonnet gpt-4o' }] + expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1) + }) + + it('is byte-identical to single-field ranking when no secondary accessor is given', () => { + const items = ['Slack message', 'Send message to Slack'] + expect(filterAndSort(items, (s) => s, 'slack')).toEqual( + filterAndSort(items, (s) => s, 'slack', undefined) + ) + }) +}) + +describe('filterAndCap', () => { + const id = (s: string) => s + + it('caps an active search to MAX_RESULTS_PER_GROUP', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + expect(filterAndCap(items, id, 'item')).toHaveLength(MAX_RESULTS_PER_GROUP) + }) + + it('never caps the empty (browse) state, even above the cap', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + const result = filterAndCap(items, id, '') + expect(result).toHaveLength(items.length) + expect(result).toBe(items) + }) + + it('treats whitespace-only input as browse: unfiltered and uncapped', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`) + const result = filterAndCap(items, id, ' ') + expect(result).toBe(items) + }) + + it('returns every match untrimmed when under the cap', () => { + const items = ['Slack', 'Slate', 'Slalom'] + expect(filterAndCap(items, id, 'sl')).toHaveLength(3) + }) + + it('caps to the top-ranked matches, preserving filterAndSort order', () => { + const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 5 }, (_, i) => `item ${i}`) + const capped = filterAndCap(items, id, 'item') + expect(capped).toEqual(filterAndSort(items, id, 'item').slice(0, MAX_RESULTS_PER_GROUP)) + }) +}) 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 7970294e93f..a9f3fe9dbbe 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 @@ -243,17 +243,65 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { return tokenFallback(lowerText, lowerQuery) } +/** Rank offset that lifts every name match above any secondary-text match. */ +const NAME_MATCH_TIER = 1_000_000 + /** - * Filters items whose value fuzzy-matches the search, ordered by descending - * score. Returns the input untouched when the search is empty. + * Ranks an item by its name first, falling back to secondary text (ids, aliases, + * option labels) only when the name doesn't match — a name match always wins, so + * an exact name hit isn't diluted by a long secondary string ("Agent" beats + * "Pi Coding Agent" for the query "agent"). */ -export function filterAndSort(items: T[], toValue: (item: T) => string, search: string): T[] { - if (!search) return items +function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult { + const byName = fuzzyMatch(name, search) + if (!extra) return byName + if (byName.matched) { + return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions } + } + const byExtra = fuzzyMatch(extra, search) + return byExtra.matched ? byExtra : NO_MATCH +} + +/** + * Filters and ranks items by fuzzy match, highest score first; returns the input + * unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank + * the name first and fall back to secondary text. + */ +export function filterAndSort( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { + const query = search.trim() + if (!query) return items const scored: Array<{ item: T; score: number }> = [] for (const item of items) { - const { matched, score } = fuzzyMatch(toValue(item), search) + const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query) if (matched) scored.push({ item, score }) } scored.sort((a, b) => b.score - a.score) return scored.map((entry) => entry.item) } + +/** + * Max rows rendered per group while searching. Re-rendering an unbounded, + * reshuffling match set every keystroke is what stalls typing; results are + * score-sorted, so the cap only drops the low-relevance tail. + */ +export const MAX_RESULTS_PER_GROUP = 50 + +/** + * {@link filterAndSort} bounded to {@link MAX_RESULTS_PER_GROUP} while searching, + * so the per-keystroke render can't block typing. The empty browse state is + * returned in full. + */ +export function filterAndCap( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { + const results = filterAndSort(items, toValue, search, toExtra) + return search.trim() ? results.slice(0, MAX_RESULTS_PER_GROUP) : results +}