diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index fc73bc3418..0db0824934 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC = ({ children const clearTimer = useRef(null) + // 添加更新item选中状态的方法 + const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => { + setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item))) + }, []) + const open = useCallback((options: QuickPanelOpenOptions) => { if (clearTimer.current) { clearTimeout(clearTimer.current) @@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC = ({ children () => ({ open, close, + updateItemSelection, isVisible, symbol, @@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC = ({ children beforeAction, afterAction }), - [open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction] + [ + open, + close, + updateItemSelection, + isVisible, + symbol, + list, + title, + defaultIndex, + pageSize, + multiple, + onClose, + beforeAction, + afterAction + ] ) return {children} diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 7cef05be23..5c8f0edffd 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -52,6 +52,7 @@ export type QuickPanelListItem = { export interface QuickPanelContextType { readonly open: (options: QuickPanelOpenOptions) => void readonly close: (action?: QuickPanelCloseAction) => void + readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void readonly isVisible: boolean readonly symbol: string readonly list: QuickPanelListItem[] diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 0dbeebaee8..37fefdf3b2 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -50,7 +50,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [isMouseOver, setIsMouseOver] = useState(false) const scrollTriggerRef = useRef('initial') - const [_index, setIndex] = useState(ctx.defaultIndex) + const [_index, setIndex] = useState(-1) const index = useDeferredValue(_index) const [historyPanel, setHistoryPanel] = useState([]) @@ -62,6 +62,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const searchText = useDeferredValue(_searchText) const searchTextRef = useRef('') + // 跟踪上一次的搜索文本和符号,用于判断是否需要重置index + const prevSearchTextRef = useRef('') + const prevSymbolRef = useRef('') + // 处理搜索,过滤列表 const list = useMemo(() => { if (!ctx.isVisible && !ctx.symbol) return [] @@ -104,7 +108,24 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } }) - setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1) + // 只有在搜索文本变化或面板符号变化时才重置index + const isSearchChanged = prevSearchTextRef.current !== searchText + const isSymbolChanged = prevSymbolRef.current !== ctx.symbol + + if (isSearchChanged || isSymbolChanged) { + setIndex(-1) // 不默认高亮任何项,让用户主动选择 + } else { + // 如果当前index超出范围,调整到有效范围内 + setIndex((prevIndex) => { + if (prevIndex >= newList.length) { + return newList.length > 0 ? newList.length - 1 : -1 + } + return prevIndex + }) + } + + prevSearchTextRef.current = searchText + prevSymbolRef.current = ctx.symbol return newList }, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText]) @@ -168,12 +189,33 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { (item: QuickPanelListItem, action?: QuickPanelCloseAction) => { if (item.disabled) return + // 在多选模式下,先更新选中状态 + if (ctx.multiple && !item.isMenu) { + const newSelectedState = !item.isSelected + ctx.updateItemSelection(item, newSelectedState) + + // 创建更新后的item对象用于回调 + const updatedItem = { ...item, isSelected: newSelectedState } + const quickPanelCallBackOptions: QuickPanelCallBackOptions = { + symbol: ctx.symbol, + action, + item: updatedItem, + searchText: searchText, + multiple: ctx.multiple + } + + ctx.beforeAction?.(quickPanelCallBackOptions) + item?.action?.(quickPanelCallBackOptions) + ctx.afterAction?.(quickPanelCallBackOptions) + return + } + const quickPanelCallBackOptions: QuickPanelCallBackOptions = { symbol: ctx.symbol, action, item, searchText: searchText, - multiple: isAssistiveKeyPressed + multiple: ctx.multiple } ctx.beforeAction?.(quickPanelCallBackOptions) @@ -200,11 +242,12 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return } - if (ctx.multiple && isAssistiveKeyPressed) return + // 多选模式下不关闭面板 + if (ctx.multiple) return handleClose(action) }, - [ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index] + [ctx, searchText, handleClose, clearSearchText, index] ) useEffect(() => { @@ -294,12 +337,16 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { scrollTriggerRef.current = 'keyboard' if (isAssistiveKeyPressed) { setIndex((prev) => { + if (prev === -1) return list.length > 0 ? list.length - 1 : -1 const newIndex = prev - ctx.pageSize if (prev === 0) return list.length - 1 return newIndex < 0 ? 0 : newIndex }) } else { - setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1)) + setIndex((prev) => { + if (prev === -1) return list.length > 0 ? list.length - 1 : -1 + return prev > 0 ? prev - 1 : list.length - 1 + }) } break @@ -307,18 +354,23 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { scrollTriggerRef.current = 'keyboard' if (isAssistiveKeyPressed) { setIndex((prev) => { + if (prev === -1) return list.length > 0 ? 0 : -1 const newIndex = prev + ctx.pageSize if (prev + 1 === list.length) return 0 return newIndex >= list.length ? list.length - 1 : newIndex }) } else { - setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0)) + setIndex((prev) => { + if (prev === -1) return list.length > 0 ? 0 : -1 + return prev < list.length - 1 ? prev + 1 : 0 + }) } break case 'PageUp': scrollTriggerRef.current = 'keyboard' setIndex((prev) => { + if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1 const newIndex = prev - ctx.pageSize return newIndex < 0 ? 0 : newIndex }) @@ -327,6 +379,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { case 'PageDown': scrollTriggerRef.current = 'keyboard' setIndex((prev) => { + if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1 const newIndex = prev + ctx.pageSize return newIndex >= list.length ? list.length - 1 : newIndex }) @@ -421,10 +474,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { (): VirtualizedRowData => ({ list, focusedIndex: index, - handleItemAction, - setIndex + handleItemAction }), - [list, index, handleItemAction, setIndex] + [list, index, handleItemAction] ) return ( @@ -487,15 +539,6 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ↩︎ {t('settings.quickPanel.confirm')} - - {ctx.multiple && ( - - - {ASSISTIVE_KEY} - - + ↩︎ {t('settings.quickPanel.multiple')} - - )} @@ -507,7 +550,6 @@ interface VirtualizedRowData { list: QuickPanelListItem[] focusedIndex: number handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void - setIndex: (index: number) => void } /** @@ -515,7 +557,7 @@ interface VirtualizedRowData { */ const VirtualizedRow = React.memo( ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { list, focusedIndex, handleItemAction, setIndex } = data + const { list, focusedIndex, handleItemAction } = data const item = list[index] if (!item) return null @@ -531,8 +573,7 @@ const VirtualizedRow = React.memo( onClick={(e) => { e.stopPropagation() handleItemAction(item, 'click') - }} - onMouseEnter={() => setIndex(index)}> + }}> {item.icon} {item.label} @@ -651,11 +692,19 @@ const QuickPanelItem = styled.div` border-radius: 6px; cursor: pointer; transition: background-color 0.1s ease; + + &:hover:not(.disabled) { + background-color: var(--focused-color); + } + &.selected { background-color: var(--selected-color); &.focused { background-color: var(--selected-color-dark); } + &:hover:not(.disabled) { + background-color: var(--selected-color-dark); + } } &.focused { background-color: var(--focused-color); diff --git a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx index 782f0cb42c..995c5c5d0b 100644 --- a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx +++ b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx @@ -122,7 +122,7 @@ describe('QuickPanelView', () => { } } - it('should focus on the first item after panel open', () => { + it('should not focus on any item after panel open by default', () => { const list = createList(100) render( @@ -134,11 +134,16 @@ describe('QuickPanelView', () => { ) ) - // 检查第一个 item 是否有 focused + // 检查是否没有任何 focused item + const panel = screen.getByTestId('quick-panel') + const focused = panel.querySelectorAll('.focused') + expect(focused.length).toBe(0) + + // 检查第一个 item 存在但没有 focused 类 const item1 = screen.getByText('Item 1') - const focused = item1.closest('.focused') - expect(focused).not.toBeNull() expect(item1).toBeInTheDocument() + const focusedItem1 = item1.closest('.focused') + expect(focusedItem1).toBeNull() }) it('should focus on the right item using ArrowUp, ArrowDown', async () => { @@ -154,10 +159,11 @@ describe('QuickPanelView', () => { ) const keySequence = [ - { key: 'ArrowUp', expected: 'Item 100' }, + { key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个 + { key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个 { key: 'ArrowUp', expected: 'Item 99' }, { key: 'ArrowDown', expected: 'Item 100' }, - { key: 'ArrowDown', expected: 'Item 1' } + { key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个 ] await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) @@ -176,11 +182,11 @@ describe('QuickPanelView', () => { ) const keySequence = [ - { key: 'PageUp', expected: 'Item 1' }, // 停留在顶部 - { key: 'ArrowUp', expected: 'Item 100' }, - { key: 'PageDown', expected: 'Item 100' }, // 停留在底部 - { key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` }, - { key: 'PageDown', expected: 'Item 100' } + { key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目 + { key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个 + { key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个 + { key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个 + { key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页,从索引99到92,对应Item 93 ] await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) @@ -199,10 +205,11 @@ describe('QuickPanelView', () => { ) const keySequence = [ - { key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, - { key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, - { key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, - { key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } + { key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个 + { key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置 + { key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去 + { key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后 + { key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个 ] await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 98095a81e1..1a8b0fed8d 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -864,7 +864,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = onInput={onInput} disabled={searching} onPaste={(e) => onPaste(e.nativeEvent)} - onClick={() => searching && dispatch(setSearching(false))} + onClick={() => { + searching && dispatch(setSearching(false)) + quickPanel.close() + }} /> diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index b022377f18..ec64e7d4a7 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -183,12 +183,15 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar label: t('common.close'), description: t('settings.mcp.disable.description'), icon: , - isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0), - action: () => updateMcpEnabled(false) + isSelected: false, + action: () => { + updateMcpEnabled(false) + quickPanel.close() + } }) return newList - }, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled]) + }, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel]) const openQuickPanel = useCallback(() => { quickPanel.open({