From 567c5c69ae01a85d1988f5dadc8c9cb93066ef9a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 10:56:45 -0700 Subject: [PATCH 1/5] perf(search): cap Cmd-K result groups so typing isn't blocked by reshuffle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/search-modal/search-modal.tsx | 28 +++++++++---------- .../components/search-modal/utils.test.ts | 22 ++++++++++++++- .../sidebar/components/search-modal/utils.ts | 17 +++++++++++ 3 files changed, 52 insertions(+), 15 deletions(-) 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..2d24869be96 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') @@ -582,53 +582,53 @@ export function SearchModal({ */ const filteredBlocks = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(blocks, (b) => b.searchValue ?? b.name, deferredSearch) + return filterAndCap(blocks, (b) => b.searchValue ?? b.name, deferredSearch) }, [isOnWorkflowPage, blocks, deferredSearch]) const filteredTools = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndSort(tools, (t) => t.searchValue ?? t.name, deferredSearch) + return filterAndCap(tools, (t) => t.searchValue ?? t.name, deferredSearch) }, [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 +639,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) + 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..eba7322c9ec 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,23 @@ describe('fuzzyMatch — positions for highlighting', () => { expect(result.matched).toBe(true) }) }) + +describe('filterAndCap', () => { + const id = (s: string) => s + + it('caps the result set 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('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..0c54d1c2929 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 @@ -257,3 +257,20 @@ export function filterAndSort(items: T[], toValue: (item: T) => string, searc scored.sort((a, b) => b.score - a.score) return scored.map((entry) => entry.item) } + +/** + * Upper bound on rendered rows per result group. The palette can hold thousands + * of items (1,000+ tool operations, plus large workspaces), and re-rendering the + * full set on every keystroke is what stalls the input as results reshuffle. + * Results are score-sorted, so capping only trims the low-relevance tail while + * keeping the DOM — and each keystroke's reconciliation — bounded. + */ +export const MAX_RESULTS_PER_GROUP = 50 + +/** + * {@link filterAndSort} capped to {@link MAX_RESULTS_PER_GROUP} rows so no single + * group can flood the DOM and block typing. + */ +export function filterAndCap(items: T[], toValue: (item: T) => string, search: string): T[] { + return filterAndSort(items, toValue, search).slice(0, MAX_RESULTS_PER_GROUP) +} From 3107b1b84d6d2dbf800399d1deaaa2386bd75af4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 11:02:07 -0700 Subject: [PATCH 2/5] perf(search): scope the result cap to active queries, never the browse list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/search-modal/utils.test.ts | 9 ++++++++- .../sidebar/components/search-modal/utils.ts | 20 +++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) 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 eba7322c9ec..f3832adfcfe 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 @@ -245,11 +245,18 @@ describe('fuzzyMatch — positions for highlighting', () => { describe('filterAndCap', () => { const id = (s: string) => s - it('caps the result set to MAX_RESULTS_PER_GROUP', () => { + 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('returns every match untrimmed when under the cap', () => { const items = ['Slack', 'Slate', 'Slalom'] expect(filterAndCap(items, id, 'sl')).toHaveLength(3) 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 0c54d1c2929..3eb3e7a1f4f 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 @@ -259,18 +259,22 @@ export function filterAndSort(items: T[], toValue: (item: T) => string, searc } /** - * Upper bound on rendered rows per result group. The palette can hold thousands - * of items (1,000+ tool operations, plus large workspaces), and re-rendering the - * full set on every keystroke is what stalls the input as results reshuffle. - * Results are score-sorted, so capping only trims the low-relevance tail while - * keeping the DOM — and each keystroke's reconciliation — bounded. + * Upper bound on rendered rows per result group while searching. Re-rendering an + * unbounded, reshuffling match set on every keystroke — the catalog alone is + * 1,000+ tool operations — is what stalls the input and drops the next + * character. Results are score-sorted, so this only trims the low-relevance tail + * of a query. */ export const MAX_RESULTS_PER_GROUP = 50 /** - * {@link filterAndSort} capped to {@link MAX_RESULTS_PER_GROUP} rows so no single - * group can flood the DOM and block typing. + * {@link filterAndSort}, but bounded to {@link MAX_RESULTS_PER_GROUP} rows *while + * searching* so the per-keystroke render can't flood the DOM and block typing. + * The empty state is returned in full — capping applies only to the top-ranked + * matches of an active query, never to the browsable list, so no result the user + * could otherwise see is hidden. */ export function filterAndCap(items: T[], toValue: (item: T) => string, search: string): T[] { - return filterAndSort(items, toValue, search).slice(0, MAX_RESULTS_PER_GROUP) + const results = filterAndSort(items, toValue, search) + return search ? results.slice(0, MAX_RESULTS_PER_GROUP) : results } From ee6df29dddca45b7c1cab48c6ba76c30611c5d28 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 11:22:11 -0700 Subject: [PATCH 3/5] fix(search): rank blocks/tools by name so exact name matches win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/search-modal/search-modal.tsx | 21 +++++-- .../components/search-modal/utils.test.ts | 41 +++++++++++++ .../sidebar/components/search-modal/utils.ts | 58 ++++++++++++++----- 3 files changed, 98 insertions(+), 22 deletions(-) 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 2d24869be96..e7f01a256f1 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 @@ -575,19 +575,28 @@ 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 filterAndCap(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 filterAndCap(tools, (t) => t.searchValue ?? t.name, deferredSearch) + return filterAndCap( + tools, + (t) => t.name, + deferredSearch, + (t) => t.searchValue + ) }, [isOnWorkflowPage, tools, deferredSearch]) const filteredTriggers = useMemo(() => { 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 f3832adfcfe..2ed28f879e3 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 @@ -242,6 +242,47 @@ describe('fuzzyMatch — positions for highlighting', () => { }) }) +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 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 3eb3e7a1f4f..0a3ef54fa80 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,15 +243,40 @@ 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 + +/** + * 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"). + */ +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 items whose value fuzzy-matches the search, ordered by descending - * score. Returns the input untouched when the search is empty. + * Filters and ranks items by fuzzy match, highest score first; returns the input + * unchanged when the search is empty. Pass `toExtra` to rank the name first and + * fall back to secondary text. */ -export function filterAndSort(items: T[], toValue: (item: T) => string, search: string): T[] { +export function filterAndSort( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { if (!search) 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), search) if (matched) scored.push({ item, score }) } scored.sort((a, b) => b.score - a.score) @@ -259,22 +284,23 @@ export function filterAndSort(items: T[], toValue: (item: T) => string, searc } /** - * Upper bound on rendered rows per result group while searching. Re-rendering an - * unbounded, reshuffling match set on every keystroke — the catalog alone is - * 1,000+ tool operations — is what stalls the input and drops the next - * character. Results are score-sorted, so this only trims the low-relevance tail - * of a query. + * 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}, but bounded to {@link MAX_RESULTS_PER_GROUP} rows *while - * searching* so the per-keystroke render can't flood the DOM and block typing. - * The empty state is returned in full — capping applies only to the top-ranked - * matches of an active query, never to the browsable list, so no result the user - * could otherwise see is hidden. + * {@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): T[] { - const results = filterAndSort(items, toValue, search) +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 ? results.slice(0, MAX_RESULTS_PER_GROUP) : results } From ebaf2854517b1a8de4b175e0287d1d5835f15d4e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 11:34:33 -0700 Subject: [PATCH 4/5] fix(search): treat whitespace-only queries as browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sidebar/components/search-modal/utils.test.ts | 6 ++++++ .../sidebar/components/search-modal/utils.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) 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 2ed28f879e3..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 @@ -298,6 +298,12 @@ describe('filterAndCap', () => { 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) 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 0a3ef54fa80..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 @@ -264,8 +264,8 @@ function scoreItem(name: string, extra: string | undefined, search: string): Fuz /** * Filters and ranks items by fuzzy match, highest score first; returns the input - * unchanged when the search is empty. Pass `toExtra` to rank the name first and - * fall back to secondary text. + * 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[], @@ -273,10 +273,11 @@ export function filterAndSort( search: string, toExtra?: (item: T) => string | undefined ): T[] { - if (!search) return items + const query = search.trim() + if (!query) return items const scored: Array<{ item: T; score: number }> = [] for (const item of items) { - const { matched, score } = scoreItem(toValue(item), toExtra?.(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) @@ -302,5 +303,5 @@ export function filterAndCap( toExtra?: (item: T) => string | undefined ): T[] { const results = filterAndSort(items, toValue, search, toExtra) - return search ? results.slice(0, MAX_RESULTS_PER_GROUP) : results + return search.trim() ? results.slice(0, MAX_RESULTS_PER_GROUP) : results } From 54a0129a0a840a93ce126c4f955699369a4ff5a2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 11:45:35 -0700 Subject: [PATCH 5/5] fix(search): keep integrations catalog hidden on whitespace-only input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/sidebar/components/search-modal/search-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e7f01a256f1..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 @@ -653,7 +653,7 @@ export function SearchModal({ /** Catalog integrations: only shown once the user has typed something. */ const filteredIntegrations = useMemo(() => { - if (!isOnIntegrationsPage || !deferredSearch) return [] + if (!isOnIntegrationsPage || !deferredSearch.trim()) return [] return filterAndCap(integrations, (i) => i.name, deferredSearch) }, [isOnIntegrationsPage, deferredSearch, integrations])