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..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
@@ -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,24 @@ 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) return
+ setAnchorPos({ left: rect.left, top: rect.top })
+ }
+ 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 +68,6 @@ export const PlusMenuDropdown = React.memo(
onResourceSelect(resource)
setOpen(false)
setSearch('')
- setActiveIndex(0)
},
[onResourceSelect]
)
@@ -79,32 +77,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 +129,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..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
@@ -50,6 +50,50 @@ 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.top = '0'
+ mirror.style.left = '0'
+ 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 +530,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
}