From 54178a88489ed10e369cc23ae3b7267af88653b0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 2 Aug 2026 00:00:32 -0700 Subject: [PATCH 1/2] 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. --- .../knowledge-base-selector.tsx | 49 +++++++++--- .../table-selector/table-selector.tsx | 24 +++++- .../command-items/command-items.tsx | 80 +++++++++++-------- .../search-groups/search-groups.tsx | 8 +- .../components/search-modal/search-modal.tsx | 31 ++++++- .../sidebar/components/search-modal/utils.ts | 23 +++--- .../w/components/sidebar/sidebar.tsx | 47 +++++++---- .../hooks/queries/utils/folder-tree.test.ts | 49 ++++++++++++ apps/sim/hooks/queries/utils/folder-tree.ts | 31 +++++++ .../hooks/queries/workflow-search-replace.ts | 44 +++++++--- .../selectors/providers/sim/selectors.ts | 52 +++++------- apps/sim/lib/folders/tree.ts | 14 ++++ 12 files changed, 325 insertions(+), 127 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index 4643aec86c1..c4b4ca377b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -13,7 +13,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' +import { useFolderMap } from '@/hooks/queries/folders' import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' interface KnowledgeBaseSelectorProps { @@ -43,6 +45,8 @@ export function KnowledgeBaseSelector({ error, } = useKnowledgeBasesList(workspaceId) + const { data: knowledgeBaseFolders = {} } = useFolderMap(workspaceId, 'knowledge_base') + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const value = isPreview ? previewValue : storeValue @@ -90,13 +94,32 @@ export function KnowledgeBaseSelector({ return Array.from(merged.values()) }, [knowledgeBases, selectedKnowledgeBaseQueries]) - const options = useMemo(() => { - return combinedKnowledgeBases.map((kb) => ({ - label: kb.name, - value: kb.id, - icon: PackageSearchIcon, - })) - }, [combinedKnowledgeBases]) + /** + * Display names, with the folder path appended when two knowledge bases share + * a name — otherwise the dropdown rows and the selected chips are + * indistinguishable from one another. Built in the same pass as the options so + * the chips and the dropdown can never disagree. + */ + const { options, labelById } = useMemo(() => { + const duplicateNames = collectDuplicateNames(combinedKnowledgeBases.map((kb) => kb.name)) + const labelById = new Map() + const options: ComboboxOption[] = combinedKnowledgeBases.map((kb) => { + const label = disambiguateLabelByFolder( + kb.name, + kb.folderId, + knowledgeBaseFolders, + duplicateNames + ) + labelById.set(kb.id, label) + return { label, value: kb.id, icon: PackageSearchIcon } + }) + return { options, labelById } + }, [combinedKnowledgeBases, knowledgeBaseFolders]) + + const labelOf = useCallback( + (kb: KnowledgeBaseData) => labelById.get(kb.id) ?? kb.name, + [labelById] + ) /** * Compute selected knowledge bases for tag display @@ -172,7 +195,7 @@ export function KnowledgeBaseSelector({ blockId, subBlockId: subBlock.id, valuePath: [index], - label: kb.name, + label: labelOf(kb), }) return (
- {formatDisplayText(kb.name, { workflowSearchHighlight })} + {formatDisplayText(labelOf(kb), { workflowSearchHighlight })} {!disabled && !isPreview && ( @@ -220,11 +243,13 @@ export function KnowledgeBaseSelector({ blockId, subBlockId: subBlock.id, valuePath: [], - label: selectedKnowledgeBases[0].name, + label: labelOf(selectedKnowledgeBases[0]), }) return workflowSearchHighlight ? ( - {formatDisplayText(selectedKnowledgeBases[0].name, { workflowSearchHighlight })} + {formatDisplayText(labelOf(selectedKnowledgeBases[0]), { + workflowSearchHighlight, + })} ) : undefined })() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx index 05c5f78d336..2a57b60aca1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx @@ -8,7 +8,9 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' +import { useFolderMap } from '@/hooks/queries/folders' import { useTablesList } from '@/hooks/queries/tables' +import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' interface TableSelectorProps { blockId: string @@ -45,15 +47,33 @@ export function TableSelector({ error, } = useTablesList(isPreview || disabled ? undefined : workspaceId) + const { data: tableFolders = {} } = useFolderMap( + isPreview || disabled ? undefined : workspaceId, + 'table' + ) + const value = isPreview ? previewValue : storeValue const tableId = typeof value === 'string' ? value : null + /** + * Two tables can share a name in different folders, so a colliding name is + * suffixed with its folder path. Table names are lowercased for display (the + * pre-existing styling here), and collisions are detected on that same + * lowercased form so `Leads` and `leads` — identical once displayed — are + * disambiguated too. The folder path keeps its authored casing. + */ const options = useMemo(() => { + const duplicateNames = collectDuplicateNames(tables.map((table) => table.name.toLowerCase())) return tables.map((table) => ({ - label: table.name.toLowerCase(), + label: disambiguateLabelByFolder( + table.name.toLowerCase(), + table.folderId, + tableFolders, + duplicateNames + ), value: table.id, })) - }, [tables]) + }, [tables, tableFolders]) const handleChange = useCallback( (selectedValue: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index 551c504cfb5..625dc23cf8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -78,6 +78,35 @@ export const MemoizedActionItem = memo( prev.shortcut === next.shortcut ) +/** + * Right-aligned folder breadcrumb. All but the last segment collapse first so a + * deep path degrades to the immediate parent rather than truncating the whole + * trail. Renders nothing at the workspace root. + */ +function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) { + if (!folderPath || folderPath.length === 0) return null + return ( + + {folderPath.length > 1 && ( + <> + + {folderPath.slice(0, -1).join(' / ')} + + / + + )} + {folderPath[folderPath.length - 1]} + + ) +} + +/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */ +function sameFolderPath(a?: string[], b?: string[]): boolean { + if (a === b) return true + if (a?.length !== b?.length) return false + return (a ?? []).every((segment, i) => segment === b?.[i]) +} + export const MemoizedWorkflowItem = memo( function WorkflowItem({ value, @@ -101,19 +130,7 @@ export const MemoizedWorkflowItem = memo( {name} {isCurrent && (current)} - {folderPath && folderPath.length > 0 && ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - - )} + ) }, @@ -121,9 +138,7 @@ export const MemoizedWorkflowItem = memo( prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent && - (prev.folderPath === next.folderPath || - (prev.folderPath?.length === next.folderPath?.length && - (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) + sameFolderPath(prev.folderPath, next.folderPath) ) export const MemoizedFileItem = memo( @@ -143,31 +158,17 @@ export const MemoizedFileItem = memo(
- + {name} - {folderPath && folderPath.length > 0 && ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - - )} + ) }, (prev, next) => prev.value === next.value && prev.name === next.name && - (prev.folderPath === next.folderPath || - (prev.folderPath?.length === next.folderPath?.length && - (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) + sameFolderPath(prev.folderPath, next.folderPath) ) export const MemoizedTaskItem = memo( @@ -253,18 +254,27 @@ export const MemoizedIconItem = memo( onSelect, name, icon: Icon, + folderPath, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> + folderPath?: string[] }) { return ( - {name} + + {name} + + ) }, - (prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon + (prev, next) => + prev.value === next.value && + prev.name === next.name && + prev.icon === next.icon && + sameFolderPath(prev.folderPath, next.folderPath) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index f8e071be9d0..9b8f24518f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -17,6 +17,7 @@ import { import type { ActionItem, FileItem, + FolderedItem, IntegrationSearchItem, PageItem, TaskItem, @@ -343,8 +344,8 @@ function createIconGroup( items, onSelect, }: { - items: TaskItem[] - onSelect: (item: TaskItem) => void + items: FolderedItem[] + onSelect: (item: FolderedItem) => void }) { if (items.length === 0) return null return ( @@ -352,10 +353,11 @@ function createIconGroup( {items.map((item) => ( onSelect(item)} name={item.name} icon={icon} + folderPath={item.folderPath} /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index df2d2d97900..34b73c1ed6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -621,21 +621,44 @@ export function SearchModal({ }, [isOnWorkflowPage, docs, deferredSearch]) const filteredTables = useMemo( - () => filterAndCap(tables, (t) => t.name, deferredSearch), + () => + filterAndCap( + tables, + (t) => t.name, + deferredSearch, + (t) => t.folderPath?.join(' ') + ), [tables, deferredSearch] ) const filteredFiles = useMemo( - () => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch), + () => + filterAndCap( + files, + (f) => f.name, + deferredSearch, + (f) => f.folderPath?.join(' ') + ), [files, deferredSearch] ) const filteredKnowledgeBases = useMemo( - () => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch), + () => + filterAndCap( + knowledgeBases, + (kb) => kb.name, + deferredSearch, + (kb) => kb.folderPath?.join(' ') + ), [knowledgeBases, deferredSearch] ) const filteredWorkflows = useMemo( () => - filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch), + filterAndCap( + workflows, + (w) => w.name, + deferredSearch, + (w) => w.folderPath?.join(' ') + ), [workflows, deferredSearch] ) const filteredChats = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 5b046c31a52..72265a6740e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -14,11 +14,15 @@ export interface TaskItem { href: string } -export interface WorkflowItem { - id: string - name: string - href: string +/** + * A {@link TaskItem} that lives in a folder tree, so the row can show which + * folder it came from — a name is only unique within its folder. + */ +export interface FolderedItem extends TaskItem { folderPath?: string[] +} + +export interface WorkflowItem extends FolderedItem { isCurrent?: boolean } @@ -39,12 +43,7 @@ export interface PageItem { hidden?: boolean } -export interface FileItem { - id: string - name: string - href: string - folderPath?: string[] -} +export type FileItem = FolderedItem /** Where an {@link ActionItem} (a verb) is available. */ export type ActionContext = 'global' | 'workflow' | 'integrations' @@ -72,9 +71,9 @@ export interface SearchModalProps { workflows?: WorkflowItem[] workspaces?: WorkspaceItem[] chats?: TaskItem[] - tables?: TaskItem[] + tables?: FolderedItem[] files?: FileItem[] - knowledgeBases?: TaskItem[] + knowledgeBases?: FolderedItem[] integrations?: IntegrationSearchItem[] connectedAccounts?: IntegrationSearchItem[] isOnWorkflowPage?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 295670fd4d9..e5c7c627ccf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -44,7 +44,7 @@ import { useSession } from '@/lib/auth/auth-client' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isChatEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' -import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' +import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -111,6 +111,7 @@ import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' +import type { WorkflowFolder } from '@/stores/folders/types' import { useSearchModalStore } from '@/stores/modals/search/store' import { useProvidersStore } from '@/stores/providers' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -124,6 +125,8 @@ const logger = createLogger('Sidebar') * invalidate every memo downstream of it. */ const EMPTY_CHATS: MothershipChatMetadata[] = [] +/** Stable identity while a folder list loads, so the search-row memos don't churn. */ +const EMPTY_FOLDER_MAP: Record = {} const SLACK_COMMUNITY_URL = 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA' @@ -538,7 +541,17 @@ export const Sidebar = memo(function Sidebar({ }) useFolders(workspaceId) - const { data: folderMap = {} } = useFolderMap(workspaceId) + const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId) + // Tables and knowledge bases keep their folders in the generic folder tree, + // keyed by resource type, so each needs its own map to resolve a path. + const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideTablesTab ? undefined : workspaceId, + 'table' + ) + const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId, + 'knowledge_base' + ) const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( @@ -712,18 +725,13 @@ export const Sidebar = memo(function Sidebar({ const searchModalWorkflows = useMemo( () => - regularWorkflows.map((workflow) => { - const folderPath = workflow.folderId - ? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name) - : [] - return { - id: workflow.id, - name: workflow.name, - href: `/workspace/${workspaceId}/w/${workflow.id}`, - folderPath: folderPath.length > 0 ? folderPath : undefined, - isCurrent: workflow.id === workflowId, - } - }), + regularWorkflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + href: `/workspace/${workspaceId}/w/${workflow.id}`, + folderPath: getFolderPathNames(folderMap, workflow.folderId), + isCurrent: workflow.id === workflowId, + })), [regularWorkflows, folderMap, workspaceId, workflowId] ) @@ -872,8 +880,9 @@ export const Sidebar = memo(function Sidebar({ id: t.id, name: t.name, href: `/workspace/${workspaceId}/tables/${t.id}`, + folderPath: getFolderPathNames(tableFolderMap, t.folderId), })), - [fetchedTables, workspaceId, permissionConfig.hideTablesTab] + [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab] ) const searchModalFiles = useMemo( @@ -897,8 +906,14 @@ export const Sidebar = memo(function Sidebar({ id: kb.id, name: kb.name, href: `/workspace/${workspaceId}/knowledge/${kb.id}`, + folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId), })), - [fetchedKnowledgeBases, workspaceId, permissionConfig.hideKnowledgeBaseTab] + [ + fetchedKnowledgeBases, + knowledgeBaseFolderMap, + workspaceId, + permissionConfig.hideKnowledgeBaseTab, + ] ) const chatIds = useMemo(() => chats.map((t) => t.id), [chats]) diff --git a/apps/sim/hooks/queries/utils/folder-tree.test.ts b/apps/sim/hooks/queries/utils/folder-tree.test.ts index 4149b4a4d7a..d8c0f65ff30 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.test.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.test.ts @@ -3,6 +3,8 @@ */ import { describe, expect, it } from 'vitest' import { + collectDuplicateNames, + disambiguateLabelByFolder, findLockedAncestorFolder, getFolderPath, isFolderEffectivelyLocked, @@ -203,3 +205,50 @@ describe('isFolderEffectivelyLocked', () => { expect(isFolderEffectivelyLocked({ locked: false, parentId: null }, {})).toBe(false) }) }) + +describe('collectDuplicateNames', () => { + it('returns only names seen more than once', () => { + expect(collectDuplicateNames(['a', 'b', 'a', 'c'])).toEqual(new Set(['a'])) + }) + + it('returns an empty set when every name is unique', () => { + expect(collectDuplicateNames(['a', 'b', 'c']).size).toBe(0) + }) + + it('reports a name once no matter how many times it repeats', () => { + expect(collectDuplicateNames(['a', 'a', 'a'])).toEqual(new Set(['a'])) + }) + + it('is case sensitive, so callers normalize before collecting', () => { + expect(collectDuplicateNames(['Leads', 'leads']).size).toBe(0) + }) +}) + +describe('disambiguateLabelByFolder', () => { + const folders = { + sales: makeFolder({ id: 'sales', name: 'Sales' }), + emea: makeFolder({ id: 'emea', name: 'EMEA', parentId: 'sales' }), + } + + it('leaves a unique name untouched', () => { + expect(disambiguateLabelByFolder('Leads', 'emea', folders, new Set())).toBe('Leads') + }) + + it('appends the full folder path to a colliding name', () => { + expect(disambiguateLabelByFolder('Leads', 'emea', folders, new Set(['Leads']))).toBe( + 'Leads (Sales / EMEA)' + ) + }) + + it('labels a colliding name at the workspace root as Root', () => { + expect(disambiguateLabelByFolder('Leads', null, folders, new Set(['Leads']))).toBe( + 'Leads (Root)' + ) + }) + + it('falls back to Root when the folder is unknown', () => { + expect(disambiguateLabelByFolder('Leads', 'deleted', folders, new Set(['Leads']))).toBe( + 'Leads (Root)' + ) + }) +}) diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index 39add522c57..656c23fa4eb 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -54,6 +54,37 @@ export function getFolderPath( return segments.length > 0 ? segments.join(separator) : null } +/** + * Names that appear more than once in the list, so callers can disambiguate + * only the entries that actually collide. + */ +export function collectDuplicateNames(names: Iterable): Set { + const seen = new Set() + const duplicates = new Set() + for (const name of names) { + if (seen.has(name)) duplicates.add(name) + else seen.add(name) + } + return duplicates +} + +/** + * Appends the folder path to a name that another item in the same list shares, + * e.g. `Leads (Sales / EMEA)` — or `Leads (Root)` at the workspace root. Names + * that are already unique are returned untouched so the common case stays + * readable. + */ +export function disambiguateLabelByFolder( + name: string, + folderId: string | null | undefined, + folders: Record, + duplicateNames: Set +): string { + if (!duplicateNames.has(name)) return name + const folderPath = getFolderPath(folderId, folders) + return folderPath ? `${name} (${folderPath})` : `${name} (Root)` +} + /** * Returns the closest locked ancestor folder for the given folderId, or `null` * when neither the folder nor any of its ancestors are locked. Cycles or diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 66a3e20fc0b..ec1cda83ddf 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -28,13 +28,19 @@ import type { WorkflowSearchMatch, WorkflowSearchReplacementOption, } from '@/lib/workflows/search-replace/types' +import { useFolderMap } from '@/hooks/queries/folders' import { fetchKnowledgeBase, fetchKnowledgeBases } from '@/hooks/queries/kb/knowledge' import { fetchOAuthCredentialDetail, fetchOAuthCredentials, } from '@/hooks/queries/oauth/oauth-credentials' +import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types' +import type { WorkflowFolder } from '@/stores/folders/types' + +/** Stable identity while a folder list loads, so `select` isn't re-keyed on it. */ +const EMPTY_FOLDER_MAP: Record = {} export interface WorkflowSearchResolvedResource { matchRawValue: string @@ -452,6 +458,10 @@ export function useWorkflowSearchKnowledgeReplacementOptions( matches: WorkflowSearchMatch[], workspaceId?: string ) { + const { data: knowledgeBaseFolders = EMPTY_FOLDER_MAP } = useFolderMap( + workspaceId, + 'knowledge_base' + ) const knowledgeGroups = useMemo( () => uniqueResourceOptionGroups(matches, 'knowledge-base'), [matches] @@ -466,15 +476,22 @@ export function useWorkflowSearchKnowledgeReplacementOptions( enabled: Boolean(workspaceId && knowledgeGroups.length > 0), staleTime: WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME, placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous, - select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] => - knowledgeGroups.flatMap((match) => + select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] => { + const duplicateNames = collectDuplicateNames(knowledgeBases.map((kb) => kb.name)) + return knowledgeGroups.flatMap((match) => knowledgeBases.map((knowledgeBase) => ({ kind: 'knowledge-base', value: knowledgeBase.id, - label: knowledgeBase.name, + label: disambiguateLabelByFolder( + knowledgeBase.name, + knowledgeBase.folderId, + knowledgeBaseFolders, + duplicateNames + ), resourceGroupKey: match.resource?.resourceGroupKey, })) - ), + ) + }, }, ], }) @@ -485,6 +502,7 @@ export function useWorkflowSearchTableReplacementOptions( workspaceId?: string ) { const tableGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'table'), [matches]) + const { data: tableFolders = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId, 'table') return useQueries({ queries: [ @@ -497,15 +515,23 @@ export function useWorkflowSearchTableReplacementOptions( }), enabled: Boolean(workspaceId && tableGroups.length > 0), staleTime: WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME, - select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] => - tableGroups.flatMap((match) => - response.data.tables.map((table) => ({ + select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] => { + const tables = response.data.tables + const duplicateNames = collectDuplicateNames(tables.map((table) => table.name)) + return tableGroups.flatMap((match) => + tables.map((table) => ({ kind: 'table', value: table.id, - label: table.name, + label: disambiguateLabelByFolder( + table.name, + table.folderId, + tableFolders, + duplicateNames + ), resourceGroupKey: match.resource?.resourceGroupKey, })) - ), + ) + }, }, ], }) diff --git a/apps/sim/hooks/selectors/providers/sim/selectors.ts b/apps/sim/hooks/selectors/providers/sim/selectors.ts index 1c7784cd982..13af6d6d08e 100644 --- a/apps/sim/hooks/selectors/providers/sim/selectors.ts +++ b/apps/sim/hooks/selectors/providers/sim/selectors.ts @@ -2,7 +2,7 @@ import { getColumnId } from '@/lib/table/column-keys' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { getTableDetailQueryOptions } from '@/hooks/queries/tables' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' -import { getFolderPath } from '@/hooks/queries/utils/folder-tree' +import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' @@ -13,37 +13,11 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' -import type { WorkflowFolder } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -/** - * Builds a label for a workflow option, appending the folder path when another - * workflow in the workspace shares the same display name. Avoids suffixing - * every workflow so the dropdown stays readable for the common case. - */ -function buildDisambiguatedLabel( - workflow: WorkflowMetadata, - duplicateNames: Set, - folders: Record -): string { - const baseLabel = workflow.name || `Workflow ${workflow.id.slice(0, 8)}` - if (!duplicateNames.has(baseLabel)) return baseLabel - - const folderPath = getFolderPath(workflow.folderId, folders) - return folderPath ? `${baseLabel} (${folderPath})` : `${baseLabel} (Root)` -} - -function collectDuplicateNames(workflows: WorkflowMetadata[]): Set { - const counts = new Map() - for (const workflow of workflows) { - const label = workflow.name || `Workflow ${workflow.id.slice(0, 8)}` - counts.set(label, (counts.get(label) ?? 0) + 1) - } - const duplicates = new Set() - for (const [label, count] of counts) { - if (count > 1) duplicates.add(label) - } - return duplicates +/** Matches the workflow list's own fallback for an unnamed workflow. */ +function workflowBaseLabel(workflow: WorkflowMetadata): string { + return workflow.name || `Workflow ${workflow.id.slice(0, 8)}` } export const simSelectors = { @@ -60,12 +34,17 @@ export const simSelectors = { await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId)) const workflows = getWorkflows(context.workspaceId) const folders = getFolderMap(context.workspaceId) - const duplicateNames = collectDuplicateNames(workflows) + const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel)) return workflows .filter((w) => w.id !== context.excludeWorkflowId) .map((w) => ({ id: w.id, - label: buildDisambiguatedLabel(w, duplicateNames, folders), + label: disambiguateLabelByFolder( + workflowBaseLabel(w), + w.folderId, + folders, + duplicateNames + ), })) .sort((a, b) => a.label.localeCompare(b.label)) }, @@ -80,10 +59,15 @@ export const simSelectors = { if (!workflow) return null const workflows = getWorkflows(context.workspaceId) const folders = getFolderMap(context.workspaceId) - const duplicateNames = collectDuplicateNames(workflows) + const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel)) return { id: detailId, - label: buildDisambiguatedLabel(workflow, duplicateNames, folders), + label: disambiguateLabelByFolder( + workflowBaseLabel(workflow), + workflow.folderId, + folders, + duplicateNames + ), } }, }, diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts index dec8e50a879..8e70dc3c4a7 100644 --- a/apps/sim/lib/folders/tree.ts +++ b/apps/sim/lib/folders/tree.ts @@ -65,3 +65,17 @@ export function getFolderPath( return path } + +/** + * Ancestor folder names, root-first — `undefined` at the workspace root. Search + * rows key their memo comparison on this, so the empty case must be `undefined` + * rather than an empty array. + */ +export function getFolderPathNames( + folders: Record, + folderId: string | null | undefined +): string[] | undefined { + if (!folderId) return undefined + const names = getFolderPath(folders, folderId).map((folder) => folder.name) + return names.length > 0 ? names : undefined +} From c3e6070d1b99ba25f9385a53eea73f2816ad0b8b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 2 Aug 2026 00:00:46 -0700 Subject: [PATCH 2/2] fix(zoho-desk): use the real Zoho Desk mark on a white tile The icon was a generic headset placeholder drawn in currentColor, so it never resembled Zoho at all. Replaces it with the mark from Zoho's official logo -- wordmark stripped, viewBox set to the mark's own bounding box so it centers -- and moves the tile to white, matching the other brand-mark integrations. --- apps/docs/components/icons.tsx | 6 +++--- apps/docs/content/docs/en/integrations/zoho_desk.mdx | 2 +- apps/sim/blocks/blocks/zoho-desk.ts | 2 +- apps/sim/components/icons.tsx | 6 +++--- apps/sim/lib/integrations/integrations.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 7b121cbc3a0..606e5b0faf6 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps) { return ( ) diff --git a/apps/docs/content/docs/en/integrations/zoho_desk.mdx b/apps/docs/content/docs/en/integrations/zoho_desk.mdx index 727f0fd9a02..2081c2a2a14 100644 --- a/apps/docs/content/docs/en/integrations/zoho_desk.mdx +++ b/apps/docs/content/docs/en/integrations/zoho_desk.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/sim/blocks/blocks/zoho-desk.ts b/apps/sim/blocks/blocks/zoho-desk.ts index bf63bb4e989..a13d05b4987 100644 --- a/apps/sim/blocks/blocks/zoho-desk.ts +++ b/apps/sim/blocks/blocks/zoho-desk.ts @@ -29,7 +29,7 @@ export const ZohoDeskBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/zoho_desk', category: 'tools', integrationType: IntegrationType.Support, - bgColor: '#E42527', + bgColor: '#FFFFFF', icon: ZohoDeskIcon, subBlocks: [ { diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 7b121cbc3a0..606e5b0faf6 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps) { return ( ) diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index db30b1d2064..a2d27494361 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -21818,7 +21818,7 @@ "name": "Zoho Desk", "description": "Manage Zoho Desk tickets, comments, threads, and contacts", "longDescription": "Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.", - "bgColor": "#E42527", + "bgColor": "#FFFFFF", "iconName": "ZohoDeskIcon", "docsUrl": "https://docs.sim.ai/integrations/zoho_desk", "operations": [