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:
Waleed
2026-07-11 14:15:57 -07:00
committed by GitHub
parent eb12333032
commit 67a2f00266
3 changed files with 156 additions and 25 deletions
@@ -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
@@ -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))
})
})
@@ -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
}