From 9a48090a17e03e634b17e2ed67ae7ddd5074888e Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 16:08:02 -0500 Subject: [PATCH] fix(ui): normalize keyboard shortcut letter keys Co-Authored-By: Paperclip --- ui/src/components/CommandPalette.tsx | 3 +- ui/src/hooks/useKeyboardShortcuts.test.tsx | 50 ++++++++++++++++++++++ ui/src/hooks/useKeyboardShortcuts.ts | 13 ++++-- ui/src/lib/keyboardShortcuts.test.ts | 24 +++++++++++ ui/src/lib/keyboardShortcuts.ts | 6 ++- ui/src/pages/Inbox.tsx | 11 +++-- 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 ui/src/hooks/useKeyboardShortcuts.test.tsx diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 9d84be52b0c..6e4ac6c3ab1 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -7,6 +7,7 @@ import { useSidebar } from "../context/SidebarContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { normalizeKeyboardShortcutKey } from "../lib/keyboardShortcuts"; import { queryKeys } from "../lib/queryKeys"; import { CommandDialog, @@ -43,7 +44,7 @@ export function CommandPalette() { useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + if (normalizeKeyboardShortcutKey(e.key) === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen(true); if (isMobile) setSidebarOpen(false); diff --git a/ui/src/hooks/useKeyboardShortcuts.test.tsx b/ui/src/hooks/useKeyboardShortcuts.test.tsx new file mode 100644 index 00000000000..554cdd2bd58 --- /dev/null +++ b/ui/src/hooks/useKeyboardShortcuts.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useKeyboardShortcuts } from "./useKeyboardShortcuts"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function ShortcutHarness({ + onNewIssue, +}: { + onNewIssue: () => void; +}) { + useKeyboardShortcuts({ onNewIssue }); + return null; +} + +describe("useKeyboardShortcuts", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("treats uppercase C as the new issue shortcut", () => { + const root = createRoot(container); + const onNewIssue = vi.fn(); + + act(() => { + root.render(); + }); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "C" })); + }); + + expect(onNewIssue).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index 6f5b33c48ad..325c0b0d241 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,8 @@ import { useEffect } from "react"; -import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; +import { + isKeyboardShortcutTextInputTarget, + normalizeKeyboardShortcutKey, +} from "../lib/keyboardShortcuts"; interface ShortcutHandlers { enabled?: boolean; @@ -18,25 +21,27 @@ export function useKeyboardShortcuts({ if (!enabled) return; function handleKeyDown(e: KeyboardEvent) { + const key = normalizeKeyboardShortcutKey(e.key); + // Don't fire shortcuts when typing in inputs if (isKeyboardShortcutTextInputTarget(e.target)) { return; } // C → New Issue - if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); onNewIssue?.(); } // [ → Toggle Sidebar - if (e.key === "[" && !e.metaKey && !e.ctrlKey) { + if (key === "[" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); onToggleSidebar?.(); } // ] → Toggle Panel - if (e.key === "]" && !e.metaKey && !e.ctrlKey) { + if (key === "]" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); onTogglePanel?.(); } diff --git a/ui/src/lib/keyboardShortcuts.test.ts b/ui/src/lib/keyboardShortcuts.test.ts index 1495fe40980..c22d92de4fb 100644 --- a/ui/src/lib/keyboardShortcuts.test.ts +++ b/ui/src/lib/keyboardShortcuts.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget, + normalizeKeyboardShortcutKey, resolveInboxQuickArchiveKeyAction, } from "./keyboardShortcuts"; @@ -39,6 +40,14 @@ describe("keyboardShortcuts helpers", () => { expect(hasBlockingShortcutDialog(root)).toBe(false); }); + + it("normalizes single-character shortcuts without changing named keys", () => { + expect(normalizeKeyboardShortcutKey("C")).toBe("c"); + expect(normalizeKeyboardShortcutKey("Y")).toBe("y"); + expect(normalizeKeyboardShortcutKey("[")).toBe("["); + expect(normalizeKeyboardShortcutKey("Enter")).toBe("Enter"); + }); + it("archives only the first clean y press", () => { const button = document.createElement("button"); @@ -54,6 +63,21 @@ describe("keyboardShortcuts helpers", () => { })).toBe("archive"); }); + it("treats uppercase shortcut letters the same as lowercase", () => { + const button = document.createElement("button"); + + expect(resolveInboxQuickArchiveKeyAction({ + armed: true, + defaultPrevented: false, + key: "Y", + metaKey: false, + ctrlKey: false, + altKey: false, + target: button, + hasOpenDialog: false, + })).toBe("archive"); + }); + it("disarms on the first non-y keypress", () => { const button = document.createElement("button"); diff --git a/ui/src/lib/keyboardShortcuts.ts b/ui/src/lib/keyboardShortcuts.ts index 7007ea6937e..2f246e9efec 100644 --- a/ui/src/lib/keyboardShortcuts.ts +++ b/ui/src/lib/keyboardShortcuts.ts @@ -12,6 +12,10 @@ const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]); export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm"; +export function normalizeKeyboardShortcutKey(key: string): string { + return key.length === 1 ? key.toLowerCase() : key; +} + export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; @@ -49,6 +53,6 @@ export function resolveInboxQuickArchiveKeyAction({ if (defaultPrevented) return "disarm"; if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore"; if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm"; - if (key === "y") return "archive"; + if (normalizeKeyboardShortcutKey(key) === "y") return "archive"; return "disarm"; } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b0898f40201..a7301956813 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -22,7 +22,11 @@ import { createIssueDetailLocationState, createIssueDetailPath, } from "../lib/issueDetailBreadcrumb"; -import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; +import { + hasBlockingShortcutDialog, + isKeyboardShortcutTextInputTarget, + normalizeKeyboardShortcutKey, +} from "../lib/keyboardShortcuts"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; @@ -1413,6 +1417,7 @@ export function Inbox() { const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; + const key = normalizeKeyboardShortcutKey(e.key); // Don't capture when typing in inputs/textareas or with modifier keys const target = e.target; @@ -1436,7 +1441,7 @@ export function Inbox() { const itemCount = st.workItems.length; if (itemCount === 0) return; - switch (e.key) { + switch (key) { case "j": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next")); @@ -1464,7 +1469,7 @@ export function Inbox() { } break; } - case "U": { + case "u": { if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return; e.preventDefault(); const item = st.workItems[st.selectedIndex];