feat(QuickPanel): Soft hide and symbol toggle fix(#9326) (#9371)

* feat(QuickPanel): 软隐藏与符号切换;性能优化与清理

- 交互改进
  - 无匹配时“软隐藏”(不销毁、折叠且不拦截)
  - 回删修正后有结果自动展开
  - 输入新符号(/ 或 @)即切换到对应面板
- 性能优化
  - 搜索 50ms 防抖,降低高频输入开销
  - 按搜索词只构建一次模糊匹配正则
  - 使用 WeakMap 缓存每项拼音,避免重复转换
  - 折叠时不渲染列表、不注册全局键盘监听
- 代码清理
  - 删除 noMatchTimeoutRef 及其清理 effect
  - 删除未使用的 currentMessageId 引用
  - 移除重复的 setText('') 清空逻辑
- 保持不变
  - 多选/固定/清空等既有模型面板逻辑
  - ESC、外部点击、删除符号的关闭语义
  - 初始空查询直接展示可选项

* feat(quickpanel): 清除模型时同时删除@符号和搜索文本

- 在MentionModelsButton中记录触发信息
- 清除操作时根据触发类型删除@符号
- 仅处理输入触发的场景,按钮触发不需要处理

* refactor(quickpanel): 提取通用的删除@符号函数

- 创建 removeAtSymbolAndText 函数统一处理删除逻辑
- 支持两种模式:精确删除(ESC,使用searchText)和自动查找(清除)
- ESC和清除操作现在使用相同的核心逻辑
- 提高代码可维护性和一致性

* handleInput 中的 ctx.close('delete-symbol') 替换为本地 handleClose('delete-symbol'),确保 Backspace 删除触发符时同步受控输入值。

* - 统一 @ 清除逻辑:基于光标+搜索词的锚点定位
- 修复 ESC/清除误删邮箱/URL 中 @ 的问题
- 精确匹配优先:从光标左侧最近的 “@+searchText”
- 失败兜底:验证触发位 position,一致删整段,不一致仅删单个 @
- 清除按钮:未知搜索词时按光标左侧最近 @ 删至空格/换行
- 保持行为一致:ESC 与“清除模型”共用同一删除函数

* - 修复:无匹配时“清除”被过滤导致不可用的问题
- 方案:为“清除”项添加 alwaysVisible 标记,不参与过滤并始终置顶展示
- 过滤改造:QuickPanel 将列表拆分为固定项与普通项,仅对普通项执行包含/模糊/拼音过滤,最终合并渲染
- 折叠逻辑:collapsed 仅依据“非固定项”的匹配数;当仅剩“清除”时仍折叠隐藏,UI 不受影响
This commit is contained in:
Jason Young 2025-08-25 16:06:14 +08:00 committed by GitHub
parent c49201f365
commit a398010213
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 229 additions and 149 deletions

View File

@ -54,6 +54,12 @@ export type QuickPanelListItem = {
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
/**
*
* alwaysVisible
*
*/
alwaysVisible?: boolean
action?: (options: QuickPanelCallBackOptions) => void
}

View File

@ -1,10 +1,12 @@
import { RightOutlined } from '@ant-design/icons'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { isMac } from '@renderer/config/constant'
import { useTimer } from '@renderer/hooks/useTimer'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { t } from 'i18next'
import { debounce } from 'lodash'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
@ -62,20 +64,32 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 无匹配项自动关闭的定时器
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearSearchTimerRef = useRef<NodeJS.Timeout>(undefined)
const focusTimerRef = useRef<NodeJS.Timeout>(undefined)
// 处理搜索,过滤列表
const { setTimeoutTimer } = useTimer()
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
const newList = ctx.list?.filter((item) => {
const _searchText = searchText.replace(/^[/@]/, '')
const _searchText = searchText.replace(/^[/@]/, '')
const lowerSearchText = _searchText.toLowerCase()
const fuzzyPattern = lowerSearchText
.split('')
.map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*')
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
const filteredNormalItems = normalItems.filter((item) => {
if (!_searchText) return true
let filterText = item.filterText || ''
@ -87,29 +101,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = _searchText.toLowerCase()
if (lowerFilterText.includes(lowerSearchText)) {
return true
}
const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
const regex = new RegExp(pattern, 'ig')
return regex.test(pinyinText)
let pinyinText = pinyinCacheRef.current.get(item)
if (!pinyinText) {
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCacheRef.current.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) {
return true
}
} else {
const regex = new RegExp(pattern, 'ig')
return regex.test(filterText.toLowerCase())
return fuzzyRegex.test(filterText.toLowerCase())
}
})
@ -122,8 +131,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
const combinedLength = pinnedItems.length + filteredNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
return prevIndex
})
@ -132,81 +142,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList
// 固定项置顶 + 过滤后的普通项
return [...pinnedItems, ...filteredNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel])
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
useEffect(() => {
const _searchText = searchText.replace(/^[/@]/, '')
// 清除之前的定时器(无论面板是否可见都要清理)
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
clearTimeout(clearSearchTimerRef.current)
clearTimeout(focusTimerRef.current)
}
// 面板不可见时不设置新定时器
if (!ctx.isVisible) {
return
}
// 只有在有搜索文本但无匹配项时才设置延迟关闭
if (_searchText && _searchText.length > 0 && list.length === 0) {
noMatchTimeoutRef.current = setTimeout(() => {
ctx.close('no-matches')
}, 300)
}
// 清理函数
return () => {
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
clearTimeout(clearSearchTimerRef.current)
clearTimeout(focusTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定使用具体属性避免过度重渲染
}, [ctx.isVisible, searchText, list.length, ctx.close])
const clearSearchText = useCallback(
(includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const cursorPosition = textArea.selectionStart ?? 0
const prevChar = textArea.value[cursorPosition - 1]
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
searchTextRef.current = prevChar
}
const textBeforeCursor = textArea.value.slice(0, cursorPosition)
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
if (!_searchText) return
// 查找最后一个 @ 或 / 符号的位置
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex)
const inputText = textArea.value
let newText = inputText
const searchPattern = new RegExp(`${_searchText}$`)
if (lastSymbolIndex === -1) return
const match = inputText.slice(0, cursorPosition).match(searchPattern)
if (match) {
const start = match.index || 0
const end = start + match[0].length
newText = inputText.slice(0, start) + inputText.slice(end)
setInputText(newText)
// 根据 includeSymbol 决定是否删除符号
const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1
const deleteEnd = cursorPosition
clearTimeout(focusTimerRef.current)
focusTimerRef.current = setTimeout(() => {
if (deleteStart >= deleteEnd) return
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(start, start)
}, 0)
}
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setSearchText('')
},
[setInputText]
[setInputText, setTimeoutTimer]
)
const handleClose = useCallback(
@ -317,9 +298,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText)
setSearchTextDebounced(newSearchText)
} else {
ctx.close('delete-symbol')
// 使用本地 handleClose确保在删除触发符时同步受控输入值
handleClose('delete-symbol')
}
}
@ -340,10 +322,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
clearTimeout(clearSearchTimerRef.current)
clearSearchTimerRef.current = setTimeout(() => {
setSearchText('')
}, 200) // 等待面板关闭动画结束后,再清空搜索词
setSearchTextDebounced.cancel()
setTimeoutTimer(
'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
@ -357,9 +343,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'none'
}, [index])
// 处理键盘事件
// 处理键盘事件(折叠时不拦截全局键盘)
useEffect(() => {
if (!ctx.isVisible) return
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
const isCollapsed = hasSearchTextFlag && list.length === 0
if (!ctx.isVisible || isCollapsed) return
const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) {
@ -495,7 +483,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
window.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('click', handleClickOutside, true)
}
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
}, [
index,
isAssistiveKeyPressed,
historyPanel,
ctx,
list,
handleItemAction,
handleClose,
clearSearchText,
searchText
])
const [footerWidth, setFooterWidth] = useState(0)
@ -515,6 +513,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const listHeight = useMemo(() => {
return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT
}, [ctx.pageSize, list.length])
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
const collapsed = hasSearchText && visibleNonPinnedCount === 0
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@ -562,6 +564,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
$collapsed={collapsed}
className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody
@ -572,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
{!collapsed && (
<DynamicVirtualList
ref={listRef}
list={list}
size={listHeight}
estimateSize={estimateSize}
overscan={5}
scrollerStyle={{
pointerEvents: isMouseOver ? 'auto' : 'none'
}}>
{rowRenderer}
</DynamicVirtualList>
)}
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
@ -626,6 +631,7 @@ const QuickPanelContainer = styled.div<{
$pageSize: number
$selectedColor: string
$selectedColorHover: string
$collapsed?: boolean
}>`
--focused-color: rgba(0, 0, 0, 0.06);
--selected-color: ${(props) => props.$selectedColor};
@ -644,8 +650,8 @@ const QuickPanelContainer = styled.div<{
pointer-events: none;
&.visible {
pointer-events: auto;
max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px;
pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')};
max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px;
}
body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1);

View File

@ -111,7 +111,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0)
const currentMessageId = useRef<string>('')
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
@ -251,7 +250,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const { message, blocks } = getUserMessage(baseUserMessage)
message.traceId = parent?.spanContext().traceId
currentMessageId.current = message.id
dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id))
// Clear input
@ -512,30 +510,42 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const cursorPosition = textArea?.selectionStart ?? 0
const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '/') {
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
})
}
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
}
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]

View File

@ -48,6 +48,66 @@ const MentionModelsButton: FC<Props> = ({
// 记录是否有模型被选择的动作发生
const hasModelActionRef = useRef<boolean>(false)
// 记录触发信息,用于清除操作
const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>(
undefined
)
// 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本
const removeAtSymbolAndText = useCallback(
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
// ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配
if (searchText !== undefined) {
const pattern = '@' + searchText
const fromIndex = Math.max(0, safeCaret - 1)
const start = currentText.lastIndexOf(pattern, fromIndex)
if (start !== -1) {
const end = start + pattern.length
return currentText.slice(0, start) + currentText.slice(end)
}
// 兜底:使用打开时的 position 做校验后再删
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
const expected = pattern
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
if (actual === expected) {
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
}
// 如果不完全匹配,安全起见仅删除单个 '@'
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
}
// 未找到匹配则不改动
return currentText
}
// 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾)
{
const fromIndex = Math.max(0, safeCaret - 1)
const start = currentText.lastIndexOf('@', fromIndex)
if (start === -1) {
// 兜底:使用打开时的 position若存在按空白边界删除
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
let endPos = fallbackPosition + 1
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
endPos++
}
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
}
return currentText
}
let endPos = start + 1
while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') {
endPos++
}
return currentText.slice(0, start) + currentText.slice(endPos)
}
},
[]
)
const pinnedModels = useLiveQuery(
async () => {
@ -140,9 +200,20 @@ const MentionModelsButton: FC<Props> = ({
label: t('settings.input.clear.all'),
description: t('settings.input.clear.models'),
icon: <CircleX />,
alwaysVisible: true,
isSelected: false,
action: () => {
onClearMentionModels()
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
if (triggerInfoRef.current?.type === 'input') {
setText((currentText) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
})
}
quickPanel.close()
}
})
@ -157,13 +228,17 @@ const MentionModelsButton: FC<Props> = ({
onMentionModel,
navigate,
quickPanel,
onClearMentionModels
onClearMentionModels,
setText,
removeAtSymbolAndText
])
const openQuickPanel = useCallback(
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
// 重置模型动作标记
hasModelActionRef.current = false
// 保存触发信息
triggerInfoRef.current = triggerInfo
quickPanel.open({
title: t('agents.edit.model.select.title'),
@ -183,28 +258,11 @@ const MentionModelsButton: FC<Props> = ({
closeTriggerInfo?.type === 'input' &&
closeTriggerInfo?.position !== undefined
) {
// 使用React的setText来更新状态
// 基于当前光标 + 搜索词精确定位并删除position 仅作兜底
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)
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!)
})
}
}
@ -213,7 +271,7 @@ const MentionModelsButton: FC<Props> = ({
}
})
},
[modelItems, quickPanel, t, setText]
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
)
const handleOpenQuickPanel = useCallback(() => {