feat(chat): toggle the floating chat window from the keyboard (MUL-5522) (#6162)

Adds a `toggleChat` shortcut action (default Mod+J, rebindable in
Settings -> Shortcuts) so the pop-up chat window can be opened and
dismissed without a mouse, and focuses the composer whenever the window
opens so you can start typing immediately.

The shortcut deliberately does not claim the chord where the overlay
cannot exist -- on the Chat tab, or when the Settings -> Chat preference
is off -- since flipping a hidden `isOpen` would read as a dead keypress
and then surprise the user on the next navigation. That route rule now
lives in one predicate shared with the overlay itself.

Focus is requested only on a real closed -> open transition: ChatWindow
stays mounted while closed and `isOpen` is restored from storage, so
treating mount as an open event would steal focus on page load.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-07-30 14:22:47 +08:00
committed by GitHub
parent 577018649e
commit 6be7adcb6b
16 changed files with 342 additions and 20 deletions

View File

@@ -6,6 +6,8 @@ import {
isReservedShortcut,
parseLegacyShortcut,
SHORTCUT_ACTIONS,
SHORTCUT_ACTION_BY_ID,
shortcutChordEquals,
shortcutFromEvent,
shortcutMatchesEvent,
} from "./definitions";
@@ -58,6 +60,39 @@ describe("keyboard shortcut definitions", () => {
}
});
it("ships at most one action per default binding", () => {
const seen: { id: string; shortcut: ReturnType<typeof createShortcutChord> }[] = [];
for (const action of SHORTCUT_ACTIONS) {
const shortcut = action.defaultShortcut;
if (!shortcut) continue;
const clash = seen.find((other) => shortcutChordEquals(other.shortcut, shortcut));
expect(clash?.id, `${action.id} duplicates ${clash?.id}`).toBeUndefined();
seen.push({ id: action.id, shortcut });
}
});
it("keeps the floating chat toggle usable on every platform and runtime", () => {
const action = SHORTCUT_ACTION_BY_ID.toggleChat;
expect(action.defaultShortcut).toEqual(
createShortcutChord("J", { primary: true }),
);
for (const platform of ["macos", "windows", "linux"] as const) {
for (const runtime of ["web", "desktop"] as const) {
expect(
isShortcutAllowedForAction(
"toggleChat",
createShortcutChord("J", { primary: true }),
platform,
runtime,
),
`Mod+J must stay assignable on ${platform}/${runtime}`,
).toBe(true);
}
}
// Dismissing chat has to work with the caret inside its own composer.
expect(action.allowInEditable).toBe(true);
});
it("strictly distinguishes Command and Control on macOS", () => {
const commandF = createShortcutChord("F", { primary: true });
const controlF = createShortcutChord("F", { control: true });

View File

@@ -9,6 +9,7 @@ export type ShortcutActionId =
| "openSearch"
| "createIssue"
| "toggleSidebar"
| "toggleChat"
| "findInIssue"
| "send"
| "goInbox"
@@ -75,6 +76,13 @@ export const SHORTCUT_ACTIONS: readonly ShortcutActionDefinition[] = [
{ id: "openSearch", category: "general", defaultShortcut: primary("K"), allowInEditable: true },
{ id: "createIssue", category: "general", defaultShortcut: createShortcutChord("C"), allowInEditable: false },
{ id: "toggleSidebar", category: "general", defaultShortcut: primary("B"), allowInEditable: false },
// Mod+J follows the "toggle a docked panel" convention, and is one of the few
// letters this module's own policy leaves free on every platform and runtime:
// it is neither app-owned (PRIMARY_RESERVED_KEYS) nor browser-owned
// (BROWSER_ONLY_PRIMARY_RESERVED_KEYS). `allowInEditable` because the point of
// the binding is reaching — and dismissing — chat without a mouse, which has
// to keep working while the caret sits in the chat composer itself.
{ id: "toggleChat", category: "general", defaultShortcut: primary("J"), allowInEditable: true },
{ id: "findInIssue", category: "general", defaultShortcut: primary("F"), allowInEditable: true },
{ id: "send", category: "general", defaultShortcut: primary("Enter"), allowInEditable: true },
{ id: "goInbox", category: "navigation", defaultShortcut: null, allowInEditable: false },

View File

@@ -11,11 +11,13 @@ import {
} from "@multica/core/chat/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { createLogger } from "@multica/core/logger";
import { useShortcut } from "@multica/core/shortcuts";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { ShortcutKeycaps } from "../../common/shortcut-keycaps";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
@@ -25,6 +27,10 @@ export function ChatFab() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
// The keyboard route to this button is only useful if it's discoverable, so
// the tooltip carries the current binding (Settings → Shortcuts can rebind or
// clear it, hence the null case).
const shortcut = useShortcut("toggleChat");
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
// FAB only needs a boolean "is anything running", and only while the window
// is closed (when open, ChatWindow owns the detailed pending query). Gating
@@ -69,7 +75,10 @@ export function ChatFab() {
>
<MessageCircle className="size-5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
<TooltipContent side="top" sideOffset={10}>
{tooltip}
{shortcut ? <ShortcutKeycaps shortcut={shortcut} className="ml-1.5" /> : null}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -50,6 +50,7 @@ import {
import { useChatStore } from "@multica/core/chat";
import { removeChatMessageFromCaches } from "@multica/core/realtime";
import { useChatDraftRestore } from "./use-chat-draft-restore";
import { useChatInputFocus } from "./use-chat-input-focus";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatResizeHandles } from "./chat-resize-handles";
@@ -171,14 +172,9 @@ export function ChatWindow() {
const appForeground = useAppForeground();
const { restoreDraftRequest, enqueueLocalRestore, handleRestoreDraftApplied } =
useChatDraftRestore(activeSessionId, isOpen && appForeground);
// Nonce handed to ChatInput to pull focus into the compose box when a new
// chat starts (⊕ or switching agent). 0 is inert so opening the window on an
// existing session never steals focus.
const [focusRequest, setFocusRequest] = useState(0);
const requestInputFocus = useCallback(
() => setFocusRequest((n) => n + 1),
[],
);
// Nonce handed to ChatInput to pull focus into the compose box: when a new
// chat starts (⊕ or switching agent), and whenever the window itself opens.
const { focusRequest, requestInputFocus } = useChatInputFocus(isOpen);
// Legacy archived sessions (the old soft-archive feature was removed but
// pre-existing rows with status='archived' may still exist) are excluded

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useChatInputFocus } from "./use-chat-input-focus";
describe("useChatInputFocus", () => {
it("stays inert on mount, whether the window starts closed or open", () => {
expect(renderHook(() => useChatInputFocus(false)).result.current.focusRequest).toBe(0);
// A persisted "open" preference must not steal focus from the page the user
// just loaded — ChatWindow is mounted (hidden) even while closed.
expect(renderHook(() => useChatInputFocus(true)).result.current.focusRequest).toBe(0);
});
it("requests focus on every closed → open transition", () => {
const { result, rerender } = renderHook(
({ isOpen }: { isOpen: boolean }) => useChatInputFocus(isOpen),
{ initialProps: { isOpen: false } },
);
rerender({ isOpen: true });
expect(result.current.focusRequest).toBe(1);
// Re-renders that don't change `isOpen` must not re-focus: the user may
// have clicked into the message list or the agent picker since.
rerender({ isOpen: true });
expect(result.current.focusRequest).toBe(1);
rerender({ isOpen: false });
expect(result.current.focusRequest).toBe(1);
rerender({ isOpen: true });
expect(result.current.focusRequest).toBe(2);
});
it("lets callers bump the nonce for new chats and agent switches", () => {
const { result } = renderHook(() => useChatInputFocus(true));
act(() => result.current.requestInputFocus());
expect(result.current.focusRequest).toBe(1);
act(() => result.current.requestInputFocus());
expect(result.current.focusRequest).toBe(2);
});
});

View File

@@ -0,0 +1,38 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Owns the floating window's composer-focus nonce.
*
* `focusRequest` is handed to ChatInput, which pulls keyboard focus into the
* editor every time the number changes; `0` is inert. Callers bump it for the
* moments that mean "you are about to type something new" — a fresh chat, an
* agent switch, a project-context change.
*
* Opening the window is one of those moments (MUL-5522): the whole point of the
* toggle shortcut is reaching chat without a mouse, so landing in a window you
* still have to click into would defeat it — and the same courtesy applies to
* opening it from the FAB.
*
* The ref is seeded with the mount-time value so only a real closed → open
* transition focuses. ChatWindow stays mounted while closed and `isOpen` is
* restored from storage, so treating mount as an open event would let a
* persisted "open" preference steal focus from whatever page the user loaded.
*/
export function useChatInputFocus(isOpen: boolean): {
focusRequest: number;
requestInputFocus: () => void;
} {
const [focusRequest, setFocusRequest] = useState(0);
const requestInputFocus = useCallback(() => setFocusRequest((n) => n + 1), []);
const wasOpenRef = useRef(isOpen);
useEffect(() => {
const wasOpen = wasOpenRef.current;
wasOpenRef.current = isOpen;
if (isOpen && !wasOpen) requestInputFocus();
}, [isOpen, requestInputFocus]);
return { focusRequest, requestInputFocus };
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { isFloatingChatRouteSuppressed } from "./floating-chat-visibility";
describe("floating chat route suppression", () => {
it("suppresses the overlay on the Chat tab and its sub-routes", () => {
expect(isFloatingChatRouteSuppressed("/acme/chat", "/acme/chat")).toBe(true);
expect(isFloatingChatRouteSuppressed("/acme/chat/session-1", "/acme/chat")).toBe(true);
});
it("keeps the overlay on every other route, including prefix look-alikes", () => {
expect(isFloatingChatRouteSuppressed("/acme/issues", "/acme/chat")).toBe(false);
// A sibling route that merely starts with the same characters is not the
// Chat tab — matching on the bare prefix would hide chat from it.
expect(isFloatingChatRouteSuppressed("/acme/chatter", "/acme/chat")).toBe(false);
// Another workspace's chat tab is a different route too.
expect(isFloatingChatRouteSuppressed("/other/chat", "/acme/chat")).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
/**
* Route gate for the floating chat overlay, shared by the overlay itself and by
* the `toggleChat` keyboard shortcut so the two can never disagree.
*
* On the Chat tab the full-page surface already owns the same `activeSessionId`,
* so a floating copy would be pure duplication — `FloatingChat` renders nothing
* there. The shortcut has to honour the same rule: flipping `isOpen` on a route
* where the overlay cannot mount reads as a dead keypress, and then surprises
* the user with a window that pops open (or vanishes) on the next navigation.
*/
export function isFloatingChatRouteSuppressed(
pathname: string,
chatPath: string,
): boolean {
return pathname === chatPath || pathname.startsWith(`${chatPath}/`);
}

View File

@@ -5,6 +5,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
import { useNavigation } from "../navigation";
import { ChatFab } from "./components/chat-fab";
import { ChatWindow } from "./components/chat-window";
import { isFloatingChatRouteSuppressed } from "./floating-chat-visibility";
/**
* Mount point for the floating chat overlay (FAB + window). Rendered once in
@@ -24,9 +25,7 @@ export function FloatingChat() {
if (!enabled) return null;
// Suppress on the Chat tab — it renders the same conversation full-page.
if (pathname === wsPaths.chat() || pathname.startsWith(`${wsPaths.chat()}/`)) {
return null;
}
if (isFloatingChatRouteSuppressed(pathname, wsPaths.chat())) return null;
return (
<>

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { configureShortcutPlatform } from "@multica/core/shortcuts";
import { GlobalShortcuts } from "./global-shortcuts";
// The floating chat overlay is reachable from the keyboard (MUL-5522). What
// matters is not just "the chord calls toggle", but that it stays a no-op —
// without swallowing the keypress — everywhere the overlay cannot exist.
const h = vi.hoisted(() => ({
chat: { floatingChatEnabled: true, toggle: vi.fn() },
navigation: { pathname: "/acme/issues", push: vi.fn() },
toggleSidebar: vi.fn(),
searchToggle: vi.fn(),
}));
vi.mock("@multica/core/chat", () => ({
useChatStore: Object.assign(
(selector: (state: typeof h.chat) => unknown) => selector(h.chat),
{ getState: () => h.chat },
),
}));
vi.mock("@multica/core/issues/stores", () => ({
openCreateIssueWithPreference: vi.fn(),
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: { getState: () => ({ modal: null }) },
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
inbox: () => "/acme/inbox",
chat: () => "/acme/chat",
myIssues: () => "/acme/my-issues",
issues: () => "/acme/issues",
projects: () => "/acme/projects",
autopilots: () => "/acme/autopilots",
agents: () => "/acme/agents",
squads: () => "/acme/squads",
usage: () => "/acme/usage",
runtimes: () => "/acme/runtimes",
skills: () => "/acme/skills",
settings: () => "/acme/settings",
}),
}));
vi.mock("@multica/ui/components/ui/sidebar", () => ({
useSidebar: () => ({ toggleSidebar: h.toggleSidebar }),
}));
vi.mock("../navigation", () => ({ useNavigation: () => h.navigation }));
vi.mock("../search/search-store", () => ({
useSearchStore: { getState: () => ({ toggle: h.searchToggle }) },
}));
/** Mod+J on macOS, dispatched the way a real keypress reaches the document. */
function pressToggleChat(target: EventTarget = document): boolean {
const event = new KeyboardEvent("keydown", {
key: "j",
metaKey: true,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event.defaultPrevented;
}
beforeEach(() => {
// Pin the platform: jsdom's user agent reports the host OS, so Command vs
// Control would otherwise depend on where the suite runs.
configureShortcutPlatform("macos");
h.chat.floatingChatEnabled = true;
h.navigation.pathname = "/acme/issues";
});
afterEach(() => {
configureShortcutPlatform(null);
vi.clearAllMocks();
});
describe("chat toggle shortcut", () => {
it("toggles the floating window and consumes the chord", () => {
render(<GlobalShortcuts />);
expect(pressToggleChat()).toBe(true);
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
});
it("still fires while the caret is inside a text input", () => {
render(<GlobalShortcuts />);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
expect(pressToggleChat(input)).toBe(true);
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
input.remove();
});
it("leaves the chord alone on the Chat tab, where the overlay is suppressed", () => {
h.navigation.pathname = "/acme/chat";
render(<GlobalShortcuts />);
// Not prevented: an action that cannot run must not swallow the keypress.
expect(pressToggleChat()).toBe(false);
expect(h.chat.toggle).not.toHaveBeenCalled();
});
it("leaves the chord alone when the floating window is turned off", () => {
h.chat.floatingChatEnabled = false;
render(<GlobalShortcuts />);
expect(pressToggleChat()).toBe(false);
expect(h.chat.toggle).not.toHaveBeenCalled();
});
it("reads the preference at press time, not at mount", () => {
render(<GlobalShortcuts />);
h.chat.floatingChatEnabled = false;
expect(pressToggleChat()).toBe(false);
h.chat.floatingChatEnabled = true;
expect(pressToggleChat()).toBe(true);
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
});
it("does not confuse the chat chord with the other global bindings", () => {
render(<GlobalShortcuts />);
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
metaKey: true,
bubbles: true,
cancelable: true,
}),
);
expect(h.toggleSidebar).toHaveBeenCalledTimes(1);
expect(h.chat.toggle).not.toHaveBeenCalled();
});
});

View File

@@ -10,10 +10,12 @@ import {
useShortcutStore,
type ShortcutActionId,
} from "@multica/core/shortcuts";
import { useChatStore } from "@multica/core/chat";
import { openCreateIssueWithPreference } from "@multica/core/issues/stores";
import { useModalStore } from "@multica/core/modals";
import { useWorkspacePaths } from "@multica/core/paths";
import { isImeComposing } from "@multica/core/utils";
import { isFloatingChatRouteSuppressed } from "../chat/floating-chat-visibility";
import { useNavigation } from "../navigation";
import { useSearchStore } from "../search/search-store";
@@ -21,6 +23,7 @@ const GLOBAL_ACTIONS: readonly ShortcutActionId[] = [
"openSearch",
"createIssue",
"toggleSidebar",
"toggleChat",
"goInbox",
"goChat",
"goMyIssues",
@@ -50,9 +53,10 @@ export function GlobalShortcuts() {
const overrides = useShortcutStore((state) => state.overrides);
useEffect(() => {
const chatPath = workspacePaths.chat();
const destinations: Partial<Record<ShortcutActionId, string>> = {
goInbox: workspacePaths.inbox(),
goChat: workspacePaths.chat(),
goChat: chatPath,
goMyIssues: workspacePaths.myIssues(),
goIssues: workspacePaths.issues(),
goProjects: workspacePaths.projects(),
@@ -65,6 +69,15 @@ export function GlobalShortcuts() {
goSettings: workspacePaths.settings(),
};
// Read at press time rather than subscribing: the preference only matters
// the instant the chord fires, and the overlay is gone from the Chat tab.
// An unavailable overlay must not claim the chord either — returning false
// from the finder leaves the keypress its outside-the-app meaning instead of
// swallowing it for an action that would visibly do nothing.
const canToggleFloatingChat = () =>
useChatStore.getState().floatingChatEnabled &&
!isFloatingChatRouteSuppressed(navigation.pathname, chatPath);
const handleKeyDown = (event: KeyboardEvent) => {
// Component/editor handlers run before this document-level listener.
// Respect their preventDefault instead of double-triggering a product
@@ -76,6 +89,7 @@ export function GlobalShortcuts() {
if (!action.allowInEditable && isEditableShortcutTarget(event.target)) {
return false;
}
if (candidate === "toggleChat" && !canToggleFloatingChat()) return false;
return shortcutMatchesEvent(getShortcut(candidate), event);
});
if (!actionId) return;
@@ -85,6 +99,10 @@ export function GlobalShortcuts() {
useSearchStore.getState().toggle();
return;
}
if (actionId === "toggleChat") {
useChatStore.getState().toggle();
return;
}
if (actionId === "toggleSidebar") {
toggleSidebar();
return;

View File

@@ -69,6 +69,7 @@
"openSearch": { "label": "Open search", "description": "Search and run commands from anywhere." },
"createIssue": { "label": "Create issue", "description": "Open your preferred issue creation flow." },
"toggleSidebar": { "label": "Toggle sidebar", "description": "Show or hide the main sidebar." },
"toggleChat": { "label": "Toggle chat window", "description": "Open or close the floating chat window. Opening it focuses the message input." },
"findInIssue": { "label": "Find in issue", "description": "Search within the open issue detail." },
"send": { "label": "Send", "description": "Send chat messages, comments, replies, feedback, and prompts, and create issues in the create dialog." },
"goInbox": { "label": "Go to Inbox", "description": "Open the workspace inbox." },

View File

@@ -66,6 +66,7 @@
"openSearch": { "label": "検索を開く", "description": "どこからでも検索やコマンドを実行します。" },
"createIssue": { "label": "Issueを作成", "description": "優先するissue作成フローを開きます。" },
"toggleSidebar": { "label": "サイドバーを切り替え", "description": "メインサイドバーの表示を切り替えます。" },
"toggleChat": { "label": "チャットウィンドウを切り替え", "description": "フローティングチャットを開閉します。開いたときは入力欄にフォーカスします。" },
"findInIssue": { "label": "Issue内を検索", "description": "開いているissueの詳細を検索します。" },
"send": { "label": "送信", "description": "チャット、コメント、返信、フィードバック、プロンプトを送信し、作成ダイアログで issue を作成します。" },
"goInbox": { "label": "受信トレイへ移動", "description": "ワークスペースの受信トレイを開きます。" },

View File

@@ -66,6 +66,7 @@
"openSearch": { "label": "검색 열기", "description": "어디서나 검색하고 명령을 실행합니다." },
"createIssue": { "label": "Issue 만들기", "description": "선호하는 issue 만들기 흐름을 엽니다." },
"toggleSidebar": { "label": "사이드바 전환", "description": "기본 사이드바를 표시하거나 숨깁니다." },
"toggleChat": { "label": "채팅 창 전환", "description": "플로팅 채팅 창을 열거나 닫습니다. 열면 메시지 입력란에 포커스합니다." },
"findInIssue": { "label": "Issue에서 찾기", "description": "열린 issue 상세 내용에서 검색합니다." },
"send": { "label": "보내기", "description": "채팅, 댓글, 답글, 피드백 및 프롬프트를 보내고, 생성 대화상자에서 이슈를 만듭니다." },
"goInbox": { "label": "받은 편지함으로 이동", "description": "워크스페이스 받은 편지함을 엽니다." },

View File

@@ -69,6 +69,7 @@
"openSearch": { "label": "打开搜索", "description": "随时搜索内容或运行命令。" },
"createIssue": { "label": "新建 issue", "description": "打开你常用的 issue 创建流程。" },
"toggleSidebar": { "label": "显示或隐藏侧边栏", "description": "切换主侧边栏的显示状态。" },
"toggleChat": { "label": "显示或隐藏聊天窗", "description": "打开或关闭悬浮聊天窗,打开时自动聚焦输入框。" },
"findInIssue": { "label": "在 issue 中查找", "description": "搜索当前打开的 issue 详情。" },
"send": { "label": "发送", "description": "发送聊天消息、评论、回复、反馈和提示词,并在创建弹窗中创建 issue。" },
"goInbox": { "label": "前往收件箱", "description": "打开工作区收件箱。" },

View File

@@ -28,13 +28,13 @@ describe("KeyboardShortcutsTab", () => {
});
fireEvent.click(recorder);
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
expect(getShortcut("openSearch")).toEqual(
createShortcutChord("J", { primary: true }),
createShortcutChord("E", { primary: true }),
);
expect(within(recorder).getByTitle("Ctrl")).toHaveTextContent("Ctrl");
expect(within(recorder).getByTitle("J")).toHaveTextContent("J");
expect(within(recorder).getByTitle("E")).toHaveTextContent("E");
});
it("only captures keys while the recorder is active", () => {
@@ -44,22 +44,22 @@ describe("KeyboardShortcutsTab", () => {
});
recorder.focus();
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
expect(getShortcut("openSearch")).toEqual(
createShortcutChord("K", { primary: true }),
);
fireEvent.click(recorder);
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
expect(getShortcut("openSearch")).toEqual(
createShortcutChord("J", { primary: true }),
createShortcutChord("E", { primary: true }),
);
// Focus remains on the button after a successful recording. Tab must move
// focus normally instead of silently replacing the shortcut with Tab.
fireEvent.keyDown(recorder, { key: "Tab" });
expect(getShortcut("openSearch")).toEqual(
createShortcutChord("J", { primary: true }),
createShortcutChord("E", { primary: true }),
);
});