feat(app): keyboard shortcuts modal from Help menu (#2064)

## Why

Users had no in-app list of keyboard shortcuts. Documenting them under **Help** makes discoverability match other support links and reduces guesswork (e.g. command palette vs search bar).

<img width="467" height="201" alt="Screenshot 2026-04-07 at 17 23 40" src="https://github.com/user-attachments/assets/f03e3379-0feb-4912-8f3a-89fca338d02a" />
<img width="901" height="1003" alt="Screenshot 2026-04-07 at 17 23 46" src="https://github.com/user-attachments/assets/d3f7bb0f-3a10-44c2-8e5f-7259382652b8" />



## What

- **Help → Keyboard shortcuts** opens a modal with shortcuts gathered from current hotkey usage (Spotlight ⌘/Ctrl+K, `/` and `s` for search/WHERE, time picker, find-in-table, log navigation, trace timeline, dashboards, etc.).
- Help dropdown order: documentation and setup first, then shortcuts, then Discord.
- Modal: comfortable width, dividers between rows, **or** vs **+** for alternatives vs chords.
- E2E: help menu includes the new item and opens the modal.
- Changeset: `@hyperdx/app` patch.

## Test plan

- [ ] Open Help → Keyboard shortcuts; confirm list and close behavior.
- [ ] CI: rely on PR checks (`make ci-lint` / `make ci-unit` if running locally).
This commit is contained in:
Elizabet Oliveira 2026-04-07 18:31:29 +01:00 committed by GitHub
parent ffc961c621
commit ad71dc2e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 202 additions and 56 deletions

View file

@ -0,0 +1,9 @@
---
"@hyperdx/app": patch
---
feat: Add keyboard shortcuts modal from the Help menu
- New **Keyboard shortcuts** item opens a modal documenting app shortcuts (command palette ⌘/Ctrl+K, search focus, time picker, tables, traces, dashboards, and more).
- Help menu items ordered by importance (documentation and setup before shortcuts and community).
- Shortcuts modal uses a readable width, row dividers, and **or** vs **+** labels so alternative keys are not confused with key chords.

View file

@ -12,6 +12,7 @@ import {
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconBook,
IconBrandDiscord,
@ -20,6 +21,7 @@ import {
IconChevronRight,
IconChevronUp,
IconHelp,
IconKeyboard,
IconLogout,
IconSettings,
IconUserCog,
@ -27,6 +29,8 @@ import {
import { IS_LOCAL_MODE } from '@/config';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import styles from './AppNav.module.scss';
export const AppNavContext = React.createContext<{
@ -176,64 +180,84 @@ export const AppNavUserMenu = ({
export const AppNavHelpMenu = ({ version }: { version?: string }) => {
const { isCollapsed } = React.useContext(AppNavContext);
const [
shortcutsOpened,
{ open: openShortcutsModal, close: closeShortcutsModal },
] = useDisclosure(false);
return (
<Menu position="right-start" transitionProps={{ transition: 'fade-right' }}>
<Menu.Target>
<UnstyledButton
data-testid="help-menu-trigger"
className={styles.navItem}
>
<span className={styles.navItemContent}>
<span className={styles.navItemIcon}>
<IconHelp size={16} />
<>
<Menu
position="right-start"
transitionProps={{ transition: 'fade-right' }}
>
<Menu.Target>
<UnstyledButton
data-testid="help-menu-trigger"
className={styles.navItem}
>
<span className={styles.navItemContent}>
<span className={styles.navItemIcon}>
<IconHelp size={16} />
</span>
{!isCollapsed && <span>Help</span>}
</span>
{!isCollapsed && <span>Help</span>}
</span>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
Help{' '}
{version && (
<Text size="xs" component="span">
v{version}
</Text>
)}
</Menu.Label>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
Help{' '}
{version && (
<Text size="xs" component="span">
v{version}
</Text>
)}
</Menu.Label>
<Menu.Item
data-testid="documentation-menu-item"
href="https://clickhouse.com/docs/use-cases/observability/clickstack"
component="a"
target="_blank"
rel="noopener noreferrer"
leftSection={<IconBook size={16} />}
>
Documentation
</Menu.Item>
<Menu.Item
data-testid="discord-menu-item"
leftSection={<IconBrandDiscord size={16} />}
component="a"
href="https://hyperdx.io/discord"
target="_blank"
rel="noopener noreferrer"
>
Discord Community
</Menu.Item>
<Menu.Item
data-testid="setup-instructions-menu-item"
leftSection={<IconBulb size={16} />}
href="https://clickhouse.com/docs/use-cases/observability/clickstack/getting-started"
component="a"
target="_blank"
rel="noopener noreferrer"
>
Setup Instructions
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu.Item
data-testid="documentation-menu-item"
href="https://clickhouse.com/docs/use-cases/observability/clickstack"
component="a"
target="_blank"
rel="noopener noreferrer"
leftSection={<IconBook size={16} />}
>
Documentation
</Menu.Item>
<Menu.Item
data-testid="setup-instructions-menu-item"
leftSection={<IconBulb size={16} />}
href="https://clickhouse.com/docs/use-cases/observability/clickstack/getting-started"
component="a"
target="_blank"
rel="noopener noreferrer"
>
Setup Instructions
</Menu.Item>
<Menu.Item
data-testid="keyboard-shortcuts-menu-item"
leftSection={<IconKeyboard size={16} />}
onClick={openShortcutsModal}
>
Keyboard shortcuts
</Menu.Item>
<Menu.Item
data-testid="discord-menu-item"
leftSection={<IconBrandDiscord size={16} />}
component="a"
href="https://hyperdx.io/discord"
target="_blank"
rel="noopener noreferrer"
>
Discord Community
</Menu.Item>
</Menu.Dropdown>
</Menu>
<KeyboardShortcutsModal
opened={shortcutsOpened}
onClose={closeShortcutsModal}
/>
</>
);
};

View file

@ -0,0 +1,99 @@
import React from 'react';
import { Divider, Group, Kbd, Modal, Stack, Text } from '@mantine/core';
type ShortcutRow = {
keys: readonly string[];
label: string;
/** Use either key. Default is `chord` (+) for combinations pressed together. */
keyJoin?: 'or' | 'chord';
};
const SHORTCUTS: ShortcutRow[] = [
{ keys: ['⌘/Ctrl', 'k'], label: 'Open command palette' },
{
keys: ['/', 's'],
label: 'Focus search or WHERE editor',
keyJoin: 'or',
},
{ keys: ['d'], label: 'Open time range picker' },
{
keys: ['Enter'],
label: 'Apply custom time range (when time picker is open)',
},
{ keys: ['⌘/Ctrl', 'f'], label: 'Find in log table' },
{ keys: ['Enter'], label: 'Next find match (find bar open)' },
{ keys: ['Shift', 'Enter'], label: 'Previous find match (find bar open)' },
{
keys: ['Esc'],
label: 'Close panel, drawer, or find bar; clear histogram bucket selection',
},
{
keys: ['←', '→'],
label: 'Move through events in log table',
keyJoin: 'or',
},
{
keys: ['↑', '↓'],
label: 'Move through events in log table',
keyJoin: 'or',
},
{
keys: ['k', 'j'],
label: 'Move through events in log table (vim-style)',
keyJoin: 'or',
},
{ keys: ['⌘/Ctrl', 'scroll'], label: 'Zoom trace timeline' },
{
keys: ['⌥/Alt', 'click'],
label: 'Collapse span children (trace waterfall)',
},
{ keys: ['Space'], label: 'Play / pause session replay' },
{
keys: ['f'],
label: 'Toggle chart fullscreen (dashboard) or exit fullscreen view',
},
{ keys: ['a'], label: 'Toggle chart AI assistant (Charts page)' },
];
export const KeyboardShortcutsModal = ({
opened,
onClose,
}: {
opened: boolean;
onClose: () => void;
}) => {
return (
<Modal
opened={opened}
onClose={onClose}
title="Keyboard Shortcuts"
size="lg"
centered
>
<Stack gap={0} data-testid="keyboard-shortcuts-modal">
{SHORTCUTS.map(({ keys, label, keyJoin = 'chord' }, rowIndex) => (
<React.Fragment key={rowIndex}>
<Group justify="space-between" wrap="nowrap" gap="md" py="sm">
<Group gap={4} wrap="nowrap">
{keys.map((key, i) => (
<React.Fragment key={`${rowIndex}-${i}-${key}`}>
{i > 0 && (
<Text span size="xs" c="dimmed" tt="lowercase" px={2}>
{keyJoin === 'or' ? 'or' : '+'}
</Text>
)}
<Kbd size="xs">{key}</Kbd>
</React.Fragment>
))}
</Group>
<Text size="sm" maw="58%" ta="right">
{label}
</Text>
</Group>
{rowIndex < SHORTCUTS.length - 1 ? <Divider /> : null}
</React.Fragment>
))}
</Stack>
</Modal>
);
};

View file

@ -134,14 +134,28 @@ test.describe('Navigation', { tag: ['@core'] }, () => {
const documentationItem = page.locator(
'[data-testid="documentation-menu-item"]',
);
const discordItem = page.locator('[data-testid="discord-menu-item"]');
const setupItem = page.locator(
'[data-testid="setup-instructions-menu-item"]',
);
const shortcutsItem = page.locator(
'[data-testid="keyboard-shortcuts-menu-item"]',
);
const discordItem = page.locator('[data-testid="discord-menu-item"]');
await expect(documentationItem).toBeVisible();
await expect(discordItem).toBeVisible();
await expect(setupItem).toBeVisible();
await expect(shortcutsItem).toBeVisible();
await expect(discordItem).toBeVisible();
});
await test.step('Open keyboard shortcuts from help menu', async () => {
const shortcutsItem = page.getByTestId('keyboard-shortcuts-menu-item');
await shortcutsItem.scrollIntoViewIfNeeded();
await shortcutsItem.click();
await expect(
page.getByRole('dialog', { name: 'Keyboard Shortcuts' }),
).toBeVisible({ timeout: 10_000 });
});
});
});