Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
perf(search): cap Cmd-K result groups so typing isn't blocked by reshuffle#5597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
567c5c6
perf(search): cap Cmd-K result groups so typing isn't blocked by resh…
waleedlatif1 3107b1b
perf(search): scope the result cap to active queries, never the brows…
waleedlatif1 ee6df29
fix(search): rank blocks/tools by name so exact name matches win
waleedlatif1 ebaf285
fix(search): treat whitespace-only queries as browse
waleedlatif1 54a0129
fix(search): keep integrations catalog hidden on whitespace-only input
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
47 changes: 28 additions & 19 deletions
47 ...app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<type>-<id>`/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]) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (!mounted) return null | ||
76 changes: 75 additions & 1 deletion
76 ...im/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
58 changes: 53 additions & 5 deletions
58 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>(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<T>( | ||
| 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<T>( | ||
| 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 | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.