Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import type {
WorkflowItem,
WorkspaceItem,
} from './utils'
import { filterAndSort } from './utils'
import { filterAndCap, filterAndSort } from './utils'

const logger = createLogger('SearchModal')

Expand Down Expand Up @@ -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(
Expand All @@ -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)
Comment thread
waleedlatif1 marked this conversation as resolved.
}, [isOnIntegrationsPage, deferredSearch, integrations])
Comment thread
waleedlatif1 marked this conversation as resolved.

if (!mounted) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Loading