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 { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -255,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</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);
|
store.setCommandCenterOpen(!store.commandCenterOpen);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
case "shortcuts.dialog.toggle": {
|
||||||
|
const store = useKeyboardShortcutsStore.getState();
|
||||||
|
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
case "message-input.action":
|
case "message-input.action":
|
||||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type KeyboardActionId =
|
|||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "sidebar.navigate.shortcut"
|
| "sidebar.navigate.shortcut"
|
||||||
| "command-center.toggle"
|
| "command-center.toggle"
|
||||||
|
| "shortcuts.dialog.toggle"
|
||||||
| "message-input.action";
|
| "message-input.action";
|
||||||
|
|
||||||
export type KeyboardShortcutPayload =
|
export type KeyboardShortcutPayload =
|
||||||
|
|||||||
@@ -31,6 +31,32 @@ function shortcutContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("keyboard-shortcuts", () => {
|
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", () => {
|
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||||
const match = resolveKeyboardShortcut({
|
const match = resolveKeyboardShortcut({
|
||||||
event: keyboardEvent({
|
event: keyboardEvent({
|
||||||
@@ -97,6 +123,31 @@ describe("keyboard-shortcuts", () => {
|
|||||||
expect(match).toBeNull();
|
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", () => {
|
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||||
const match = resolveKeyboardShortcut({
|
const match = resolveKeyboardShortcut({
|
||||||
event: keyboardEvent({
|
event: keyboardEvent({
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ function hasDigit(event: KeyboardEvent): boolean {
|
|||||||
return parseDigit(event) !== null;
|
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(
|
function withMessageInputAction(
|
||||||
kind: MessageInputKeyboardActionKind
|
kind: MessageInputKeyboardActionKind
|
||||||
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
||||||
@@ -92,6 +103,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||||
when: () => true,
|
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",
|
id: "sidebar-toggle-left-mac-cmd-b",
|
||||||
action: "sidebar.toggle.left",
|
action: "sidebar.toggle.left",
|
||||||
@@ -179,7 +196,7 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.repeat,
|
!event.repeat,
|
||||||
payload: withMessageInputAction("voice-mute-toggle"),
|
payload: withMessageInputAction("voice-mute-toggle"),
|
||||||
when: (context) =>
|
when: (context) =>
|
||||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
!context.commandCenterOpen && context.focusScope === "other",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sidebar-shortcut-alt-digit",
|
id: "sidebar-shortcut-alt-digit",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type MessageInputActionRequest = {
|
|||||||
|
|
||||||
interface KeyboardShortcutsState {
|
interface KeyboardShortcutsState {
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
|
shortcutsDialogOpen: boolean;
|
||||||
altDown: boolean;
|
altDown: boolean;
|
||||||
cmdOrCtrlDown: boolean;
|
cmdOrCtrlDown: boolean;
|
||||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||||
@@ -16,6 +17,7 @@ interface KeyboardShortcutsState {
|
|||||||
messageInputActionRequest: MessageInputActionRequest | null;
|
messageInputActionRequest: MessageInputActionRequest | null;
|
||||||
|
|
||||||
setCommandCenterOpen: (open: boolean) => void;
|
setCommandCenterOpen: (open: boolean) => void;
|
||||||
|
setShortcutsDialogOpen: (open: boolean) => void;
|
||||||
setAltDown: (down: boolean) => void;
|
setAltDown: (down: boolean) => void;
|
||||||
setCmdOrCtrlDown: (down: boolean) => void;
|
setCmdOrCtrlDown: (down: boolean) => void;
|
||||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||||
@@ -31,12 +33,14 @@ interface KeyboardShortcutsState {
|
|||||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
commandCenterOpen: false,
|
commandCenterOpen: false,
|
||||||
|
shortcutsDialogOpen: false,
|
||||||
altDown: false,
|
altDown: false,
|
||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutAgentKeys: [],
|
sidebarShortcutAgentKeys: [],
|
||||||
messageInputActionRequest: null,
|
messageInputActionRequest: null,
|
||||||
|
|
||||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||||
|
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||||
setAltDown: (down) => set({ altDown: down }),
|
setAltDown: (down) => set({ altDown: down }),
|
||||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||||
|
|||||||
Reference in New Issue
Block a user