diff --git a/web/src/__tests__/useKeyboard.test.tsx b/web/src/__tests__/useKeyboard.test.tsx index fabf059d..c542477a 100644 --- a/web/src/__tests__/useKeyboard.test.tsx +++ b/web/src/__tests__/useKeyboard.test.tsx @@ -113,6 +113,62 @@ describe('KeyboardProvider', () => { ); }); + it.each(['metaKey', 'ctrlKey', 'altKey', 'shiftKey'] as const)( + 'leaves %s combinations to the platform', + (modifier) => { + const create = vi.fn(); + const move = vi.fn(); + function RegisterCreate() { + const { setOpenCreateDialog } = useKeyboard(); + React.useEffect(() => setOpenCreateDialog(create), [setOpenCreateDialog]); + return null; + } + render( + + + + + ); + for (const key of ['c', 'j', 'k', '1', 'ArrowDown', 'ArrowUp', 'Enter']) { + const event = new KeyboardEvent('keydown', { key, [modifier]: true, cancelable: true }); + fireEvent(window, event); + expect(event.defaultPrevented).toBe(false); + } + expect(create).not.toHaveBeenCalled(); + expect(move).not.toHaveBeenCalled(); + expect(screen.getByTestId('selected').textContent).toBe('none'); + fireEvent.keyDown(window, { key: 'c' }); + expect(create).toHaveBeenCalledOnce(); + } + ); + + it('preserves the intentional chat chord and ignores IME composition', () => { + const chat = vi.fn(); + const create = vi.fn(); + function RegisterCommands() { + const { setOpenChatPanel, setOpenCreateDialog } = useKeyboard(); + React.useEffect(() => { + setOpenChatPanel(chat); + setOpenCreateDialog(create); + }, [setOpenChatPanel, setOpenCreateDialog]); + return null; + } + render( + + + + ); + fireEvent.keyDown(window, { key: 'C', metaKey: true, shiftKey: true }); + fireEvent.keyDown(window, { key: 'C', ctrlKey: true, shiftKey: true }); + expect(chat).toHaveBeenCalledTimes(2); + fireEvent.keyDown(window, { key: 'C', ctrlKey: true, shiftKey: true, altKey: true }); + fireEvent.keyDown(window, { key: 'C', metaKey: true, shiftKey: true, isComposing: true }); + fireEvent.keyDown(window, { key: 'c', isComposing: true }); + fireEvent.keyDown(window, { key: 'c', keyCode: 229 }); + expect(chat).toHaveBeenCalledTimes(2); + expect(create).not.toHaveBeenCalled(); + }); + it('starts with no selected task and help closed', () => { renderWithProvider(); expect(screen.getByTestId('selected').textContent).toBe('none'); diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index 9ea2be52..bfd847b8 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -375,6 +375,7 @@ export function KanbanBoard() { // Register filtered tasks with keyboard context useEffect(() => { setTasks(filteredTasks); + return () => setTasks([]); }, [filteredTasks, setTasks]); // Handler for opening a task diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx index e1c57961..abd842ee 100644 --- a/web/src/hooks/useKeyboard.tsx +++ b/web/src/hooks/useKeyboard.tsx @@ -116,6 +116,8 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { const target = e.target instanceof HTMLElement ? e.target : null; if ( e.defaultPrevented || + e.isComposing || + e.keyCode === 229 || target?.isContentEditable || target?.closest( 'input, textarea, select, button, a[href], summary, [role="button"], [role="link"], [role="switch"], [role="checkbox"], [role="radio"], [role="combobox"], [role="listbox"], [role="option"], [role="slider"], [role="spinbutton"], [role="tab"], [role="menuitem"]' @@ -143,12 +145,16 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { const currentIndex = selectedTaskId ? taskList.findIndex((t) => t.id === selectedTaskId) : -1; // Cmd+Shift+C (or Ctrl+Shift+C on Windows/Linux) - Toggle chat panel - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { + if (e.metaKey !== e.ctrlKey && !e.altKey && e.shiftKey && e.key.toLowerCase() === 'c') { e.preventDefault(); openChatPanel(); return; } + // Unregistered modifiers belong to the platform or focused application. + // Shift is permitted only for '?' so shortcut help remains reachable. + if (e.metaKey || e.ctrlKey || e.altKey || (e.shiftKey && e.key !== '?')) return; + switch (e.key) { case 'c': e.preventDefault(); @@ -215,7 +221,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { }); return; } - if (selectedTaskId && onMoveTaskRef.current) { + if (selectedTaskId && currentIndex >= 0 && onMoveTaskRef.current) { onMoveTaskRef.current(selectedTaskId, newStatus); } else { // Show toast when no task is selected