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,32 @@ export const PlusMenuDropdown = React.memo(
6976 } , [ availableResources ] )
7077
7178 const filteredItems = useMemo ( ( ) => {
72- const q = search . toLowerCase ( ) . trim ( )
73- if ( ! q ) return null
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+ if ( isMention && ! q ) {
84+ return availableResources . flatMap ( ( { type, items } ) =>
85+ items . map ( ( item ) => ( { type, item } ) )
86+ )
87+ }
7488 return availableResources . flatMap ( ( { type, items } ) =>
7589 items . filter ( ( item ) => item . name . toLowerCase ( ) . includes ( q ) ) . map ( ( item ) => ( { type, item } ) )
7690 )
77- } , [ search , availableResources ] )
91+ } , [ isMention , mentionQuery , search , availableResources ] )
92+
93+ const filteredItemsRef = useRef ( filteredItems )
94+ filteredItemsRef . current = filteredItems
95+ const activeIndexRef = useRef ( activeIndex )
96+ activeIndexRef . current = activeIndex
97+ const isMentionRef = useRef ( isMention )
98+ isMentionRef . current = isMention
99+
100+ // Reset highlight to the top whenever the mention query changes so the user always
101+ // sees the best match selected as they type.
102+ useEffect ( ( ) => {
103+ if ( isMention ) setActiveIndex ( 0 )
104+ } , [ isMention , mentionQuery ] )
78105
79106 const handleSelect = ( resource : MothershipResource ) => {
80107 onResourceSelect ( resource )
@@ -83,6 +110,40 @@ export const PlusMenuDropdown = React.memo(
83110 setActiveIndex ( 0 )
84111 }
85112
113+ const handleSelectRef = useRef ( handleSelect )
114+ handleSelectRef . current = handleSelect
115+
116+ React . useImperativeHandle (
117+ ref ,
118+ ( ) => ( {
119+ open : doOpen ,
120+ close : doClose ,
121+ moveActive : ( delta : number ) => {
122+ const items = filteredItemsRef . current
123+ if ( ! items || items . length === 0 ) return
124+ setActiveIndex ( ( i ) => {
125+ const next = i + delta
126+ if ( next < 0 ) return items . length - 1
127+ if ( next >= items . length ) return 0
128+ return next
129+ } )
130+ } ,
131+ selectActive : ( ) => {
132+ const items = filteredItemsRef . current
133+ if ( ! items || items . length === 0 ) return false
134+ const target = items [ activeIndexRef . current ] ?? items [ 0 ]
135+ if ( ! target ) return false
136+ handleSelectRef . current ( {
137+ type : target . type ,
138+ id : target . item . id ,
139+ title : target . item . name ,
140+ } )
141+ return true
142+ } ,
143+ } ) ,
144+ [ doOpen , doClose ]
145+ )
146+
86147 // Sync DOM scroll to the keyboard-highlighted filtered row.
87148 useEffect ( ( ) => {
88149 if ( ! filteredItems || filteredItems . length === 0 ) return
@@ -156,6 +217,13 @@ export const PlusMenuDropdown = React.memo(
156217 textarea . focus ( )
157218 }
158219
220+ // Radix's FocusScope normally focuses the content on open and traps focus inside.
221+ // Preventing the mount auto-focus keeps the textarea focused AND, because the focus
222+ // trap activates on focusin, the trap stays dormant — typing continues uninterrupted.
223+ const handleOpenAutoFocus = ( e : Event ) => {
224+ if ( isMentionRef . current ) e . preventDefault ( )
225+ }
226+
159227 return (
160228 < >
161229 < DropdownMenu open = { open } onOpenChange = { handleOpenChange } >
@@ -176,86 +244,73 @@ export const PlusMenuDropdown = React.memo(
176244 align = 'start'
177245 side = 'top'
178246 sideOffset = { 8 }
247+ avoidCollisions = { ! isMention }
179248 className = 'flex w-[320px] flex-col overflow-hidden'
180249 onCloseAutoFocus = { handleCloseAutoFocus }
250+ onOpenAutoFocus = { handleOpenAutoFocus }
181251 onKeyDown = { handleContentKeyDown }
182252 >
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- />
253+ { ! isMention && (
254+ < DropdownMenuSearchInput
255+ ref = { searchRef }
256+ placeholder = 'Search resources...'
257+ value = { search }
258+ onChange = { ( e ) => {
259+ setSearch ( e . target . value )
260+ setActiveIndex ( 0 )
261+ } }
262+ onKeyDown = { handleSearchKeyDown }
263+ />
264+ ) }
193265 < div className = 'min-h-0 flex-1 overflow-y-auto' >
194266 { /* Always-mounted; swapping this subtree with filtered results makes Radix's
195267 menu FocusScope steal focus from the search input back to the content root. */ }
196268 < 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 >
269+ { workflowTree . length > 0 && (
270+ < DropdownMenuSub >
271+ < DropdownMenuSubTrigger >
272+ < div
273+ className = 'h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
274+ style = { {
275+ backgroundColor : '#808080' ,
276+ borderColor : '#80808060' ,
277+ backgroundClip : 'padding-box' ,
278+ } }
279+ />
280+ < span > Workflows</ span >
281+ </ DropdownMenuSubTrigger >
282+ < DropdownMenuSubContent >
283+ < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
284+ </ DropdownMenuSubContent >
285+ </ DropdownMenuSub >
286+ ) }
287+ { availableResources
288+ . filter ( ( { type } ) => type !== 'workflow' && type !== 'folder' )
289+ . map ( ( { type, items } ) => {
290+ if ( items . length === 0 ) return null
291+ const config = getResourceConfig ( type )
292+ const Icon = config . icon
293+ return (
294+ < DropdownMenuSub key = { type } >
214295 < 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 >
296+ < Icon className = 'h-[14px] w-[14px]' />
297+ < span > { config . label } </ span >
224298 </ DropdownMenuSubTrigger >
225299 < DropdownMenuSubContent >
226- < WorkflowFolderTreeItems nodes = { workflowTree } onSelect = { handleSelect } />
300+ { items . map ( ( item ) => (
301+ < DropdownMenuItem
302+ key = { item . id }
303+ onClick = { ( ) => {
304+ handleSelect ( { type, id : item . id , title : item . name } )
305+ } }
306+ >
307+ { config . renderDropdownItem ( { item } ) }
308+ </ DropdownMenuItem >
309+ ) ) }
227310 </ DropdownMenuSubContent >
228311 </ 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 >
312+ )
313+ } ) }
259314 </ div >
260315 { /* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
261316 menu Collection, or FocusScope restores focus to the content root. */ }
0 commit comments