From 48f1da449f4c5bd10b6fdc556a28f73eb3527028 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 22:54:53 -0700 Subject: [PATCH] fix(chat): show table and knowledge base folders in the resource menus Tables and knowledge bases gained folder support, but the chat attachment menu and the embedded resource menu still listed them flat. Both now render the same hierarchy the Tables and Knowledge pages show. Unifies the two near-duplicate workflow/file tree builders into one shared buildResourceFolderTree plus a spec-driven useResourceTreeSections, so all four foldered families go through one code path. Items whose folder no longer resolves now surface at the root instead of vanishing. --- .../add-resource-dropdown.tsx | 384 +++++++++--------- .../components/add-resource-dropdown/index.ts | 8 +- .../resource-folder-tree.test.ts | 94 +++++ .../resource-folder-tree.ts | 96 +++++ .../plus-menu-dropdown/plus-menu-dropdown.tsx | 67 +-- 5 files changed, 413 insertions(+), 236 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts 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. */}