From 68986c39c86eeda39f3bc7aee1df343c1ec7b8c6 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 28 Mar 2026 14:17:21 -0700 Subject: [PATCH 1/3] improvement(home): position @ mention popup at caret and fix icon consistency --- .../resource-registry/resource-registry.tsx | 17 +- .../user-input/components/constants.ts | 2 +- .../components/plus-menu-dropdown.tsx | 312 +++++++++--------- .../home/components/user-input/user-input.tsx | 45 ++- 4 files changed, 221 insertions(+), 155 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index bf5c3029d3d..4b545dc298b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { return ( <>
- + + {item.name} + + ) +} + +function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) { + return ( + <> + {item.name} ) @@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, file: { type: 'file', @@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, } as const 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 dc6fa0aab58..93c3d79fa39 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,7 @@ export type WindowWithSpeech = Window & { } export interface PlusMenuHandle { - open: () => void + open: (anchor?: { left: number; top: number }) => void } 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 1b7606b7822..90eb3416d83 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,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Paperclip } from 'lucide-react' import { DropdownMenu, @@ -13,7 +13,6 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { Plus, Sim } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' @@ -37,24 +36,23 @@ export const PlusMenuDropdown = React.memo( ) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') - const [activeIndex, setActiveIndex] = useState(0) - const activeIndexRef = useRef(activeIndex) + const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) + const buttonRef = useRef(null) + const searchRef = useRef(null) + const contentRef = useRef(null) - useEffect(() => { - activeIndexRef.current = activeIndex - }, [activeIndex]) + const doOpen = useCallback((anchor?: { left: number; top: number }) => { + if (anchor) { + setAnchorPos(anchor) + } else { + const rect = buttonRef.current?.getBoundingClientRect() + if (rect) setAnchorPos({ left: rect.left, top: rect.bottom }) + } + setOpen(true) + setSearch('') + }, []) - React.useImperativeHandle( - ref, - () => ({ - open: () => { - setOpen(true) - setSearch('') - setActiveIndex(0) - }, - }), - [] - ) + React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen]) const filteredItems = useMemo(() => { const q = search.toLowerCase().trim() @@ -69,7 +67,6 @@ export const PlusMenuDropdown = React.memo( onResourceSelect(resource) setOpen(false) setSearch('') - setActiveIndex(0) }, [onResourceSelect] ) @@ -79,32 +76,37 @@ export const PlusMenuDropdown = React.memo( const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { - const items = filteredItemsRef.current - if (!items) return if (e.key === 'ArrowDown') { e.preventDefault() - setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((prev) => Math.max(prev - 1, 0)) + const firstItem = contentRef.current?.querySelector('[role="menuitem"]') + firstItem?.focus() } else if (e.key === 'Enter') { e.preventDefault() - const idx = activeIndexRef.current - if (items.length > 0 && items[idx]) { - const { type, item } = items[idx] - handleSelect({ type, id: item.id, title: item.name }) - } + const first = filteredItemsRef.current?.[0] + if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name }) } }, [handleSelect] ) + const handleContentKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + const items = Array.from( + contentRef.current?.querySelectorAll('[role="menuitem"]') ?? [] + ) + if (items[0] && items[0] === document.activeElement) { + e.preventDefault() + searchRef.current?.focus() + } + } + }, []) + const handleOpenChange = useCallback( (isOpen: boolean) => { setOpen(isOpen) if (!isOpen) { setSearch('') - setActiveIndex(0) + setAnchorPos(null) onClose() } }, @@ -126,126 +128,138 @@ export const PlusMenuDropdown = React.memo( ) return ( - - - - - - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> -
- {filteredItems ? ( - filteredItems.length > 0 ? ( - filteredItems.map(({ type, item }, index) => { - const config = getResourceConfig(type) - return ( - setActiveIndex(index)} - onClick={() => { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - {config.label} - - - ) - }) + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
+ {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
+ No results +
+ ) ) : ( -
- No results -
- ) - ) : ( - <> - { - setOpen(false) - onFileSelect() - }} - > - - Attachments - - - - - Workspace - - - {availableResources.map(({ type, items }) => { - if (items.length === 0) return null - const config = getResourceConfig(type) - const Icon = config.icon - return ( - - - {type === 'workflow' ? ( -
- ) : ( - - )} - {config.label} - - - {items.map((item) => ( - { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - ))} - - - ) - })} - - - - )} -
- - + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
+ ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
+ + + + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 3fb58aceb18..ee35f75aee6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -50,6 +50,48 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' +function getCaretAnchor( + textarea: HTMLTextAreaElement, + caretPos: number +): { left: number; top: number } { + const textareaRect = textarea.getBoundingClientRect() + const style = window.getComputedStyle(textarea) + + const mirror = document.createElement('div') + mirror.style.position = 'absolute' + mirror.style.visibility = 'hidden' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = 'break-word' + mirror.style.font = style.font + mirror.style.padding = style.padding + mirror.style.border = style.border + mirror.style.width = style.width + mirror.style.lineHeight = style.lineHeight + mirror.style.boxSizing = style.boxSizing + mirror.style.letterSpacing = style.letterSpacing + mirror.style.textTransform = style.textTransform + mirror.style.textIndent = style.textIndent + mirror.style.textAlign = style.textAlign + mirror.textContent = textarea.value.substring(0, caretPos) + + const marker = document.createElement('span') + marker.style.display = 'inline-block' + marker.style.width = '0px' + marker.style.padding = '0' + marker.style.border = '0' + mirror.appendChild(marker) + + document.body.appendChild(mirror) + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + document.body.removeChild(mirror) + + return { + left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, + top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, + } +} + interface UserInputProps { defaultValue?: string editValue?: string @@ -486,7 +528,8 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - plusMenuRef.current?.open() + const anchor = getCaretAnchor(e.target, caret - 1) + plusMenuRef.current?.open(anchor) restartRecognition(adjusted) return } From 617983e6a4a8a659c23f9d9b1372bafaf93df877 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 28 Mar 2026 14:39:00 -0700 Subject: [PATCH 2/3] fix(home): pin mirror div to document origin and guard button anchor --- .../components/user-input/components/plus-menu-dropdown.tsx | 3 ++- .../[workspaceId]/home/components/user-input/user-input.tsx | 2 ++ apps/sim/lib/auth/hybrid.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) 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 90eb3416d83..54a0142fc81 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 @@ -46,7 +46,8 @@ export const PlusMenuDropdown = React.memo( setAnchorPos(anchor) } else { const rect = buttonRef.current?.getBoundingClientRect() - if (rect) setAnchorPos({ left: rect.left, top: rect.bottom }) + if (!rect) return + setAnchorPos({ left: rect.left, top: rect.top }) } setOpen(true) setSearch('') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index ee35f75aee6..b830123d1a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -59,6 +59,8 @@ function getCaretAnchor( const mirror = document.createElement('div') mirror.style.position = 'absolute' + mirror.style.top = '0' + mirror.style.left = '0' mirror.style.visibility = 'hidden' mirror.style.whiteSpace = 'pre-wrap' mirror.style.overflowWrap = 'break-word' diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 67fc19a36af..c793fe5d900 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -25,7 +25,7 @@ const BEARER_PREFIX = 'Bearer ' export function hasExternalApiCredentials(headers: Headers): boolean { if (headers.has(API_KEY_HEADER)) return true const auth = headers.get('authorization') - return auth?.startsWith(BEARER_PREFIX) ?? false + return auth?.startsWith(BEARER_PREFIX) } export interface AuthResult { From 120bd88b17b28f92d624f473d63e1ceab3e413a3 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 28 Mar 2026 14:43:04 -0700 Subject: [PATCH 3/3] chore(auth): restore hybrid.ts to staging --- apps/sim/lib/auth/hybrid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index c793fe5d900..67fc19a36af 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -25,7 +25,7 @@ const BEARER_PREFIX = 'Bearer ' export function hasExternalApiCredentials(headers: Headers): boolean { if (headers.has(API_KEY_HEADER)) return true const auth = headers.get('authorization') - return auth?.startsWith(BEARER_PREFIX) + return auth?.startsWith(BEARER_PREFIX) ?? false } export interface AuthResult {