11'use client'
22
33import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react'
4- import { Paperclip } from 'lucide-react'
54import {
65 DropdownMenu ,
76 DropdownMenuContent ,
@@ -12,7 +11,7 @@ import {
1211 DropdownMenuSubTrigger ,
1312 DropdownMenuTrigger ,
1413} from '@/components/emcn'
15- import { Plus , Sim } from '@/components/emcn/icons'
14+ import { Plus } from '@/components/emcn/icons'
1615import { cn } from '@/lib/core/utils/cn'
1716import {
1817 buildWorkflowFolderTree ,
@@ -28,39 +27,47 @@ export type AvailableResourceGroup = ReturnType<typeof useAvailableResources>[nu
2827interface PlusMenuDropdownProps {
2928 availableResources : AvailableResourceGroup [ ]
3029 onResourceSelect : ( resource : MothershipResource ) => void
31- onFileSelect : ( ) => void
3230 onClose : ( ) => void
3331 textareaRef : React . RefObject < HTMLTextAreaElement | null >
3432 pendingCursorRef : React . MutableRefObject < number | null >
33+ /** When in mention mode the dropdown hides its search input and uses this query for filtering. */
34+ mentionQuery ?: string
3535}
3636
3737export const PlusMenuDropdown = React . memo (
3838 React . forwardRef < PlusMenuHandle , PlusMenuDropdownProps > ( function PlusMenuDropdown (
39- { availableResources, onResourceSelect, onFileSelect , onClose, textareaRef, pendingCursorRef } ,
39+ { availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery } ,
4040 ref
4141 ) {
4242 const [ open , setOpen ] = useState ( false )
43+ const [ isMention , setIsMention ] = useState ( false )
4344 const [ search , setSearch ] = useState ( '' )
4445 const [ anchorPos , setAnchorPos ] = useState < { left : number ; top : number } | null > ( null )
4546 const [ activeIndex , setActiveIndex ] = useState ( 0 )
4647 const buttonRef = useRef < HTMLButtonElement > ( null )
4748 const searchRef = useRef < HTMLInputElement > ( null )
4849 const contentRef = useRef < HTMLDivElement > ( null )
4950
50- const doOpen = useCallback ( ( anchor ?: { left : number ; top : number } ) => {
51- if ( anchor ) {
52- setAnchorPos ( anchor )
53- } else {
54- const rect = buttonRef . current ?. getBoundingClientRect ( )
55- if ( ! rect ) return
56- setAnchorPos ( { left : rect . left , top : rect . top } )
57- }
58- setOpen ( true )
59- setSearch ( '' )
60- setActiveIndex ( 0 )
61- } , [ ] )
51+ const doOpen = useCallback (
52+ ( anchor ?: { left : number ; top : number } , options ?: { mention ?: boolean } ) => {
53+ if ( anchor ) {
54+ setAnchorPos ( anchor )
55+ } else {
56+ const rect = buttonRef . current ?. getBoundingClientRect ( )
57+ if ( ! rect ) return
58+ setAnchorPos ( { left : rect . left , top : rect . top } )
59+ }
60+ setIsMention ( ! ! options ?. mention )
61+ setOpen ( true )
62+ setSearch ( '' )
63+ setActiveIndex ( 0 )
64+ } ,
65+ [ ]
66+ )
6267
63- React . useImperativeHandle ( ref , ( ) => ( { open : doOpen } ) , [ doOpen ] )
68+ const doClose = useCallback ( ( ) => {
69+ setOpen ( false )
70+ } , [ ] )
6471
6572 const workflowTree = useMemo ( ( ) => {
6673 const workflowGroup = availableResources . find ( ( g ) => g . type === 'workflow' )
@@ -69,12 +76,33 @@ export const PlusMenuDropdown = React.memo(
6976 } , [ availableResources ] )
7077
7178 const filteredItems = useMemo ( ( ) => {
72- const q = search . toLowerCase ( ) . trim ( )
73- if ( ! q ) return null
74- return availableResources . flatMap ( ( { type, items } ) =>
79+ const rawQuery = isMention ? ( mentionQuery ?? '' ) : search
80+ const q = rawQuery . toLowerCase ( ) . trim ( )
81+ // In mention mode always render a flat filtered list — empty query = show everything.
82+ if ( ! isMention && ! q ) return null
83+ // Folders organize resources but aren't a valid mention/insertable target — drop them
84+ // from the flat list (matches the nested rendering, which also excludes them).
85+ const flatGroups = availableResources . filter ( ( { type } ) => type !== 'folder' )
86+ if ( isMention && ! q ) {
87+ return flatGroups . flatMap ( ( { type, items } ) => items . map ( ( item ) => ( { type, item } ) ) )
88+ }
89+ return flatGroups . flatMap ( ( { type, items } ) =>
7590 items . filter ( ( item ) => item . name . toLowerCase ( ) . includes ( q ) ) . map ( ( item ) => ( { type, item } ) )
7691 )
77- } , [ search , availableResources ] )
92+ } , [ isMention , mentionQuery , search , availableResources ] )
93+
94+ const filteredItemsRef = useRef ( filteredItems )
95+ filteredItemsRef . current = filteredItems
96+ const activeIndexRef = useRef ( activeIndex )
97+ activeIndexRef . current = activeIndex
98+ const isMentionRef = useRef ( isMention )
99+ isMentionRef . current = isMention
100+
101+ // Reset highlight to the top whenever the mention query changes so the user always
102+ // sees the best match selected as they type.
103+ useEffect ( ( ) => {
104+ if ( isMention ) setActiveIndex ( 0 )
105+ } , [ isMention , mentionQuery ] )
78106
79107 const handleSelect = ( resource : MothershipResource ) => {
80108 onResourceSelect ( resource )
@@ -83,6 +111,40 @@ export const PlusMenuDropdown = React.memo(
83111 setActiveIndex ( 0 )
84112 }
85113
114+ const handleSelectRef = useRef ( handleSelect )
115+ handleSelectRef . current = handleSelect
116+
117+ React . useImperativeHandle (
118+ ref ,
119+ ( ) => ( {
120+ open : doOpen ,
121+ close : doClose ,
122+ moveActive : ( delta : number ) => {
123+ const items = filteredItemsRef . current
124+ if ( ! items || items . length === 0 ) return
125+ setActiveIndex ( ( i ) => {
126+ const next = i + delta
127+ if ( next < 0 ) return items . length - 1
128+ if ( next >= items . length ) return 0
129+ return next
130+ } )
131+ } ,
132+ selectActive : ( ) => {
133+ const items = filteredItemsRef . current
134+ if ( ! items || items . length === 0 ) return false
135+ const target = items [ activeIndexRef . current ] ?? items [ 0 ]
136+ if ( ! target ) return false
137+ handleSelectRef . current ( {
138+ type : target . type ,
139+ id : target . item . id ,
140+ title : target . item . name ,
141+ } )
142+ return true
143+ } ,
144+ } ) ,
145+ [ doOpen , doClose ]
146+ )
147+
86148 // Sync DOM scroll to the keyboard-highlighted filtered row.
87149 useEffect ( ( ) => {
88150 if ( ! filteredItems || filteredItems . length === 0 ) return
@@ -156,6 +218,13 @@ export const PlusMenuDropdown = React.memo(
156218 textarea . focus ( )
157219 }
158220
221+ // Radix's FocusScope normally focuses the content on open and traps focus inside.
222+ // Preventing the mount auto-focus keeps the textarea focused AND, because the focus
223+ // trap activates on focusin, the trap stays dormant — typing continues uninterrupted.
224+ const handleOpenAutoFocus = ( e : Event ) => {
225+ if ( isMentionRef . current ) e . preventDefault ( )
226+ }
227+
159228 return (
160229 < >
161230 < DropdownMenu open = { open } onOpenChange = { handleOpenChange } >
@@ -176,86 +245,79 @@ export const PlusMenuDropdown = React.memo(
176245 align = 'start'
177246 side = 'top'
178247 sideOffset = { 8 }
179- className = 'flex w-[320px] flex-col overflow-hidden'
248+ avoidCollisions = { ! isMention }
249+ className = { cn (
250+ 'flex flex-col overflow-hidden' ,
251+ // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
252+ // to its content via the emcn DropdownMenuContent default max-w.
253+ // Mention mode renders resource names directly, so widen for breathing room.
254+ isMention && 'w-[300px] max-w-[calc(100vw-32px)]'
255+ ) }
180256 onCloseAutoFocus = { handleCloseAutoFocus }
257+ onOpenAutoFocus = { handleOpenAutoFocus }
181258 onKeyDown = { handleContentKeyDown }
182259 >
183- < DropdownMenuSearchInput
184- ref = { searchRef }
185- placeholder = 'Search resources...'
186- value = { search }
187- onChange = { ( e ) => {
188- setSearch ( e . target . value )
189- setActiveIndex ( 0 )
190- } }
191- onKeyDown = { handleSearchKeyDown }
192- />
260+ { ! isMention && (
261+ < DropdownMenuSearchInput
262+ ref = { searchRef }
263+ placeholder = 'Search resources...'
264+ value = { search }
265+ onChange = { ( e ) => {
266+ setSearch ( e . target . value )
267+ setActiveIndex ( 0 )
268+ } }
269+ onKeyDown = { handleSearchKeyDown }
270+ />
271+ ) }
193272 < div className = 'min-h-0 flex-1 overflow-y-auto' >
194273 { /* Always-mounted; swapping this subtree with filtered results makes Radix's
195274 menu FocusScope steal focus from the search input back to the content root. */ }
196275 < div hidden = { filteredItems !== null } >
197- < DropdownMenuItem
198- onClick = { ( ) => {
199- setOpen ( false )
200- onFileSelect ( )
201- } }
202- >
203- < Paperclip className = 'h-[14px] w-[14px]' strokeWidth = { 2 } />
204- < span > Attachments</ span >
205- </ DropdownMenuItem >
206- < DropdownMenuSub >
207- < DropdownMenuSubTrigger >
208- < Sim className = 'h-[14px] w-[14px]' fill = 'currentColor' />
209- < span > Workspace</ span >
210- </ DropdownMenuSubTrigger >
211- < DropdownMenuSubContent >
212- { workflowTree . length > 0 && (
213- < DropdownMenuSub >
276+ { workflowTree . length > 0 && (
277+ < DropdownMenuSub >
278+ < DropdownMenuSubTrigger >
279+ < div
280+ className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
281+ style = { {
282+ backgroundColor : '#808080' ,
283+ borderColor : '#80808060' ,
284+ backgroundClip : 'padding-box' ,
285+ } }
286+ />
287+ < span > Workflows</ span >
288+ </ DropdownMenuSubTrigger >
289+ < DropdownMenuSubContent className = 'w-[300px] max-w-[calc(100vw-32px)]' >
290+ < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
291+ </ DropdownMenuSubContent >
292+ </ DropdownMenuSub >
293+ ) }
294+ { availableResources
295+ . filter ( ( { type } ) => type !== 'workflow' && type !== 'folder' )
296+ . map ( ( { type, items } ) => {
297+ if ( items . length === 0 ) return null
298+ const config = getResourceConfig ( type )
299+ const Icon = config . icon
300+ return (
301+ < DropdownMenuSub key = { type } >
214302 < DropdownMenuSubTrigger >
215- < div
216- className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
217- style = { {
218- backgroundColor : '#808080' ,
219- borderColor : '#80808060' ,
220- backgroundClip : 'padding-box' ,
221- } }
222- />
223- < span > Workflows</ span >
303+ < Icon className = 'h-[14px] w-[14px]' />
304+ < span > { config . label } </ span >
224305 </ DropdownMenuSubTrigger >
225- < DropdownMenuSubContent >
226- < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
306+ < DropdownMenuSubContent className = 'w-[300px] max-w-[calc(100vw-32px)]' >
307+ { items . map ( ( item ) => (
308+ < DropdownMenuItem
309+ key = { item . id }
310+ onClick = { ( ) => {
311+ handleSelect ( { type, id : item . id , title : item . name } )
312+ } }
313+ >
314+ { config . renderDropdownItem ( { item } ) }
315+ </ DropdownMenuItem >
316+ ) ) }
227317 </ DropdownMenuSubContent >
228318 </ DropdownMenuSub >
229- ) }
230- { availableResources
231- . filter ( ( { type } ) => type !== 'workflow' && type !== 'folder' )
232- . map ( ( { type, items } ) => {
233- if ( items . length === 0 ) return null
234- const config = getResourceConfig ( type )
235- const Icon = config . icon
236- return (
237- < DropdownMenuSub key = { type } >
238- < DropdownMenuSubTrigger >
239- < Icon className = 'h-[14px] w-[14px]' />
240- < span > { config . label } </ span >
241- </ DropdownMenuSubTrigger >
242- < DropdownMenuSubContent >
243- { items . map ( ( item ) => (
244- < DropdownMenuItem
245- key = { item . id }
246- onClick = { ( ) => {
247- handleSelect ( { type, id : item . id , title : item . name } )
248- } }
249- >
250- { config . renderDropdownItem ( { item } ) }
251- </ DropdownMenuItem >
252- ) ) }
253- </ DropdownMenuSubContent >
254- </ DropdownMenuSub >
255- )
256- } ) }
257- </ DropdownMenuSubContent >
258- </ DropdownMenuSub >
319+ )
320+ } ) }
259321 </ div >
260322 { /* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
261323 menu Collection, or FocusScope restores focus to the content root. */ }
0 commit comments