mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(ui): update context menu (#4362)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
91092842e3
commit
49c12011dc
+4
-1
@@ -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(
|
||||
|
||||
+152
-90
@@ -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<typeof useAvailableResources>[nu
|
||||
interface PlusMenuDropdownProps {
|
||||
availableResources: AvailableResourceGroup[]
|
||||
onResourceSelect: (resource: MothershipResource) => void
|
||||
onFileSelect: () => void
|
||||
onClose: () => void
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
pendingCursorRef: React.MutableRefObject<number | null>
|
||||
/** 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<PlusMenuHandle, PlusMenuDropdownProps>(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<HTMLInputElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -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}
|
||||
>
|
||||
<DropdownMenuSearchInput
|
||||
ref={searchRef}
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{!isMention && (
|
||||
<DropdownMenuSearchInput
|
||||
ref={searchRef}
|
||||
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'>
|
||||
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
|
||||
menu FocusScope steal focus from the search input back to the content root. */}
|
||||
<div hidden={filteredItems !== null}>
|
||||
<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>
|
||||
{workflowTree.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
{workflowTree.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span>Workflows</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
|
||||
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{availableResources
|
||||
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
|
||||
.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
<span>Workflows</span>
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
|
||||
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({ type, id: item.id, title: item.name })
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{availableResources
|
||||
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
|
||||
.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<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>
|
||||
{/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
|
||||
menu Collection, or FocusScope restores focus to the content root. */}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -56,50 +58,6 @@ import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
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
|
||||
draftScopeKey?: string
|
||||
@@ -344,6 +302,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
const wasSendingRef = useRef(false)
|
||||
const atInsertPosRef = useRef<number | null>(null)
|
||||
const pendingCursorRef = useRef<number | null>(null)
|
||||
const mentionRangeRef = useRef<{ start: number; end: number } | null>(null)
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -389,17 +350,35 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
const currentValue = valueRef.current
|
||||
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
|
||||
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
|
||||
const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
|
||||
const before = currentValue.slice(0, insertAt)
|
||||
const after = currentValue.slice(insertAt)
|
||||
const range = mentionRangeRef.current
|
||||
let before: string
|
||||
let after: string
|
||||
let insertText: string
|
||||
let newPos: number
|
||||
|
||||
if (range) {
|
||||
before = currentValue.slice(0, range.start)
|
||||
after = currentValue.slice(range.end)
|
||||
const needsSpaceBefore =
|
||||
range.start > 0 && !/\s/.test(currentValue.charAt(range.start - 1))
|
||||
insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
|
||||
newPos = before.length + insertText.length
|
||||
} else {
|
||||
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
|
||||
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
|
||||
insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
|
||||
before = currentValue.slice(0, insertAt)
|
||||
after = currentValue.slice(insertAt)
|
||||
newPos = before.length + insertText.length
|
||||
}
|
||||
|
||||
const newValue = `${before}${insertText}${after}`
|
||||
const newPos = before.length + insertText.length
|
||||
pendingCursorRef.current = newPos
|
||||
// Eagerly sync refs so successive drop-handler iterations see the updated position
|
||||
valueRef.current = newValue
|
||||
atInsertPosRef.current = newPos
|
||||
mentionRangeRef.current = null
|
||||
setMentionQuery(null)
|
||||
setValue(newValue)
|
||||
}
|
||||
|
||||
@@ -411,6 +390,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
|
||||
const handlePlusMenuClose = useCallback(() => {
|
||||
atInsertPosRef.current = null
|
||||
mentionRangeRef.current = null
|
||||
setMentionQuery(null)
|
||||
}, [])
|
||||
|
||||
const handleFileSelectStable = useCallback(() => {
|
||||
@@ -554,6 +535,11 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
currentFiles.clearAttachedFiles()
|
||||
prevSelectedContextsRef.current = []
|
||||
currentContext.clearContexts()
|
||||
// Programmatic close() bypasses Radix's onOpenChange, so handlePlusMenuClose won't
|
||||
// fire — clear mention state inline so ArrowUp etc. aren't intercepted post-submit.
|
||||
plusMenuRef.current?.close()
|
||||
mentionRangeRef.current = null
|
||||
setMentionQuery(null)
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
@@ -562,6 +548,27 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mentionRangeRef.current && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
plusMenuRef.current?.moveActive(1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
plusMenuRef.current?.moveActive(-1)
|
||||
return
|
||||
}
|
||||
if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) {
|
||||
// Confirm the highlighted match if there is one. If no items match, fall
|
||||
// through so Enter still submits and Tab still does its default thing.
|
||||
if (plusMenuRef.current?.selectActive()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const isEmpty = valueRef.current.length === 0 && filesRef.current.attachedFiles.length === 0
|
||||
if (isEmpty && onEditQueuedTailRef.current) {
|
||||
@@ -653,27 +660,47 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
[handleSubmit, mentionTokensWithContext, value, textareaRef]
|
||||
)
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
const getActiveMentionAtRef = useRef(mentionMenu.getActiveMentionQueryAtPosition)
|
||||
getActiveMentionAtRef.current = mentionMenu.getActiveMentionQueryAtPosition
|
||||
|
||||
if (
|
||||
caret > 0 &&
|
||||
newValue.charAt(caret - 1) === '@' &&
|
||||
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
|
||||
) {
|
||||
const before = newValue.slice(0, caret - 1)
|
||||
const after = newValue.slice(caret)
|
||||
const adjusted = `${before}${after}`
|
||||
setValue(adjusted)
|
||||
atInsertPosRef.current = caret - 1
|
||||
const anchor = getCaretAnchor(e.target, caret - 1)
|
||||
plusMenuRef.current?.open(anchor)
|
||||
return
|
||||
}
|
||||
const syncMentionState = useCallback(
|
||||
(_textarea: HTMLTextAreaElement, text: string, caret: number) => {
|
||||
const active = getActiveMentionAtRef.current(caret, text)
|
||||
// Treat any whitespace inside the query as a closer — typing a space
|
||||
// after `@foo` should leave the raw `@foo` text and dismiss the menu.
|
||||
const isOpenable = active && !/\s/.test(active.query)
|
||||
if (!isOpenable) {
|
||||
if (mentionRangeRef.current !== null) {
|
||||
mentionRangeRef.current = null
|
||||
setMentionQuery(null)
|
||||
plusMenuRef.current?.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setValue(newValue)
|
||||
}
|
||||
const wasActive = mentionRangeRef.current !== null
|
||||
mentionRangeRef.current = { start: active.start, end: active.end }
|
||||
setMentionQuery(active.query)
|
||||
if (!wasActive) {
|
||||
// Anchor above the whole input box (not at the caret) so the menu can never
|
||||
// overlap the user's typing.
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const anchor = rect ? { left: rect.left, top: rect.top } : { left: 0, top: 0 }
|
||||
plusMenuRef.current?.open(anchor, { mention: true })
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
setValue(newValue)
|
||||
syncMentionState(e.target, newValue, caret)
|
||||
},
|
||||
[syncMentionState]
|
||||
)
|
||||
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -685,8 +712,10 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(snapPos, snapPos)
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
}, [textareaRef, mentionTokensWithContext])
|
||||
syncMentionState(textarea, textarea.value, pos)
|
||||
}, [textareaRef, mentionTokensWithContext, syncMentionState])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
@@ -805,6 +834,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleContainerClick}
|
||||
className={cn(
|
||||
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
|
||||
@@ -855,11 +885,25 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
ref={plusMenuRef}
|
||||
availableResources={availableResources}
|
||||
onResourceSelect={handleResourceSelect}
|
||||
onFileSelect={handleFileSelectStable}
|
||||
onClose={handlePlusMenuClose}
|
||||
textareaRef={textareaRef}
|
||||
pendingCursorRef={pendingCursorRef}
|
||||
mentionQuery={mentionQuery ?? undefined}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleFileSelectStable}
|
||||
aria-label='Attach file'
|
||||
className='h-[28px] w-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px] text-[var(--text-icon)]' strokeWidth={2} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Attach file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
|
||||
|
||||
+1
-1
@@ -347,7 +347,7 @@ export const ShortInput = memo(function ShortInput({
|
||||
<>
|
||||
<Input
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
|
||||
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] selection:text-transparent placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder ?? ''}
|
||||
type='text'
|
||||
|
||||
@@ -87,9 +87,20 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
type DropdownMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Forwarded to Radix's internal FocusScope. The Radix primitive supports this prop at
|
||||
* runtime but does not expose it in its public TypeScript types — surface it here so
|
||||
* callers can opt out of the default focus-into-menu behavior on open.
|
||||
*/
|
||||
onOpenAutoFocus?: (event: Event) => void
|
||||
}
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
DropdownMenuContentProps
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
|
||||
Reference in New Issue
Block a user