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 6749f71fb75..ea49ebd3371 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 @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Button, DropdownMenu, @@ -13,7 +13,7 @@ import { DropdownMenuTrigger, Tooltip, } from '@/components/emcn' -import { Plus } from '@/components/emcn/icons' +import { Folder, Plus } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { @@ -68,6 +68,8 @@ export function useAvailableResources( id: w.id, name: w.name, color: w.color, + folderId: w.folderId ?? null, + sortOrder: w.sortOrder, isOpen: existingKeys.has(`workflow:${w.id}`), })), }, @@ -76,6 +78,8 @@ export function useAvailableResources( items: folders.map((f) => ({ id: f.id, name: f.name, + parentId: f.parentId ?? null, + sortOrder: f.sortOrder, isOpen: existingKeys.has(`folder:${f.id}`), })), }, @@ -116,6 +120,104 @@ export function useAvailableResources( }, [workflows, folders, tables, files, knowledgeBases, tasks, existingKeys, excludeTypes]) } +export type WorkflowTreeNode = + | { kind: 'workflow'; id: string; name: string; color: string; isOpen?: boolean } + | { 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, + color: (w.color as string) ?? '#808080', + isOpen: w.isOpen, + }) + + 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), + }) + } + + 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) +} + +interface WorkflowFolderTreeItemsProps { + nodes: WorkflowTreeNode[] + onSelect: (resource: MothershipResource, isOpen?: boolean) => void +} + +export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeItemsProps) { + return ( + <> + {nodes.map((node) => + node.kind === 'workflow' ? ( + + onSelect({ type: 'workflow', id: node.id, title: node.name }, node.isOpen) + } + > + {getResourceConfig('workflow').renderDropdownItem({ + item: { id: node.id, name: node.name, color: node.color }, + })} + + ) : ( + + + + {node.name} + + + + + + ) + )} + + ) +} + export function AddResourceDropdown({ workspaceId, existingKeys, @@ -128,27 +230,30 @@ export function AddResourceDropdown({ const [activeIndex, setActiveIndex] = useState(0) const available = useAvailableResources(workspaceId, existingKeys, excludeTypes) - const handleOpenChange = useCallback((next: boolean) => { + const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { setSearch('') setActiveIndex(0) } - }, []) + } - const select = useCallback( - (resource: MothershipResource, isOpen?: boolean) => { - if (isOpen && onSwitch) { - onSwitch(resource.id) - } else { - onAdd(resource) - } - setOpen(false) - setSearch('') - setActiveIndex(0) - }, - [onAdd, onSwitch] - ) + const select = (resource: MothershipResource, isOpen?: boolean) => { + if (isOpen && onSwitch) { + onSwitch(resource.id) + } else { + onAdd(resource) + } + setOpen(false) + setSearch('') + 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 filtered = useMemo(() => { const q = search.toLowerCase().trim() @@ -158,25 +263,22 @@ export function AddResourceDropdown({ ) }, [search, available]) - const handleSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!filtered) return - if (e.key === 'ArrowDown') { + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (!filtered) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) { + if (filtered.length > 0 && filtered[activeIndex]) { e.preventDefault() - setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) { - if (filtered.length > 0 && filtered[activeIndex]) { - e.preventDefault() - const { type, item } = filtered[activeIndex] - select({ type, id: item.id, title: item.name }, item.isOpen) - } + const { type, item } = filtered[activeIndex] + select({ type, id: item.id, title: item.name }, item.isOpen) } - }, - [filtered, activeIndex, select] - ) + } + } return ( @@ -199,7 +301,7 @@ export function AddResourceDropdown({ e.preventDefault()} > select({ type, id: item.id, title: item.name }, item.isOpen)} > {config.renderDropdownItem({ item })} - - {config.label} - ) }) @@ -237,25 +336,33 @@ export function AddResourceDropdown({ ) ) : ( <> + {workflowTree.length > 0 && ( + + +
+ Workflows + + + + + + )} {available.map(({ type, items }) => { + if (type === 'workflow' || type === 'folder') return null if (items.length === 0) return null const config = getResourceConfig(type) const Icon = config.icon return ( - {type === 'workflow' ? ( -
- ) : ( - - )} + {config.label} 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 bcf979b39b3..a89dbf0db7e 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,2 +1,11 @@ -export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown' -export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown' +export type { + AddResourceDropdownProps, + AvailableItem, + WorkflowTreeNode, +} from './add-resource-dropdown' +export { + AddResourceDropdown, + buildWorkflowFolderTree, + useAvailableResources, + WorkflowFolderTreeItems, +} from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index d5656d4cf23..cc56b338efb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -13,7 +13,11 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { Plus, Sim } from '@/components/emcn/icons' -import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' +import { + buildWorkflowFolderTree, + type useAvailableResources, + WorkflowFolderTreeItems, +} 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' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' @@ -55,6 +59,12 @@ export const PlusMenuDropdown = React.memo( React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen]) + const workflowTree = useMemo(() => { + const workflowGroup = availableResources.find((g) => g.type === 'workflow') + const folderGroup = availableResources.find((g) => g.type === 'folder') + return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) + }, [availableResources]) + const filteredItems = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return null @@ -63,34 +73,25 @@ export const PlusMenuDropdown = React.memo( ) }, [search, availableResources]) - const handleSelect = useCallback( - (resource: MothershipResource) => { - onResourceSelect(resource) - setOpen(false) - setSearch('') - }, - [onResourceSelect] - ) - - const filteredItemsRef = useRef(filteredItems) - filteredItemsRef.current = filteredItems + const handleSelect = (resource: MothershipResource) => { + onResourceSelect(resource) + setOpen(false) + setSearch('') + } - const handleSearchKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - const firstItem = contentRef.current?.querySelector('[role="menuitem"]') - firstItem?.focus() - } else if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault() - const first = filteredItemsRef.current?.[0] - if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name }) - } - }, - [handleSelect] - ) + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + const firstItem = contentRef.current?.querySelector('[role="menuitem"]') + firstItem?.focus() + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + const first = filteredItems?.[0] + if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name }) + } + } - const handleContentKeyDown = useCallback((e: React.KeyboardEvent) => { + const handleContentKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp') { const items = Array.from( contentRef.current?.querySelectorAll('[role="menuitem"]') ?? [] @@ -106,33 +107,27 @@ export const PlusMenuDropdown = React.memo( focused.click() } } - }, []) + } - const handleOpenChange = useCallback( - (isOpen: boolean) => { - setOpen(isOpen) - if (!isOpen) { - setSearch('') - setAnchorPos(null) - onClose() - } - }, - [onClose] - ) + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + setSearch('') + setAnchorPos(null) + onClose() + } + } - const handleCloseAutoFocus = useCallback( - (e: Event) => { - e.preventDefault() - const textarea = textareaRef.current - if (!textarea) return - if (pendingCursorRef.current !== null) { - textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) - pendingCursorRef.current = null - } - textarea.focus() - }, - [textareaRef, pendingCursorRef] - ) + const handleCloseAutoFocus = (e: Event) => { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + if (pendingCursorRef.current !== null) { + textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current) + pendingCursorRef.current = null + } + textarea.focus() + } return ( <> @@ -154,7 +149,7 @@ export const PlusMenuDropdown = React.memo( align='start' side='top' sideOffset={8} - className='flex w-[240px] flex-col overflow-hidden' + className='flex w-[320px] flex-col overflow-hidden' onCloseAutoFocus={handleCloseAutoFocus} onKeyDown={handleContentKeyDown} > @@ -168,7 +163,7 @@ export const PlusMenuDropdown = React.memo(
{filteredItems ? ( filteredItems.length > 0 ? ( - filteredItems.map(({ type, item }, index) => { + filteredItems.map(({ type, item }) => { const config = getResourceConfig(type) return ( {config.renderDropdownItem({ item })} - - {config.label} - ) }) ) : ( -
+
No results
) @@ -210,46 +202,51 @@ export const PlusMenuDropdown = React.memo( Workspace - {availableResources.map(({ type, items }) => { - if (items.length === 0) return null - const config = getResourceConfig(type) - const Icon = config.icon - return ( - - - {type === 'workflow' ? ( -
- ) : ( + {workflowTree.length > 0 && ( + + +
+ Workflows + + + + + + )} + {availableResources + .filter(({ type }) => type !== 'workflow' && type !== 'folder') + .map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + - )} - {config.label} - - - {items.map((item) => ( - { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - ))} - - - ) - })} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ type, id: item.id, title: item.name }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} @@ -261,7 +258,7 @@ export const PlusMenuDropdown = React.memo( ref={buttonRef} type='button' onClick={() => doOpen()} - className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]' + className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[var(--border-1)] transition-colors hover:bg-[var(--surface-hover)]' title='Add attachments or resources' > 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 587d17cc9e3..dcf1b91ec1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -75,6 +75,7 @@ import { useWorkspaceManagement, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { + compareByOrder, createSidebarDragGhost, groupWorkflowsByFolder, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' @@ -507,6 +508,42 @@ export const Sidebar = memo(function Sidebar() { [isCollapsed, regularWorkflows] ) + const collapsedRootItems = useMemo(() => { + type RootItem = + | { + kind: 'folder' + sortOrder: number + createdAt?: Date + id: string + node: (typeof folderTree)[number] + } + | { + kind: 'workflow' + sortOrder: number + createdAt?: Date + id: string + workflow: (typeof regularWorkflows)[number] + } + const items: RootItem[] = [ + ...folderTree.map((node) => ({ + kind: 'folder' as const, + sortOrder: node.sortOrder, + createdAt: node.createdAt, + id: node.id, + node, + })), + ...(workflowsByFolder.root ?? []).map((w) => ({ + kind: 'workflow' as const, + sortOrder: w.sortOrder, + createdAt: w.createdAt, + id: w.id, + workflow: w, + })), + ] + items.sort(compareByOrder) + return items + }, [folderTree, workflowsByFolder]) + const [activeNavItemHref, setActiveNavItemHref] = useState(null) const { isOpen: isNavContextMenuOpen, @@ -1655,42 +1692,46 @@ export const Sidebar = memo(function Sidebar() { No workflows yet ) : ( <> - - {(workflowsByFolder.root || []).map((workflow) => ( - - handleCollapsedWorkflowOpenInNewTab(workflow) - } - onRename={() => handleCollapsedWorkflowRename(workflow)} - canRename={canEdit} - /> - ))} + {collapsedRootItems.map((item) => + item.kind === 'folder' ? ( + + ) : ( + + handleCollapsedWorkflowOpenInNewTab(item.workflow) + } + onRename={() => handleCollapsedWorkflowRename(item.workflow)} + canRename={canEdit} + /> + ) + )} )}