fix(search): disambiguate tables and knowledge bases by folder (#6192)

* 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.

* 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.
This commit is contained in:
Waleed
2026-08-02 00:04:35 -07:00
committed by GitHub
parent f113b0bc24
commit 25e609167f
17 changed files with 334 additions and 136 deletions
+3 -3
View File
@@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 24 24'
viewBox='-24 -6 308 308'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<path
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
fill='currentColor'
d='M22.3,293c-1.6,0-3.3-0.5-4.7-1.4c-2.8-1.8-4.2-5.1-3.7-8.4l24.3-149.3c1.3-8,5.4-15.2,11.5-20.4 c6.1-5.2,14-8.1,22-8.1h100.1l5.6-34.3H168c-4.2,0-8.1-1.8-10.8-5c-2.7-3.2-3.9-7.3-3.2-11.4l6.4-39.7c1.1-6.9,7-11.9,14-11.9h37.3 c10,0,19.4,4.4,25.9,12s9.3,17.6,7.7,27.5l-15,92.4c-0.7,4-3,7.6-6.5,9.8L140,196.5c-5.1,3.1-11.6,2.8-16.3-1l-39-31.1 c-3.7-2.9-4.3-8.3-1.3-11.9c2.9-3.7,8.3-4.3,11.9-1.3l37.5,29.9l81-50.2l14.8-91.1c0.8-4.9-0.6-9.9-3.8-13.7c-3.2-3.8-7.9-6-12.9-6 h-34.9l-5.5,34h9.3c4.2,0,8.1,1.8,10.8,5c2.7,3.2,3.9,7.3,3.2,11.4l-6.5,39.9c-1.1,6.9-7,11.9-14,11.9H71.7 c-8.4,0-15.4,6-16.8,14.3L33.6,267.5L63.5,249c2.2-1.4,4.8-2.1,7.5-2.1h109.5c8.4,0,15.4-6,16.8-14.3l6.8-41.9 c0.8-4.6,5.1-7.8,9.7-7c4.6,0.8,7.8,5.1,7,9.7l-6.8,41.9c-1.3,8-5.4,15.2-11.5,20.4c-6.1,5.2-14,8.1-22,8.1H71.7l-45,27.9 C25.4,292.6,23.8,293,22.3,293z'
fill='#089949'
/>
</svg>
)
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="zoho_desk"
color="#E42527"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
@@ -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<ComboboxOption[]>(() => {
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<string, string>()
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 (
<div
@@ -181,14 +204,14 @@ export function KnowledgeBaseSelector({
>
<PackageSearchIcon className='mr-1 size-3 text-[var(--brand-knowledge)]' />
<span className='font-medium text-[var(--brand-knowledge)]'>
{formatDisplayText(kb.name, { workflowSearchHighlight })}
{formatDisplayText(labelOf(kb), { workflowSearchHighlight })}
</span>
{!disabled && !isPreview && (
<button
type='button'
onClick={() => handleRemoveKnowledgeBase(kb.id)}
className='ml-1 text-[color-mix(in_srgb,var(--brand-knowledge)_60%,transparent)] hover-hover:text-[var(--brand-knowledge)]'
aria-label={`Remove ${kb.name}`}
aria-label={`Remove ${labelOf(kb)}`}
>
<X className='size-3' />
</button>
@@ -220,11 +243,13 @@ export function KnowledgeBaseSelector({
blockId,
subBlockId: subBlock.id,
valuePath: [],
label: selectedKnowledgeBases[0].name,
label: labelOf(selectedKnowledgeBases[0]),
})
return workflowSearchHighlight ? (
<span className='truncate text-[var(--text-primary)]'>
{formatDisplayText(selectedKnowledgeBases[0].name, { workflowSearchHighlight })}
{formatDisplayText(labelOf(selectedKnowledgeBases[0]), {
workflowSearchHighlight,
})}
</span>
) : undefined
})()
@@ -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<ComboboxOption[]>(() => {
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) => {
@@ -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 (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)
}
/** 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(
<span className='truncate'>{name}</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)}
<FolderPathSuffix folderPath={folderPath} />
</Command.Item>
)
},
@@ -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(
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
<File className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)}
<FolderPathSuffix folderPath={folderPath} />
</Command.Item>
)
},
(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 (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>{name}</span>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
</span>
<FolderPathSuffix folderPath={folderPath} />
</Command.Item>
)
},
(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)
)
@@ -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) => (
<MemoizedIconItem
key={item.id}
value={`${item.name} ${prefix}-${item.id}`}
value={`${item.name} ${item.folderPath?.join(' / ') ?? ''} ${prefix}-${item.id}`}
onSelect={() => onSelect(item)}
name={item.name}
icon={icon}
folderPath={item.folderPath}
/>
))}
</Command.Group>
@@ -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(
@@ -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
@@ -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<string, WorkflowFolder> = {}
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])
+1 -1
View File
@@ -29,7 +29,7 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
docsLink: 'https://docs.sim.ai/integrations/zoho_desk',
category: 'tools',
integrationType: IntegrationType.Support,
bgColor: '#E42527',
bgColor: '#FFFFFF',
icon: ZohoDeskIcon,
subBlocks: [
{
+3 -3
View File
@@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 24 24'
viewBox='-24 -6 308 308'
fill='none'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
>
<path
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
fill='currentColor'
d='M22.3,293c-1.6,0-3.3-0.5-4.7-1.4c-2.8-1.8-4.2-5.1-3.7-8.4l24.3-149.3c1.3-8,5.4-15.2,11.5-20.4 c6.1-5.2,14-8.1,22-8.1h100.1l5.6-34.3H168c-4.2,0-8.1-1.8-10.8-5c-2.7-3.2-3.9-7.3-3.2-11.4l6.4-39.7c1.1-6.9,7-11.9,14-11.9h37.3 c10,0,19.4,4.4,25.9,12s9.3,17.6,7.7,27.5l-15,92.4c-0.7,4-3,7.6-6.5,9.8L140,196.5c-5.1,3.1-11.6,2.8-16.3-1l-39-31.1 c-3.7-2.9-4.3-8.3-1.3-11.9c2.9-3.7,8.3-4.3,11.9-1.3l37.5,29.9l81-50.2l14.8-91.1c0.8-4.9-0.6-9.9-3.8-13.7c-3.2-3.8-7.9-6-12.9-6 h-34.9l-5.5,34h9.3c4.2,0,8.1,1.8,10.8,5c2.7,3.2,3.9,7.3,3.2,11.4l-6.5,39.9c-1.1,6.9-7,11.9-14,11.9H71.7 c-8.4,0-15.4,6-16.8,14.3L33.6,267.5L63.5,249c2.2-1.4,4.8-2.1,7.5-2.1h109.5c8.4,0,15.4-6,16.8-14.3l6.8-41.9 c0.8-4.6,5.1-7.8,9.7-7c4.6,0.8,7.8,5.1,7,9.7l-6.8,41.9c-1.3,8-5.4,15.2-11.5,20.4c-6.1,5.2-14,8.1-22,8.1H71.7l-45,27.9 C25.4,292.6,23.8,293,22.3,293z'
fill='#089949'
/>
</svg>
)
@@ -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)'
)
})
})
@@ -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<string>): Set<string> {
const seen = new Set<string>()
const duplicates = new Set<string>()
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<string, WorkflowFolder>,
duplicateNames: Set<string>
): 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
@@ -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<string, WorkflowFolder> = {}
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,
}))
),
)
},
},
],
})
@@ -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<string>,
folders: Record<string, WorkflowFolder>
): 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<string> {
const counts = new Map<string, number>()
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<string>()
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
),
}
},
},
+14
View File
@@ -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<string, WorkflowFolder>,
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
}
+1 -1
View File
@@ -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": [