feat(shortcuts): global keyboard shortcuts + help directory (#4126)

This commit is contained in:
Steven Enamakel
2026-06-25 13:33:36 -07:00
committed by GitHub
parent f4674078bc
commit a29878ed40
41 changed files with 1461 additions and 190 deletions
+31 -24
View File
@@ -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]) => (
<Command.Group
key={groupName}
heading={groupName}
heading={GROUP_LABEL_KEYS[groupName] ? t(GROUP_LABEL_KEYS[groupName]) : groupName}
className="[&_[cmdk-group-heading]]:px-4 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-cmd-foreground-muted">
{items.map(action => (
<Command.Item
key={action.id}
value={action.id}
keywords={[action.label, ...(action.keywords ?? [])]}
onSelect={() => runAction(action)}
className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
{action.icon ? (
<action.icon className="w-4 h-4 text-cmd-foreground-muted" />
) : (
<span className="w-4" />
)}
<span className="flex-1 truncate">{action.label}</span>
{action.hint && (
<span className="text-xs text-cmd-foreground-muted truncate">
{action.hint}
</span>
)}
{action.shortcut && <Kbd shortcut={action.shortcut} />}
</Command.Item>
))}
{items.map(action => {
const label = action.labelKey ? t(action.labelKey) : action.label;
return (
<Command.Item
key={action.id}
value={action.id}
keywords={[label, action.label, ...(action.keywords ?? [])]}
onSelect={() => runAction(action)}
className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
{action.icon ? (
<action.icon className="w-4 h-4 text-cmd-foreground-muted" />
) : (
<span className="w-4" />
)}
<span className="flex-1 truncate">{label}</span>
{action.hint && (
<span className="text-xs text-cmd-foreground-muted truncate">
{action.hint}
</span>
)}
{action.shortcut && <Kbd shortcut={action.shortcut} />}
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="px-4 py-2 border-t border-cmd-border text-xs text-cmd-foreground-muted">
{t('commandPalette.shortcutHint')}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-cmd-border text-xs text-cmd-foreground-muted">
{/* Advertise ⌘/ (allowed while this search box is focused) rather
than ?, which the hotkey manager skips inside inputs. */}
<Kbd shortcut="mod+/" />
<span>{t('shortcuts.title')}</span>
</div>
</Command>
</Dialog.Content>
+44 -13
View File
@@ -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<symbol | null>(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<GlobalActionHandlers>(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) {
<ScopeContext.Provider value={value}>
{children}
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
</ScopeContext.Provider>
);
}
@@ -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(<Harness open={true} onOpenChange={() => {}} />);
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();
});
});
@@ -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(
<CommandProvider>
<div>child</div>
</CommandProvider>
);
}
describe('CommandProvider', () => {
it('mounts and registers seed actions', () => {
render(
<MemoryRouter>
<CommandProvider>
<div>child</div>
</CommandProvider>
</MemoryRouter>
);
renderProvider();
expect(screen.getByText('child')).toBeInTheDocument();
});
it('opens palette on mod+K', async () => {
render(
<MemoryRouter>
<CommandProvider>
<div>child</div>
</CommandProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<CommandProvider>
<div>child</div>
</CommandProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<CommandProvider>
<div>child</div>
</CommandProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<CommandProvider>
<div>child</div>
</CommandProvider>
</MemoryRouter>
);
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();
@@ -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(<CollapsedNavRail />, { 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(<CollapsedNavRail />, { 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(<CollapsedNavRail />, { 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(<CollapsedNavRail />, { 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(<CollapsedNavRail />, { 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(<CollapsedNavRail />, { 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'
);
@@ -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 (
<nav className="flex flex-col items-center gap-2" aria-label={t('nav.home')}>
@@ -75,22 +71,16 @@ export default function CollapsedNavRail() {
</button>
</Tooltip>
{/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
<Tooltip label={t('nav.wallet')}>
{/* Keyboard shortcuts — mirrors SidebarHeader's shortcuts button for the
collapsed state. Opens the help directory (also reachable via ? / ⌘/). */}
<Tooltip label={t('shortcuts.title')}>
<button
type="button"
onClick={() => 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'
}`}>
<NavIcon id="wallet" className="h-5 w-5" />
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`}>
<NavIcon id="keyboard" className="h-5 w-5" />
</button>
</Tooltip>
@@ -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;
@@ -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(<SidebarHeader />, { 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(<SidebarHeader />, { 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(<SidebarHeader />, { 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(<SidebarHeader />, { 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 <Tooltip> 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', () => {
@@ -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() {
</Tooltip>
<div className="flex items-center gap-0.5">
{/* Wallet shortcut — one-click access to wallet balances. */}
<Tooltip label={t('nav.wallet')}>
{/* Keyboard shortcuts — one-click open of the help directory (also ? / ⌘/). */}
<Tooltip label={t('shortcuts.title')}>
<button
type="button"
onClick={() => 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')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M7 10h.01M11 10h.01M15 10h.01M17 10h.01M7 13h.01M9 16h6"
/>
</svg>
</button>
@@ -143,6 +143,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
</svg>
);
case 'keyboard':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M7 10h.01M11 10h.01M15 10h.01M17 10h.01M7 13h.01M9 16h6"
/>
</svg>
);
default:
return null;
}
@@ -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<typeof import('react-router-dom')>();
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<string, unknown[]> = {};
let mockStreamingByThread: Record<string, unknown> = {};
let mockPendingSendThreadIds: Record<string, true> = {};
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 <MemoryRouter>{children}</MemoryRouter>;
}
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');
});
});
@@ -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]);
}
@@ -82,6 +82,12 @@ export const SETTINGS_NAV_ICONS: Record<string, ReactNode> = {
)
),
'developer-options': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')),
'keyboard-shortcuts': icon(
<Fragment>
{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')}
</Fragment>
),
about: icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')),
usage: icon(
stroke(
@@ -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 (
<SettingsPanel description={t('shortcuts.subtitle')} testId="settings-keyboard-shortcuts">
<ShortcutsList variant="panel" />
</SettingsPanel>
);
};
export default KeyboardShortcutsPanel;
@@ -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 {
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
<Route path="keyboard-shortcuts" element={wrapSettingsPage(<KeyboardShortcutsPanel />)} />
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
@@ -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',
@@ -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 (
<ModalShell
onClose={() => onOpenChange(false)}
title={t('shortcuts.title')}
titleId="keyboard-shortcuts-title"
subtitle={t('shortcuts.subtitle')}
maxWidthClassName="max-w-lg"
contentClassName="px-5 py-4">
<div className="max-h-[60vh] overflow-y-auto pr-1" data-testid="keyboard-shortcuts-list">
<ShortcutsList variant="modal" />
</div>
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-stone-400 dark:text-neutral-500">
{t('shortcuts.openHint')}
<Kbd shortcut="?" />
</p>
</ModalShell>
);
}
@@ -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(<ShortcutsList />);
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(<ShortcutsList />);
expect(screen.getByText('Has Shortcut')).toBeInTheDocument();
expect(screen.queryByText('No Shortcut')).not.toBeInTheDocument();
});
it('renders an empty state when no shortcuts are active', () => {
render(<ShortcutsList />);
expect(screen.getByText(/no keyboard shortcuts/i)).toBeInTheDocument();
});
});
@@ -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<string, RegisteredAction[]>();
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 (
<p className="px-1 py-6 text-center text-sm text-stone-500 dark:text-neutral-400">
{t('shortcuts.empty')}
</p>
);
}
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 (
<div className={variant === 'panel' ? 'space-y-5' : 'space-y-4'}>
{groups.map(({ group, items }) => (
<section key={group}>
<h3 className="px-1 pb-1 text-xs font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
{GROUP_LABEL_KEYS[group] ? t(GROUP_LABEL_KEYS[group]) : group}
</h3>
<div
className={
variant === 'panel'
? 'overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 divide-y divide-neutral-100 dark:divide-neutral-800'
: 'divide-y divide-stone-100 dark:divide-neutral-800'
}>
{items.map(action => (
<div key={action.id} className={rowClass}>
<span className="min-w-0 flex items-center gap-3 truncate text-sm text-stone-700 dark:text-neutral-200">
{action.icon ? (
<action.icon className="w-4 h-4 flex-shrink-0 text-stone-400 dark:text-neutral-500" />
) : null}
<span className="truncate">
{action.labelKey ? t(action.labelKey) : action.label}
</span>
</span>
{action.shortcut ? <Kbd shortcut={action.shortcut} size="md" /> : null}
</div>
))}
</div>
</section>
))}
</div>
);
}
@@ -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> = {}): 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();
});
});
+226 -21
View File
@@ -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<string, string> = {
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);
});
}
+3
View File
@@ -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 }>;
+20
View File
@@ -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': 'قطع الاتصال',
+20
View File
@@ -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': 'সংযোগ বিচ্ছিন্ন করুন',
+20
View File
@@ -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',
+20
View File
@@ -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',
+20
View File
@@ -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',
+20
View File
@@ -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 lapplication.',
'shortcuts.empty': 'Aucun raccourci clavier nest 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 à laccueil',
'shortcuts.action.chat': 'Aller au chat',
'shortcuts.action.knowledge': 'Aller à Connaissances et mémoire',
'shortcuts.action.connections': 'Aller aux Connexions',
'shortcuts.action.activity': 'Aller à lactivité',
'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',
+20
View File
@@ -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': 'डिस्कनेक्ट करें',
+20
View File
@@ -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',
+20
View File
@@ -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 dellapp.',
'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',
+20
View File
@@ -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': '연결 끊기',
+20
View File
@@ -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',
+20
View File
@@ -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',
+20
View File
@@ -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': 'Отключиться',
+20
View File
@@ -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': '断开连接',
+28 -16
View File
@@ -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<ReadonlySet<string>>(
() => 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);
+20
View File
@@ -313,6 +313,13 @@ export type QueueMode = 'interrupt' | 'steer' | 'followup' | 'collect' | 'parall
interface ChatRuntimeState {
inferenceStatusByThread: Record<string, InferenceStatus>;
streamingAssistantByThread: Record<string, StreamingAssistantState>;
/**
* 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<string, true>;
/**
* 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,
+2
View File
@@ -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<string, unknown>) {
@@ -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<void> {
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');
});
});
+8
View File
@@ -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