diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 670fa3fa9d1..78febe23caf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -14,7 +14,7 @@ import { DropdownMenuTrigger, Tooltip, } from '@sim/emcn' -import { Folder, Plus, Workflow } from '@sim/emcn/icons' +import { Folder, Plus } from '@sim/emcn/icons' import { truncate } from '@sim/utils/string' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { @@ -22,6 +22,11 @@ import { TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' import { isTerminalAvailable } from '@/lib/terminal/transport' +import { + type AvailableItem, + buildResourceFolderTree, + type ResourceTreeNode, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { RESOURCE_TAB_ICON_BUTTON_CLASS, @@ -55,15 +60,25 @@ export interface AddResourceDropdownProps { excludeTypes?: readonly MothershipResourceType[] } -export type AvailableItem = { id: string; name: string; [key: string]: unknown } - interface AvailableItemsByType { type: MothershipResourceType items: AvailableItem[] } +/** + * Folder hierarchies that exist purely to structure the browse menus. Unlike + * workflow (`folder`) and workspace-file (`filefolder`) folders these are not + * attachable resources, so they stay out of `groups` — which also feeds the + * flat search results, where a non-attachable row would be a dead end. + */ +interface StructureFolders { + table: AvailableItem[] + knowledgebase: AvailableItem[] +} + interface AvailableResources { groups: AvailableItemsByType[] + structureFolders: StructureFolders /** * True while enabled and at least one list has yet to produce data. Callers * that act on "no candidates" must check this first — an empty result during @@ -128,6 +143,16 @@ export function useAvailableResources( { enabled } ) const { data: folders, isPending: foldersPending } = useFolders(workspaceId, { enabled }) + // Folder lists exist only to shape their family's submenu, so they skip the + // fetch entirely when that family is excluded. + const { data: tableFolders } = useFolders(workspaceId, { + enabled: enabled && !excludeTypes?.includes('table'), + resourceType: 'table', + }) + const { data: knowledgeBaseFolders } = useFolders(workspaceId, { + enabled: enabled && !excludeTypes?.includes('knowledgebase'), + resourceType: 'knowledge_base', + }) const { data: fileFolders, isPending: fileFoldersPending } = useWorkspaceFileFolders( workspaceId, 'active', @@ -148,6 +173,11 @@ export function useAvailableResources( * Keyed off `isPending` rather than `data === undefined` so a failed list * settles to "not hydrating" — an errored query must not block the caller * forever. + * + * Only the lists feeding `groups` count. The table and knowledge-base folder + * lists shape submenus but never add candidates, so gating on them would + * swallow an `@`-mention Enter behind two round-trips that cannot change the + * answer. */ const isHydrating = enabled && @@ -185,7 +215,11 @@ export function useAvailableResources( }, { type: 'table' as const, - items: (tables ?? []).map((t) => ({ id: t.id, name: t.name })), + items: (tables ?? []).map((t) => ({ + id: t.id, + name: t.name, + folderId: t.folderId ?? null, + })), }, { type: 'file' as const, @@ -201,7 +235,11 @@ export function useAvailableResources( }, { type: 'knowledgebase' as const, - items: (knowledgeBases ?? []).map((kb) => ({ id: kb.id, name: kb.name })), + items: (knowledgeBases ?? []).map((kb) => ({ + id: kb.id, + name: kb.name, + folderId: kb.folderId ?? null, + })), }, { type: 'integration' as const, @@ -275,88 +313,61 @@ export function useAvailableResources( excludeTypes, ]) - // `groups` keeps its own stable identity so the consumers' downstream memos - // still key on it; only this wrapper changes when hydration settles. - return useMemo(() => ({ groups, isHydrating }), [groups, isHydrating]) -} - -export type WorkflowTreeNode = - | { kind: 'workflow'; id: string; name: string } - | { kind: 'folder'; id: string; name: string; children: WorkflowTreeNode[] } - -export function buildWorkflowFolderTree( - workflowItems: AvailableItem[], - folderItems: AvailableItem[] -): WorkflowTreeNode[] { - const knownFolderIds = new Set(folderItems.map((f) => f.id)) - - const byFolder = new Map() - for (const w of workflowItems) { - const fid = (w.folderId as string | null | undefined) ?? null - const key = fid && knownFolderIds.has(fid) ? fid : null - const bucket = byFolder.get(key) ?? [] - bucket.push(w) - byFolder.set(key, bucket) - } - - const toWorkflowNode = (w: AvailableItem): WorkflowTreeNode => ({ - kind: 'workflow', - id: w.id, - name: w.name, - }) - - const buildLevel = (parentId: string | null): WorkflowTreeNode[] => { - const childFolders = folderItems.filter( - (f) => ((f.parentId as string | null | undefined) ?? null) === parentId - ) - const childWorkflows = byFolder.get(parentId) ?? [] - - const mixed: Array<{ sortOrder: number; id: string; node: WorkflowTreeNode }> = [] - - for (const f of childFolders) { - const children = buildLevel(f.id) - if (children.length === 0) continue - mixed.push({ - sortOrder: (f.sortOrder as number) ?? 0, - id: f.id, - node: { kind: 'folder', id: f.id, name: f.name, children }, - }) - } - - for (const w of childWorkflows) { - mixed.push({ - sortOrder: (w.sortOrder as number) ?? 0, - id: w.id, - node: toWorkflowNode(w), - }) + /** + * Sorted by name to match how the Tables and Knowledge pages order folders — + * the list endpoint makes no ordering guarantee, and these folders carry no + * user-defined ordering the way workflow folders do. + */ + const structureFolders = useMemo(() => { + const toFolderItems = (source: typeof tableFolders): AvailableItem[] => + (source ?? []) + .map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null })) + .sort((a, b) => a.name.localeCompare(b.name)) + return { + table: toFolderItems(tableFolders), + knowledgebase: toFolderItems(knowledgeBaseFolders), } - - mixed.sort((a, b) => - a.sortOrder !== b.sortOrder ? a.sortOrder - b.sortOrder : a.id.localeCompare(b.id) - ) - return mixed.map((m) => m.node) - } - - return buildLevel(null) + }, [tableFolders, knowledgeBaseFolders]) + + // `groups` and `structureFolders` keep their own stable identities so the + // consumers' downstream memos still key on them; only this wrapper changes + // when hydration settles. + return useMemo( + () => ({ groups, structureFolders, isHydrating }), + [groups, structureFolders, isHydrating] + ) } -interface WorkflowFolderTreeItemsProps { - nodes: WorkflowTreeNode[] +interface ResourceFolderTreeItemsProps { + nodes: ResourceTreeNode[] + /** Resource type of the leaf items. */ + type: MothershipResourceType + /** + * Set when the folder is itself an attachable resource (workspace files): the + * folder is then offered as the first entry of its own submenu. Omitted for + * folders that only provide structure (workflows, tables, knowledge bases). + */ + folderType?: MothershipResourceType onSelect: (resource: MothershipResource) => void } -export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) { +/** Renders a {@link buildResourceFolderTree} result as nested dropdown submenus. */ +export function ResourceFolderTreeItems({ + nodes, + type, + folderType, + onSelect, +}: ResourceFolderTreeItemsProps) { + const config = getResourceConfig(type) return ( <> {nodes.map((node) => - node.kind === 'workflow' ? ( + node.kind === 'item' ? ( onSelect({ type: 'workflow', id: node.id, title: node.name })} + onClick={() => onSelect({ type, id: node.id, title: node.item.name })} > - {getResourceConfig('workflow').renderDropdownItem({ - item: { id: node.id, name: node.name }, - })} + {config.renderDropdownItem({ item: node.item })} ) : ( @@ -365,7 +376,20 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI {node.name} - + {folderType && ( + onSelect({ type: folderType, id: node.id, title: node.name })} + > + + {node.name} + + )} + ) @@ -374,83 +398,115 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI ) } -export type FileFolderTreeNode = - | { kind: 'file'; id: string; name: string } - | { kind: 'folder'; id: string; name: string; children: FileFolderTreeNode[] } +interface FolderedSectionSpec { + /** Leaf resource type — also supplies the submenu's label and icon. */ + type: MothershipResourceType + /** + * Where this family's folders come from: another entry in `groups` when the + * folders are attachable resources, or `structureFolders` when they are not. + */ + folders: + | { kind: 'group'; type: MothershipResourceType } + | { kind: 'structure'; key: keyof StructureFolders } + /** + * Set when the folder is itself attachable. Doubles as the pruning rule: a + * folder the user cannot select is dead UI when empty, while a selectable one + * must stay reachable. + */ + folderType?: MothershipResourceType + /** Interleave folders and items by `sortOrder` — the workflow sidebar's manual ordering. */ + orderBySortOrder?: boolean +} -export function buildFileFolderTree( - fileItems: AvailableItem[], - folderItems: AvailableItem[] -): FileFolderTreeNode[] { - const byFolder = new Map() - for (const f of fileItems) { - const key = (f.folderId as string | null | undefined) ?? null - const bucket = byFolder.get(key) ?? [] - bucket.push(f) - byFolder.set(key, bucket) - } +/** Single source of truth for the foldered submenus, in display order. */ +const FOLDERED_SECTION_SPECS: readonly FolderedSectionSpec[] = [ + { type: 'workflow', folders: { kind: 'group', type: 'folder' }, orderBySortOrder: true }, + { type: 'file', folders: { kind: 'group', type: 'filefolder' }, folderType: 'filefolder' }, + { type: 'table', folders: { kind: 'structure', key: 'table' } }, + { type: 'knowledgebase', folders: { kind: 'structure', key: 'knowledgebase' } }, +] + +/** + * Every resource type the foldered submenus already render, derived from the + * specs so a new family cannot be added to one list and missed in the other — + * which would render it twice, once as a submenu and again in the flat tail. + */ +export const FOLDERED_RESOURCE_TYPES = new Set( + FOLDERED_SECTION_SPECS.flatMap((spec) => + spec.folders.kind === 'group' ? [spec.type, spec.folders.type] : [spec.type] + ) +) - const buildLevel = (parentId: string | null): FileFolderTreeNode[] => { - const childFolders = folderItems.filter( - (f) => ((f.parentId as string | null | undefined) ?? null) === parentId - ) - const childFiles = byFolder.get(parentId) ?? [] - const nodes: FileFolderTreeNode[] = [] - for (const folder of childFolders) { - nodes.push({ - kind: 'folder', - id: folder.id, - name: folder.name, - children: buildLevel(folder.id), - }) - } - for (const file of childFiles) { - nodes.push({ kind: 'file', id: file.id, name: file.name }) - } - return nodes - } +export interface ResourceTreeSection { + type: MothershipResourceType + folderType?: MothershipResourceType + nodes: ResourceTreeNode[] +} - return buildLevel(null) +/** + * Builds the foldered submenus every browse menu shares, in display order and + * with empty families dropped. + */ +export function useResourceTreeSections({ + groups, + structureFolders, +}: Pick): ResourceTreeSection[] { + return useMemo(() => { + const itemsOf = (type: MothershipResourceType) => + groups.find((group) => group.type === type)?.items ?? [] + return FOLDERED_SECTION_SPECS.map((spec) => ({ + type: spec.type, + folderType: spec.folderType, + nodes: buildResourceFolderTree( + itemsOf(spec.type), + spec.folders.kind === 'group' + ? itemsOf(spec.folders.type) + : structureFolders[spec.folders.key], + { orderBySortOrder: spec.orderBySortOrder, pruneEmpty: !spec.folderType } + ), + })).filter((section) => section.nodes.length > 0) + }, [groups, structureFolders]) } -interface FileFolderTreeItemsProps { - nodes: FileFolderTreeNode[] +interface ResourceTreeSectionsProps { + sections: ResourceTreeSection[] onSelect: (resource: MothershipResource) => void + /** + * Width override for the submenu panels. The chat menu widens them past the + * canonical 280px and clamps to the viewport so a deep folder path cannot + * overflow a narrow window. + */ + subContentClassName?: string } -export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) { +/** Renders {@link useResourceTreeSections} output as one submenu per family. */ +export function ResourceTreeSections({ + sections, + onSelect, + subContentClassName, +}: ResourceTreeSectionsProps) { return ( <> - {nodes.map((node) => - node.kind === 'file' ? ( - onSelect({ type: 'file', id: node.id, title: node.name })} - > - {getResourceConfig('file').renderDropdownItem({ - item: { id: node.id, name: node.name }, - })} - - ) : ( - + {sections.map((section) => { + const config = getResourceConfig(section.type) + const SectionIcon = config.icon + return ( + - - {node.name} + + {config.label} - - onSelect({ type: 'filefolder', id: node.id, title: node.name })} - > - - {node.name} - - {node.children.length > 0 && ( - - )} + + ) - )} + })} ) } @@ -466,7 +522,11 @@ export function AddResourceDropdown({ const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) // Gated on `open` so an idle tab bar never fetches the workspace lists. - const { groups: available } = useAvailableResources(workspaceId, { enabled: open, excludeTypes }) + const { groups: available, structureFolders } = useAvailableResources(workspaceId, { + enabled: open, + excludeTypes, + }) + const treeSections = useResourceTreeSections({ groups: available, structureFolders }) const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { @@ -486,18 +546,6 @@ export function AddResourceDropdown({ setActiveIndex(0) } - const workflowTree = useMemo(() => { - const workflowGroup = available.find((g) => g.type === 'workflow') - const folderGroup = available.find((g) => g.type === 'folder') - return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) - }, [available]) - - const fileFolderTree = useMemo(() => { - const fileGroup = available.find((g) => g.type === 'file') - const fileFolderGroup = available.find((g) => g.type === 'filefolder') - return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) - }, [available]) - const filtered = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return null @@ -579,39 +627,9 @@ export function AddResourceDropdown({ ) ) : ( <> - {workflowTree.length > 0 && ( - - - - Workflows - - - - - - )} - {fileFolderTree.length > 0 && ( - - - {(() => { - const Icon = getResourceConfig('file').icon - return - })()} - Files - - - - - - )} + {available.map(({ type, items }) => { - if ( - type === 'workflow' || - type === 'folder' || - type === 'file' || - type === 'filefolder' - ) - return null + if (FOLDERED_RESOURCE_TYPES.has(type)) return null if (items.length === 0) return null const config = getResourceConfig(type) const Icon = config.icon diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index 7d0e88042b7..337b05909ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -1,8 +1,8 @@ export { AddResourceDropdown, - buildFileFolderTree, - buildWorkflowFolderTree, - FileFolderTreeItems, + FOLDERED_RESOURCE_TYPES, + ResourceFolderTreeItems, + ResourceTreeSections, useAvailableResources, - WorkflowFolderTreeItems, + useResourceTreeSections, } from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts new file mode 100644 index 00000000000..c78157db8bb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type AvailableItem, + buildResourceFolderTree, + type ResourceTreeNode, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree' + +function item(id: string, folderId: string | null = null, sortOrder?: number): AvailableItem { + return { id, name: id, folderId, ...(sortOrder === undefined ? {} : { sortOrder }) } +} + +function folder(id: string, parentId: string | null = null, sortOrder?: number): AvailableItem { + return { id, name: id, parentId, ...(sortOrder === undefined ? {} : { sortOrder }) } +} + +/** Flattens to `id` strings, folders as `id[...children]`, for terse assertions. */ +function shape(nodes: ResourceTreeNode[]): string[] { + return nodes.map((node) => + node.kind === 'item' ? node.id : `${node.id}[${shape(node.children).join(',')}]` + ) +} + +describe('buildResourceFolderTree', () => { + it('nests items under their folder and keeps root items at the top level', () => { + const tree = buildResourceFolderTree( + [item('rootItem'), item('nested', 'f1'), item('deep', 'f2')], + [folder('f1'), folder('f2', 'f1')] + ) + expect(shape(tree)).toEqual(['f1[f2[deep],nested]', 'rootItem']) + }) + + it('exposes the full source item to the caller, not just id and name', () => { + const source = { id: 't1', name: 'Table', folderId: null, custom: 42 } + const [node] = buildResourceFolderTree([source], []) + expect(node).toEqual({ kind: 'item', id: 't1', item: source }) + }) + + it('surfaces items whose folder is unknown at the root instead of dropping them', () => { + const tree = buildResourceFolderTree([item('orphan', 'deleted-folder')], [folder('f1')]) + expect(shape(tree)).toEqual(['f1[]', 'orphan']) + }) + + it('surfaces folders whose parent is unknown at the root', () => { + const tree = buildResourceFolderTree([item('child', 'f1')], [folder('f1', 'deleted-parent')]) + expect(shape(tree)).toEqual(['f1[child]']) + }) + + it('keeps empty folders by default', () => { + const tree = buildResourceFolderTree([], [folder('empty')]) + expect(shape(tree)).toEqual(['empty[]']) + }) + + it('prunes folders with no items at any depth when pruneEmpty is set', () => { + const tree = buildResourceFolderTree( + [item('kept', 'full')], + [folder('full'), folder('empty'), folder('emptyChild', 'empty')], + { pruneEmpty: true } + ) + expect(shape(tree)).toEqual(['full[kept]']) + }) + + it('interleaves folders and items by sortOrder when orderBySortOrder is set', () => { + const tree = buildResourceFolderTree( + [item('itemA', null, 1), item('itemC', null, 3)], + [folder('folderB', null, 2)], + { orderBySortOrder: true, pruneEmpty: false } + ) + expect(shape(tree)).toEqual(['itemA', 'folderB[]', 'itemC']) + }) + + it('breaks sortOrder ties by id so ordering stays stable', () => { + const tree = buildResourceFolderTree([item('b', null, 0), item('a', null, 0)], [], { + orderBySortOrder: true, + }) + expect(shape(tree)).toEqual(['a', 'b']) + }) + + it('treats a missing sortOrder as 0 rather than sorting it last', () => { + const tree = buildResourceFolderTree([item('withOrder', null, 5), item('noOrder')], [], { + orderBySortOrder: true, + }) + expect(shape(tree)).toEqual(['noOrder', 'withOrder']) + }) + + it('drops folders in a parent cycle rather than rooting them, so the walk terminates', () => { + const tree = buildResourceFolderTree([item('rootItem')], [folder('a', 'b'), folder('b', 'a')], { + pruneEmpty: false, + }) + expect(shape(tree)).toEqual(['rootItem']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts new file mode 100644 index 00000000000..ac9bd787dac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts @@ -0,0 +1,96 @@ +export type AvailableItem = { id: string; name: string; [key: string]: unknown } + +export type ResourceTreeNode = + | { kind: 'item'; id: string; item: AvailableItem } + | { kind: 'folder'; id: string; name: string; children: ResourceTreeNode[] } + +export interface BuildResourceFolderTreeOptions { + /** + * Interleave folders and items at each level by their `sortOrder`, mirroring + * the workflow sidebar's manual ordering. Otherwise folders come first, then + * items, each in source order. + */ + orderBySortOrder?: boolean + /** + * Drop folders holding no items at any depth. Callers derive this from + * whether the folder is selectable: one the user cannot select is dead UI + * when empty, while a selectable one must stay reachable. + */ + pruneEmpty?: boolean +} + +/** + * Nests flat item and folder lists into the hierarchy the browse menus render. + * Shared by every foldered resource family (workflows, files, tables, knowledge + * bases); the families differ only in the options above and in whether the + * caller renders folders as selectable. + * + * Items and folders whose parent does not resolve to a known folder surface at + * the root rather than vanishing, matching how the resource pages heal orphans. + * Folders forming a parent cycle resolve to no root-reachable parent and are + * therefore skipped, so the walk always terminates. + */ +export function buildResourceFolderTree( + items: AvailableItem[], + folders: AvailableItem[], + options?: BuildResourceFolderTreeOptions +): ResourceTreeNode[] { + const knownFolderIds = new Set(folders.map((folder) => folder.id)) + + const parentOf = (entry: AvailableItem, key: 'parentId' | 'folderId'): string | null => { + const id = (entry[key] as string | null | undefined) ?? null + return id && knownFolderIds.has(id) ? id : null + } + + const bucket = ( + map: Map, + key: string | null, + value: AvailableItem + ) => { + const existing = map.get(key) + if (existing) existing.push(value) + else map.set(key, [value]) + } + + const foldersByParent = new Map() + for (const folder of folders) bucket(foldersByParent, parentOf(folder, 'parentId'), folder) + + const itemsByFolder = new Map() + for (const item of items) bucket(itemsByFolder, parentOf(item, 'folderId'), item) + + const sortOrderOf = (entry: AvailableItem) => (entry.sortOrder as number | undefined) ?? 0 + + const buildLevel = (parentId: string | null): ResourceTreeNode[] => { + const childFolders = foldersByParent.get(parentId) ?? [] + const childItems = itemsByFolder.get(parentId) ?? [] + + // Folders first, then items — the default order. `sources[i]` is the record + // `nodes[i]` was built from; the sort below permutes by index against that + // pairing. + const sources: AvailableItem[] = [] + const nodes: ResourceTreeNode[] = [] + + for (const folder of childFolders) { + const children = buildLevel(folder.id) + if (options?.pruneEmpty && children.length === 0) continue + sources.push(folder) + nodes.push({ kind: 'folder', id: folder.id, name: folder.name, children }) + } + for (const item of childItems) { + sources.push(item) + nodes.push({ kind: 'item', id: item.id, item }) + } + + if (!options?.orderBySortOrder) return nodes + + return nodes + .map((_, index) => index) + .sort((a, b) => { + const delta = sortOrderOf(sources[a]) - sortOrderOf(sources[b]) + return delta !== 0 ? delta : sources[a].id.localeCompare(sources[b].id) + }) + .map((index) => nodes[index]) + } + + return buildLevel(null) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 872e4466791..3972160af60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -12,13 +12,11 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' -import { Workflow } from '@sim/emcn/icons' import { - buildFileFolderTree, - buildWorkflowFolderTree, - FileFolderTreeItems, + FOLDERED_RESOURCE_TYPES, + ResourceTreeSections, useAvailableResources, - WorkflowFolderTreeItems, + useResourceTreeSections, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' @@ -72,7 +70,11 @@ export const PlusMenuDropdown = React.memo( const contentRef = useRef(null) // Gated so an idle chat surface never fetches the workspace lists. - const { groups: availableResources, isHydrating } = useAvailableResources(workspaceId, { + const { + groups: availableResources, + structureFolders, + isHydrating, + } = useAvailableResources(workspaceId, { enabled: open || !!warm, }) @@ -102,17 +104,10 @@ export const PlusMenuDropdown = React.memo( : attachable.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)) }, [isMention, availableResources]) - const workflowTree = useMemo(() => { - const workflowGroup = visibleResources.find((g) => g.type === 'workflow') - const folderGroup = visibleResources.find((g) => g.type === 'folder') - return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) - }, [visibleResources]) - - const fileFolderTree = useMemo(() => { - const fileGroup = visibleResources.find((g) => g.type === 'file') - const fileFolderGroup = visibleResources.find((g) => g.type === 'filefolder') - return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) - }, [visibleResources]) + const treeSections = useResourceTreeSections({ + groups: visibleResources, + structureFolders, + }) const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search @@ -310,39 +305,13 @@ export const PlusMenuDropdown = React.memo( {/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */}