mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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.
This commit is contained in:
+28
-19
@@ -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])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
+75
-1
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
+53
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user