From 535dcf477851342971d0cbb234a6d582b4fc5bac Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sun, 17 Aug 2025 11:43:44 +0800 Subject: [PATCH] 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 --- .../src/components/QuickPanel/provider.tsx | 18 ++-- .../src/components/QuickPanel/types.ts | 12 ++- .../src/components/QuickPanel/view.tsx | 6 +- .../src/pages/home/Inputbar/Inputbar.tsx | 6 +- .../src/pages/home/Inputbar/InputbarTools.tsx | 4 +- .../home/Inputbar/MentionModelsButton.tsx | 83 ++++++++++++------- 6 files changed, 87 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index 0db0824934..c06d337248 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -5,7 +5,8 @@ import { QuickPanelCloseAction, QuickPanelContextType, QuickPanelListItem, - QuickPanelOpenOptions + QuickPanelOpenOptions, + QuickPanelTriggerInfo } from './types' const QuickPanelContext = createContext(null) @@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC = ({ children const [defaultIndex, setDefaultIndex] = useState(0) const [pageSize, setPageSize] = useState(7) const [multiple, setMultiple] = useState(false) - const [onClose, setOnClose] = useState< - ((Options: Pick) => void) | undefined - >() + const [triggerInfo, setTriggerInfo] = useState() + const [onClose, setOnClose] = useState<((Options: Partial) => 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 = ({ 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 = ({ 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 = ({ children setAfterAction(undefined) setTitle(undefined) setSymbol('') + setTriggerInfo(undefined) }, 200) }, - [onClose, symbol] + [onClose, symbol, triggerInfo] ) useEffect(() => { @@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + triggerInfo, onClose, beforeAction, afterAction @@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + triggerInfo, onClose, beforeAction, afterAction diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 5c8f0edffd..8cf79fb270 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -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 diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index c955453903..34ebf07080 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -204,7 +204,9 @@ export const QuickPanelView: React.FC = ({ 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 = ({ setInputText }) => { clearSearchText(true) } }, - [ctx, clearSearchText, setInputText] + [ctx, clearSearchText, setInputText, searchText] ) const handleItemAction = useCallback( diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index b84f4d6274..4257a1fa57 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -530,7 +530,11 @@ const Inputbar: FC = ({ 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] diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 4f6f264ace..69baf4dafe 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -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() })) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 822d52fef6..9cc4d000b7 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -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 = ({ 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])