Fix keyboard shortcuts for non-QWERTY layouts (#6162)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
roomote[bot] 2025-07-28 14:45:35 -04:00 committed by GitHub
parent 6c87ce8cd7
commit 878b0bd8d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 290 additions and 2 deletions

View file

@ -1693,8 +1693,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check for Command/Ctrl + Period (with or without Shift)
// Using event.code for better cross-platform compatibility
if ((event.metaKey || event.ctrlKey) && event.code === "Period") {
// Using event.key to respect keyboard layouts (e.g., Dvorak)
if ((event.metaKey || event.ctrlKey) && event.key === ".") {
event.preventDefault() // Prevent default browser behavior
if (event.shiftKey) {

View file

@ -0,0 +1,288 @@
// npx vitest run src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx
import React from "react"
import { render, fireEvent } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import ChatView, { ChatViewProps } from "../ChatView"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock use-sound hook
vi.mock("use-sound", () => ({
default: vi.fn().mockImplementation(() => {
return [vi.fn()]
}),
}))
// Mock components
vi.mock("../BrowserSessionRow", () => ({
default: () => null,
}))
vi.mock("../ChatRow", () => ({
default: () => null,
}))
vi.mock("../AutoApproveMenu", () => ({
default: () => null,
}))
vi.mock("../../common/VersionIndicator", () => ({
default: () => null,
}))
vi.mock("@src/components/modals/Announcement", () => ({
default: () => null,
}))
vi.mock("@src/components/welcome/RooCloudCTA", () => ({
default: () => null,
}))
vi.mock("@src/components/welcome/RooTips", () => ({
default: () => null,
}))
vi.mock("@src/components/welcome/RooHero", () => ({
default: () => null,
}))
vi.mock("../common/TelemetryBanner", () => ({
default: () => null,
}))
// Mock i18n
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
initReactI18next: {
type: "3rdParty",
init: () => {},
},
Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>,
}))
vi.mock("../ChatTextArea", () => {
return {
default: React.forwardRef(function MockChatTextArea(
_props: any,
ref: React.ForwardedRef<{ focus: () => void }>,
) {
React.useImperativeHandle(ref, () => ({
focus: vi.fn(),
}))
return <div data-testid="chat-textarea" />
}),
}
})
// Mock VSCode components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
VSCodeLink: ({ children, href }: any) => <a href={href}>{children}</a>,
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
cloudIsAuthenticated: false,
telemetrySetting: "enabled",
mode: "code",
customModes: [],
...state,
},
},
"*",
)
}
const defaultProps: ChatViewProps = {
isHidden: false,
showAnnouncement: false,
hideAnnouncement: () => {},
}
const queryClient = new QueryClient()
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
return render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<ChatView {...defaultProps} {...props} />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
}
describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("uses event.key instead of event.code for keyboard shortcuts", async () => {
renderChatView()
// Hydrate state
mockPostMessage({
mode: "code",
customModes: [],
})
// Wait for component to be ready
await new Promise((resolve) => setTimeout(resolve, 100))
// Clear any initial calls
vi.clearAllMocks()
// Test 1: Period key should trigger mode switch
fireEvent.keyDown(window, {
key: ".",
code: "Period",
ctrlKey: true,
metaKey: false,
shiftKey: false,
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Check if mode switch was triggered
const callsAfterPeriod = (vscode.postMessage as any).mock.calls
const modeSwitchAfterPeriod = callsAfterPeriod.some((call: any[]) => call[0]?.type === "mode")
expect(modeSwitchAfterPeriod).toBe(true)
// Clear mocks
vi.clearAllMocks()
// Test 2: V key on physical Period key (Dvorak) should NOT trigger mode switch
fireEvent.keyDown(window, {
key: "v",
code: "Period", // Physical key is Period, but produces 'v'
ctrlKey: true,
metaKey: false,
shiftKey: false,
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Check that NO mode switch was triggered
const callsAfterV = (vscode.postMessage as any).mock.calls
const modeSwitchAfterV = callsAfterV.some((call: any[]) => call[0]?.type === "mode")
expect(modeSwitchAfterV).toBe(false)
})
it("prevents default behavior when mode switch is triggered", () => {
renderChatView()
// Hydrate state
mockPostMessage({
mode: "code",
customModes: [],
})
// Create a keyboard event with preventDefault spy
const event = new KeyboardEvent("keydown", {
key: ".",
code: "Period",
ctrlKey: true,
metaKey: false,
shiftKey: false,
bubbles: true,
cancelable: true,
})
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
// Dispatch the event
window.dispatchEvent(event)
// Verify preventDefault was called
expect(preventDefaultSpy).toHaveBeenCalled()
})
it("works with Cmd key on Mac", async () => {
renderChatView()
// Hydrate state
mockPostMessage({
mode: "code",
customModes: [],
})
// Wait for component to be ready
await new Promise((resolve) => setTimeout(resolve, 100))
// Clear any initial calls
vi.clearAllMocks()
// Test with Cmd key (Mac)
fireEvent.keyDown(window, {
key: ".",
code: "Period",
ctrlKey: false,
metaKey: true, // Cmd key on Mac
shiftKey: false,
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls
const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
expect(modeSwitch).toBe(true)
})
it("handles Shift modifier for previous mode", async () => {
renderChatView()
// Hydrate state
mockPostMessage({
mode: "code",
customModes: [],
})
// Wait for component to be ready
await new Promise((resolve) => setTimeout(resolve, 100))
// Clear any initial calls
vi.clearAllMocks()
// Test with Shift modifier
fireEvent.keyDown(window, {
key: ".",
code: "Period",
ctrlKey: true,
metaKey: false,
shiftKey: true, // Should go to previous mode
})
// Wait for event to be processed
await new Promise((resolve) => setTimeout(resolve, 50))
// Check if mode switch was triggered
const calls = (vscode.postMessage as any).mock.calls
const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
expect(modeSwitch).toBe(true)
})
})