Skip to content

Commit 878b0bd

Browse files
Fix keyboard shortcuts for non-QWERTY layouts (#6162)
Co-authored-by: Roo Code <[email protected]>
1 parent 6c87ce8 commit 878b0bd

File tree

2 files changed

+290
-2
lines changed

2 files changed

+290
-2
lines changed

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,8 +1693,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16931693
const handleKeyDown = useCallback(
16941694
(event: KeyboardEvent) => {
16951695
// Check for Command/Ctrl + Period (with or without Shift)
1696-
// Using event.code for better cross-platform compatibility
1697-
if ((event.metaKey || event.ctrlKey) && event.code === "Period") {
1696+
// Using event.key to respect keyboard layouts (e.g., Dvorak)
1697+
if ((event.metaKey || event.ctrlKey) && event.key === ".") {
16981698
event.preventDefault() // Prevent default browser behavior
16991699

17001700
if (event.shiftKey) {
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// npx vitest run src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx
2+
3+
import React from "react"
4+
import { render, fireEvent } from "@/utils/test-utils"
5+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
6+
7+
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
8+
import { vscode } from "@src/utils/vscode"
9+
10+
import ChatView, { ChatViewProps } from "../ChatView"
11+
12+
// Mock vscode API
13+
vi.mock("@src/utils/vscode", () => ({
14+
vscode: {
15+
postMessage: vi.fn(),
16+
},
17+
}))
18+
19+
// Mock use-sound hook
20+
vi.mock("use-sound", () => ({
21+
default: vi.fn().mockImplementation(() => {
22+
return [vi.fn()]
23+
}),
24+
}))
25+
26+
// Mock components
27+
vi.mock("../BrowserSessionRow", () => ({
28+
default: () => null,
29+
}))
30+
31+
vi.mock("../ChatRow", () => ({
32+
default: () => null,
33+
}))
34+
35+
vi.mock("../AutoApproveMenu", () => ({
36+
default: () => null,
37+
}))
38+
39+
vi.mock("../../common/VersionIndicator", () => ({
40+
default: () => null,
41+
}))
42+
43+
vi.mock("@src/components/modals/Announcement", () => ({
44+
default: () => null,
45+
}))
46+
47+
vi.mock("@src/components/welcome/RooCloudCTA", () => ({
48+
default: () => null,
49+
}))
50+
51+
vi.mock("@src/components/welcome/RooTips", () => ({
52+
default: () => null,
53+
}))
54+
55+
vi.mock("@src/components/welcome/RooHero", () => ({
56+
default: () => null,
57+
}))
58+
59+
vi.mock("../common/TelemetryBanner", () => ({
60+
default: () => null,
61+
}))
62+
63+
// Mock i18n
64+
vi.mock("react-i18next", () => ({
65+
useTranslation: () => ({
66+
t: (key: string) => key,
67+
}),
68+
initReactI18next: {
69+
type: "3rdParty",
70+
init: () => {},
71+
},
72+
Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>,
73+
}))
74+
75+
vi.mock("../ChatTextArea", () => {
76+
return {
77+
default: React.forwardRef(function MockChatTextArea(
78+
_props: any,
79+
ref: React.ForwardedRef<{ focus: () => void }>,
80+
) {
81+
React.useImperativeHandle(ref, () => ({
82+
focus: vi.fn(),
83+
}))
84+
return <div data-testid="chat-textarea" />
85+
}),
86+
}
87+
})
88+
89+
// Mock VSCode components
90+
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
91+
VSCodeButton: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
92+
VSCodeLink: ({ children, href }: any) => <a href={href}>{children}</a>,
93+
}))
94+
95+
// Mock window.postMessage to trigger state hydration
96+
const mockPostMessage = (state: any) => {
97+
window.postMessage(
98+
{
99+
type: "state",
100+
state: {
101+
version: "1.0.0",
102+
clineMessages: [],
103+
taskHistory: [],
104+
shouldShowAnnouncement: false,
105+
allowedCommands: [],
106+
alwaysAllowExecute: false,
107+
cloudIsAuthenticated: false,
108+
telemetrySetting: "enabled",
109+
mode: "code",
110+
customModes: [],
111+
...state,
112+
},
113+
},
114+
"*",
115+
)
116+
}
117+
118+
const defaultProps: ChatViewProps = {
119+
isHidden: false,
120+
showAnnouncement: false,
121+
hideAnnouncement: () => {},
122+
}
123+
124+
const queryClient = new QueryClient()
125+
126+
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
127+
return render(
128+
<ExtensionStateContextProvider>
129+
<QueryClientProvider client={queryClient}>
130+
<ChatView {...defaultProps} {...props} />
131+
</QueryClientProvider>
132+
</ExtensionStateContextProvider>,
133+
)
134+
}
135+
136+
describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks()
139+
})
140+
141+
it("uses event.key instead of event.code for keyboard shortcuts", async () => {
142+
renderChatView()
143+
144+
// Hydrate state
145+
mockPostMessage({
146+
mode: "code",
147+
customModes: [],
148+
})
149+
150+
// Wait for component to be ready
151+
await new Promise((resolve) => setTimeout(resolve, 100))
152+
153+
// Clear any initial calls
154+
vi.clearAllMocks()
155+
156+
// Test 1: Period key should trigger mode switch
157+
fireEvent.keyDown(window, {
158+
key: ".",
159+
code: "Period",
160+
ctrlKey: true,
161+
metaKey: false,
162+
shiftKey: false,
163+
})
164+
165+
// Wait for event to be processed
166+
await new Promise((resolve) => setTimeout(resolve, 50))
167+
168+
// Check if mode switch was triggered
169+
const callsAfterPeriod = (vscode.postMessage as any).mock.calls
170+
const modeSwitchAfterPeriod = callsAfterPeriod.some((call: any[]) => call[0]?.type === "mode")
171+
expect(modeSwitchAfterPeriod).toBe(true)
172+
173+
// Clear mocks
174+
vi.clearAllMocks()
175+
176+
// Test 2: V key on physical Period key (Dvorak) should NOT trigger mode switch
177+
fireEvent.keyDown(window, {
178+
key: "v",
179+
code: "Period", // Physical key is Period, but produces 'v'
180+
ctrlKey: true,
181+
metaKey: false,
182+
shiftKey: false,
183+
})
184+
185+
// Wait for event to be processed
186+
await new Promise((resolve) => setTimeout(resolve, 50))
187+
188+
// Check that NO mode switch was triggered
189+
const callsAfterV = (vscode.postMessage as any).mock.calls
190+
const modeSwitchAfterV = callsAfterV.some((call: any[]) => call[0]?.type === "mode")
191+
expect(modeSwitchAfterV).toBe(false)
192+
})
193+
194+
it("prevents default behavior when mode switch is triggered", () => {
195+
renderChatView()
196+
197+
// Hydrate state
198+
mockPostMessage({
199+
mode: "code",
200+
customModes: [],
201+
})
202+
203+
// Create a keyboard event with preventDefault spy
204+
const event = new KeyboardEvent("keydown", {
205+
key: ".",
206+
code: "Period",
207+
ctrlKey: true,
208+
metaKey: false,
209+
shiftKey: false,
210+
bubbles: true,
211+
cancelable: true,
212+
})
213+
214+
const preventDefaultSpy = vi.spyOn(event, "preventDefault")
215+
216+
// Dispatch the event
217+
window.dispatchEvent(event)
218+
219+
// Verify preventDefault was called
220+
expect(preventDefaultSpy).toHaveBeenCalled()
221+
})
222+
223+
it("works with Cmd key on Mac", async () => {
224+
renderChatView()
225+
226+
// Hydrate state
227+
mockPostMessage({
228+
mode: "code",
229+
customModes: [],
230+
})
231+
232+
// Wait for component to be ready
233+
await new Promise((resolve) => setTimeout(resolve, 100))
234+
235+
// Clear any initial calls
236+
vi.clearAllMocks()
237+
238+
// Test with Cmd key (Mac)
239+
fireEvent.keyDown(window, {
240+
key: ".",
241+
code: "Period",
242+
ctrlKey: false,
243+
metaKey: true, // Cmd key on Mac
244+
shiftKey: false,
245+
})
246+
247+
// Wait for event to be processed
248+
await new Promise((resolve) => setTimeout(resolve, 50))
249+
250+
// Check if mode switch was triggered
251+
const calls = (vscode.postMessage as any).mock.calls
252+
const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
253+
expect(modeSwitch).toBe(true)
254+
})
255+
256+
it("handles Shift modifier for previous mode", async () => {
257+
renderChatView()
258+
259+
// Hydrate state
260+
mockPostMessage({
261+
mode: "code",
262+
customModes: [],
263+
})
264+
265+
// Wait for component to be ready
266+
await new Promise((resolve) => setTimeout(resolve, 100))
267+
268+
// Clear any initial calls
269+
vi.clearAllMocks()
270+
271+
// Test with Shift modifier
272+
fireEvent.keyDown(window, {
273+
key: ".",
274+
code: "Period",
275+
ctrlKey: true,
276+
metaKey: false,
277+
shiftKey: true, // Should go to previous mode
278+
})
279+
280+
// Wait for event to be processed
281+
await new Promise((resolve) => setTimeout(resolve, 50))
282+
283+
// Check if mode switch was triggered
284+
const calls = (vscode.postMessage as any).mock.calls
285+
const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
286+
expect(modeSwitch).toBe(true)
287+
})
288+
})

0 commit comments

Comments
 (0)