Compare commits

...

1 Commits

Author SHA1 Message Date
dotta
9a48090a17 fix(ui): normalize keyboard shortcut letter keys
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 16:08:02 -05:00
6 changed files with 98 additions and 9 deletions

View File

@@ -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);

View File

@@ -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(<ShortcutHarness onNewIssue={onNewIssue} />);
});
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "C" }));
});
expect(onNewIssue).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
});

View File

@@ -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?.();
}

View File

@@ -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");

View File

@@ -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";
}

View File

@@ -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];