Fix/at symbol deletion issue (#9206)

* fix: prevent incorrect @ symbol deletion in QuickPanel

- Track trigger source (input vs button) and @ position
- Only delete @ when triggered by input with model selection
- Button-triggered panels never delete text content
- Validate @ still exists at recorded position before deletion

* feat: delete search text along with @ symbol

- Pass searchText from QuickPanel to onClose callback
- Delete both @ and search text (e.g., @cla) when model selected
- Validate text matches before deletion for safety
- Fallback to deleting only @ if text doesn't match

* refactor: clarify ESC vs Backspace behavior in QuickPanel

- ESC: Cancel operation, delete @ + searchText when models selected
- Backspace: Natural editing, @ already deleted by browser, no extra action
- Clear separation of intent improves predictability and UX
This commit is contained in:
Jason Young 2025-08-17 11:43:44 +08:00 committed by GitHub
parent 4dad2a593b
commit 535dcf4778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 87 additions and 42 deletions

View File

@ -5,7 +5,8 @@ import {
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelListItem,
QuickPanelOpenOptions
QuickPanelOpenOptions,
QuickPanelTriggerInfo
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [onClose, setOnClose] = useState<
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
>()
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setPageSize(options.pageSize ?? 7)
setMultiple(options.multiple ?? false)
setSymbol(options.symbol)
setTriggerInfo(options.triggerInfo)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
}, [])
const close = useCallback(
(action?: QuickPanelCloseAction) => {
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
onClose?.({ symbol, action })
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
clearTimer.current = setTimeout(() => {
setList([])
@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setAfterAction(undefined)
setTitle(undefined)
setSymbol('')
setTriggerInfo(undefined)
}, 200)
},
[onClose, symbol]
[onClose, symbol, triggerInfo]
)
useEffect(() => {
@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction
@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction

View File

@ -1,6 +1,12 @@
import React from 'react'
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
export type QuickPanelTriggerInfo = {
type: 'input' | 'button'
position?: number
originalText?: string
}
export type QuickPanelCallBackOptions = {
symbol: string
action: QuickPanelCloseAction
@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = {
searchText?: string
/** 是否处于多选状态 */
multiple?: boolean
triggerInfo?: QuickPanelTriggerInfo
}
export type QuickPanelOpenOptions = {
@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = {
* /@#
*/
symbol: string
/** 触发信息,记录面板是如何被打开的 */
triggerInfo?: QuickPanelTriggerInfo
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (options: QuickPanelCallBackOptions) => void
onClose?: (options: QuickPanelCallBackOptions) => void
@ -51,7 +60,7 @@ export type QuickPanelListItem = {
// 定义上下文类型
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean
readonly symbol: string
@ -60,6 +69,7 @@ export interface QuickPanelContextType {
readonly defaultIndex: number
readonly pageSize: number
readonly multiple: boolean
readonly triggerInfo?: QuickPanelTriggerInfo
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void

View File

@ -204,7 +204,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const handleClose = useCallback(
(action?: QuickPanelCloseAction) => {
ctx.close(action)
// 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /
const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : ''
ctx.close(action, cleanSearchText)
setHistoryPanel([])
scrollTriggerRef.current = 'initial'
@ -217,7 +219,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText]
[ctx, clearSearchText, setInputText, searchText]
)
const handleItemAction = useCallback(

View File

@ -530,7 +530,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel()
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]

View File

@ -49,7 +49,7 @@ export interface InputbarToolsRef {
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: () => void
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
openAttachmentQuickPanel: () => void
}
@ -292,7 +292,7 @@ const InputbarTools = ({
useImperativeHandle(ref, () => ({
getQuickPanelMenu: getQuickPanelMenuImpl,
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo),
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
}))

View File

@ -17,7 +17,7 @@ import { useNavigate } from 'react-router'
import styled from 'styled-components'
export interface MentionModelsButtonRef {
openQuickPanel: () => void
openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
}
interface Props {
@ -137,42 +137,67 @@ const MentionModelsButton: FC<Props> = ({
return items
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
const openQuickPanel = useCallback(() => {
// 重置模型动作标记
hasModelActionRef.current = false
const openQuickPanel = useCallback(
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
// 重置模型动作标记
hasModelActionRef.current = false
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
symbol: '@',
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
},
onClose({ action }) {
// ESC或Backspace关闭时的特殊处理
if (action === 'esc' || action === 'delete-symbol') {
// 如果有模型选择动作发生,删除@字符
if (hasModelActionRef.current) {
// 使用React的setText来更新状态而不是直接操作DOM
setText((currentText) => {
const lastAtIndex = currentText.lastIndexOf('@')
if (lastAtIndex !== -1) {
return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1)
}
return currentText
})
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
symbol: '@',
multiple: true,
triggerInfo: triggerInfo || { type: 'button' },
afterAction({ item }) {
item.isSelected = !item.isSelected
},
onClose({ action, triggerInfo: closeTriggerInfo, searchText }) {
// ESC关闭时的处理删除 @ 和搜索文本
if (action === 'esc') {
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
if (
hasModelActionRef.current &&
closeTriggerInfo?.type === 'input' &&
closeTriggerInfo?.position !== undefined
) {
// 使用React的setText来更新状态
setText((currentText) => {
const position = closeTriggerInfo.position!
// 验证位置的字符是否仍是 @
if (currentText[position] !== '@') {
return currentText
}
// 计算删除范围:@ + searchText
const deleteLength = 1 + (searchText?.length || 0)
// 验证要删除的内容是否匹配预期
const expectedText = '@' + (searchText || '')
const actualText = currentText.slice(position, position + deleteLength)
if (actualText !== expectedText) {
// 如果实际文本不匹配,只删除 @ 字符
return currentText.slice(0, position) + currentText.slice(position + 1)
}
// 删除 @ 和搜索文本
return currentText.slice(0, position) + currentText.slice(position + deleteLength)
})
}
}
// Backspace删除@的情况delete-symbol
// @ 已经被Backspace自然删除面板关闭不需要额外操作
}
}
})
}, [modelItems, quickPanel, t, setText])
})
},
[modelItems, quickPanel, t, setText]
)
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
quickPanel.close()
} else {
openQuickPanel()
openQuickPanel({ type: 'button' })
}
}, [openQuickPanel, quickPanel])