Skip to content
Merged
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
4 changes: 2 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1584,8 +1584,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})