Skip to content

Commit 3411385

Browse files
feat(ui): update context menu
1 parent 3afcad2 commit 3411385

5 files changed

Lines changed: 268 additions & 162 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export type WindowWithSpeech = Window & {
3434
}
3535

3636
export interface PlusMenuHandle {
37-
open: (anchor?: { left: number; top: number }) => void
37+
open: (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => void
38+
close: () => void
39+
moveActive: (delta: number) => void
40+
selectActive: () => boolean
3841
}
3942

4043
export const TEXTAREA_BASE_CLASSES = cn(

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx

Lines changed: 142 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4-
import { Paperclip } from 'lucide-react'
54
import {
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'
1615
import { cn } from '@/lib/core/utils/cn'
1716
import {
1817
buildWorkflowFolderTree,
@@ -28,39 +27,47 @@ export type AvailableResourceGroup = ReturnType<typeof useAvailableResources>[nu
2827
interface 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

3737
export 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

Comments
 (0)