feat: Add @ and keyboard navigation to tool picker in prompt editor

This commit is contained in:
zhsama
2026-01-30 17:26:48 +08:00
parent dbc32af932
commit 304d8e5fe7
5 changed files with 100 additions and 2 deletions

View File

@ -12,6 +12,7 @@ import type { ToolDefaultValue, ToolValue } from './types'
import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import type { OnSelectBlock } from '@/app/components/workflow/types'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useEventListener } from 'ahooks'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -55,6 +56,8 @@ type AllToolsProps = {
onFeaturedInstallSuccess?: () => Promise<void> | void
hideFeaturedTool?: boolean
hideSelectedInfo?: boolean
enableKeyboardNavigation?: boolean
onClose?: () => void
}
const DEFAULT_TAGS: AllToolsProps['tags'] = []
@ -80,12 +83,17 @@ const AllTools = ({
onFeaturedInstallSuccess,
hideFeaturedTool = false,
hideSelectedInfo = false,
enableKeyboardNavigation = false,
onClose,
}: AllToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const tabs = useToolTabs()
const [activeTab, setActiveTab] = useState(ToolTypeEnum.All)
const [activeView, setActiveView] = useState<ViewType>(ViewType.flat)
const activeIndexRef = useRef(-1)
const itemElementsRef = useRef<HTMLElement[]>([])
const highlightedElementRef = useRef<HTMLElement | null>(null)
const trimmedSearchText = searchText.trim()
const hasSearchText = trimmedSearchText.length > 0
const hasTags = tags.length > 0
@ -188,6 +196,34 @@ const AllTools = ({
const pluginRef = useRef<ListRef>(null)
const wrapElemRef = useRef<HTMLDivElement>(null)
const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab)
const refreshKeyboardItems = useCallback(() => {
if (!wrapElemRef.current) {
itemElementsRef.current = []
return []
}
const items = Array.from(wrapElemRef.current.querySelectorAll<HTMLElement>('[data-tool-picker-item="true"]'))
itemElementsRef.current = items
return items
}, [])
const clearHighlight = useCallback(() => {
if (highlightedElementRef.current)
highlightedElementRef.current.classList.remove('bg-state-base-hover')
highlightedElementRef.current = null
activeIndexRef.current = -1
}, [])
const applyHighlight = useCallback((index: number, items: HTMLElement[]) => {
if (highlightedElementRef.current)
highlightedElementRef.current.classList.remove('bg-state-base-hover')
const nextItem = items[index]
if (nextItem) {
nextItem.classList.add('bg-state-base-hover')
highlightedElementRef.current = nextItem
activeIndexRef.current = index
return
}
highlightedElementRef.current = null
activeIndexRef.current = -1
}, [])
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter
const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations
@ -201,6 +237,45 @@ const AllTools = ({
&& !hideFeaturedTool
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
useEffect(() => {
if (!enableKeyboardNavigation) {
itemElementsRef.current = []
clearHighlight()
return
}
const items = refreshKeyboardItems()
if (activeIndexRef.current >= items.length)
clearHighlight()
}, [enableKeyboardNavigation, refreshKeyboardItems, clearHighlight, tools, activeTab, activeView, hasSearchText])
useEventListener('keydown', (event: KeyboardEvent) => {
if (!enableKeyboardNavigation)
return
const items = refreshKeyboardItems()
if (items.length === 0)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
event.preventDefault()
event.stopPropagation()
if (event.key === 'Escape') {
onClose?.()
return
}
if (event.key === 'Enter') {
const index = activeIndexRef.current
if (index < 0 || index >= items.length)
return
items[index]?.click()
return
}
const delta = event.key === 'ArrowDown' ? 1 : -1
const baseIndex = activeIndexRef.current < 0 ? -1 : activeIndexRef.current
const nextIndex = Math.min(Math.max(baseIndex + delta, 0), items.length - 1)
applyHighlight(nextIndex, items)
items[nextIndex]?.scrollIntoView({ block: 'nearest' })
}, { target: typeof document !== 'undefined' ? document : undefined, capture: true })
const handleRAGSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
if (!pluginDefaultValue)
return

View File

@ -57,6 +57,7 @@ type Props = {
searchText?: string
onSearchTextChange?: (value: string) => void
hideSearchBox?: boolean
enableKeyboardNavigation?: boolean
}
const ToolPicker: FC<Props> = ({
@ -79,6 +80,7 @@ const ToolPicker: FC<Props> = ({
searchText: controlledSearchText,
onSearchTextChange,
hideSearchBox = false,
enableKeyboardNavigation = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
@ -239,6 +241,8 @@ const ToolPicker: FC<Props> = ({
showFeatured={scope === 'all' && enable_marketplace}
hideFeaturedTool={hideFeaturedTool}
hideSelectedInfo={hideSelectedInfo}
enableKeyboardNavigation={enableKeyboardNavigation}
onClose={() => onShowChange(false)}
onFeaturedInstallSuccess={async () => {
invalidateBuiltInTools()
invalidateCustomTools()

View File

@ -78,6 +78,7 @@ const ToolItem: FC<Props> = ({
>
<div
key={payload.name}
data-tool-picker-item="true"
className="flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover"
onClick={() => {
if (disabled)

View File

@ -181,6 +181,7 @@ const Tool: FC<Props> = ({
<div className={cn(className)}>
<div
className="group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover"
data-tool-picker-item="true"
onClick={() => {
if (hasAction) {
setFold(!isFold)

View File

@ -5,7 +5,9 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import {
$createTextNode,
$getSelection,
$insertNodes,
$isRangeSelection,
} from 'lexical'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
@ -44,6 +46,20 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => {
const options = useMemo(() => [new ToolPickerMenuOption()], [])
const getMatchFromSelection = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed())
return null
const anchor = selection.anchor
if (anchor.type !== 'text')
return null
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText())
return null
const text = anchorNode.getTextContent().slice(0, anchor.offset)
return checkForTriggerMatch(text, editor)
}, [checkForTriggerMatch, editor])
const buildNextMetadata = useCallback((metadata: Record<string, unknown>, toolEntries: {
configId: string
tool: ToolDefaultValue
@ -73,7 +89,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => {
tool,
}))
editor.update(() => {
const match = checkForTriggerMatch('@', editor)
const match = getMatchFromSelection()
const nodeToRemove = match ? $splitNodeContainingQuery(match) : null
if (nodeToRemove)
nodeToRemove.remove()
@ -124,7 +140,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => {
...nextMetadata,
})
pinTab(activeTabId)
}, [buildNextMetadata, checkForTriggerMatch, editor, isUsingExternalMetadata, storeApi, toolBlockContext])
}, [buildNextMetadata, editor, getMatchFromSelection, isUsingExternalMetadata, storeApi, toolBlockContext])
const renderMenu = useCallback((
anchorElementRef: React.RefObject<HTMLElement | null>,
@ -163,6 +179,7 @@ const ToolPickerBlock = ({ scope = 'all' }: ToolPickerBlockProps) => {
searchText={queryString}
onSearchTextChange={setQueryString}
hideSearchBox
enableKeyboardNavigation
scope={scope}
hideFeaturedTool
preventFocusLoss