mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(shortcuts): global keyboard shortcuts + help directory (#4126)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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': 'قطع الاتصال',
|
||||
|
||||
@@ -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': 'সংযোগ বিচ্ছিন্ন করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'डिस्कनेक्ट करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '연결 끊기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Отключиться',
|
||||
|
||||
@@ -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': '断开连接',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user