Skip to content

Commit 54178a8

Browse files
committed
fix(search): disambiguate tables and knowledge bases by folder
The Cmd-K search modal listed tables and knowledge bases without the folder breadcrumb workflows and files already showed, and the table, knowledge base, and search-and-replace pickers in the workflow editor rendered bare names -- so two resources sharing a name in different folders were indistinguishable. Extracts the disambiguation the workflow selector already did into shared collectDuplicateNames + disambiguateLabelByFolder, and shares the search row's folder breadcrumb and its memo comparator, which were duplicated between the workflow and file rows. Also routes folder text through filterAndCap's secondary-rank parameter rather than concatenating it into the name, so an exact name match can no longer be outranked by a folder that happens to fuzzy-match.
1 parent f113b0b commit 54178a8

12 files changed

Lines changed: 325 additions & 127 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
1313
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1414
import type { SubBlockConfig } from '@/blocks/types'
1515
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
16+
import { useFolderMap } from '@/hooks/queries/folders'
1617
import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge'
18+
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
1719
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
1820

1921
interface KnowledgeBaseSelectorProps {
@@ -43,6 +45,8 @@ export function KnowledgeBaseSelector({
4345
error,
4446
} = useKnowledgeBasesList(workspaceId)
4547

48+
const { data: knowledgeBaseFolders = {} } = useFolderMap(workspaceId, 'knowledge_base')
49+
4650
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
4751

4852
const value = isPreview ? previewValue : storeValue
@@ -90,13 +94,32 @@ export function KnowledgeBaseSelector({
9094
return Array.from(merged.values())
9195
}, [knowledgeBases, selectedKnowledgeBaseQueries])
9296

93-
const options = useMemo<ComboboxOption[]>(() => {
94-
return combinedKnowledgeBases.map((kb) => ({
95-
label: kb.name,
96-
value: kb.id,
97-
icon: PackageSearchIcon,
98-
}))
99-
}, [combinedKnowledgeBases])
97+
/**
98+
* Display names, with the folder path appended when two knowledge bases share
99+
* a name — otherwise the dropdown rows and the selected chips are
100+
* indistinguishable from one another. Built in the same pass as the options so
101+
* the chips and the dropdown can never disagree.
102+
*/
103+
const { options, labelById } = useMemo(() => {
104+
const duplicateNames = collectDuplicateNames(combinedKnowledgeBases.map((kb) => kb.name))
105+
const labelById = new Map<string, string>()
106+
const options: ComboboxOption[] = combinedKnowledgeBases.map((kb) => {
107+
const label = disambiguateLabelByFolder(
108+
kb.name,
109+
kb.folderId,
110+
knowledgeBaseFolders,
111+
duplicateNames
112+
)
113+
labelById.set(kb.id, label)
114+
return { label, value: kb.id, icon: PackageSearchIcon }
115+
})
116+
return { options, labelById }
117+
}, [combinedKnowledgeBases, knowledgeBaseFolders])
118+
119+
const labelOf = useCallback(
120+
(kb: KnowledgeBaseData) => labelById.get(kb.id) ?? kb.name,
121+
[labelById]
122+
)
100123

101124
/**
102125
* Compute selected knowledge bases for tag display
@@ -172,7 +195,7 @@ export function KnowledgeBaseSelector({
172195
blockId,
173196
subBlockId: subBlock.id,
174197
valuePath: [index],
175-
label: kb.name,
198+
label: labelOf(kb),
176199
})
177200
return (
178201
<div
@@ -181,14 +204,14 @@ export function KnowledgeBaseSelector({
181204
>
182205
<PackageSearchIcon className='mr-1 size-3 text-[var(--brand-knowledge)]' />
183206
<span className='font-medium text-[var(--brand-knowledge)]'>
184-
{formatDisplayText(kb.name, { workflowSearchHighlight })}
207+
{formatDisplayText(labelOf(kb), { workflowSearchHighlight })}
185208
</span>
186209
{!disabled && !isPreview && (
187210
<button
188211
type='button'
189212
onClick={() => handleRemoveKnowledgeBase(kb.id)}
190213
className='ml-1 text-[color-mix(in_srgb,var(--brand-knowledge)_60%,transparent)] hover-hover:text-[var(--brand-knowledge)]'
191-
aria-label={`Remove ${kb.name}`}
214+
aria-label={`Remove ${labelOf(kb)}`}
192215
>
193216
<X className='size-3' />
194217
</button>
@@ -220,11 +243,13 @@ export function KnowledgeBaseSelector({
220243
blockId,
221244
subBlockId: subBlock.id,
222245
valuePath: [],
223-
label: selectedKnowledgeBases[0].name,
246+
label: labelOf(selectedKnowledgeBases[0]),
224247
})
225248
return workflowSearchHighlight ? (
226249
<span className='truncate text-[var(--text-primary)]'>
227-
{formatDisplayText(selectedKnowledgeBases[0].name, { workflowSearchHighlight })}
250+
{formatDisplayText(labelOf(selectedKnowledgeBases[0]), {
251+
workflowSearchHighlight,
252+
})}
228253
</span>
229254
) : undefined
230255
})()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w
88
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
99
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1010
import type { SubBlockConfig } from '@/blocks/types'
11+
import { useFolderMap } from '@/hooks/queries/folders'
1112
import { useTablesList } from '@/hooks/queries/tables'
13+
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
1214

1315
interface TableSelectorProps {
1416
blockId: string
@@ -45,15 +47,33 @@ export function TableSelector({
4547
error,
4648
} = useTablesList(isPreview || disabled ? undefined : workspaceId)
4749

50+
const { data: tableFolders = {} } = useFolderMap(
51+
isPreview || disabled ? undefined : workspaceId,
52+
'table'
53+
)
54+
4855
const value = isPreview ? previewValue : storeValue
4956
const tableId = typeof value === 'string' ? value : null
5057

58+
/**
59+
* Two tables can share a name in different folders, so a colliding name is
60+
* suffixed with its folder path. Table names are lowercased for display (the
61+
* pre-existing styling here), and collisions are detected on that same
62+
* lowercased form so `Leads` and `leads` — identical once displayed — are
63+
* disambiguated too. The folder path keeps its authored casing.
64+
*/
5165
const options = useMemo<ComboboxOption[]>(() => {
66+
const duplicateNames = collectDuplicateNames(tables.map((table) => table.name.toLowerCase()))
5267
return tables.map((table) => ({
53-
label: table.name.toLowerCase(),
68+
label: disambiguateLabelByFolder(
69+
table.name.toLowerCase(),
70+
table.folderId,
71+
tableFolders,
72+
duplicateNames
73+
),
5474
value: table.id,
5575
}))
56-
}, [tables])
76+
}, [tables, tableFolders])
5777

