From 49c12011dcc7bb8be0338602e9cbcd37bfe2211c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Apr 2026 14:33:22 -0700 Subject: [PATCH] feat(ui): update context menu (#4362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../user-input/components/constants.ts | 5 +- .../components/plus-menu-dropdown.tsx | 242 +++++++++++------- .../home/components/user-input/user-input.tsx | 186 +++++++++----- .../components/short-input/short-input.tsx | 2 +- .../dropdown-menu/dropdown-menu.tsx | 13 +- 5 files changed, 284 insertions(+), 164 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 8c2516d518..a689fb9880 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -34,7 +34,10 @@ export type WindowWithSpeech = Window & { } export interface PlusMenuHandle { - open: (anchor?: { left: number; top: number }) => void + open: (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => void + close: () => void + moveActive: (delta: number) => void + selectActive: () => boolean } export const TEXTAREA_BASE_CLASSES = cn( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 20549c0205..8bca03e6ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -1,7 +1,6 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Paperclip } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -12,7 +11,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/emcn' -import { Plus, Sim } from '@/components/emcn/icons' +import { Plus } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { buildWorkflowFolderTree, @@ -28,18 +27,20 @@ export type AvailableResourceGroup = ReturnType[nu interface PlusMenuDropdownProps { availableResources: AvailableResourceGroup[] onResourceSelect: (resource: MothershipResource) => void - onFileSelect: () => void onClose: () => void textareaRef: React.RefObject pendingCursorRef: React.MutableRefObject + /** When in mention mode the dropdown hides its search input and uses this query for filtering. */ + mentionQuery?: string } export const PlusMenuDropdown = React.memo( React.forwardRef(function PlusMenuDropdown( - { availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef }, + { availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery }, ref ) { const [open, setOpen] = useState(false) + const [isMention, setIsMention] = useState(false) const [search, setSearch] = useState('') const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) const [activeIndex, setActiveIndex] = useState(0) @@ -47,20 +48,26 @@ export const PlusMenuDropdown = React.memo( const searchRef = useRef(null) const contentRef = useRef(null) - const doOpen = useCallback((anchor?: { left: number; top: number }) => { - if (anchor) { - setAnchorPos(anchor) - } else { - const rect = buttonRef.current?.getBoundingClientRect() - if (!rect) return - setAnchorPos({ left: rect.left, top: rect.top }) - } - setOpen(true) - setSearch('') - setActiveIndex(0) - }, []) + const doOpen = useCallback( + (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => { + if (anchor) { + setAnchorPos(anchor) + } else { + const rect = buttonRef.current?.getBoundingClientRect() + if (!rect) return + setAnchorPos({ left: rect.left, top: rect.top }) + } + setIsMention(!!options?.mention) + setOpen(true) + setSearch('') + setActiveIndex(0) + }, + [] + ) - React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen]) + const doClose = useCallback(() => { + setOpen(false) + }, []) const workflowTree = useMemo(() => { const workflowGroup = availableResources.find((g) => g.type === 'workflow') @@ -69,12 +76,33 @@ export const PlusMenuDropdown = React.memo( }, [availableResources]) const filteredItems = useMemo(() => { - const q = search.toLowerCase().trim() - if (!q) return null - return availableResources.flatMap(({ type, items }) => + const rawQuery = isMention ? (mentionQuery ?? '') : search + const q = rawQuery.toLowerCase().trim() + // In mention mode always render a flat filtered list — empty query = show everything. + if (!isMention && !q) return null + // Folders organize resources but aren't a valid mention/insertable target — drop them + // from the flat list (matches the nested rendering, which also excludes them). + const flatGroups = availableResources.filter(({ type }) => type !== 'folder') + if (isMention && !q) { + return flatGroups.flatMap(({ type, items }) => items.map((item) => ({ type, item }))) + } + return flatGroups.flatMap(({ type, items }) => items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) ) - }, [search, availableResources]) + }, [isMention, mentionQuery, search, availableResources]) + + const filteredItemsRef = useRef(filteredItems) + filteredItemsRef.current = filteredItems + const activeIndexRef = useRef(activeIndex) + activeIndexRef.current = activeIndex + const isMentionRef = useRef(isMention) + isMentionRef.current = isMention + + // Reset highlight to the top whenever the mention query changes so the user always + // sees the best match selected as they type. + useEffect(() => { + if (isMention) setActiveIndex(0) + }, [isMention, mentionQuery]) const handleSelect = (resource: MothershipResource) => { onResourceSelect(resource) @@ -83,6 +111,40 @@ export const PlusMenuDropdown = React.memo( setActiveIndex(0) } + const handleSelectRef = useRef(handleSelect) + handleSelectRef.current = handleSelect + + React.useImperativeHandle( + ref, + () => ({ + open: doOpen, + close: doClose, + moveActive: (delta: number) => { + const items = filteredItemsRef.current + if (!items || items.length === 0) return + setActiveIndex((i) => { + const next = i + delta + if (next < 0) return items.length - 1 + if (next >= items.length) return 0 + return next + }) + }, + selectActive: () => { + const items = filteredItemsRef.current + if (!items || items.length === 0) return false + const target = items[activeIndexRef.current] ?? items[0] + if (!target) return false + handleSelectRef.current({ + type: target.type, + id: target.item.id, + title: target.item.name, + }) + return true + }, + }), + [doOpen, doClose] + ) + // Sync DOM scroll to the keyboard-highlighted filtered row. useEffect(() => { if (!filteredItems || filteredItems.length === 0) return @@ -156,6 +218,13 @@ export const PlusMenuDropdown = React.memo( textarea.focus() } + // Radix's FocusScope normally focuses the content on open and traps focus inside. + // Preventing the mount auto-focus keeps the textarea focused AND, because the focus + // trap activates on focusin, the trap stays dormant — typing continues uninterrupted. + const handleOpenAutoFocus = (e: Event) => { + if (isMentionRef.current) e.preventDefault() + } + return ( <> @@ -176,86 +245,79 @@ export const PlusMenuDropdown = React.memo( align='start' side='top' sideOffset={8} - className='flex w-[320px] flex-col overflow-hidden' + avoidCollisions={!isMention} + className={cn( + 'flex flex-col overflow-hidden', + // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size + // to its content via the emcn DropdownMenuContent default max-w. + // Mention mode renders resource names directly, so widen for breathing room. + isMention && 'w-[300px] max-w-[calc(100vw-32px)]' + )} onCloseAutoFocus={handleCloseAutoFocus} + onOpenAutoFocus={handleOpenAutoFocus} onKeyDown={handleContentKeyDown} > - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> + {!isMention && ( + { + setSearch(e.target.value) + setActiveIndex(0) + }} + onKeyDown={handleSearchKeyDown} + /> + )}
{/* Always-mounted; swapping this subtree with filtered results makes Radix's menu FocusScope steal focus from the search input back to the content root. */}