Skip to content

Commit e3855dc

Browse files
committed
fix(search): prune recents by frecency and make category icons exhaustive
Review-loop findings: - Recents were pruned by raw recency but ranked by frecency, so a frequently used older block could be evicted while still ranking high. Prune by the same frecencyScore used for display; add a regression test - Make the integration category icon map exhaustive over IntegrationType so a newly added category is a compile error instead of a silent generic-icon fallback; drive core-block/trigger icons off the category kind - Factor the shared gate-and-cap rule for the catalog search groups into a single cappedCatalog() helper instead of repeating it across five memos
1 parent 12cdad1 commit e3855dc

4 files changed

Lines changed: 91 additions & 61 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,33 +45,42 @@ import type {
4545
WorkspaceItem,
4646
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
4747
import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
48+
import { IntegrationType } from '@/blocks/types'
4849
import type {
4950
SearchBlockItem,
5051
SearchCategory,
5152
SearchDocItem,
5253
SearchToolOperationItem,
5354
} from '@/stores/modals/search/types'
5455

55-
/** Icon for each browsable category, keyed by {@link SearchCategory.id}. */
56-
const CATEGORY_ICONS: Record<string, LucideIcon> = {
57-
blocks: Blocks,
58-
triggers: Zap,
59-
ai: Sparkles,
60-
analytics: BarChart3,
61-
commerce: ShoppingCart,
62-
communication: MessageCircle,
63-
databases: Database,
64-
devops: GitBranch,
65-
documents: FileText,
66-
email: Mail,
67-
hr: Users,
68-
marketing: Megaphone,
69-
observability: Activity,
70-
productivity: ListChecks,
71-
sales: TrendingUp,
72-
search: SearchIcon,
73-
security: Shield,
74-
support: LifeBuoy,
56+
/**
57+
* Icon per integration category. Exhaustive over {@link IntegrationType} so a
58+
* newly added category is a compile error here rather than a silent fallback.
59+
*/
60+
const INTEGRATION_CATEGORY_ICONS: Record<IntegrationType, LucideIcon> = {
61+
[IntegrationType.AI]: Sparkles,
62+
[IntegrationType.Analytics]: BarChart3,
63+
[IntegrationType.Commerce]: ShoppingCart,
64+
[IntegrationType.Communication]: MessageCircle,
65+
[IntegrationType.Databases]: Database,
66+
[IntegrationType.DevOps]: GitBranch,
67+
[IntegrationType.Documents]: FileText,
68+
[IntegrationType.Email]: Mail,
69+
[IntegrationType.HR]: Users,
70+
[IntegrationType.Marketing]: Megaphone,
71+
[IntegrationType.Observability]: Activity,
72+
[IntegrationType.Productivity]: ListChecks,
73+
[IntegrationType.Sales]: TrendingUp,
74+
[IntegrationType.Search]: SearchIcon,
75+
[IntegrationType.Security]: Shield,
76+
[IntegrationType.Support]: LifeBuoy,
77+
}
78+
79+
/** Resolves the icon for a browse category from its kind, then its integration slug. */
80+
function categoryIcon(category: SearchCategory): LucideIcon {
81+
if (category.kind === 'block') return Blocks
82+
if (category.kind === 'trigger') return Zap
83+
return INTEGRATION_CATEGORY_ICONS[category.id as IntegrationType] ?? Blocks
7584
}
7685

7786
export const ActionsGroup = memo(function ActionsGroup({
@@ -144,7 +153,7 @@ export const BrowseGroup = memo(function BrowseGroup({
144153
key={category.id}
145154
value={`${category.label} category-${category.id}`}
146155
onSelect={() => onSelect(category)}
147-
icon={CATEGORY_ICONS[category.id] ?? Blocks}
156+
icon={categoryIcon(category)}
148157
name={category.label}
149158
count={category.count}
150159
/>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ function toRecentRow(
9999
return { id: key, label: item.name, icon: item.icon, bgColor: item.bgColor }
100100
}
101101

102+
/**
103+
* Filters a catalog group for the global search view: empty until the user is
104+
* actually searching (`enabled`), then score-sorted and capped so a broad query
105+
* never floods the DOM. Single source of the gate-and-cap rule shared by the
106+
* blocks, tools, triggers, tool-operations, and docs groups.
107+
*/
108+
function cappedCatalog<T>(
109+
enabled: boolean,
110+
items: T[],
111+
toValue: (item: T) => string,
112+
search: string
113+
): T[] {
114+
if (!enabled) return []
115+
return filterAndSort(items, toValue, search).slice(0, MAX_RESULTS_PER_GROUP)
116+
}
117+
102118
export type { SearchModalProps } from './utils'
103119

104120
export function SearchModal({
@@ -660,45 +676,36 @@ export function SearchModal({
660676
*/
661677
const showCatalogResults = isOnWorkflowPage && isSearching && !scope
662678

663-
const filteredBlocks = useMemo(() => {
664-
if (!showCatalogResults) return []
665-
return filterAndSort(blocks, (b) => b.searchValue ?? b.name, deferredSearch).slice(
666-
0,
667-
MAX_RESULTS_PER_GROUP
668-
)
669-
}, [showCatalogResults, blocks, deferredSearch])
679+
const filteredBlocks = useMemo(
680+
() => cappedCatalog(showCatalogResults, blocks, (b) => b.searchValue ?? b.name, deferredSearch),
681+
[showCatalogResults, blocks, deferredSearch]
682+
)
670683

671-
const filteredTools = useMemo(() => {
672-
if (!showCatalogResults) return []
673-
return filterAndSort(tools, (t) => t.searchValue ?? t.name, deferredSearch).slice(
674-
0,
675-
MAX_RESULTS_PER_GROUP
676-
)
677-
}, [showCatalogResults, tools, deferredSearch])
684+
const filteredTools = useMemo(
685+
() => cappedCatalog(showCatalogResults, tools, (t) => t.searchValue ?? t.name, deferredSearch),
686+
[showCatalogResults, tools, deferredSearch]
687+
)
678688

679-
const filteredTriggers = useMemo(() => {
680-
if (!showCatalogResults) return []
681-
return filterAndSort(triggers, (t) => `${t.name} ${t.id}`, deferredSearch).slice(
682-
0,
683-
MAX_RESULTS_PER_GROUP
684-
)
685-
}, [showCatalogResults, triggers, deferredSearch])
689+
const filteredTriggers = useMemo(
690+
() => cappedCatalog(showCatalogResults, triggers, (t) => `${t.name} ${t.id}`, deferredSearch),
691+
[showCatalogResults, triggers, deferredSearch]
692+
)
686693

687-
const filteredToolOps = useMemo(() => {
688-
if (!showCatalogResults) return []
689-
return filterAndSort(toolOperations, (op) => op.searchValue, deferredSearch).slice(
690-
0,
691-
MAX_RESULTS_PER_GROUP
692-
)
693-
}, [showCatalogResults, toolOperations, deferredSearch])
694+
const filteredToolOps = useMemo(
695+
() => cappedCatalog(showCatalogResults, toolOperations, (op) => op.searchValue, deferredSearch),
696+
[showCatalogResults, toolOperations, deferredSearch]
697+
)
694698

695-
const filteredDocs = useMemo(() => {
696-
if (!showCatalogResults) return []
697-
return filterAndSort(docs, (d) => `${d.name} docs documentation`, deferredSearch).slice(
698-
0,
699-
MAX_RESULTS_PER_GROUP
700-
)
701-
}, [showCatalogResults, docs, deferredSearch])
699+
const filteredDocs = useMemo(
700+
() =>
701+
cappedCatalog(
702+
showCatalogResults,
703+
docs,
704+
(d) => `${d.name} docs documentation`,
705+
deferredSearch
706+
),
707+
[showCatalogResults, docs, deferredSearch]
708+
)
702709

703710
/** Items shown while drilled into a browse category, filtered within scope. */
704711
const scopedItems = useMemo(() => {

apps/sim/stores/modals/search/recents.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ describe('useSearchRecentsStore', () => {
7575
expect(entries['tool:item-59']).toBeDefined()
7676
})
7777

78+
it('prunes by frecency, keeping a frequent older item over recent single uses', () => {
79+
for (let i = 0; i < 10; i++) {
80+
vi.spyOn(Date, 'now').mockReturnValue(i)
81+
useSearchRecentsStore.getState().record('tool:popular')
82+
}
83+
for (let i = 0; i < 50; i++) {
84+
vi.spyOn(Date, 'now').mockReturnValue(100 + i)
85+
useSearchRecentsStore.getState().record(`tool:once-${i}`)
86+
}
87+
const { entries } = useSearchRecentsStore.getState()
88+
expect(Object.keys(entries)).toHaveLength(50)
89+
expect(entries['tool:popular']).toBeDefined()
90+
expect(entries['tool:once-0']).toBeUndefined()
91+
})
92+
7893
it('clears all entries', () => {
7994
useSearchRecentsStore.getState().record('block:agent')
8095
useSearchRecentsStore.getState().clear()

apps/sim/stores/modals/search/recents.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,20 @@ export const useSearchRecentsStore = create<SearchRecentsState>()(
3939

4040
record: (key) =>
4141
set((state) => {
42+
const now = Date.now()
4243
const previous = state.entries[key]
4344
const entries: Record<string, RecentEntry> = {
4445
...state.entries,
45-
[key]: { count: (previous?.count ?? 0) + 1, lastUsedAt: Date.now() },
46+
[key]: { count: (previous?.count ?? 0) + 1, lastUsedAt: now },
4647
}
4748

4849
const keys = Object.keys(entries)
4950
if (keys.length <= MAX_ENTRIES) return { entries }
5051

5152
const kept = keys
52-
.sort((a, b) => entries[b].lastUsedAt - entries[a].lastUsedAt)
53+
.sort((a, b) => frecencyScore(entries[b], now) - frecencyScore(entries[a], now))
5354
.slice(0, MAX_ENTRIES)
54-
const pruned: Record<string, RecentEntry> = {}
55-
for (const k of kept) pruned[k] = entries[k]
56-
return { entries: pruned }
55+
return { entries: Object.fromEntries(kept.map((k) => [k, entries[k]])) }
5756
}),
5857

5958
clear: () => set({ entries: {} }),

0 commit comments

Comments
 (0)