Skip to content

Commit 49c1201

Browse files
feat(ui): update context menu (#4362)
* feat(ui): update context menu * fix(user-input): clear mention state on submit Programmatic plusMenuRef.close() sets the dropdown's internal open=false but Radix doesn't fire onOpenChange for controlled changes, so handlePlusMenuClose never ran and mentionRangeRef stayed truthy after submitting a message with an active @mention. That caused the keydown handler to keep intercepting ArrowUp/ArrowDown/Tab post-submit, breaking the "edit last queued message" ArrowUp shortcut until the user typed again. Clear mentionRangeRef and mentionQuery inline alongside the close() call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ui): widen mention/plus dropdown to 420px Long resource names were truncating in a 320px-wide menu. Bump to 420px and cap with max-w on viewport so it can't overflow on small screens. Overrides the emcn DropdownMenuContent's default max-w-[220px] via twMerge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ui): trim mention/plus dropdown width to 360px 420px felt too wide; 360px gives long resource names enough room without dominating the input area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * tweak context menu widths * address greptile review on PR #4362 - plus-menu-dropdown: drop folder type from the flat mention list (folders organize resources but aren't an insertable mention target — the nested rendering already excludes them). - user-input: fold Enter into the mention-mode keydown guard so Enter confirms the highlighted resource instead of submitting the form. Falls through to the normal Enter-submit path when no match is highlighted (Tab keeps prior behavior). - dropdown-menu wrapper: drop the misleading cast on the spread — runtime accepts onOpenAutoFocus regardless and the cast was hiding any future props added to DropdownMenuContentProps. - plus-menu-dropdown: trim mention-mode + submenu widths to 300px (320px clipped on the narrower copilot panel). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9109284 commit 49c1201

5 files changed

Lines changed: 284 additions & 164 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: 152 additions & 90 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,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

Comments
 (0)