5878
const handleChange = useCallback(
5979
(selectedValue: string) => {

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

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,35 @@ export const MemoizedActionItem = memo(
7878
prev.shortcut === next.shortcut
7979
)
8080

81+
/**
82+
* Right-aligned folder breadcrumb. All but the last segment collapse first so a
83+
* deep path degrades to the immediate parent rather than truncating the whole
84+
* trail. Renders nothing at the workspace root.
85+
*/
86+
function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) {
87+
if (!folderPath || folderPath.length === 0) return null
88+
return (
89+
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
90+
{folderPath.length > 1 && (
91+
<>
92+
<span className='min-w-0 truncate [flex-shrink:9999]'>
93+
{folderPath.slice(0, -1).join(' / ')}
94+
</span>
95+
<span className='flex-shrink-0 whitespace-pre'> / </span>
96+
</>
97+
)}
98+
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
99+
</span>
100+
)
101+
}
102+
103+
/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */
104+
function sameFolderPath(a?: string[], b?: string[]): boolean {
105+
if (a === b) return true
106+
if (a?.length !== b?.length) return false
107+
return (a ?? []).every((segment, i) => segment === b?.[i])
108+
}
109+
81110
export const MemoizedWorkflowItem = memo(
82111
function WorkflowItem({
83112
value,
@@ -101,29 +130,15 @@ export const MemoizedWorkflowItem = memo(
101130
<span className='truncate'>{name}</span>
102131
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
103132
</span>
104-
{folderPath && folderPath.length > 0 && (
105-
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
106-
{folderPath.length > 1 && (
107-
<>
108-
<span className='min-w-0 truncate [flex-shrink:9999]'>
109-
{folderPath.slice(0, -1).join(' / ')}
110-
</span>
111-
<span className='flex-shrink-0 whitespace-pre'> / </span>
112-
</>
113-
)}
114-
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
115-
</span>
116-
)}
133+
<FolderPathSuffix folderPath={folderPath} />
117134
</Command.Item>
118135
)
119136
},
120137
(prev, next) =>
121138
prev.value === next.value &&
122139
prev.name === next.name &&
123140
prev.isCurrent === next.isCurrent &&
124-
(prev.folderPath === next.folderPath ||
125-
(prev.folderPath?.length === next.folderPath?.length &&
126-
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
141+
sameFolderPath(prev.folderPath, next.folderPath)
127142
)
128143

129144
export const MemoizedFileItem = memo(
@@ -143,31 +158,17 @@ export const MemoizedFileItem = memo(
143158
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
144159
<File className='size-[14px] text-[var(--text-icon)]' />
145160
</div>
146-
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
161+
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
147162
<span className='truncate'>{name}</span>
148163
</span>
149-
{folderPath && folderPath.length > 0 && (
150-
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
151-
{folderPath.length > 1 && (
152-
<>
153-
<span className='min-w-0 truncate [flex-shrink:9999]'>
154-
{folderPath.slice(0, -1).join(' / ')}
155-
</span>
156-
<span className='flex-shrink-0 whitespace-pre'> / </span>
157-
</>
158-
)}
159-
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
160-
</span>
161-
)}
164+
<FolderPathSuffix folderPath={folderPath} />
162165
</Command.Item>
163166
)
164167
},
165168
(prev, next) =>
166169
prev.value === next.value &&
167170
prev.name === next.name &&
168-
(prev.folderPath === next.folderPath ||
169-
(prev.folderPath?.length === next.folderPath?.length &&
170-
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
171+
sameFolderPath(prev.folderPath, next.folderPath)
171172
)
172173

173174
export const MemoizedTaskItem = memo(
@@ -253,18 +254,27 @@ export const MemoizedIconItem = memo(
253254
onSelect,
254255
name,
255256
icon: Icon,
257+
folderPath,
256258
}: {
257259
value: string
258260
onSelect: () => void
259261
name: string
260262
icon: ComponentType<{ className?: string }>
263+
folderPath?: string[]
261264
}) {
262265
return (
263266
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
264267
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
265-
<span className='truncate text-[var(--text-body)]'>{name}</span>
268+
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
269+
<span className='truncate'>{name}</span>
270+
</span>
271+
<FolderPathSuffix folderPath={folderPath} />
266272
</Command.Item>
267273
)
268274
},
269-
(prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon
275+
(prev, next) =>
276+
prev.value === next.value &&
277+
prev.name === next.name &&
278+
prev.icon === next.icon &&
279+
sameFolderPath(prev.folderPath, next.folderPath)
270280
)

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import type {
1818
ActionItem,
1919
FileItem,
20+
FolderedItem,
2021
IntegrationSearchItem,
2122
PageItem,
2223
TaskItem,
@@ -343,19 +344,20 @@ function createIconGroup(
343344
items,
344345
onSelect,
345346
}: {
346-
items: TaskItem[]
347-
onSelect: (item: TaskItem) => void
347+
items: FolderedItem[]
348+
onSelect: (item: FolderedItem) => void
348349
}) {
349350
if (items.length === 0) return null
350351
return (
351352
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
352353
{items.map((item) => (
353354
<MemoizedIconItem
354355
key={item.id}
355-
value={`${item.name} ${prefix}-${item.id}`}
356+
value={`${item.name} ${item.folderPath?.join(' / ') ?? ''} ${prefix}-${item.id}`}
356357
onSelect={() => onSelect(item)}
357358
name={item.name}
358359
icon={icon}
360+
folderPath={item.folderPath}
359361
/>
360362
))}
361363
</Command.Group>

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,21 +621,44 @@ export function SearchModal({
621621
}, [isOnWorkflowPage, docs, deferredSearch])
622622

623623
const filteredTables = useMemo(
624-
() => filterAndCap(tables, (t) => t.name, deferredSearch),
624+
() =>
625+
filterAndCap(
626+
tables,
627+
(t) => t.name,
628+
deferredSearch,
629+
(t) => t.folderPath?.join(' ')
630+
),
625631
[tables, deferredSearch]
626632
)
627633
const filteredFiles = useMemo(
628-
() => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
634+
() =>
635+
filterAndCap(
636+
files,
637+
(f) => f.name,
638+
deferredSearch,
639+
(f) => f.folderPath?.join(' ')
640+
),
629641
[files, deferredSearch]
630642
)
631643
const filteredKnowledgeBases = useMemo(
632-
() => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch),
644+
() =>
645+
filterAndCap(
646+
knowledgeBases,
647+
(kb) => kb.name,
648+
deferredSearch,
649+
(kb) => kb.folderPath?.join(' ')
650+
),
633651
[knowledgeBases, deferredSearch]
634652
)
635653

636654
const filteredWorkflows = useMemo(
637655
() =>
638-
filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
656+
filterAndCap(
657+
workflows,
658+
(w) => w.name,
659+
deferredSearch,
660+
(w) => w.folderPath?.join(' ')
661+
),
639662
[workflows, deferredSearch]
640663
)
641664
const filteredChats = useMemo(

0 commit comments

Comments
 (0)