From a29878ed40072563bf67bee25d41b09841ae8b30 Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Thu, 25 Jun 2026 13:33:36 -0700
Subject: [PATCH] feat(shortcuts): global keyboard shortcuts + help directory
(#4126)
---
.../components/commands/CommandPalette.tsx | 55 ++--
.../components/commands/CommandProvider.tsx | 57 +++-
.../__tests__/CommandPalette.test.tsx | 6 +-
.../__tests__/CommandProvider.test.tsx | 67 ++---
.../layout/shell/CollapsedNavRail.test.tsx | 36 +--
.../layout/shell/CollapsedNavRail.tsx | 30 +--
.../layout/shell/RootShellLayout.tsx | 6 +-
.../layout/shell/SidebarHeader.test.tsx | 31 ++-
.../components/layout/shell/SidebarHeader.tsx | 19 +-
app/src/components/layout/shell/navIcons.tsx | 17 ++
.../layout/shell/useNewChat.test.tsx | 215 +++++++++++++++
app/src/components/layout/shell/useNewChat.ts | 90 +++++++
.../settings/layout/settingsNavIcons.tsx | 6 +
.../panels/KeyboardShortcutsPanel.tsx | 21 ++
.../settings/settingsRouteElements.tsx | 2 +
.../settings/settingsRouteRegistry.ts | 9 +
.../shortcuts/KeyboardShortcutsModal.tsx | 38 +++
.../__tests__/shortcutsView.test.tsx | 73 ++++++
.../components/shortcuts/shortcutsView.tsx | 116 ++++++++
.../commands/__tests__/globalActions.test.tsx | 84 +++++-
app/src/lib/commands/globalActions.ts | 247 ++++++++++++++++--
app/src/lib/commands/types.ts | 3 +
app/src/lib/i18n/ar.ts | 20 ++
app/src/lib/i18n/bn.ts | 20 ++
app/src/lib/i18n/de.ts | 20 ++
app/src/lib/i18n/en.ts | 20 ++
app/src/lib/i18n/es.ts | 20 ++
app/src/lib/i18n/fr.ts | 20 ++
app/src/lib/i18n/hi.ts | 20 ++
app/src/lib/i18n/id.ts | 20 ++
app/src/lib/i18n/it.ts | 20 ++
app/src/lib/i18n/ko.ts | 20 ++
app/src/lib/i18n/pl.ts | 20 ++
app/src/lib/i18n/pt.ts | 20 ++
app/src/lib/i18n/ru.ts | 20 ++
app/src/lib/i18n/zh-CN.ts | 20 ++
app/src/pages/Conversations.tsx | 44 ++--
app/src/store/chatRuntimeSlice.ts | 20 ++
app/src/test/test-utils.tsx | 2 +
app/test/e2e/specs/command-palette.spec.ts | 69 +++++
docs/TEST-COVERAGE-MATRIX.md | 8 +
41 files changed, 1461 insertions(+), 190 deletions(-)
create mode 100644 app/src/components/layout/shell/useNewChat.test.tsx
create mode 100644 app/src/components/layout/shell/useNewChat.ts
create mode 100644 app/src/components/settings/panels/KeyboardShortcutsPanel.tsx
create mode 100644 app/src/components/shortcuts/KeyboardShortcutsModal.tsx
create mode 100644 app/src/components/shortcuts/__tests__/shortcutsView.test.tsx
create mode 100644 app/src/components/shortcuts/shortcutsView.tsx
diff --git a/app/src/components/commands/CommandPalette.tsx b/app/src/components/commands/CommandPalette.tsx
index 8bb4da6b1..9024a8476 100644
--- a/app/src/components/commands/CommandPalette.tsx
+++ b/app/src/components/commands/CommandPalette.tsx
@@ -2,6 +2,7 @@ import * as Dialog from '@radix-ui/react-dialog';
import { Command } from 'cmdk';
import { useMemo, useSyncExternalStore } from 'react';
+import { GROUP_LABEL_KEYS } from '../../lib/commands/globalActions';
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
import { registry } from '../../lib/commands/registry';
import type { RegisteredAction } from '../../lib/commands/types';
@@ -81,34 +82,40 @@ export default function CommandPalette({ open, onOpenChange }: Props) {
{groups.map(([groupName, items]) => (
- {items.map(action => (
- runAction(action)}
- className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
- {action.icon ? (
-
- ) : (
-
- )}
- {action.label}
- {action.hint && (
-
- {action.hint}
-
- )}
- {action.shortcut && }
-
- ))}
+ {items.map(action => {
+ const label = action.labelKey ? t(action.labelKey) : action.label;
+ return (
+ runAction(action)}
+ className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
+ {action.icon ? (
+
+ ) : (
+
+ )}
+ {label}
+ {action.hint && (
+
+ {action.hint}
+
+ )}
+ {action.shortcut && }
+
+ );
+ })}
))}
-
- {t('commandPalette.shortcutHint')}
+
+ {/* Advertise ⌘/ (allowed while this search box is focused) rather
+ than ?, which the hotkey manager skips inside inputs. */}
+
+ {t('shortcuts.title')}
diff --git a/app/src/components/commands/CommandProvider.tsx b/app/src/components/commands/CommandProvider.tsx
index 19a78da7f..cc7eec229 100644
--- a/app/src/components/commands/CommandProvider.tsx
+++ b/app/src/components/commands/CommandProvider.tsx
@@ -1,10 +1,15 @@
-import { type ReactNode, useEffect, useMemo, useState } from 'react';
+import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { registerGlobalActions } from '../../lib/commands/globalActions';
+import { type GlobalActionHandlers, registerGlobalActions } from '../../lib/commands/globalActions';
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
import { registry } from '../../lib/commands/registry';
import { ScopeContext } from '../../lib/commands/ScopeContext';
+import { useAppDispatch } from '../../store/hooks';
+import { toggleSidebar } from '../../store/layoutSlice';
+import { APP_SHELL_LAYOUT_ID } from '../layout/shell/RootShellLayout';
+import { useNewChat } from '../layout/shell/useNewChat';
+import KeyboardShortcutsModal from '../shortcuts/KeyboardShortcutsModal';
import CommandPalette from './CommandPalette';
let instanceCount = 0;
@@ -15,9 +20,32 @@ interface Props {
export default function CommandProvider({ children }: Props) {
const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const newChat = useNewChat();
const [paletteOpen, setPaletteOpen] = useState(false);
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [globalFrame, setGlobalFrame] = useState
(null);
+ // Latest handlers held in a ref so the registration effect runs exactly once
+ // (on mount) — `useHomeNav`'s callback changes as the route/threads change,
+ // and re-running the effect would tear down and rebuild the whole hotkey
+ // frame on every navigation.
+ const handlersRef = useRef(null as unknown as GlobalActionHandlers);
+ handlersRef.current = {
+ navigate: path => navigate(path),
+ newChat,
+ toggleSidebar: () => dispatch(toggleSidebar({ id: APP_SHELL_LAYOUT_ID })),
+ // The two overlays are mutually exclusive: opening one dismisses the other.
+ openPalette: () => {
+ setShortcutsOpen(false);
+ setPaletteOpen(o => !o);
+ },
+ openShortcuts: () => {
+ setPaletteOpen(false);
+ setShortcutsOpen(o => !o);
+ },
+ };
+
useEffect(() => {
instanceCount += 1;
if (instanceCount > 1) {
@@ -26,25 +54,27 @@ export default function CommandProvider({ children }: Props) {
hotkeyManager.init();
const frame = hotkeyManager.pushFrame('global', 'root');
registry.setActiveStack(hotkeyManager.getStackSymbols());
- const disposeGlobalActions = registerGlobalActions(navigate, frame);
- const paletteBinding = hotkeyManager.bind(frame, {
- shortcut: 'mod+k',
- handler: () => {
- setPaletteOpen(o => !o);
- },
- allowInInput: true,
- id: 'meta.open-palette',
- });
+
+ // Stable indirection: registered handlers always call through to the latest
+ // ref value, so closures captured at registration time stay current.
+ const stableHandlers: GlobalActionHandlers = {
+ navigate: path => handlersRef.current.navigate(path),
+ newChat: () => handlersRef.current.newChat(),
+ toggleSidebar: () => handlersRef.current.toggleSidebar(),
+ openPalette: () => handlersRef.current.openPalette(),
+ openShortcuts: () => handlersRef.current.openShortcuts(),
+ };
+ const disposeGlobalActions = registerGlobalActions(stableHandlers, frame);
setGlobalFrame(frame);
return () => {
disposeGlobalActions();
- hotkeyManager.unbind(frame, paletteBinding);
hotkeyManager.popFrame(frame);
registry.setActiveStack(hotkeyManager.getStackSymbols());
instanceCount -= 1;
};
- }, [navigate]);
+ // Mount-once: handlers are reached via `handlersRef`, not deps.
+ }, []);
useEffect(() => {
if (!globalFrame) return;
@@ -61,6 +91,7 @@ export default function CommandProvider({ children }: Props) {
{children}
+
);
}
diff --git a/app/src/components/commands/__tests__/CommandPalette.test.tsx b/app/src/components/commands/__tests__/CommandPalette.test.tsx
index 27924bdda..8cb5b8f06 100644
--- a/app/src/components/commands/__tests__/CommandPalette.test.tsx
+++ b/app/src/components/commands/__tests__/CommandPalette.test.tsx
@@ -73,8 +73,10 @@ describe('CommandPalette', () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
- it('renders footer hint', () => {
+ it('renders footer hint advertising the in-palette shortcuts key', () => {
render( {}} />);
- expect(screen.getByText(/Press \? for all shortcuts/i)).toBeInTheDocument();
+ // Advertises ⌘/ (works while the search box is focused), not ? (skipped in
+ // inputs). The label reuses the shared "Keyboard Shortcuts" string.
+ expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument();
});
});
diff --git a/app/src/components/commands/__tests__/CommandProvider.test.tsx b/app/src/components/commands/__tests__/CommandProvider.test.tsx
index 3c52923b2..4da142523 100644
--- a/app/src/components/commands/__tests__/CommandProvider.test.tsx
+++ b/app/src/components/commands/__tests__/CommandProvider.test.tsx
@@ -1,65 +1,57 @@
-import { act, render, screen } from '@testing-library/react';
+import { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it } from 'vitest';
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
import { pressKey } from '../../../test/commandTestUtils';
+import { renderWithProviders } from '../../../test/test-utils';
import CommandProvider from '../CommandProvider';
beforeEach(() => {
hotkeyManager.teardown();
});
+function renderProvider() {
+ return renderWithProviders(
+
+ child
+
+ );
+}
+
describe('CommandProvider', () => {
it('mounts and registers seed actions', () => {
- render(
-
-
- child
-
-
- );
+ renderProvider();
expect(screen.getByText('child')).toBeInTheDocument();
});
it('opens palette on mod+K', async () => {
- render(
-
-
- child
-
-
- );
+ renderProvider();
act(() => {
pressKey({ key: 'k', mod: true });
});
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
});
- it.skip('opens help on ? (disabled — help overlay temporarily off)', async () => {
- render(
-
-
- child
-
-
- );
+ it('opens help on ?', async () => {
+ renderProvider();
act(() => {
pressKey({ key: '?' });
});
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
});
+ it('opens help on mod+/', async () => {
+ renderProvider();
+ act(() => {
+ pressKey({ key: '/', mod: true });
+ });
+ expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
+ });
+
it('Esc closes open overlay', async () => {
const user = userEvent.setup();
- render(
-
-
- child
-
-
- );
+ renderProvider();
act(() => {
pressKey({ key: 'k', mod: true });
});
@@ -68,20 +60,15 @@ describe('CommandProvider', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
- it.skip('palette and help mutually exclusive (disabled — help overlay temporarily off)', async () => {
- render(
-
-
- child
-
-
- );
+ it('palette and help are mutually exclusive', async () => {
+ renderProvider();
act(() => {
pressKey({ key: 'k', mod: true });
});
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
+ // Opening the shortcuts directory dismisses the palette.
act(() => {
- pressKey({ key: '?' });
+ pressKey({ key: '/', mod: true });
});
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /Command palette/i })).not.toBeInTheDocument();
diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx
index 9b0508992..f9281b4c0 100644
--- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx
+++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { registry } from '../../../lib/commands/registry';
import { renderWithProviders } from '../../../test/test-utils';
import CollapsedNavRail from './CollapsedNavRail';
@@ -19,11 +20,11 @@ vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
describe('CollapsedNavRail', () => {
beforeEach(() => vi.clearAllMocks());
- it('renders Home, Wallet, and every primary nav destination as icon buttons', () => {
+ it('renders Home, Keyboard Shortcuts, and every primary nav destination as icon buttons', () => {
renderWithProviders( , { initialEntries: ['/home'] });
for (const key of [
'nav.home',
- 'nav.wallet',
+ 'shortcuts.title',
'nav.chat',
'nav.human',
'nav.brain',
@@ -34,30 +35,22 @@ describe('CollapsedNavRail', () => {
}
});
- it('wallet button navigates to /settings/wallet-balances', () => {
+ it('shortcuts button opens the keyboard-shortcuts help directory', () => {
+ const runAction = vi.spyOn(registry, 'runAction').mockReturnValue(true);
renderWithProviders( , { initialEntries: ['/home'] });
- fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
- // Carries the backgroundLocation so the desktop Settings modal renders over
- // the page it was opened from.
- expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
- state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
- });
+ fireEvent.click(screen.getByRole('button', { name: 'shortcuts.title' }));
+ expect(runAction).toHaveBeenCalledWith('meta.keyboard-shortcuts');
+ runAction.mockRestore();
});
- it('wallet button has correct data-analytics-id', () => {
+ it('shortcuts button has correct data-analytics-id', () => {
renderWithProviders( , { initialEntries: ['/home'] });
- expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
+ expect(screen.getByRole('button', { name: 'shortcuts.title' })).toHaveAttribute(
'data-analytics-id',
- 'collapsed-rail-wallet'
+ 'collapsed-rail-shortcuts'
);
});
- it('wallet button is marked active when on /settings/wallet-balances', () => {
- renderWithProviders( , { initialEntries: ['/settings/wallet-balances'] });
- const btn = screen.getByRole('button', { name: 'nav.wallet' });
- expect(btn.className).toMatch(/bg-white|dark:bg-neutral-800/);
- });
-
it('navigates to a destination path when its icon is clicked', () => {
renderWithProviders( , { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.brain' }));
@@ -104,12 +97,9 @@ describe('CollapsedNavRail', () => {
);
});
- it('defers to Wallet on the wallet sub-page — only one icon stays active', () => {
+ it('marks Settings active on the wallet sub-page (no separate wallet rail item)', () => {
renderWithProviders( , { initialEntries: ['/settings/wallet-balances'] });
- expect(screen.getByRole('button', { name: 'nav.settings' })).not.toHaveAttribute(
- 'aria-current'
- );
- expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
+ expect(screen.getByRole('button', { name: 'nav.settings' })).toHaveAttribute(
'aria-current',
'page'
);
diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx
index 4caa8dd22..ef74a57fc 100644
--- a/app/src/components/layout/shell/CollapsedNavRail.tsx
+++ b/app/src/components/layout/shell/CollapsedNavRail.tsx
@@ -2,11 +2,11 @@ import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { NAV_TABS, type NavTab } from '../../../config/navConfig';
+import { registry } from '../../../lib/commands/registry';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { useAppSelector } from '../../../store/hooks';
import { selectUnreadCount } from '../../../store/notificationSlice';
-import { settingsNavState } from '../../settings/modal/settingsOverlay';
import { Tooltip } from '../../ui';
import { NavIcon } from './navIcons';
import { useHomeNav } from './useHomeNav';
@@ -51,11 +51,7 @@ export default function CollapsedNavRail() {
};
const homeActive = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
- // Settings defers to the more-specific Wallet rail item so the wallet sub-page
- // doesn't light up both icons at once.
- const settingsActive =
- matchActive('/settings', location.pathname) &&
- !matchActive('/settings/wallet-balances', location.pathname);
+ const settingsActive = matchActive('/settings', location.pathname);
return (
@@ -75,22 +71,16 @@ export default function CollapsedNavRail() {
- {/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
-
+ {/* Keyboard shortcuts — mirrors SidebarHeader's shortcuts button for the
+ collapsed state. Opens the help directory (also reachable via ? / ⌘/). */}
+
navigate('/settings/wallet-balances', settingsNavState(location))}
- aria-label={t('nav.wallet')}
- aria-current={
- matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
- }
- data-analytics-id="collapsed-rail-wallet"
- className={`${RAIL_BTN} ${
- matchActive('/settings/wallet-balances', location.pathname)
- ? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
- : 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
- }`}>
-
+ onClick={() => registry.runAction('meta.keyboard-shortcuts')}
+ aria-label={t('shortcuts.title')}
+ data-analytics-id="collapsed-rail-shortcuts"
+ className={`${RAIL_BTN} text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200`}>
+
diff --git a/app/src/components/layout/shell/RootShellLayout.tsx b/app/src/components/layout/shell/RootShellLayout.tsx
index 87bb3996c..e8d52a433 100644
--- a/app/src/components/layout/shell/RootShellLayout.tsx
+++ b/app/src/components/layout/shell/RootShellLayout.tsx
@@ -13,8 +13,10 @@ import { Tooltip } from '../../ui';
import CollapsedNavRail from './CollapsedNavRail';
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
-// fresh with the sidebar visible by default.
-const LAYOUT_ID = 'app-shell';
+// fresh with the sidebar visible by default. Exported so the global command
+// layer (mod+B "Toggle sidebar") can target this exact panel.
+export const APP_SHELL_LAYOUT_ID = 'app-shell';
+const LAYOUT_ID = APP_SHELL_LAYOUT_ID;
const DEFAULT_WIDTH = 224;
const MIN_WIDTH = 188;
const MAX_WIDTH = 420;
diff --git a/app/src/components/layout/shell/SidebarHeader.test.tsx b/app/src/components/layout/shell/SidebarHeader.test.tsx
index bebca46aa..505fa962f 100644
--- a/app/src/components/layout/shell/SidebarHeader.test.tsx
+++ b/app/src/components/layout/shell/SidebarHeader.test.tsx
@@ -1,6 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { registry } from '../../../lib/commands/registry';
import { renderWithProviders } from '../../../test/test-utils';
import SidebarHeader from './SidebarHeader';
@@ -20,39 +21,37 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string)
describe('SidebarHeader', () => {
beforeEach(() => vi.clearAllMocks());
- it('renders Home, Wallet, Settings, and Collapse buttons', () => {
+ it('renders Home, Keyboard Shortcuts, Settings, and Collapse buttons', () => {
renderWithProviders( , { initialEntries: ['/home'] });
expect(screen.getByRole('button', { name: 'nav.home' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'nav.wallet' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'shortcuts.title' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'nav.settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'chat.hideSidebar' })).toBeInTheDocument();
});
- it('wallet button navigates to /settings/wallet-balances', () => {
+ it('shortcuts button opens the keyboard-shortcuts help directory', () => {
+ const runAction = vi.spyOn(registry, 'runAction').mockReturnValue(true);
renderWithProviders( , { initialEntries: ['/home'] });
- fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
- // Carries the backgroundLocation so the desktop Settings modal renders over
- // the page it was opened from.
- expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
- state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
- });
+ fireEvent.click(screen.getByRole('button', { name: 'shortcuts.title' }));
+ expect(runAction).toHaveBeenCalledWith('meta.keyboard-shortcuts');
+ runAction.mockRestore();
});
- it('wallet button has correct data-analytics-id', () => {
+ it('shortcuts button has correct data-analytics-id', () => {
renderWithProviders( , { initialEntries: ['/home'] });
- expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
+ expect(screen.getByRole('button', { name: 'shortcuts.title' })).toHaveAttribute(
'data-analytics-id',
- 'sidebar-header-wallet'
+ 'sidebar-header-shortcuts'
);
});
- it('wallet button has matching aria-label and title', () => {
+ it('shortcuts button has matching aria-label and title', () => {
renderWithProviders( , { initialEntries: ['/home'] });
- const btn = screen.getByRole('button', { name: 'nav.wallet' });
- expect(btn).toHaveAttribute('aria-label', 'nav.wallet');
+ const btn = screen.getByRole('button', { name: 'shortcuts.title' });
+ expect(btn).toHaveAttribute('aria-label', 'shortcuts.title');
// The styled wrapper re-applies a native `title` fallback so the
// label still surfaces if the portal pill is occluded by a CEF webview.
- expect(btn).toHaveAttribute('title', 'nav.wallet');
+ expect(btn).toHaveAttribute('title', 'shortcuts.title');
});
it('settings button navigates to /settings', () => {
diff --git a/app/src/components/layout/shell/SidebarHeader.tsx b/app/src/components/layout/shell/SidebarHeader.tsx
index dd50482f4..96c2b7224 100644
--- a/app/src/components/layout/shell/SidebarHeader.tsx
+++ b/app/src/components/layout/shell/SidebarHeader.tsx
@@ -1,5 +1,6 @@
import { useLocation, useNavigate } from 'react-router-dom';
+import { registry } from '../../../lib/commands/registry';
import { useT } from '../../../lib/i18n/I18nContext';
import { settingsNavState } from '../../settings/modal/settingsOverlay';
import { Tooltip } from '../../ui';
@@ -42,20 +43,26 @@ export default function SidebarHeader() {
- {/* Wallet shortcut — one-click access to wallet balances. */}
-
+ {/* Keyboard shortcuts — one-click open of the help directory (also ? / ⌘/). */}
+
navigate('/settings/wallet-balances', settingsNavState(location))}
+ onClick={() => registry.runAction('meta.keyboard-shortcuts')}
className={ICON_BTN}
- data-analytics-id="sidebar-header-wallet"
- aria-label={t('nav.wallet')}>
+ data-analytics-id="sidebar-header-shortcuts"
+ aria-label={t('shortcuts.title')}>
+
diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx
index 2801c4cef..ef8fd25a0 100644
--- a/app/src/components/layout/shell/navIcons.tsx
+++ b/app/src/components/layout/shell/navIcons.tsx
@@ -143,6 +143,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
);
+ case 'keyboard':
+ return (
+
+
+
+
+ );
default:
return null;
}
diff --git a/app/src/components/layout/shell/useNewChat.test.tsx b/app/src/components/layout/shell/useNewChat.test.tsx
new file mode 100644
index 000000000..2cdf90ae0
--- /dev/null
+++ b/app/src/components/layout/shell/useNewChat.test.tsx
@@ -0,0 +1,215 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
+import { useNewChat } from './useNewChat';
+
+// Drive the real hook body while stubbing its router + store dependencies so
+// each branch (reuse-empty-thread, create-new-thread) runs deterministically
+// without a live core RPC.
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async importOriginal => {
+ const actual = await importOriginal();
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+interface MockThread {
+ id: string;
+ messageCount: number;
+ labels?: string[];
+ parentThreadId?: string;
+}
+
+interface MockAction {
+ type?: string;
+}
+
+interface WrapperProps {
+ children: ReactNode;
+}
+
+const mockDispatch = vi.fn();
+let mockThreads: MockThread[] = [];
+let mockMessagesByThreadId: Record = {};
+let mockStreamingByThread: Record = {};
+let mockPendingSendThreadIds: Record = {};
+vi.mock('../../../store/hooks', () => ({
+ useAppDispatch: () => mockDispatch,
+ useAppSelector: (sel: (s: unknown) => unknown) =>
+ sel({
+ thread: { threads: mockThreads, messagesByThreadId: mockMessagesByThreadId },
+ chatRuntime: {
+ streamingAssistantByThread: mockStreamingByThread,
+ pendingSendThreadIds: mockPendingSendThreadIds,
+ },
+ }),
+}));
+
+vi.mock('../../../store/accountsSlice', () => ({
+ setActiveAccount: vi.fn((id: string) => ({ type: 'accounts/setActiveAccount', payload: id })),
+}));
+vi.mock('../../../store/threadSlice', () => ({
+ createNewThread: vi.fn(() => ({ type: 'thread/createNewThread' })),
+ loadThreadMessages: vi.fn((id: string) => ({ type: 'thread/loadThreadMessages', payload: id })),
+ setSelectedThread: vi.fn((id: string) => ({ type: 'thread/setSelectedThread', payload: id })),
+}));
+
+function wrapper({ children }: WrapperProps) {
+ return {children} ;
+}
+
+function dispatchedTypes() {
+ return mockDispatch.mock.calls.map(([action]) => action?.type);
+}
+
+describe('useNewChat', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockThreads = [];
+ mockMessagesByThreadId = {};
+ mockStreamingByThread = {};
+ mockPendingSendThreadIds = {};
+ mockDispatch.mockImplementation((action: MockAction) => {
+ if (action?.type === 'thread/createNewThread') {
+ return { unwrap: () => Promise.resolve({ id: 'fresh-thread' }) };
+ }
+ return undefined;
+ });
+ });
+
+ it('always switches to the agent account (never reopens a connected-app webview)', () => {
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'accounts/setActiveAccount',
+ payload: AGENT_ACCOUNT_ID,
+ });
+ });
+
+ it('reuses an existing empty thread regardless of route (does not just /chat)', () => {
+ mockThreads = [{ id: 'empty-1', messageCount: 0 }];
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ // The key regression: it selects a blank thread and navigates straight to it
+ // instead of navigating to bare '/chat' (which would restore the persisted
+ // conversation).
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/empty-1');
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat');
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'thread/setSelectedThread',
+ payload: 'empty-1',
+ });
+ expect(dispatchedTypes()).not.toContain('thread/createNewThread');
+ });
+
+ it('does not reuse a count-empty thread that already has cached messages', async () => {
+ // First message was just sent: messageCount is still a stale 0 but the
+ // message cache is populated. Must NOT reuse/reopen it — create instead.
+ mockThreads = [{ id: 'just-sent', messageCount: 0 }];
+ mockMessagesByThreadId = { 'just-sent': [{ id: 'm1' }] };
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat/just-sent');
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
+ });
+ });
+
+ it('does not reuse a count-empty thread that has an in-flight streaming turn', async () => {
+ // The thread looks empty (no count, no cache) but a send is in flight
+ // (streamingAssistantByThread populated) — never reopen it.
+ mockThreads = [{ id: 'sending', messageCount: 0 }];
+ mockMessagesByThreadId = {};
+ mockStreamingByThread = { sending: { requestId: 'r1', lifecycle: 'started' } };
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat/sending');
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
+ });
+ });
+
+ it('does not reuse a thread with an optimistic send pending (pre-fulfillment window)', async () => {
+ // Earliest window: send recorded in pendingSendThreadIds before
+ // addMessageLocal resolves and before any streaming state exists.
+ mockThreads = [{ id: 'optimistic', messageCount: 0 }];
+ mockMessagesByThreadId = {};
+ mockStreamingByThread = {};
+ mockPendingSendThreadIds = { optimistic: true };
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat/optimistic');
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
+ });
+ });
+
+ it('does not reuse a blank non-General thread (task / subconscious / parented)', async () => {
+ // A blank task thread (parentThreadId) is hidden from the General tab, so
+ // New Chat must not land on it — create a fresh general chat instead.
+ mockThreads = [{ id: 'task-1', messageCount: 0, parentThreadId: 'parent' }];
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(mockNavigate).not.toHaveBeenCalledWith('/chat/task-1');
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
+ });
+ });
+
+ it('reuses a genuinely-blank thread (count-empty, no cache, no in-flight turn)', () => {
+ // Round-trip of the earlier review: a blank current chat must be reused
+ // rather than spawning yet another empty thread.
+ mockThreads = [{ id: 'blank', messageCount: 0 }];
+ mockMessagesByThreadId = {};
+ mockStreamingByThread = {};
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/blank');
+ expect(dispatchedTypes()).not.toContain('thread/createNewThread');
+ });
+
+ it('creates a new thread when there is no empty thread', async () => {
+ mockThreads = [{ id: 't-busy', messageCount: 4 }];
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ result.current();
+
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ await waitFor(() => {
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'thread/setSelectedThread',
+ payload: 'fresh-thread',
+ });
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'thread/loadThreadMessages',
+ payload: 'fresh-thread',
+ });
+ expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
+ });
+
+ it('swallows a failed thread creation without throwing', async () => {
+ mockThreads = [{ id: 't-busy', messageCount: 2 }];
+ mockDispatch.mockImplementation((action: MockAction) => {
+ if (action?.type === 'thread/createNewThread') {
+ return { unwrap: () => Promise.reject(new Error('boom')) };
+ }
+ return undefined;
+ });
+ const { result } = renderHook(() => useNewChat(), { wrapper });
+ expect(() => result.current()).not.toThrow();
+ await Promise.resolve();
+ expect(dispatchedTypes()).toContain('thread/createNewThread');
+ });
+});
diff --git a/app/src/components/layout/shell/useNewChat.ts b/app/src/components/layout/shell/useNewChat.ts
new file mode 100644
index 000000000..3006a1fec
--- /dev/null
+++ b/app/src/components/layout/shell/useNewChat.ts
@@ -0,0 +1,90 @@
+import { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import {
+ GENERAL_TAB_VALUE,
+ isThreadVisibleInTab,
+} from '../../../pages/conversations/utils/threadFilter';
+import { setActiveAccount } from '../../../store/accountsSlice';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { createNewThread, loadThreadMessages, setSelectedThread } from '../../../store/threadSlice';
+import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
+import { chatThreadPath } from '../../../utils/chatRoutes';
+
+/**
+ * The "New Chat" action behind the ⌘N / Ctrl+N shortcut.
+ *
+ * Unlike {@link useHomeNav}, this always lands on a *blank* thread regardless of
+ * the current route. `useHomeNav`'s off-chat branch only navigates to `/chat`
+ * and lets the mounting Conversations page own blank-thread landing — but that
+ * restores the persisted `selectedThreadId` first, so from a non-chat route it
+ * would reopen the previous conversation instead of starting a new one. Here we
+ * explicitly select/create the blank thread before navigating, so a new chat is
+ * always a new chat:
+ *
+ * - switch back to the agent account (a selected connected app would otherwise
+ * keep rendering its webview instead of the agent thread);
+ * - reuse an existing empty thread if one exists (avoids piling up blanks),
+ * else create one;
+ * - select + load it and navigate straight to it. Selecting the thread before
+ * navigation also prevents the Conversations page from racing to create a
+ * second blank thread on mount.
+ *
+ * A thread counts as empty (reusable) only when it has no server message
+ * count, no locally-cached messages, and no in-flight assistant turn. The first
+ * two guard the post-send window where `addMessageLocal` has populated
+ * `messagesByThreadId` (or the thread-list `messageCount`) but the other lags;
+ * the `pendingSendThreadIds` marker (set synchronously the instant the user
+ * sends, before `addMessageLocal` is even awaited) closes the earliest
+ * optimistic-send window, and the streaming buffer covers later ones. We key
+ * off "is this thread actually occupied?" rather than "is it selected?" so a
+ * genuinely-blank current chat is still reused (no piling up of empties) while
+ * a chat with a send in flight is never reopened.
+ *
+ * Only **General**-tab threads are reuse candidates (same
+ * `isThreadVisibleInTab(..., GENERAL_TAB_VALUE)` filter the `/chat` landing
+ * uses), so New Chat never lands on a hidden task/subconscious/parented
+ * session.
+ */
+export function useNewChat(): () => void {
+ const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const threads = useAppSelector(state => state.thread.threads);
+ const messagesByThreadId = useAppSelector(state => state.thread.messagesByThreadId);
+ // Optional-chained so minimal test stores without the chatRuntime reducer
+ // still resolve (mirrors `selectPanelLayout`'s defensive access).
+ const streamingByThread = useAppSelector(state => state.chatRuntime?.streamingAssistantByThread);
+ const pendingSendThreadIds = useAppSelector(state => state.chatRuntime?.pendingSendThreadIds);
+
+ return useCallback(() => {
+ dispatch(setActiveAccount(AGENT_ACCOUNT_ID));
+
+ const empty = threads.find(
+ thr =>
+ isThreadVisibleInTab(thr, GENERAL_TAB_VALUE) &&
+ (thr.messageCount ?? 0) === 0 &&
+ (messagesByThreadId[thr.id]?.length ?? 0) === 0 &&
+ !streamingByThread?.[thr.id] &&
+ !pendingSendThreadIds?.[thr.id]
+ );
+ if (empty) {
+ dispatch(setSelectedThread(empty.id));
+ void dispatch(loadThreadMessages(empty.id));
+ navigate(chatThreadPath(empty.id));
+ return;
+ }
+
+ void dispatch(createNewThread())
+ .unwrap()
+ .then(thr => {
+ dispatch(setSelectedThread(thr.id));
+ void dispatch(loadThreadMessages(thr.id));
+ navigate(chatThreadPath(thr.id));
+ })
+ .catch(err => {
+ // Don't silently drop the primary New Chat path — log so the failure is
+ // diagnosable. The user stays where they are (no broken navigation).
+ console.error('[new-chat] createNewThread failed', err);
+ });
+ }, [navigate, dispatch, threads, messagesByThreadId, streamingByThread, pendingSendThreadIds]);
+}
diff --git a/app/src/components/settings/layout/settingsNavIcons.tsx b/app/src/components/settings/layout/settingsNavIcons.tsx
index 0bb59c72a..6b07d0bdf 100644
--- a/app/src/components/settings/layout/settingsNavIcons.tsx
+++ b/app/src/components/settings/layout/settingsNavIcons.tsx
@@ -82,6 +82,12 @@ export const SETTINGS_NAV_ICONS: Record = {
)
),
'developer-options': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')),
+ 'keyboard-shortcuts': icon(
+
+ {stroke('M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z')}
+ {stroke('M7 10h.01M11 10h.01M15 10h.01M17 10h.01M7 13h.01M9 16h6')}
+
+ ),
about: icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')),
usage: icon(
stroke(
diff --git a/app/src/components/settings/panels/KeyboardShortcutsPanel.tsx b/app/src/components/settings/panels/KeyboardShortcutsPanel.tsx
new file mode 100644
index 000000000..54eb2b63e
--- /dev/null
+++ b/app/src/components/settings/panels/KeyboardShortcutsPanel.tsx
@@ -0,0 +1,21 @@
+/**
+ * Settings → Keyboard Shortcuts.
+ *
+ * A discoverable home for the app-wide shortcut map. Renders the same live,
+ * registry-driven list as the `?` help overlay (see `shortcutsView`), so the
+ * two surfaces can never disagree.
+ */
+import { useT } from '../../../lib/i18n/I18nContext';
+import { ShortcutsList } from '../../shortcuts/shortcutsView';
+import SettingsPanel from '../layout/SettingsPanel';
+
+const KeyboardShortcutsPanel = () => {
+ const { t } = useT();
+ return (
+
+
+
+ );
+};
+
+export default KeyboardShortcutsPanel;
diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx
index a37b9a5de..c9da5773d 100644
--- a/app/src/components/settings/settingsRouteElements.tsx
+++ b/app/src/components/settings/settingsRouteElements.tsx
@@ -25,6 +25,7 @@ import DevicesPanel from './panels/DevicesPanel';
import DevWorkflowPanel from './panels/DevWorkflowPanel';
import EventLogPanel from './panels/EventLogPanel';
import IntegrationsPanel from './panels/IntegrationsPanel';
+import KeyboardShortcutsPanel from './panels/KeyboardShortcutsPanel';
import LocalModelDebugPanel from './panels/LocalModelDebugPanel';
import McpServerPanel from './panels/McpServerPanel';
import MeetingSettingsPanel from './panels/MeetingSettingsPanel';
@@ -150,6 +151,7 @@ export function settingsRouteElements(): ReactNode {
)} />
{/* ── System ──────────────────────────────────────────────── */}
+ )} />
)} />
)} />
diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts
index a4c8885a8..3c06afcd6 100644
--- a/app/src/components/settings/settingsRouteRegistry.ts
+++ b/app/src/components/settings/settingsRouteRegistry.ts
@@ -254,6 +254,15 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
// to /settings/notifications and /settings/wallet-balances.
// --- About ---
+ {
+ id: 'keyboard-shortcuts',
+ titleKey: 'shortcuts.title',
+ descriptionKey: 'shortcuts.menuDesc',
+ section: 'home',
+ searchKeywords: ['keyboard', 'shortcuts', 'keys', 'hotkeys', 'bindings', 'cheatsheet'],
+ navGroup: 'general',
+ navOrder: 98,
+ },
{
id: 'about',
titleKey: 'settings.about',
diff --git a/app/src/components/shortcuts/KeyboardShortcutsModal.tsx b/app/src/components/shortcuts/KeyboardShortcutsModal.tsx
new file mode 100644
index 000000000..8f52eaad5
--- /dev/null
+++ b/app/src/components/shortcuts/KeyboardShortcutsModal.tsx
@@ -0,0 +1,38 @@
+import { useT } from '../../lib/i18n/I18nContext';
+import Kbd from '../commands/Kbd';
+import { ModalShell } from '../ui/ModalShell';
+import { ShortcutsList } from './shortcutsView';
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * The keyboard-shortcuts help directory. Opened with `?` or ⌘/Ctrl+/ (see
+ * `globalActions`), it lists every currently-active shortcut, grouped, straight
+ * from the command registry so it can't drift from the real bindings.
+ */
+export default function KeyboardShortcutsModal({ open, onOpenChange }: Props) {
+ const { t } = useT();
+
+ if (!open) return null;
+
+ return (
+ onOpenChange(false)}
+ title={t('shortcuts.title')}
+ titleId="keyboard-shortcuts-title"
+ subtitle={t('shortcuts.subtitle')}
+ maxWidthClassName="max-w-lg"
+ contentClassName="px-5 py-4">
+
+
+
+
+ {t('shortcuts.openHint')}
+
+
+
+ );
+}
diff --git a/app/src/components/shortcuts/__tests__/shortcutsView.test.tsx b/app/src/components/shortcuts/__tests__/shortcutsView.test.tsx
new file mode 100644
index 000000000..c72b5f765
--- /dev/null
+++ b/app/src/components/shortcuts/__tests__/shortcutsView.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
+import { registry } from '../../../lib/commands/registry';
+import { ShortcutsList } from '../shortcutsView';
+
+beforeEach(() => {
+ hotkeyManager.teardown();
+ registry.reset();
+ hotkeyManager.init();
+});
+
+afterEach(() => {
+ hotkeyManager.teardown();
+ registry.reset();
+});
+
+function seed(): symbol {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ // Registered out of display order on purpose — the view must re-order them.
+ registry.registerAction(
+ { id: 'd', label: 'Command Palette', group: 'General', shortcut: 'mod+k', handler() {} },
+ frame
+ );
+ registry.registerAction(
+ { id: 'c', label: 'Toggle Sidebar', group: 'View', shortcut: 'mod+b', handler() {} },
+ frame
+ );
+ registry.registerAction(
+ { id: 'a', label: 'Go to Chat', group: 'Navigation', shortcut: 'mod+2', handler() {} },
+ frame
+ );
+ registry.registerAction(
+ { id: 'b', label: 'New Chat', group: 'Chat', shortcut: 'mod+n', handler() {} },
+ frame
+ );
+ registry.setActiveStack([frame]);
+ return frame;
+}
+
+describe('ShortcutsList', () => {
+ it('groups shortcuts and orders the groups Navigation → Chat → View → General', () => {
+ seed();
+ render( );
+ const headings = screen.getAllByRole('heading').map(h => h.textContent);
+ expect(headings).toEqual(['Navigation', 'Chat', 'View', 'General']);
+ expect(screen.getByText('New Chat')).toBeInTheDocument();
+ expect(screen.getByText('Toggle Sidebar')).toBeInTheDocument();
+ });
+
+ it('omits actions that have no shortcut', () => {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ registry.registerAction(
+ { id: 'with', label: 'Has Shortcut', group: 'Navigation', shortcut: 'mod+2', handler() {} },
+ frame
+ );
+ registry.registerAction(
+ { id: 'without', label: 'No Shortcut', group: 'Navigation', handler() {} },
+ frame
+ );
+ registry.setActiveStack([frame]);
+
+ render( );
+ expect(screen.getByText('Has Shortcut')).toBeInTheDocument();
+ expect(screen.queryByText('No Shortcut')).not.toBeInTheDocument();
+ });
+
+ it('renders an empty state when no shortcuts are active', () => {
+ render( );
+ expect(screen.getByText(/no keyboard shortcuts/i)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/shortcuts/shortcutsView.tsx b/app/src/components/shortcuts/shortcutsView.tsx
new file mode 100644
index 000000000..752ef55a1
--- /dev/null
+++ b/app/src/components/shortcuts/shortcutsView.tsx
@@ -0,0 +1,116 @@
+import { useMemo, useSyncExternalStore } from 'react';
+
+import { GROUP_LABEL_KEYS } from '../../lib/commands/globalActions';
+import { hotkeyManager } from '../../lib/commands/hotkeyManager';
+import { registry } from '../../lib/commands/registry';
+import type { RegisteredAction } from '../../lib/commands/types';
+import { useT } from '../../lib/i18n/I18nContext';
+import Kbd from '../commands/Kbd';
+
+// Display order for the grouped shortcut list. Mirrors the command palette's
+// ordering and appends Help. Unknown groups sort alphabetically after these.
+const DISPLAY_ORDER = ['Navigation', 'Profiles', 'Chat', 'View', 'General', 'Help'];
+
+export interface ShortcutGroup {
+ group: string;
+ items: RegisteredAction[];
+}
+
+function subscribe(listener: () => void): () => void {
+ const u1 = registry.subscribe(listener);
+ const u2 = hotkeyManager.subscribe(listener);
+ return () => {
+ u1();
+ u2();
+ };
+}
+
+function getSnapshot(): RegisteredAction[] {
+ return registry.getActiveActions(hotkeyManager.getStackSymbols());
+}
+
+/**
+ * Live-grouped view of every currently-active action that has a keyboard
+ * shortcut. Driven by the same registry the command palette uses, so the help
+ * directory can never drift from the real bindings.
+ */
+export function useActiveShortcutGroups(): ShortcutGroup[] {
+ const actions = useSyncExternalStore(subscribe, getSnapshot);
+ return useMemo(() => {
+ const byGroup = new Map();
+ for (const a of actions) {
+ if (!a.shortcut) continue;
+ const g = a.group ?? 'General';
+ if (!byGroup.has(g)) byGroup.set(g, []);
+ byGroup.get(g)!.push(a);
+ }
+ const keys = [...byGroup.keys()].sort((a, b) => {
+ const ai = DISPLAY_ORDER.indexOf(a);
+ const bi = DISPLAY_ORDER.indexOf(b);
+ if (ai === -1 && bi === -1) return a.localeCompare(b);
+ if (ai === -1) return 1;
+ if (bi === -1) return -1;
+ return ai - bi;
+ });
+ return keys.map(group => ({ group, items: byGroup.get(group)! }));
+ }, [actions]);
+}
+
+interface ShortcutsListProps {
+ /** Visual density. `modal` is compact; `panel` matches settings cards. */
+ variant?: 'modal' | 'panel';
+}
+
+/**
+ * Renders the grouped, live shortcut list. Shared by the `?`/⌘/ help overlay
+ * and the Settings → Keyboard Shortcuts panel so they never disagree.
+ */
+export function ShortcutsList({ variant = 'modal' }: ShortcutsListProps) {
+ const { t } = useT();
+ const groups = useActiveShortcutGroups();
+
+ if (groups.length === 0) {
+ return (
+
+ {t('shortcuts.empty')}
+
+ );
+ }
+
+ const rowClass =
+ variant === 'panel'
+ ? 'flex items-center justify-between gap-4 px-4 py-3'
+ : 'flex items-center justify-between gap-4 px-1 py-1.5';
+
+ return (
+
+ {groups.map(({ group, items }) => (
+
+
+ {GROUP_LABEL_KEYS[group] ? t(GROUP_LABEL_KEYS[group]) : group}
+
+
+ {items.map(action => (
+
+
+ {action.icon ? (
+
+ ) : null}
+
+ {action.labelKey ? t(action.labelKey) : action.label}
+
+
+ {action.shortcut ?
: null}
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/app/src/lib/commands/__tests__/globalActions.test.tsx b/app/src/lib/commands/__tests__/globalActions.test.tsx
index b7e2f5f9f..d5368da33 100644
--- a/app/src/lib/commands/__tests__/globalActions.test.tsx
+++ b/app/src/lib/commands/__tests__/globalActions.test.tsx
@@ -1,10 +1,20 @@
-import type { NavigateFunction } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { GROUP_ORDER, registerGlobalActions } from '../globalActions';
+import { type GlobalActionHandlers, GROUP_ORDER, registerGlobalActions } from '../globalActions';
import { hotkeyManager } from '../hotkeyManager';
import { registry } from '../registry';
+function makeHandlers(overrides: Partial = {}): GlobalActionHandlers {
+ return {
+ navigate: vi.fn(),
+ newChat: vi.fn(),
+ toggleSidebar: vi.fn(),
+ openPalette: vi.fn(),
+ openShortcuts: vi.fn(),
+ ...overrides,
+ };
+}
+
beforeEach(() => {
hotkeyManager.teardown();
registry.reset();
@@ -17,10 +27,9 @@ afterEach(() => {
});
describe('registerGlobalActions', () => {
- it('registers the 6 seed nav actions into the global frame', () => {
+ it('registers the nav seed actions plus the new chat/view/general actions', () => {
const frame = hotkeyManager.pushFrame('global', 'root');
- const navigate = vi.fn() as unknown as NavigateFunction;
- registerGlobalActions(navigate, frame);
+ registerGlobalActions(makeHandlers(), frame);
const ids = [
'nav.home',
'nav.chat',
@@ -28,6 +37,10 @@ describe('registerGlobalActions', () => {
'nav.skills',
'nav.activity',
'nav.settings',
+ 'chat.new',
+ 'view.toggle-sidebar',
+ 'meta.command-palette',
+ 'meta.keyboard-shortcuts',
];
for (const id of ids) expect(registry.getAction(id)?.id).toBe(id);
expect(registry.getAction('help.show')).toBeUndefined();
@@ -36,13 +49,70 @@ describe('registerGlobalActions', () => {
it('nav.home handler calls navigate("/home")', () => {
const frame = hotkeyManager.pushFrame('global', 'root');
const navigate = vi.fn();
- registerGlobalActions(navigate as unknown as NavigateFunction, frame);
+ registerGlobalActions(makeHandlers({ navigate }), frame);
registry.setActiveStack([frame]);
registry.runAction('nav.home');
expect(navigate).toHaveBeenCalledWith('/home');
});
+ it('routes chat/view/general actions to their handlers', () => {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ const newChat = vi.fn();
+ const toggleSidebar = vi.fn();
+ const openPalette = vi.fn();
+ const openShortcuts = vi.fn();
+ registerGlobalActions(
+ makeHandlers({ newChat, toggleSidebar, openPalette, openShortcuts }),
+ frame
+ );
+ registry.setActiveStack([frame]);
+
+ registry.runAction('chat.new');
+ expect(newChat).toHaveBeenCalledTimes(1);
+ registry.runAction('view.toggle-sidebar');
+ expect(toggleSidebar).toHaveBeenCalledTimes(1);
+ registry.runAction('meta.command-palette');
+ expect(openPalette).toHaveBeenCalledTimes(1);
+ registry.runAction('meta.keyboard-shortcuts');
+ expect(openShortcuts).toHaveBeenCalledTimes(1);
+ });
+
+ it('binds alias keys (mod+p → palette, ? → shortcuts) to the same handlers', () => {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ const openPalette = vi.fn();
+ const openShortcuts = vi.fn();
+ registerGlobalActions(makeHandlers({ openPalette, openShortcuts }), frame);
+
+ // mod+p alias (use ctrl on the non-mac CI default).
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true }));
+ expect(openPalette).toHaveBeenCalledTimes(1);
+
+ // '?' alias opens the shortcuts directory.
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
+ expect(openShortcuts).toHaveBeenCalledTimes(1);
+ });
+
+ it('disposer unwinds every registered action', () => {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ const dispose = registerGlobalActions(makeHandlers(), frame);
+ expect(registry.getAction('chat.new')?.id).toBe('chat.new');
+ dispose();
+ expect(registry.getAction('chat.new')).toBeUndefined();
+ expect(registry.getAction('nav.home')).toBeUndefined();
+ });
+
it('exports GROUP_ORDER', () => {
- expect(GROUP_ORDER).toEqual(['Navigation']);
+ expect(GROUP_ORDER).toEqual(['Navigation', 'Profiles', 'Chat', 'View', 'General']);
+ });
+
+ it('wires profile-switch shortcuts but keeps them hidden (disabled) until profiles exist', () => {
+ const frame = hotkeyManager.pushFrame('global', 'root');
+ registerGlobalActions(makeHandlers(), frame);
+ registry.setActiveStack([frame]);
+ // Hidden: not surfaced in the active (registry) action list…
+ expect(registry.getAction('profile.switch-1')).toBeUndefined();
+ // …and inert: the binding exists but is disabled, so running it is a no-op
+ // that never throws.
+ expect(() => registry.runAction('profile.switch-1')).not.toThrow();
});
});
diff --git a/app/src/lib/commands/globalActions.ts b/app/src/lib/commands/globalActions.ts
index 6ef277054..3663f7713 100644
--- a/app/src/lib/commands/globalActions.ts
+++ b/app/src/lib/commands/globalActions.ts
@@ -1,80 +1,285 @@
-import type { NavigateFunction } from 'react-router-dom';
-
import { hotkeyManager } from './hotkeyManager';
import { registry } from './registry';
+import { isMac } from './shortcut';
-export const GROUP_ORDER = ['Navigation'] as const;
+// Group headings shown (in this order) by the command palette and the
+// keyboard-shortcuts help directory. Kept in sync with the `group` values
+// assigned to each action below.
+export const GROUP_ORDER = ['Navigation', 'Profiles', 'Chat', 'View', 'General'] as const;
-export function registerGlobalActions(
- navigate: NavigateFunction,
- globalScopeSymbol: symbol
-): () => void {
+/**
+ * i18n keys for the group headings surfaced in the command palette / shortcuts
+ * directory. Grouping/ordering keys off the stable English `group` string; the
+ * heading text is resolved through `useT()` at display time via this map.
+ */
+export const GROUP_LABEL_KEYS: Record = {
+ Navigation: 'shortcuts.group.navigation',
+ Profiles: 'shortcuts.group.profiles',
+ Chat: 'shortcuts.group.chat',
+ View: 'shortcuts.group.view',
+ General: 'shortcuts.group.general',
+ Help: 'shortcuts.group.help',
+};
+
+/**
+ * Navigation tabs are bound to the **Control** key.
+ *
+ * On macOS `ctrl+N` is the physical Control key (distinct from ⌘), which is
+ * exactly what we want so ⌘+N can later mean "switch profile". On Windows/Linux
+ * there is no ⌘ and our matcher treats a bare `ctrl+N` as unreachable (mod *is*
+ * Ctrl there), so nav folds to `mod+N` — i.e. Ctrl+N physically on every OS.
+ */
+const NAV_MOD = isMac() ? 'ctrl' : 'mod';
+
+/**
+ * Profile-switch shortcuts (⌘1–⌘4 on macOS) are wired but inert: profiles don't
+ * exist yet. While this is false the actions stay bound-but-disabled and hidden
+ * from the palette/help directory; flip it true (and supply a real handler) when
+ * the profiles feature lands.
+ */
+const PROFILES_ENABLED = false;
+
+/**
+ * Side-effecting handlers the global command layer drives. The owning
+ * `CommandProvider` supplies these (navigation, new chat, sidebar toggle, and
+ * the two provider-level overlays) so the shortcut map itself stays declarative
+ * and testable here, decoupled from React state.
+ */
+export interface GlobalActionHandlers {
+ navigate: (path: string) => void;
+ newChat: () => void;
+ toggleSidebar: () => void;
+ openPalette: () => void;
+ openShortcuts: () => void;
+}
+
+interface BindCombo {
+ combo: string;
+ /** Allow this combo to fire while a text input / editable is focused. */
+ allowInInput?: boolean;
+}
+
+interface GlobalActionDef {
+ id: string;
+ label: string;
+ /** i18n key for the label, resolved at display time (falls back to `label`). */
+ labelKey?: string;
+ group: (typeof GROUP_ORDER)[number] | 'Help';
+ /** Primary shortcut — shown in the palette / help list and bound. */
+ shortcut: string;
+ /** `allowInInput` for the primary shortcut's binding. */
+ allowInInput?: boolean;
+ /** Extra key combos bound to the same handler (not shown separately). */
+ aliases?: BindCombo[];
+ /** Gate for both the binding (won't fire) and palette/help visibility. */
+ enabled?: () => boolean;
+ /**
+ * When false the combo is still bound (so it's "wired") but NOT surfaced in
+ * the palette / help directory. Used for placeholder shortcuts. Default true.
+ */
+ register?: boolean;
+ handler: () => void;
+ keywords?: string[];
+}
+
+/**
+ * Builds the canonical list of global actions from the supplied handlers. This
+ * is the single source of truth for the app-wide shortcut map: the command
+ * palette, the keyboard-shortcuts help directory, and the actual key bindings
+ * all derive from it.
+ */
+function buildGlobalActions(h: GlobalActionHandlers): GlobalActionDef[] {
const nav = (path: string) => () => {
- navigate(path);
+ h.navigate(path);
};
- const actions = [
+ // Placeholder until the profiles feature exists; the index is captured so the
+ // real switch can drop straight in here later.
+ const switchProfile = (index: number) => () => {
+ void index;
+ };
+
+ const profileActions: GlobalActionDef[] = [1, 2, 3, 4].map(n => ({
+ id: `profile.switch-${n}`,
+ label: `Switch to Profile ${n}`,
+ group: 'Profiles' as const,
+ // ⌘N on macOS (mod === ⌘); Ctrl+N on Windows/Linux. Distinct from the
+ // Control-based nav row on macOS; harmlessly inert elsewhere while disabled.
+ shortcut: `mod+${n}`,
+ enabled: () => PROFILES_ENABLED,
+ register: PROFILES_ENABLED,
+ handler: switchProfile(n),
+ keywords: ['profile', 'switch', 'persona', `profile ${n}`],
+ }));
+
+ return [
+ // ── Navigation (Control-based) ──────────────────────────────────────
{
id: 'nav.home',
label: 'Go Home',
+ labelKey: 'shortcuts.action.home',
group: 'Navigation',
- shortcut: 'mod+1',
+ shortcut: `${NAV_MOD}+1`,
handler: nav('/home'),
keywords: ['dashboard'],
},
{
id: 'nav.chat',
label: 'Go to Chat',
+ labelKey: 'shortcuts.action.chat',
group: 'Navigation',
- shortcut: 'mod+2',
+ shortcut: `${NAV_MOD}+2`,
handler: nav('/chat'),
keywords: ['conversations', 'messages', 'inbox'],
},
{
id: 'nav.intelligence',
label: 'Go to Knowledge & Memory',
+ labelKey: 'shortcuts.action.knowledge',
group: 'Navigation',
- shortcut: 'mod+3',
+ shortcut: `${NAV_MOD}+3`,
handler: nav('/settings/intelligence'),
keywords: ['memory', 'knowledge', 'intelligence'],
},
{
id: 'nav.skills',
label: 'Go to Connections',
+ labelKey: 'shortcuts.action.connections',
group: 'Navigation',
- shortcut: 'mod+4',
+ shortcut: `${NAV_MOD}+4`,
handler: nav('/connections'),
keywords: ['plugins', 'tools', 'connections', 'apps', 'skills'],
},
{
id: 'nav.activity',
label: 'Go to Activity',
+ labelKey: 'shortcuts.action.activity',
group: 'Navigation',
- shortcut: 'mod+5',
+ shortcut: `${NAV_MOD}+5`,
handler: nav('/activity'),
keywords: ['tasks', 'automations', 'alerts', 'background'],
},
{
id: 'nav.settings',
label: 'Open Settings',
+ labelKey: 'shortcuts.action.settings',
group: 'Navigation',
+ // Settings keeps the conventional ⌘, (mod) — it isn't a numbered tab.
shortcut: 'mod+,',
handler: nav('/settings'),
keywords: ['preferences', 'config'],
},
+
+ // ── Profiles (wired, hidden until the feature exists) ───────────────
+ ...profileActions,
+
+ // ── Chat ────────────────────────────────────────────────────────────
+ {
+ id: 'chat.new',
+ label: 'New Chat',
+ labelKey: 'shortcuts.action.newChat',
+ group: 'Chat',
+ shortcut: 'mod+n',
+ // Must fire from the composer too — that's the normal focus state when a
+ // user decides to start fresh — and preventDefault keeps the OS "new
+ // window" accelerator from swallowing it.
+ allowInInput: true,
+ handler: h.newChat,
+ keywords: ['new', 'thread', 'compose', 'conversation', 'session'],
+ },
+
+ // ── View ────────────────────────────────────────────────────────────
+ {
+ id: 'view.toggle-sidebar',
+ label: 'Toggle Sidebar',
+ labelKey: 'shortcuts.action.toggleSidebar',
+ group: 'View',
+ shortcut: 'mod+b',
+ handler: h.toggleSidebar,
+ keywords: ['sidebar', 'panel', 'rail', 'hide', 'show', 'collapse', 'expand'],
+ },
+
+ // ── General ─────────────────────────────────────────────────────────
+ {
+ id: 'meta.command-palette',
+ label: 'Command Palette',
+ labelKey: 'shortcuts.action.commandPalette',
+ group: 'General',
+ shortcut: 'mod+k',
+ allowInInput: true,
+ aliases: [{ combo: 'mod+p', allowInInput: true }],
+ handler: h.openPalette,
+ keywords: ['command', 'palette', 'search', 'actions', 'run'],
+ },
+ {
+ id: 'meta.keyboard-shortcuts',
+ label: 'Keyboard Shortcuts',
+ labelKey: 'shortcuts.title',
+ group: 'General',
+ // `mod+/` is allowed in inputs so it can replace the command palette
+ // while its search box is focused; the bare `?` alias is not, so it
+ // never hijacks a literal "?" typed into a message.
+ shortcut: 'mod+/',
+ allowInInput: true,
+ aliases: [{ combo: '?', allowInInput: false }],
+ handler: h.openShortcuts,
+ keywords: ['keyboard', 'shortcuts', 'keys', 'hotkeys', 'help', 'cheatsheet'],
+ },
];
+}
+
+/**
+ * Registers every global action (palette entries + key bindings) against the
+ * provided global scope frame. Returns a disposer that unwinds all of them.
+ */
+export function registerGlobalActions(
+ handlers: GlobalActionHandlers,
+ globalScopeSymbol: symbol
+): () => void {
+ const actions = buildGlobalActions(handlers);
const disposers: Array<() => void> = [];
for (const a of actions) {
- const disposeRegistry = registry.registerAction(a, globalScopeSymbol);
- const bindingSym = hotkeyManager.bind(globalScopeSymbol, {
- shortcut: a.shortcut,
- handler: a.handler,
- id: a.id,
- });
+ // `register: false` actions are still bound (wired) but stay out of the
+ // palette / help directory.
+ const disposeRegistry =
+ a.register === false
+ ? () => {}
+ : registry.registerAction(
+ {
+ id: a.id,
+ label: a.label,
+ labelKey: a.labelKey,
+ group: a.group,
+ shortcut: a.shortcut,
+ handler: a.handler,
+ keywords: a.keywords,
+ allowInInput: a.allowInInput,
+ enabled: a.enabled,
+ },
+ globalScopeSymbol
+ );
+
+ const combos: BindCombo[] = [
+ { combo: a.shortcut, allowInInput: a.allowInInput },
+ ...(a.aliases ?? []),
+ ];
+ const bindingSyms: symbol[] = [];
+ for (const { combo, allowInInput } of combos) {
+ bindingSyms.push(
+ hotkeyManager.bind(globalScopeSymbol, {
+ shortcut: combo,
+ handler: a.handler,
+ allowInInput,
+ enabled: a.enabled,
+ id: `${a.id}:${combo}`,
+ })
+ );
+ }
+
disposers.push(() => {
disposeRegistry();
- hotkeyManager.unbind(globalScopeSymbol, bindingSym);
+ for (const sym of bindingSyms) hotkeyManager.unbind(globalScopeSymbol, sym);
});
}
diff --git a/app/src/lib/commands/types.ts b/app/src/lib/commands/types.ts
index 70c2e4a76..a0d19f938 100644
--- a/app/src/lib/commands/types.ts
+++ b/app/src/lib/commands/types.ts
@@ -13,7 +13,10 @@ export interface ParsedShortcut {
export interface Action {
id: string;
+ /** English fallback label (used for cmdk search + when no `labelKey`). */
label: string;
+ /** i18n key resolved through `useT()` at display time; falls back to `label`. */
+ labelKey?: string;
hint?: string;
group?: string;
icon?: ComponentType<{ className?: string }>;
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 7e79bdfa6..25cea2a8c 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -2896,6 +2896,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'اضغط ؟ لعرض جميع الاختصارات',
'commandPalette.title': 'لوحة الأوامر',
'kbd.ariaLabel': 'اختصار لوحة المفاتيح: {shortcut}',
+ 'shortcuts.title': 'اختصارات لوحة المفاتيح',
+ 'shortcuts.subtitle': 'سرّع سير عملك باستخدام اختصارات لوحة المفاتيح هذه.',
+ 'shortcuts.menuDesc': 'تصفّح جميع اختصارات لوحة المفاتيح في التطبيق.',
+ 'shortcuts.empty': 'لا توجد اختصارات لوحة مفاتيح متاحة حاليًا.',
+ 'shortcuts.openHint': 'افتح هذه القائمة في أي وقت باستخدام',
+ 'shortcuts.group.navigation': 'التنقل',
+ 'shortcuts.group.profiles': 'الملفات الشخصية',
+ 'shortcuts.group.chat': 'الدردشة',
+ 'shortcuts.group.view': 'العرض',
+ 'shortcuts.group.general': 'عام',
+ 'shortcuts.group.help': 'المساعدة',
+ 'shortcuts.action.home': 'الذهاب إلى الرئيسية',
+ 'shortcuts.action.chat': 'الذهاب إلى الدردشة',
+ 'shortcuts.action.knowledge': 'الذهاب إلى المعرفة والذاكرة',
+ 'shortcuts.action.connections': 'الذهاب إلى الاتصالات',
+ 'shortcuts.action.activity': 'الذهاب إلى النشاط',
+ 'shortcuts.action.settings': 'فتح الإعدادات',
+ 'shortcuts.action.newChat': 'دردشة جديدة',
+ 'shortcuts.action.toggleSidebar': 'تبديل الشريط الجانبي',
+ 'shortcuts.action.commandPalette': 'لوحة الأوامر',
'iosMascot.connectedTo': 'متصل بـ',
'iosMascot.defaultPairedLabel': 'سطح المكتب',
'iosMascot.disconnect': 'قطع الاتصال',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 935dd3d65..1edc134d9 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -2957,6 +2957,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'সব শর্টকাটের জন্য ? চাপুন',
'commandPalette.title': 'কমান্ড প্যালেট',
'kbd.ariaLabel': 'কীবোর্ড শর্টকাট: {shortcut}',
+ 'shortcuts.title': 'কীবোর্ড শর্টকাট',
+ 'shortcuts.subtitle': 'এই কীবোর্ড শর্টকাটগুলি দিয়ে আপনার কাজ দ্রুত করুন।',
+ 'shortcuts.menuDesc': 'অ্যাপ-জুড়ে সব কীবোর্ড শর্টকাট দেখুন।',
+ 'shortcuts.empty': 'এই মুহূর্তে কোনো কীবোর্ড শর্টকাট উপলব্ধ নেই।',
+ 'shortcuts.openHint': 'যেকোনো সময় এই তালিকা খুলুন',
+ 'shortcuts.group.navigation': 'নেভিগেশন',
+ 'shortcuts.group.profiles': 'প্রোফাইল',
+ 'shortcuts.group.chat': 'চ্যাট',
+ 'shortcuts.group.view': 'ভিউ',
+ 'shortcuts.group.general': 'সাধারণ',
+ 'shortcuts.group.help': 'সহায়তা',
+ 'shortcuts.action.home': 'হোমে যান',
+ 'shortcuts.action.chat': 'চ্যাটে যান',
+ 'shortcuts.action.knowledge': 'জ্ঞান ও স্মৃতিতে যান',
+ 'shortcuts.action.connections': 'সংযোগে যান',
+ 'shortcuts.action.activity': 'কার্যকলাপে যান',
+ 'shortcuts.action.settings': 'সেটিংস খুলুন',
+ 'shortcuts.action.newChat': 'নতুন চ্যাট',
+ 'shortcuts.action.toggleSidebar': 'সাইডবার টগল করুন',
+ 'shortcuts.action.commandPalette': 'কমান্ড প্যালেট',
'iosMascot.connectedTo': '[[I18N_SEP_92731]] এর সাথে সংযুক্ত',
'iosMascot.defaultPairedLabel': 'ডেস্কটপ',
'iosMascot.disconnect': 'সংযোগ বিচ্ছিন্ন করুন',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 89de9cd26..4c9a6a88b 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -3027,6 +3027,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Drücke ? für alle Verknüpfungen',
'commandPalette.title': 'Befehlspalette',
'kbd.ariaLabel': 'Tastenkombination: {shortcut}',
+ 'shortcuts.title': 'Tastenkürzel',
+ 'shortcuts.subtitle': 'Beschleunige deinen Workflow mit diesen Tastenkürzeln.',
+ 'shortcuts.menuDesc': 'Alle app-weiten Tastenkürzel durchsuchen.',
+ 'shortcuts.empty': 'Derzeit sind keine Tastenkürzel verfügbar.',
+ 'shortcuts.openHint': 'Diese Liste jederzeit öffnen mit',
+ 'shortcuts.group.navigation': 'Navigation',
+ 'shortcuts.group.profiles': 'Profile',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Ansicht',
+ 'shortcuts.group.general': 'Allgemein',
+ 'shortcuts.group.help': 'Hilfe',
+ 'shortcuts.action.home': 'Zur Startseite',
+ 'shortcuts.action.chat': 'Zum Chat',
+ 'shortcuts.action.knowledge': 'Zu Wissen & Gedächtnis',
+ 'shortcuts.action.connections': 'Zu Verbindungen',
+ 'shortcuts.action.activity': 'Zur Aktivität',
+ 'shortcuts.action.settings': 'Einstellungen öffnen',
+ 'shortcuts.action.newChat': 'Neuer Chat',
+ 'shortcuts.action.toggleSidebar': 'Seitenleiste umschalten',
+ 'shortcuts.action.commandPalette': 'Befehlspalette',
'iosMascot.connectedTo': 'Verbunden mit',
'iosMascot.defaultPairedLabel': 'Desktop',
'iosMascot.disconnect': 'Trennen',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index f51bda630..5546520e0 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -3493,6 +3493,26 @@ const en: TranslationMap = {
'commandPalette.shortcutHint': 'Press ? for all shortcuts',
'commandPalette.title': 'Command palette',
'kbd.ariaLabel': 'Keyboard shortcut: {shortcut}',
+ 'shortcuts.title': 'Keyboard Shortcuts',
+ 'shortcuts.subtitle': 'Speed up your workflow with these keyboard shortcuts.',
+ 'shortcuts.menuDesc': 'Browse every app-wide keyboard shortcut.',
+ 'shortcuts.empty': 'No keyboard shortcuts are available right now.',
+ 'shortcuts.openHint': 'Open this list anytime with',
+ 'shortcuts.group.navigation': 'Navigation',
+ 'shortcuts.group.profiles': 'Profiles',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'View',
+ 'shortcuts.group.general': 'General',
+ 'shortcuts.group.help': 'Help',
+ 'shortcuts.action.home': 'Go Home',
+ 'shortcuts.action.chat': 'Go to Chat',
+ 'shortcuts.action.knowledge': 'Go to Knowledge & Memory',
+ 'shortcuts.action.connections': 'Go to Connections',
+ 'shortcuts.action.activity': 'Go to Activity',
+ 'shortcuts.action.settings': 'Open Settings',
+ 'shortcuts.action.newChat': 'New Chat',
+ 'shortcuts.action.toggleSidebar': 'Toggle Sidebar',
+ 'shortcuts.action.commandPalette': 'Command Palette',
'iosMascot.connectedTo': 'Connected to',
'iosMascot.defaultPairedLabel': 'Desktop',
'iosMascot.disconnect': 'Disconnect',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 5453b3da7..21d7b6366 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -3006,6 +3006,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Pulsa ? para ver todos los atajos',
'commandPalette.title': 'Paleta de comandos',
'kbd.ariaLabel': 'Atajo de teclado: {shortcut}',
+ 'shortcuts.title': 'Atajos de teclado',
+ 'shortcuts.subtitle': 'Agiliza tu flujo de trabajo con estos atajos de teclado.',
+ 'shortcuts.menuDesc': 'Explora todos los atajos de teclado de la aplicación.',
+ 'shortcuts.empty': 'No hay atajos de teclado disponibles en este momento.',
+ 'shortcuts.openHint': 'Abre esta lista en cualquier momento con',
+ 'shortcuts.group.navigation': 'Navegación',
+ 'shortcuts.group.profiles': 'Perfiles',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Vista',
+ 'shortcuts.group.general': 'General',
+ 'shortcuts.group.help': 'Ayuda',
+ 'shortcuts.action.home': 'Ir al inicio',
+ 'shortcuts.action.chat': 'Ir al chat',
+ 'shortcuts.action.knowledge': 'Ir a Conocimiento y memoria',
+ 'shortcuts.action.connections': 'Ir a Conexiones',
+ 'shortcuts.action.activity': 'Ir a Actividad',
+ 'shortcuts.action.settings': 'Abrir Configuración',
+ 'shortcuts.action.newChat': 'Nuevo chat',
+ 'shortcuts.action.toggleSidebar': 'Mostrar/ocultar barra lateral',
+ 'shortcuts.action.commandPalette': 'Paleta de comandos',
'iosMascot.connectedTo': 'Conectado a',
'iosMascot.defaultPairedLabel': 'Escritorio',
'iosMascot.disconnect': 'Desconectar',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index c1d32d862..d4fec3212 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -3021,6 +3021,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Appuyez sur ? pour tous les raccourcis',
'commandPalette.title': 'Palette de commandes',
'kbd.ariaLabel': 'Raccourci clavier : {shortcut}',
+ 'shortcuts.title': 'Raccourcis clavier',
+ 'shortcuts.subtitle': 'Accélérez votre travail grâce à ces raccourcis clavier.',
+ 'shortcuts.menuDesc': 'Parcourez tous les raccourcis clavier de l’application.',
+ 'shortcuts.empty': 'Aucun raccourci clavier n’est disponible pour le moment.',
+ 'shortcuts.openHint': 'Ouvrez cette liste à tout moment avec',
+ 'shortcuts.group.navigation': 'Navigation',
+ 'shortcuts.group.profiles': 'Profils',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Affichage',
+ 'shortcuts.group.general': 'Général',
+ 'shortcuts.group.help': 'Aide',
+ 'shortcuts.action.home': 'Aller à l’accueil',
+ 'shortcuts.action.chat': 'Aller au chat',
+ 'shortcuts.action.knowledge': 'Aller à Connaissances et mémoire',
+ 'shortcuts.action.connections': 'Aller aux Connexions',
+ 'shortcuts.action.activity': 'Aller à l’activité',
+ 'shortcuts.action.settings': 'Ouvrir les paramètres',
+ 'shortcuts.action.newChat': 'Nouveau chat',
+ 'shortcuts.action.toggleSidebar': 'Afficher/masquer la barre latérale',
+ 'shortcuts.action.commandPalette': 'Palette de commandes',
'iosMascot.connectedTo': 'Connecté à',
'iosMascot.defaultPairedLabel': 'Bureau',
'iosMascot.disconnect': 'Déconnecter',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 4ac9c13f6..32ae90a22 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -2958,6 +2958,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'सभी शॉर्टकट के लिए ? दबाएँ',
'commandPalette.title': 'कमांड पैलेट',
'kbd.ariaLabel': 'कीबोर्ड शॉर्टकट: {shortcut}',
+ 'shortcuts.title': 'कीबोर्ड शॉर्टकट',
+ 'shortcuts.subtitle': 'इन कीबोर्ड शॉर्टकट से अपने काम को तेज़ करें।',
+ 'shortcuts.menuDesc': 'ऐप के सभी कीबोर्ड शॉर्टकट देखें।',
+ 'shortcuts.empty': 'अभी कोई कीबोर्ड शॉर्टकट उपलब्ध नहीं है।',
+ 'shortcuts.openHint': 'इस सूची को कभी भी इससे खोलें',
+ 'shortcuts.group.navigation': 'नेविगेशन',
+ 'shortcuts.group.profiles': 'प्रोफ़ाइल',
+ 'shortcuts.group.chat': 'चैट',
+ 'shortcuts.group.view': 'व्यू',
+ 'shortcuts.group.general': 'सामान्य',
+ 'shortcuts.group.help': 'सहायता',
+ 'shortcuts.action.home': 'होम पर जाएं',
+ 'shortcuts.action.chat': 'चैट पर जाएं',
+ 'shortcuts.action.knowledge': 'ज्ञान और मेमोरी पर जाएं',
+ 'shortcuts.action.connections': 'कनेक्शन पर जाएं',
+ 'shortcuts.action.activity': 'गतिविधि पर जाएं',
+ 'shortcuts.action.settings': 'सेटिंग्स खोलें',
+ 'shortcuts.action.newChat': 'नई चैट',
+ 'shortcuts.action.toggleSidebar': 'साइडबार टॉगल करें',
+ 'shortcuts.action.commandPalette': 'कमांड पैलेट',
'iosMascot.connectedTo': 'से जुड़ा हुआ है',
'iosMascot.defaultPairedLabel': 'डेस्कटॉप',
'iosMascot.disconnect': 'डिस्कनेक्ट करें',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index c49afe3c6..f6d7d5404 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -2962,6 +2962,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Tekan ? untuk semua pintasan',
'commandPalette.title': 'Palet perintah',
'kbd.ariaLabel': 'Pintasan keyboard: {shortcut}',
+ 'shortcuts.title': 'Pintasan Keyboard',
+ 'shortcuts.subtitle': 'Percepat alur kerja Anda dengan pintasan keyboard ini.',
+ 'shortcuts.menuDesc': 'Jelajahi semua pintasan keyboard di aplikasi.',
+ 'shortcuts.empty': 'Tidak ada pintasan keyboard yang tersedia saat ini.',
+ 'shortcuts.openHint': 'Buka daftar ini kapan saja dengan',
+ 'shortcuts.group.navigation': 'Navigasi',
+ 'shortcuts.group.profiles': 'Profil',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Tampilan',
+ 'shortcuts.group.general': 'Umum',
+ 'shortcuts.group.help': 'Bantuan',
+ 'shortcuts.action.home': 'Ke Beranda',
+ 'shortcuts.action.chat': 'Ke Chat',
+ 'shortcuts.action.knowledge': 'Ke Pengetahuan & Memori',
+ 'shortcuts.action.connections': 'Ke Koneksi',
+ 'shortcuts.action.activity': 'Ke Aktivitas',
+ 'shortcuts.action.settings': 'Buka Pengaturan',
+ 'shortcuts.action.newChat': 'Chat Baru',
+ 'shortcuts.action.toggleSidebar': 'Alihkan bilah sisi',
+ 'shortcuts.action.commandPalette': 'Palet Perintah',
'iosMascot.connectedTo': 'Tersambung ke',
'iosMascot.defaultPairedLabel': 'Desktop',
'iosMascot.disconnect': 'Putuskan sambungan',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index cc466b166..b62072076 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -2999,6 +2999,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Premi ? per tutte le scorciatoie',
'commandPalette.title': 'Palette dei comandi',
'kbd.ariaLabel': 'Scorciatoia da tastiera: {shortcut}',
+ 'shortcuts.title': 'Scorciatoie da tastiera',
+ 'shortcuts.subtitle': 'Velocizza il tuo lavoro con queste scorciatoie da tastiera.',
+ 'shortcuts.menuDesc': 'Esplora tutte le scorciatoie da tastiera dell’app.',
+ 'shortcuts.empty': 'Al momento non è disponibile alcuna scorciatoia da tastiera.',
+ 'shortcuts.openHint': 'Apri questo elenco in qualsiasi momento con',
+ 'shortcuts.group.navigation': 'Navigazione',
+ 'shortcuts.group.profiles': 'Profili',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Vista',
+ 'shortcuts.group.general': 'Generale',
+ 'shortcuts.group.help': 'Aiuto',
+ 'shortcuts.action.home': 'Vai alla home',
+ 'shortcuts.action.chat': 'Vai alla chat',
+ 'shortcuts.action.knowledge': 'Vai a Conoscenza e memoria',
+ 'shortcuts.action.connections': 'Vai a Connessioni',
+ 'shortcuts.action.activity': 'Vai ad Attività',
+ 'shortcuts.action.settings': 'Apri Impostazioni',
+ 'shortcuts.action.newChat': 'Nuova chat',
+ 'shortcuts.action.toggleSidebar': 'Attiva/disattiva barra laterale',
+ 'shortcuts.action.commandPalette': 'Palette dei comandi',
'iosMascot.connectedTo': 'Connesso a',
'iosMascot.defaultPairedLabel': 'Desktop',
'iosMascot.disconnect': 'Disconnetti',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 3077d5b08..0ba079a95 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -2931,6 +2931,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': '모든 단축키를 보려면 ?를 누르세요',
'commandPalette.title': '명령 팔레트',
'kbd.ariaLabel': '키보드 단축키: {shortcut}',
+ 'shortcuts.title': '키보드 단축키',
+ 'shortcuts.subtitle': '이 키보드 단축키로 작업 속도를 높이세요.',
+ 'shortcuts.menuDesc': '앱 전체의 모든 키보드 단축키를 살펴보세요.',
+ 'shortcuts.empty': '현재 사용할 수 있는 키보드 단축키가 없습니다.',
+ 'shortcuts.openHint': '언제든지 이 목록을 여는 방법:',
+ 'shortcuts.group.navigation': '탐색',
+ 'shortcuts.group.profiles': '프로필',
+ 'shortcuts.group.chat': '채팅',
+ 'shortcuts.group.view': '보기',
+ 'shortcuts.group.general': '일반',
+ 'shortcuts.group.help': '도움말',
+ 'shortcuts.action.home': '홈으로 이동',
+ 'shortcuts.action.chat': '채팅으로 이동',
+ 'shortcuts.action.knowledge': '지식 및 메모리로 이동',
+ 'shortcuts.action.connections': '연결로 이동',
+ 'shortcuts.action.activity': '활동으로 이동',
+ 'shortcuts.action.settings': '설정 열기',
+ 'shortcuts.action.newChat': '새 채팅',
+ 'shortcuts.action.toggleSidebar': '사이드바 전환',
+ 'shortcuts.action.commandPalette': '명령 팔레트',
'iosMascot.connectedTo': '연결됨',
'iosMascot.defaultPairedLabel': '데스크탑',
'iosMascot.disconnect': '연결 끊기',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 50eed5d3a..44403dafd 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -2987,6 +2987,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Wciśnij ?, aby zobaczyć wszystkie skróty',
'commandPalette.title': 'Paleta poleceń',
'kbd.ariaLabel': 'Skrót klawiszowy: {shortcut}',
+ 'shortcuts.title': 'Skróty klawiszowe',
+ 'shortcuts.subtitle': 'Przyspiesz swoją pracę dzięki tym skrótom klawiszowym.',
+ 'shortcuts.menuDesc': 'Przeglądaj wszystkie skróty klawiszowe w aplikacji.',
+ 'shortcuts.empty': 'Obecnie brak dostępnych skrótów klawiszowych.',
+ 'shortcuts.openHint': 'Otwórz tę listę w dowolnej chwili za pomocą',
+ 'shortcuts.group.navigation': 'Nawigacja',
+ 'shortcuts.group.profiles': 'Profile',
+ 'shortcuts.group.chat': 'Czat',
+ 'shortcuts.group.view': 'Widok',
+ 'shortcuts.group.general': 'Ogólne',
+ 'shortcuts.group.help': 'Pomoc',
+ 'shortcuts.action.home': 'Przejdź do strony głównej',
+ 'shortcuts.action.chat': 'Przejdź do czatu',
+ 'shortcuts.action.knowledge': 'Przejdź do Wiedzy i pamięci',
+ 'shortcuts.action.connections': 'Przejdź do Połączeń',
+ 'shortcuts.action.activity': 'Przejdź do Aktywności',
+ 'shortcuts.action.settings': 'Otwórz ustawienia',
+ 'shortcuts.action.newChat': 'Nowy czat',
+ 'shortcuts.action.toggleSidebar': 'Przełącz panel boczny',
+ 'shortcuts.action.commandPalette': 'Paleta poleceń',
'iosMascot.connectedTo': 'Połączono z',
'iosMascot.defaultPairedLabel': 'Komputer',
'iosMascot.disconnect': 'Rozłącz',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 0a68f9f68..9996cbd5d 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -3004,6 +3004,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Pressione ? para ver todos os atalhos',
'commandPalette.title': 'Paleta de comandos',
'kbd.ariaLabel': 'Atalho de teclado: {shortcut}',
+ 'shortcuts.title': 'Atalhos de teclado',
+ 'shortcuts.subtitle': 'Agilize seu fluxo de trabalho com estes atalhos de teclado.',
+ 'shortcuts.menuDesc': 'Explore todos os atalhos de teclado do aplicativo.',
+ 'shortcuts.empty': 'Nenhum atalho de teclado disponível no momento.',
+ 'shortcuts.openHint': 'Abra esta lista a qualquer momento com',
+ 'shortcuts.group.navigation': 'Navegação',
+ 'shortcuts.group.profiles': 'Perfis',
+ 'shortcuts.group.chat': 'Chat',
+ 'shortcuts.group.view': 'Visualização',
+ 'shortcuts.group.general': 'Geral',
+ 'shortcuts.group.help': 'Ajuda',
+ 'shortcuts.action.home': 'Ir para o início',
+ 'shortcuts.action.chat': 'Ir para o chat',
+ 'shortcuts.action.knowledge': 'Ir para Conhecimento e memória',
+ 'shortcuts.action.connections': 'Ir para Conexões',
+ 'shortcuts.action.activity': 'Ir para Atividade',
+ 'shortcuts.action.settings': 'Abrir Configurações',
+ 'shortcuts.action.newChat': 'Nova conversa',
+ 'shortcuts.action.toggleSidebar': 'Alternar barra lateral',
+ 'shortcuts.action.commandPalette': 'Paleta de comandos',
'iosMascot.connectedTo': 'Conectado a',
'iosMascot.defaultPairedLabel': 'Desktop',
'iosMascot.disconnect': 'Desconectar',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 154c4fcd5..cc900831b 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -2979,6 +2979,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': 'Нажмите ?, чтобы увидеть все сочетания',
'commandPalette.title': 'Палитра команд',
'kbd.ariaLabel': 'Сочетание клавиш: {shortcut}',
+ 'shortcuts.title': 'Горячие клавиши',
+ 'shortcuts.subtitle': 'Ускорьте работу с помощью этих горячих клавиш.',
+ 'shortcuts.menuDesc': 'Просмотрите все горячие клавиши приложения.',
+ 'shortcuts.empty': 'Сейчас нет доступных горячих клавиш.',
+ 'shortcuts.openHint': 'Откройте этот список в любой момент с помощью',
+ 'shortcuts.group.navigation': 'Навигация',
+ 'shortcuts.group.profiles': 'Профили',
+ 'shortcuts.group.chat': 'Чат',
+ 'shortcuts.group.view': 'Вид',
+ 'shortcuts.group.general': 'Общие',
+ 'shortcuts.group.help': 'Справка',
+ 'shortcuts.action.home': 'На главную',
+ 'shortcuts.action.chat': 'Перейти в чат',
+ 'shortcuts.action.knowledge': 'Знания и память',
+ 'shortcuts.action.connections': 'Перейти к подключениям',
+ 'shortcuts.action.activity': 'Перейти к активности',
+ 'shortcuts.action.settings': 'Открыть настройки',
+ 'shortcuts.action.newChat': 'Новый чат',
+ 'shortcuts.action.toggleSidebar': 'Переключить боковую панель',
+ 'shortcuts.action.commandPalette': 'Палитра команд',
'iosMascot.connectedTo': 'Подключен к',
'iosMascot.defaultPairedLabel': 'Рабочий стол',
'iosMascot.disconnect': 'Отключиться',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index ad0b44e2e..2cf2a52a0 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -2817,6 +2817,26 @@ const messages: TranslationMap = {
'commandPalette.shortcutHint': '按 ? 查看所有快捷键',
'commandPalette.title': '命令面板',
'kbd.ariaLabel': '键盘快捷键:{shortcut}',
+ 'shortcuts.title': '键盘快捷键',
+ 'shortcuts.subtitle': '使用这些键盘快捷键加快你的工作流程。',
+ 'shortcuts.menuDesc': '浏览应用中的所有键盘快捷键。',
+ 'shortcuts.empty': '当前没有可用的键盘快捷键。',
+ 'shortcuts.openHint': '随时可用以下按键打开此列表',
+ 'shortcuts.group.navigation': '导航',
+ 'shortcuts.group.profiles': '配置文件',
+ 'shortcuts.group.chat': '聊天',
+ 'shortcuts.group.view': '视图',
+ 'shortcuts.group.general': '常规',
+ 'shortcuts.group.help': '帮助',
+ 'shortcuts.action.home': '转到主页',
+ 'shortcuts.action.chat': '转到聊天',
+ 'shortcuts.action.knowledge': '转到知识与记忆',
+ 'shortcuts.action.connections': '转到连接',
+ 'shortcuts.action.activity': '转到活动',
+ 'shortcuts.action.settings': '打开设置',
+ 'shortcuts.action.newChat': '新建聊天',
+ 'shortcuts.action.toggleSidebar': '切换侧边栏',
+ 'shortcuts.action.commandPalette': '命令面板',
'iosMascot.connectedTo': '连接到',
'iosMascot.defaultPairedLabel': '桌面',
'iosMascot.disconnect': '断开连接',
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 2c9528b92..3c2434cd5 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -48,9 +48,11 @@ import {
beginInferenceTurn,
clearFollowupsForThread,
clearRuntimeForThread,
+ clearThreadSendPending,
enqueueFollowup,
fetchAndHydrateTurnState,
markSubagentCancelled,
+ markThreadSendPending,
type QueuedFollowup,
registerParallelRequest,
setTaskBoardForThread,
@@ -287,22 +289,32 @@ const Conversations = ({
const [pendingSendingThreadIds, setPendingSendingThreadIds] = useState>(
() => new Set()
);
- const addPendingSendingThread = useCallback((threadId: string) => {
- setPendingSendingThreadIds(prev => {
- if (prev.has(threadId)) return prev;
- const next = new Set(prev);
- next.add(threadId);
- return next;
- });
- }, []);
- const removePendingSendingThread = useCallback((threadId: string) => {
- setPendingSendingThreadIds(prev => {
- if (!prev.has(threadId)) return prev;
- const next = new Set(prev);
- next.delete(threadId);
- return next;
- });
- }, []);
+ const addPendingSendingThread = useCallback(
+ (threadId: string) => {
+ // Mirror to Redux so global surfaces (e.g. the New Chat shortcut) can see
+ // an in-flight send before any message/streaming state exists.
+ dispatch(markThreadSendPending({ threadId }));
+ setPendingSendingThreadIds(prev => {
+ if (prev.has(threadId)) return prev;
+ const next = new Set(prev);
+ next.add(threadId);
+ return next;
+ });
+ },
+ [dispatch]
+ );
+ const removePendingSendingThread = useCallback(
+ (threadId: string) => {
+ dispatch(clearThreadSendPending({ threadId }));
+ setPendingSendingThreadIds(prev => {
+ if (!prev.has(threadId)) return prev;
+ const next = new Set(prev);
+ next.delete(threadId);
+ return next;
+ });
+ },
+ [dispatch]
+ );
const socketStatus = useAppSelector(selectSocketStatus);
const agentProfiles = useAppSelector(selectAgentProfiles);
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts
index a62d8e76b..bb7271b62 100644
--- a/app/src/store/chatRuntimeSlice.ts
+++ b/app/src/store/chatRuntimeSlice.ts
@@ -313,6 +313,13 @@ export type QueueMode = 'interrupt' | 'steer' | 'followup' | 'collect' | 'parall
interface ChatRuntimeState {
inferenceStatusByThread: Record;
streamingAssistantByThread: Record;
+ /**
+ * Threads with an optimistic user send in flight, set the instant the user
+ * sends (before `addMessageLocal` resolves and before any streaming state
+ * exists). Lets global surfaces — e.g. the New Chat shortcut — tell a
+ * mid-send conversation apart from a genuinely-blank one.
+ */
+ pendingSendThreadIds: Record;
/**
* Live streams for concurrent PARALLEL (forked) turns on a thread, nested
* `threadId -> requestId -> stream`. A separate lane from
@@ -390,6 +397,7 @@ export interface QueuedFollowup {
const initialState: ChatRuntimeState = {
inferenceStatusByThread: {},
streamingAssistantByThread: {},
+ pendingSendThreadIds: {},
parallelStreamsByThread: {},
parallelRequestThreads: {},
toolTimelineByThread: {},
@@ -657,6 +665,14 @@ const chatRuntimeSlice = createSlice({
clearStreamingAssistantForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.streamingAssistantByThread[action.payload.threadId];
},
+ /** Mark a thread as having an optimistic user send in flight. */
+ markThreadSendPending: (state, action: PayloadAction<{ threadId: string }>) => {
+ state.pendingSendThreadIds[action.payload.threadId] = true;
+ },
+ /** Clear the in-flight-send marker once the send settles (or fails). */
+ clearThreadSendPending: (state, action: PayloadAction<{ threadId: string }>) => {
+ delete state.pendingSendThreadIds[action.payload.threadId];
+ },
/**
* Register a parallel (forked) turn so its socket events route to the
* parallel lane. Called when a `queueMode: 'parallel'` send is accepted.
@@ -1068,6 +1084,7 @@ const chatRuntimeSlice = createSlice({
delete state.pendingPlanReviewByThread[action.payload.threadId];
delete state.queueStatusByThread[action.payload.threadId];
delete state.queuedFollowupsByThread[action.payload.threadId];
+ delete state.pendingSendThreadIds[action.payload.threadId];
// Note: artifactsByThread intentionally NOT cleared here. The
// ArtifactCard renders inline in the message timeline, so the
// snapshot needs to survive turn boundaries — historic artifacts
@@ -1088,6 +1105,7 @@ const chatRuntimeSlice = createSlice({
state.artifactsByThread = {};
state.queueStatusByThread = {};
state.queuedFollowupsByThread = {};
+ state.pendingSendThreadIds = {};
},
recordChatTurnUsage: (
state,
@@ -1224,6 +1242,8 @@ export const {
clearInferenceStatusForThread,
setStreamingAssistantForThread,
clearStreamingAssistantForThread,
+ markThreadSendPending,
+ clearThreadSendPending,
registerParallelRequest,
setParallelStream,
clearParallelRequest,
diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx
index 0464ba753..6743a29e4 100644
--- a/app/src/test/test-utils.tsx
+++ b/app/src/test/test-utils.tsx
@@ -26,6 +26,7 @@ import personaReducer from '../store/personaSlice';
import { pttReducer } from '../store/pttSlice';
import socketReducer from '../store/socketSlice';
import themeReducer from '../store/themeSlice';
+import threadReducer from '../store/threadSlice';
/**
* Creates a fresh Redux store for testing.
@@ -53,6 +54,7 @@ const testRootReducer = combineReducers({
ptt: pttReducer,
socket: socketReducer,
theme: themeReducer,
+ thread: threadReducer,
});
export function createTestStore(preloadedState?: Record) {
diff --git a/app/test/e2e/specs/command-palette.spec.ts b/app/test/e2e/specs/command-palette.spec.ts
index 2660a3a6d..80c9cc2c6 100644
--- a/app/test/e2e/specs/command-palette.spec.ts
+++ b/app/test/e2e/specs/command-palette.spec.ts
@@ -64,6 +64,25 @@ async function dispatchKey(
}
}
+// Close an overlay via Escape, escalating to a document-targeted synthetic
+// event as a last resort. ModalShell's `useEscapeKey` binds to `document`, so a
+// `window`-dispatched fallback would miss it — dispatch on `document` directly.
+async function closeOverlayWithEscape(el: WebdriverIO.Element, timeoutMsg: string): Promise {
+ try {
+ await browser.keys('Escape');
+ } catch {
+ await dispatchKey('Escape');
+ }
+ try {
+ await browser.waitUntil(async () => !(await el.isExisting()), { timeout: 3000 });
+ } catch {
+ await browser.execute(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+ await browser.waitUntil(async () => !(await el.isExisting()), { timeout: 3000, timeoutMsg });
+ }
+}
+
describe('Command palette', () => {
before(async () => {
// CommandProvider is mounted inside the auth-gated provider chain.
@@ -204,4 +223,54 @@ describe('Command palette', () => {
});
}
});
+
+ it('opens the keyboard-shortcuts help via mod+/ and lists grouped shortcuts', async () => {
+ // `mod+/` is allowed even while an input is focused, so it reliably opens
+ // the help directory regardless of where focus currently sits.
+ let list = await browser.$('[data-testid="keyboard-shortcuts-list"]');
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await dispatchKey('/', MOD_KEY);
+ list = await browser.$('[data-testid="keyboard-shortcuts-list"]');
+ try {
+ await list.waitForExist({ timeout: 3000 });
+ break;
+ } catch {
+ if (attempt === 2) throw new Error('Shortcuts help did not open after 3 mod+/ attempts');
+ }
+ }
+
+ // The directory renders live from the command registry — assert a couple of
+ // the new global actions and a group heading are present.
+ for (const label of ['New Chat', 'Toggle Sidebar', 'Navigation']) {
+ const found = await browser.execute((lbl: string) => {
+ const root = document.querySelector('[data-testid="keyboard-shortcuts-list"]');
+ return !!root && (root.textContent ?? '').includes(lbl);
+ }, label);
+ expect(found).toBe(true);
+ }
+
+ // Esc closes the overlay (ModalShell's useEscapeKey).
+ await closeOverlayWithEscape(list, 'shortcuts help did not close on Escape');
+ });
+
+ it('opens the keyboard-shortcuts help via the ? key', async () => {
+ // `?` must NOT fire while a text field is focused (so users can still type
+ // a literal "?"), so blur first to emulate pressing it from app chrome.
+ await browser.execute(() => (document.activeElement as HTMLElement | null)?.blur?.());
+
+ let list = await browser.$('[data-testid="keyboard-shortcuts-list"]');
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await dispatchKey('?');
+ list = await browser.$('[data-testid="keyboard-shortcuts-list"]');
+ try {
+ await list.waitForExist({ timeout: 3000 });
+ break;
+ } catch {
+ if (attempt === 2) throw new Error('Shortcuts help did not open after 3 ? attempts');
+ }
+ }
+ expect(await list.isExisting()).toBe(true);
+
+ await closeOverlayWithEscape(list, 'shortcuts help did not close');
+ });
});
diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md
index 2a787e05d..795312d19 100644
--- a/docs/TEST-COVERAGE-MATRIX.md
+++ b/docs/TEST-COVERAGE-MATRIX.md
@@ -555,6 +555,14 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
| 13.5.3 | Full State Reset | WD | `app/test/e2e/specs/settings-data-management.spec.ts` | ✅ | Restart-and-verify fresh-install state |
| 13.5.4 | Migration from another assistant (OpenClaw) | VU+RU | `app/src/components/settings/panels/__tests__/MigrationPanel.test.tsx`, `src/openhuman/migration/ops.rs` (existing) | ✅ | Was ❌ — UI now wraps the existing `openhuman.migrate_openclaw` RPC with preview-then-apply + confirm. Hermes tracked as follow-up under #1440 |
+### 13.6 Keyboard Shortcuts & Command Surface
+
+| ID | Feature | Layer | Test path(s) | Status | Notes |
+| ------ | ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 13.6.1 | Command Palette (⌘K / ⌘P) | VU+WD | `app/src/lib/commands/__tests__/globalActions.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Opens via ⌘K, runs an action, lists seed nav actions, Esc closes |
+| 13.6.2 | Keyboard Shortcuts help directory (? / ⌘/) | VU+WD | `app/src/components/shortcuts/__tests__/shortcutsView.test.tsx`, `app/src/components/commands/__tests__/CommandProvider.test.tsx`, `app/test/e2e/specs/command-palette.spec.ts` | ✅ | Registry-driven grouped list; opens via `?` and ⌘/ (mutually exclusive with palette); also reachable from the sidebar shortcut icon + Settings → Keyboard Shortcuts |
+| 13.6.3 | Global shortcut map (nav / chat / view / profiles) | VU | `app/src/lib/commands/__tests__/globalActions.test.tsx` | ✅ | Control-based nav (`ctrl`/`mod` per-OS), New Chat / Toggle Sidebar handlers, alias keys, and wired-but-hidden profile switches |
+
---
## Summary