11'use client'
22
3- import { useCallback , useMemo , useState } from 'react'
3+ import { useMemo , useState } from 'react'
44import {
55 Button ,
66 DropdownMenu ,
@@ -13,7 +13,7 @@ import {
1313 DropdownMenuTrigger ,
1414 Tooltip ,
1515} from '@/components/emcn'
16- import { Plus } from '@/components/emcn/icons'
16+ import { Folder , Plus } from '@/components/emcn/icons'
1717import { cn } from '@/lib/core/utils/cn'
1818import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
1919import {
@@ -68,6 +68,8 @@ export function useAvailableResources(
6868 id : w . id ,
6969 name : w . name ,
7070 color : w . color ,
71+ folderId : w . folderId ?? null ,
72+ sortOrder : w . sortOrder ,
7173 isOpen : existingKeys . has ( `workflow:${ w . id } ` ) ,
7274 } ) ) ,
7375 } ,
@@ -76,6 +78,8 @@ export function useAvailableResources(
7678 items : folders . map ( ( f ) => ( {
7779 id : f . id ,
7880 name : f . name ,
81+ parentId : f . parentId ?? null ,
82+ sortOrder : f . sortOrder ,
7983 isOpen : existingKeys . has ( `folder:${ f . id } ` ) ,
8084 } ) ) ,
8185 } ,
@@ -116,6 +120,104 @@ export function useAvailableResources(
116120 } , [ workflows , folders , tables , files , knowledgeBases , tasks , existingKeys , excludeTypes ] )
117121}
118122
123+ export type WorkflowTreeNode =
124+ | { kind : 'workflow' ; id : string ; name : string ; color : string ; isOpen ?: boolean }
125+ | { kind : 'folder' ; id : string ; name : string ; children : WorkflowTreeNode [ ] }
126+
127+ export function buildWorkflowFolderTree (
128+ workflowItems : AvailableItem [ ] ,
129+ folderItems : AvailableItem [ ]
130+ ) : WorkflowTreeNode [ ] {
131+ const knownFolderIds = new Set ( folderItems . map ( ( f ) => f . id ) )
132+
133+ const byFolder = new Map < string | null , AvailableItem [ ] > ( )
134+ for ( const w of workflowItems ) {
135+ const fid = ( w . folderId as string | null | undefined ) ?? null
136+ const key = fid && knownFolderIds . has ( fid ) ? fid : null
137+ const bucket = byFolder . get ( key ) ?? [ ]
138+ bucket . push ( w )
139+ byFolder . set ( key , bucket )
140+ }
141+
142+ const toWorkflowNode = ( w : AvailableItem ) : WorkflowTreeNode => ( {
143+ kind : 'workflow' ,
144+ id : w . id ,
145+ name : w . name ,
146+ color : ( w . color as string ) ?? '#808080' ,
147+ isOpen : w . isOpen ,
148+ } )
149+
150+ const buildLevel = ( parentId : string | null ) : WorkflowTreeNode [ ] => {
151+ const childFolders = folderItems . filter (
152+ ( f ) => ( ( f . parentId as string | null | undefined ) ?? null ) === parentId
153+ )
154+ const childWorkflows = byFolder . get ( parentId ) ?? [ ]
155+
156+ const mixed : Array < { sortOrder : number ; id : string ; node : WorkflowTreeNode } > = [ ]
157+
158+ for ( const f of childFolders ) {
159+ const children = buildLevel ( f . id )
160+ if ( children . length === 0 ) continue
161+ mixed . push ( {
162+ sortOrder : ( f . sortOrder as number ) ?? 0 ,
163+ id : f . id ,
164+ node : { kind : 'folder' , id : f . id , name : f . name , children } ,
165+ } )
166+ }
167+
168+ for ( const w of childWorkflows ) {
169+ mixed . push ( {
170+ sortOrder : ( w . sortOrder as number ) ?? 0 ,
171+ id : w . id ,
172+ node : toWorkflowNode ( w ) ,
173+ } )
174+ }
175+
176+ mixed . sort ( ( a , b ) =>
177+ a . sortOrder !== b . sortOrder ? a . sortOrder - b . sortOrder : a . id . localeCompare ( b . id )
178+ )
179+ return mixed . map ( ( m ) => m . node )
180+ }
181+
182+ return buildLevel ( null )
183+ }
184+
185+ interface WorkflowFolderTreeItemsProps {
186+ nodes : WorkflowTreeNode [ ]
187+ onSelect : ( resource : MothershipResource , isOpen ?: boolean ) => void
188+ }
189+
190+ export function WorkflowFolderTreeItems ( { nodes, onSelect } : WorkflowFolderTreeItemsProps ) {
191+ return (
192+ < >
193+ { nodes . map ( ( node ) =>
194+ node . kind === 'workflow' ? (
195+ < DropdownMenuItem
196+ key = { node . id }
197+ onClick = { ( ) =>
198+ onSelect ( { type : 'workflow' , id : node . id , title : node . name } , node . isOpen )
199+ }
200+ >
201+ { getResourceConfig ( 'workflow' ) . renderDropdownItem ( {
202+ item : { id : node . id , name : node . name , color : node . color } ,
203+ } ) }
204+ </ DropdownMenuItem >
205+ ) : (
206+ < DropdownMenuSub key = { node . id } >
207+ < DropdownMenuSubTrigger >
208+ < Folder className = 'h-[14px] w-[14px]' />
209+ < span > { node . name } </ span >
210+ </ DropdownMenuSubTrigger >
211+ < DropdownMenuSubContent >
212+ < WorkflowFolderTreeItems nodes = { node . children } onSelect = { onSelect } />
213+ </ DropdownMenuSubContent >
214+ </ DropdownMenuSub >
215+ )
216+ ) }
217+ </ >
218+ )
219+ }
220+
119221export function AddResourceDropdown ( {
120222 workspaceId,
121223 existingKeys,
@@ -128,27 +230,30 @@ export function AddResourceDropdown({
128230 const [ activeIndex , setActiveIndex ] = useState ( 0 )
129231 const available = useAvailableResources ( workspaceId , existingKeys , excludeTypes )
130232
131- const handleOpenChange = useCallback ( ( next : boolean ) => {
233+ const handleOpenChange = ( next : boolean ) => {
132234 setOpen ( next )
133235 if ( ! next ) {
134236 setSearch ( '' )
135237 setActiveIndex ( 0 )
136238 }
137- } , [ ] )
239+ }
138240
139- const select = useCallback (
140- ( resource : MothershipResource , isOpen ?: boolean ) => {
141- if ( isOpen && onSwitch ) {
142- onSwitch ( resource . id )
143- } else {
144- onAdd ( resource )
145- }
146- setOpen ( false )
147- setSearch ( '' )
148- setActiveIndex ( 0 )
149- } ,
150- [ onAdd , onSwitch ]
151- )
241+ const select = ( resource : MothershipResource , isOpen ?: boolean ) => {
242+ if ( isOpen && onSwitch ) {
243+ onSwitch ( resource . id )
244+ } else {
245+ onAdd ( resource )
246+ }
247+ setOpen ( false )
248+ setSearch ( '' )
249+ setActiveIndex ( 0 )
250+ }
251+
252+ const workflowTree = useMemo ( ( ) => {
253+ const workflowGroup = available . find ( ( g ) => g . type === 'workflow' )
254+ const folderGroup = available . find ( ( g ) => g . type === 'folder' )
255+ return buildWorkflowFolderTree ( workflowGroup ?. items ?? [ ] , folderGroup ?. items ?? [ ] )
256+ } , [ available ] )
152257
153258 const filtered = useMemo ( ( ) => {
154259 const q = search . toLowerCase ( ) . trim ( )
@@ -158,25 +263,22 @@ export function AddResourceDropdown({
158263 )
159264 } , [ search , available ] )
160265
161- const handleSearchKeyDown = useCallback (
162- ( e : React . KeyboardEvent < HTMLInputElement > ) => {
163- if ( ! filtered ) return
164- if ( e . key === 'ArrowDown' ) {
266+ const handleSearchKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
267+ if ( ! filtered ) return
268+ if ( e . key === 'ArrowDown' ) {
269+ e . preventDefault ( )
270+ setActiveIndex ( ( prev ) => Math . min ( prev + 1 , filtered . length - 1 ) )
271+ } else if ( e . key === 'ArrowUp' ) {
272+ e . preventDefault ( )
273+ setActiveIndex ( ( prev ) => Math . max ( prev - 1 , 0 ) )
274+ } else if ( e . key === 'Enter' || ( e . key === 'Tab' && ! e . shiftKey ) ) {
275+ if ( filtered . length > 0 && filtered [ activeIndex ] ) {
165276 e . preventDefault ( )
166- setActiveIndex ( ( prev ) => Math . min ( prev + 1 , filtered . length - 1 ) )
167- } else if ( e . key === 'ArrowUp' ) {
168- e . preventDefault ( )
169- setActiveIndex ( ( prev ) => Math . max ( prev - 1 , 0 ) )
170- } else if ( e . key === 'Enter' || ( e . key === 'Tab' && ! e . shiftKey ) ) {
171- if ( filtered . length > 0 && filtered [ activeIndex ] ) {
172- e . preventDefault ( )
173- const { type, item } = filtered [ activeIndex ]
174- select ( { type, id : item . id , title : item . name } , item . isOpen )
175- }
277+ const { type, item } = filtered [ activeIndex ]
278+ select ( { type, id : item . id , title : item . name } , item . isOpen )
176279 }
177- } ,
178- [ filtered , activeIndex , select ]
179- )
280+ }
281+ }
180282
181283 return (
182284 < DropdownMenu open = { open } onOpenChange = { handleOpenChange } >
@@ -199,7 +301,7 @@ export function AddResourceDropdown({
199301 < DropdownMenuContent
200302 align = 'start'
201303 sideOffset = { 8 }
202- className = 'flex w-[240px ] flex-col overflow-hidden'
304+ className = 'flex w-[320px ] flex-col overflow-hidden'
203305 onCloseAutoFocus = { ( e ) => e . preventDefault ( ) }
204306 >
205307 < DropdownMenuSearchInput
@@ -224,9 +326,6 @@ export function AddResourceDropdown({
224326 onClick = { ( ) => select ( { type, id : item . id , title : item . name } , item . isOpen ) }
225327 >
226328 { config . renderDropdownItem ( { item } ) }
227- < span className = 'ml-auto pl-2 text-[var(--text-tertiary)] text-xs' >
228- { config . label }
229- </ span >
230329 </ DropdownMenuItem >
231330 )
232331 } )
@@ -237,25 +336,33 @@ export function AddResourceDropdown({
237336 )
238337 ) : (
239338 < >
339+ { workflowTree . length > 0 && (
340+ < DropdownMenuSub >
341+ < DropdownMenuSubTrigger >
342+ < div
343+ className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
344+ style = { {
345+ backgroundColor : '#808080' ,
346+ borderColor : '#80808060' ,
347+ backgroundClip : 'padding-box' ,
348+ } }
349+ />
350+ < span > Workflows</ span >
351+ </ DropdownMenuSubTrigger >
352+ < DropdownMenuSubContent >
353+ < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { select } />
354+ </ DropdownMenuSubContent >
355+ </ DropdownMenuSub >
356+ ) }
240357 { available . map ( ( { type, items } ) => {
358+ if ( type === 'workflow' || type === 'folder' ) return null
241359 if ( items . length === 0 ) return null
242360 const config = getResourceConfig ( type )
243361 const Icon = config . icon
244362 return (
245363 < DropdownMenuSub key = { type } >
246364 < DropdownMenuSubTrigger >
247- { type === 'workflow' ? (
248- < div
249- className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
250- style = { {
251- backgroundColor : '#808080' ,
252- borderColor : '#80808060' ,
253- backgroundClip : 'padding-box' ,
254- } }
255- />
256- ) : (
257- < Icon className = 'h-[14px] w-[14px]' />
258- ) }
365+ < Icon className = 'h-[14px] w-[14px]' />
259366 < span > { config . label } </ span >
260367 </ DropdownMenuSubTrigger >
261368 < DropdownMenuSubContent >
0 commit comments