diff --git a/src/components/agent-chat/ChatComposer.tsx b/src/components/agent-chat/ChatComposer.tsx index 87cac103..4f014ff3 100644 --- a/src/components/agent-chat/ChatComposer.tsx +++ b/src/components/agent-chat/ChatComposer.tsx @@ -2,6 +2,8 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useSta import { Send, Square } from 'lucide-react' import { cn } from '@/lib/utils' import { getDraft, setDraft, clearDraft } from '@/lib/draft-store' +import { useAppDispatch } from '@/store/hooks' +import { switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' export interface ChatComposerHandle { focus: () => void @@ -19,6 +21,7 @@ interface ChatComposerProps { } const ChatComposer = forwardRef(function ChatComposer({ paneId, onSend, onInterrupt, disabled, isRunning, placeholder, autoFocus, shouldFocusOnReady }, ref) { + const dispatch = useAppDispatch() const [text, setText] = useState(() => (paneId ? getDraft(paneId) : '')) const textareaRef = useRef(null) @@ -81,6 +84,22 @@ const ChatComposer = forwardRef(function }, [text, onSend, paneId]) const handleKeyDown = useCallback((e: KeyboardEvent) => { + // Tab switching: Ctrl+Shift+[ (prev) and Ctrl+Shift+] (next) + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === 'BracketLeft') { + e.preventDefault() + e.stopPropagation() + dispatch(switchToPrevTab()) + return + } + if (e.code === 'BracketRight') { + e.preventDefault() + e.stopPropagation() + dispatch(switchToNextTab()) + return + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() @@ -89,7 +108,7 @@ const ChatComposer = forwardRef(function e.preventDefault() onInterrupt() } - }, [handleSend, isRunning, onInterrupt]) + }, [dispatch, handleSend, isRunning, onInterrupt]) const handleInput = useCallback(() => { if (textareaRef.current) resizeTextarea(textareaRef.current) diff --git a/test/unit/client/components/agent-chat/ChatComposer.test.tsx b/test/unit/client/components/agent-chat/ChatComposer.test.tsx index f9fdb36f..141ab7ae 100644 --- a/test/unit/client/components/agent-chat/ChatComposer.test.tsx +++ b/test/unit/client/components/agent-chat/ChatComposer.test.tsx @@ -1,15 +1,31 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' -import { render, screen, cleanup } from '@testing-library/react' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ChatComposer from '../../../../../src/components/agent-chat/ChatComposer' import { getDraft, clearDraft } from '@/lib/draft-store' +const mockDispatch = vi.fn() +vi.mock('@/store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: () => ({}), +})) + +vi.mock('@/store/tabsSlice', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + switchToNextTab: () => ({ type: 'tabs/switchToNextTab' }), + switchToPrevTab: () => ({ type: 'tabs/switchToPrevTab' }), + } +}) + describe('ChatComposer', () => { afterEach(() => { cleanup() clearDraft('test-pane') clearDraft('pane-a') clearDraft('pane-b') + mockDispatch.mockClear() }) it('renders textarea and send button', () => { render( {}} onInterrupt={() => {}} />) @@ -228,4 +244,28 @@ describe('ChatComposer', () => { expect(focusSpy).not.toHaveBeenCalled() }) }) + + describe('tab switching shortcuts', () => { + it('dispatches switchToNextTab on Ctrl+Shift+]', () => { + render( {}} onInterrupt={() => {}} />) + const textarea = screen.getByRole('textbox') + fireEvent.keyDown(textarea, { code: 'BracketRight', ctrlKey: true, shiftKey: true }) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'tabs/switchToNextTab' }) + }) + + it('dispatches switchToPrevTab on Ctrl+Shift+[', () => { + render( {}} onInterrupt={() => {}} />) + const textarea = screen.getByRole('textbox') + fireEvent.keyDown(textarea, { code: 'BracketLeft', ctrlKey: true, shiftKey: true }) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'tabs/switchToPrevTab' }) + }) + + it('does not dispatch tab switch without Ctrl+Shift', () => { + render( {}} onInterrupt={() => {}} />) + const textarea = screen.getByRole('textbox') + fireEvent.keyDown(textarea, { code: 'BracketRight', ctrlKey: false, shiftKey: true }) + fireEvent.keyDown(textarea, { code: 'BracketLeft', ctrlKey: true, shiftKey: false }) + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) })