feat(app): add global shortcut help dialog
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -255,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
type ShortcutHelpRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type ShortcutHelpSection = {
|
||||
title: string;
|
||||
rows: ShortcutHelpRow[];
|
||||
};
|
||||
|
||||
function buildShortcutHelpSections(input: {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
}): ShortcutHelpSection[] {
|
||||
const leftSidebarKeys: ShortcutKey[] = input.isMac ? ["mod", "B"] : ["mod", "."];
|
||||
const newAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "N"]
|
||||
: ["mod", "alt", "N"];
|
||||
const quickAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "1-9"]
|
||||
: ["alt", "1-9"];
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Global",
|
||||
rows: [
|
||||
{
|
||||
id: "show-shortcuts",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
{
|
||||
id: "toggle-left-sidebar",
|
||||
label: "Toggle left sidebar",
|
||||
keys: leftSidebarKeys,
|
||||
},
|
||||
{
|
||||
id: "toggle-right-sidebar",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
{
|
||||
id: "toggle-command-center",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
{
|
||||
id: "new-agent",
|
||||
label: "Create new agent",
|
||||
keys: newAgentKeys,
|
||||
},
|
||||
{
|
||||
id: "quick-open-agent",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: quickAgentKeys,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Agent Input",
|
||||
rows: [
|
||||
{
|
||||
id: "dictation-toggle",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-toggle",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-mute-toggle",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const sections = useMemo(
|
||||
() => buildShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Keyboard shortcuts"
|
||||
visible={open}
|
||||
onClose={() => setOpen(false)}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.rows}>
|
||||
{section.rows.map((row) => (
|
||||
<View key={row.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
|
||||
</View>
|
||||
<Shortcut keys={row.keys} style={styles.rowShortcut} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rows: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
rowNote: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rowShortcut: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -179,6 +179,11 @@ export function useKeyboardShortcuts({
|
||||
store.setCommandCenterOpen(!store.commandCenterOpen);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts.dialog.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
||||
return true;
|
||||
}
|
||||
case "message-input.action":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||
return false;
|
||||
|
||||
@@ -18,6 +18,7 @@ export type KeyboardActionId =
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.navigate.shortcut"
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "message-input.action";
|
||||
|
||||
export type KeyboardShortcutPayload =
|
||||
|
||||
@@ -31,6 +31,32 @@ function shortcutContext(
|
||||
}
|
||||
|
||||
describe("keyboard-shortcuts", () => {
|
||||
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("shortcuts.dialog.toggle");
|
||||
});
|
||||
|
||||
it("does not match question-mark shortcut inside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
@@ -97,6 +123,31 @@ describe("keyboard-shortcuts", () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps space typing available in message input", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("routes space to voice mute toggle outside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
|
||||
});
|
||||
|
||||
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
|
||||
@@ -55,6 +55,17 @@ function hasDigit(event: KeyboardEvent): boolean {
|
||||
return parseDigit(event) !== null;
|
||||
}
|
||||
|
||||
function isQuestionMarkShortcut(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
!event.repeat &&
|
||||
(event.key === "?" || event.code === "Slash")
|
||||
);
|
||||
}
|
||||
|
||||
function withMessageInputAction(
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
||||
@@ -92,6 +103,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||
when: () => true,
|
||||
},
|
||||
{
|
||||
id: "shortcuts-dialog-toggle-question-mark",
|
||||
action: "shortcuts.dialog.toggle",
|
||||
matches: isQuestionMarkShortcut,
|
||||
when: (context) => context.focusScope === "other",
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mac-cmd-b",
|
||||
action: "sidebar.toggle.left",
|
||||
@@ -179,7 +196,7 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.repeat,
|
||||
payload: withMessageInputAction("voice-mute-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
!context.commandCenterOpen && context.focusScope === "other",
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-alt-digit",
|
||||
|
||||
@@ -9,6 +9,7 @@ export type MessageInputActionRequest = {
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
shortcutsDialogOpen: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
@@ -16,6 +17,7 @@ interface KeyboardShortcutsState {
|
||||
messageInputActionRequest: MessageInputActionRequest | null;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||
@@ -31,12 +33,14 @@ interface KeyboardShortcutsState {
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
(set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
messageInputActionRequest: null,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||
|
||||
Reference in New Issue
Block a user