improvement(home): position @ mention popup at caret and fix icon consistency (#3831)

* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging
This commit is contained in:
Waleed
2026-03-28 14:48:41 -07:00
committed by GitHub
parent 7b0ce8064a
commit d013132d0e
4 changed files with 224 additions and 155 deletions

View File

@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<div
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
@@ -72,7 +72,16 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
const DocIcon = getDocumentIcon('', item.name)
return (
<>
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
return (
<>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
@@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
},
file: {
type: 'file',
@@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
},
} as const

View File

@@ -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(

View File

@@ -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<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLElement>('[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<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
const items = Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[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 (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
<>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
onKeyDown={handleContentKeyDown}
>
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
>
<DropdownMenuSearchInput
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<button
ref={buttonRef}
type='button'
onClick={() => doOpen()}
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
>
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</>
)
})
)

View File

@@ -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
}