Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/components/agent-chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +21,7 @@ interface ChatComposerProps {
}

const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer({ paneId, onSend, onInterrupt, disabled, isRunning, placeholder, autoFocus, shouldFocusOnReady }, ref) {
const dispatch = useAppDispatch()
const [text, setText] = useState(() => (paneId ? getDraft(paneId) : ''))
const textareaRef = useRef<HTMLTextAreaElement | null>(null)

Expand Down Expand Up @@ -81,6 +84,22 @@ const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function
}, [text, onSend, paneId])

const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
// 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
Comment on lines +88 to +93

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop duplicate tab-switch dispatch from chat composer shortcut

Dispatching switchToPrevTab()/switchToNextTab() here without stopping propagation causes the same keydown to be handled again by App’s window-level shortcut listener (src/App.tsx keyboard handler around lines 999-1004), because preventDefault() does not stop bubbling. In FreshClaude textareas this results in two tab-switch actions per keypress (e.g., with 2 tabs it appears to do nothing, with 3+ tabs it skips a tab), which is a functional regression introduced by this change.

Useful? React with 👍 / 👎.

}
if (e.code === 'BracketRight') {
e.preventDefault()
e.stopPropagation()
dispatch(switchToNextTab())
return
}
}

if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
Expand All @@ -89,7 +108,7 @@ const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function
e.preventDefault()
onInterrupt()
}
}, [handleSend, isRunning, onInterrupt])
}, [dispatch, handleSend, isRunning, onInterrupt])

const handleInput = useCallback(() => {
if (textareaRef.current) resizeTextarea(textareaRef.current)
Expand Down
42 changes: 41 additions & 1 deletion test/unit/client/components/agent-chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/store/tabsSlice')>()
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(<ChatComposer onSend={() => {}} onInterrupt={() => {}} />)
Expand Down Expand Up @@ -228,4 +244,28 @@ describe('ChatComposer', () => {
expect(focusSpy).not.toHaveBeenCalled()
})
})

describe('tab switching shortcuts', () => {
it('dispatches switchToNextTab on Ctrl+Shift+]', () => {
render(<ChatComposer onSend={() => {}} 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(<ChatComposer onSend={() => {}} 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(<ChatComposer onSend={() => {}} 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()
})
})
})