From 341138579b958ea9b5a84036a23808f08196032a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Apr 2026 12:28:20 -0700 Subject: [PATCH 1/6] feat(ui): update context menu --- .../user-input/components/constants.ts | 5 +- .../components/plus-menu-dropdown.tsx | 229 +++++++++++------- .../home/components/user-input/user-input.tsx | 179 ++++++++------ .../components/short-input/short-input.tsx | 2 +- .../dropdown-menu/dropdown-menu.tsx | 15 +- 5 files changed, 268 insertions(+), 162 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 8c2516d5181..a689fb98801 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 20549c02050..86d7c84622d 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,32 @@ export const PlusMenuDropdown = React.memo( }, [availableResources]) const filteredItems = useMemo(() => { - const q = search.toLowerCase().trim() - if (!q) return null + 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 + if (isMention && !q) { + return availableResources.flatMap(({ type, items }) => + items.map((item) => ({ type, item })) + ) + } return availableResources.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 +110,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 +217,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 +244,73 @@ export const PlusMenuDropdown = React.memo( align='start' side='top' sideOffset={8} + avoidCollisions={!isMention} className='flex w-[320px] flex-col overflow-hidden' 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. */}