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 { Command } from 'cmdk';
|
||||||
import { useMemo, useSyncExternalStore } from 'react';
|
import { useMemo, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { GROUP_LABEL_KEYS } from '../../lib/commands/globalActions';
|
||||||
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
||||||
import { registry } from '../../lib/commands/registry';
|
import { registry } from '../../lib/commands/registry';
|
||||||
import type { RegisteredAction } from '../../lib/commands/types';
|
import type { RegisteredAction } from '../../lib/commands/types';
|
||||||
@@ -81,34 +82,40 @@ export default function CommandPalette({ open, onOpenChange }: Props) {
|
|||||||
{groups.map(([groupName, items]) => (
|
{groups.map(([groupName, items]) => (
|
||||||
<Command.Group
|
<Command.Group
|
||||||
key={groupName}
|
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">
|
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 => (
|
{items.map(action => {
|
||||||
<Command.Item
|
const label = action.labelKey ? t(action.labelKey) : action.label;
|
||||||
key={action.id}
|
return (
|
||||||
value={action.id}
|
<Command.Item
|
||||||
keywords={[action.label, ...(action.keywords ?? [])]}
|
key={action.id}
|
||||||
onSelect={() => runAction(action)}
|
value={action.id}
|
||||||
className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
|
keywords={[label, action.label, ...(action.keywords ?? [])]}
|
||||||
{action.icon ? (
|
onSelect={() => runAction(action)}
|
||||||
<action.icon className="w-4 h-4 text-cmd-foreground-muted" />
|
className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
|
||||||
) : (
|
{action.icon ? (
|
||||||
<span className="w-4" />
|
<action.icon className="w-4 h-4 text-cmd-foreground-muted" />
|
||||||
)}
|
) : (
|
||||||
<span className="flex-1 truncate">{action.label}</span>
|
<span className="w-4" />
|
||||||
{action.hint && (
|
)}
|
||||||
<span className="text-xs text-cmd-foreground-muted truncate">
|
<span className="flex-1 truncate">{label}</span>
|
||||||
{action.hint}
|
{action.hint && (
|
||||||
</span>
|
<span className="text-xs text-cmd-foreground-muted truncate">
|
||||||
)}
|
{action.hint}
|
||||||
{action.shortcut && <Kbd shortcut={action.shortcut} />}
|
</span>
|
||||||
</Command.Item>
|
)}
|
||||||
))}
|
{action.shortcut && <Kbd shortcut={action.shortcut} />}
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Command.Group>
|
</Command.Group>
|
||||||
))}
|
))}
|
||||||
</Command.List>
|
</Command.List>
|
||||||
<div className="px-4 py-2 border-t border-cmd-border text-xs text-cmd-foreground-muted">
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-cmd-border text-xs text-cmd-foreground-muted">
|
||||||
{t('commandPalette.shortcutHint')}
|
{/* 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>
|
</div>
|
||||||
</Command>
|
</Command>
|
||||||
</Dialog.Content>
|
</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 { 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 { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
||||||
import { registry } from '../../lib/commands/registry';
|
import { registry } from '../../lib/commands/registry';
|
||||||
import { ScopeContext } from '../../lib/commands/ScopeContext';
|
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';
|
import CommandPalette from './CommandPalette';
|
||||||
|
|
||||||
let instanceCount = 0;
|
let instanceCount = 0;
|
||||||
@@ -15,9 +20,32 @@ interface Props {
|
|||||||
|
|
||||||
export default function CommandProvider({ children }: Props) {
|
export default function CommandProvider({ children }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const newChat = useNewChat();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
const [globalFrame, setGlobalFrame] = useState<symbol | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
instanceCount += 1;
|
instanceCount += 1;
|
||||||
if (instanceCount > 1) {
|
if (instanceCount > 1) {
|
||||||
@@ -26,25 +54,27 @@ export default function CommandProvider({ children }: Props) {
|
|||||||
hotkeyManager.init();
|
hotkeyManager.init();
|
||||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||||
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
||||||
const disposeGlobalActions = registerGlobalActions(navigate, frame);
|
|
||||||
const paletteBinding = hotkeyManager.bind(frame, {
|
// Stable indirection: registered handlers always call through to the latest
|
||||||
shortcut: 'mod+k',
|
// ref value, so closures captured at registration time stay current.
|
||||||
handler: () => {
|
const stableHandlers: GlobalActionHandlers = {
|
||||||
setPaletteOpen(o => !o);
|
navigate: path => handlersRef.current.navigate(path),
|
||||||
},
|
newChat: () => handlersRef.current.newChat(),
|
||||||
allowInInput: true,
|
toggleSidebar: () => handlersRef.current.toggleSidebar(),
|
||||||
id: 'meta.open-palette',
|
openPalette: () => handlersRef.current.openPalette(),
|
||||||
});
|
openShortcuts: () => handlersRef.current.openShortcuts(),
|
||||||
|
};
|
||||||
|
const disposeGlobalActions = registerGlobalActions(stableHandlers, frame);
|
||||||
setGlobalFrame(frame);
|
setGlobalFrame(frame);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposeGlobalActions();
|
disposeGlobalActions();
|
||||||
hotkeyManager.unbind(frame, paletteBinding);
|
|
||||||
hotkeyManager.popFrame(frame);
|
hotkeyManager.popFrame(frame);
|
||||||
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
||||||
instanceCount -= 1;
|
instanceCount -= 1;
|
||||||
};
|
};
|
||||||
}, [navigate]);
|
// Mount-once: handlers are reached via `handlersRef`, not deps.
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!globalFrame) return;
|
if (!globalFrame) return;
|
||||||
@@ -61,6 +91,7 @@ export default function CommandProvider({ children }: Props) {
|
|||||||
<ScopeContext.Provider value={value}>
|
<ScopeContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
|
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
</ScopeContext.Provider>
|
</ScopeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ describe('CommandPalette', () => {
|
|||||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer hint', () => {
|
it('renders footer hint advertising the in-palette shortcuts key', () => {
|
||||||
render(<Harness open={true} onOpenChange={() => {}} />);
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
|
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
|
||||||
import { pressKey } from '../../../test/commandTestUtils';
|
import { pressKey } from '../../../test/commandTestUtils';
|
||||||
|
import { renderWithProviders } from '../../../test/test-utils';
|
||||||
import CommandProvider from '../CommandProvider';
|
import CommandProvider from '../CommandProvider';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hotkeyManager.teardown();
|
hotkeyManager.teardown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<CommandProvider>
|
||||||
|
<div>child</div>
|
||||||
|
</CommandProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('CommandProvider', () => {
|
describe('CommandProvider', () => {
|
||||||
it('mounts and registers seed actions', () => {
|
it('mounts and registers seed actions', () => {
|
||||||
render(
|
renderProvider();
|
||||||
<MemoryRouter>
|
|
||||||
<CommandProvider>
|
|
||||||
<div>child</div>
|
|
||||||
</CommandProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
expect(screen.getByText('child')).toBeInTheDocument();
|
expect(screen.getByText('child')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens palette on mod+K', async () => {
|
it('opens palette on mod+K', async () => {
|
||||||
render(
|
renderProvider();
|
||||||
<MemoryRouter>
|
|
||||||
<CommandProvider>
|
|
||||||
<div>child</div>
|
|
||||||
</CommandProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
act(() => {
|
act(() => {
|
||||||
pressKey({ key: 'k', mod: true });
|
pressKey({ key: 'k', mod: true });
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('opens help on ? (disabled — help overlay temporarily off)', async () => {
|
it('opens help on ?', async () => {
|
||||||
render(
|
renderProvider();
|
||||||
<MemoryRouter>
|
|
||||||
<CommandProvider>
|
|
||||||
<div>child</div>
|
|
||||||
</CommandProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
act(() => {
|
act(() => {
|
||||||
pressKey({ key: '?' });
|
pressKey({ key: '?' });
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
|
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 () => {
|
it('Esc closes open overlay', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
renderProvider();
|
||||||
<MemoryRouter>
|
|
||||||
<CommandProvider>
|
|
||||||
<div>child</div>
|
|
||||||
</CommandProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
act(() => {
|
act(() => {
|
||||||
pressKey({ key: 'k', mod: true });
|
pressKey({ key: 'k', mod: true });
|
||||||
});
|
});
|
||||||
@@ -68,20 +60,15 @@ describe('CommandProvider', () => {
|
|||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('palette and help mutually exclusive (disabled — help overlay temporarily off)', async () => {
|
it('palette and help are mutually exclusive', async () => {
|
||||||
render(
|
renderProvider();
|
||||||
<MemoryRouter>
|
|
||||||
<CommandProvider>
|
|
||||||
<div>child</div>
|
|
||||||
</CommandProvider>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
act(() => {
|
act(() => {
|
||||||
pressKey({ key: 'k', mod: true });
|
pressKey({ key: 'k', mod: true });
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
||||||
|
// Opening the shortcuts directory dismisses the palette.
|
||||||
act(() => {
|
act(() => {
|
||||||
pressKey({ key: '?' });
|
pressKey({ key: '/', mod: true });
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
|
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('dialog', { name: /Command palette/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: /Command palette/i })).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { registry } from '../../../lib/commands/registry';
|
||||||
import { renderWithProviders } from '../../../test/test-utils';
|
import { renderWithProviders } from '../../../test/test-utils';
|
||||||
import CollapsedNavRail from './CollapsedNavRail';
|
import CollapsedNavRail from './CollapsedNavRail';
|
||||||
|
|
||||||
@@ -19,11 +20,11 @@ vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
|
|||||||
describe('CollapsedNavRail', () => {
|
describe('CollapsedNavRail', () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
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'] });
|
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'nav.home',
|
'nav.home',
|
||||||
'nav.wallet',
|
'shortcuts.title',
|
||||||
'nav.chat',
|
'nav.chat',
|
||||||
'nav.human',
|
'nav.human',
|
||||||
'nav.brain',
|
'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'] });
|
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
|
fireEvent.click(screen.getByRole('button', { name: 'shortcuts.title' }));
|
||||||
// Carries the backgroundLocation so the desktop Settings modal renders over
|
expect(runAction).toHaveBeenCalledWith('meta.keyboard-shortcuts');
|
||||||
// the page it was opened from.
|
runAction.mockRestore();
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
|
|
||||||
state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wallet button has correct data-analytics-id', () => {
|
it('shortcuts button has correct data-analytics-id', () => {
|
||||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
|
expect(screen.getByRole('button', { name: 'shortcuts.title' })).toHaveAttribute(
|
||||||
'data-analytics-id',
|
'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', () => {
|
it('navigates to a destination path when its icon is clicked', () => {
|
||||||
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'nav.brain' }));
|
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'] });
|
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/settings/wallet-balances'] });
|
||||||
expect(screen.getByRole('button', { name: 'nav.settings' })).not.toHaveAttribute(
|
expect(screen.getByRole('button', { name: 'nav.settings' })).toHaveAttribute(
|
||||||
'aria-current'
|
|
||||||
);
|
|
||||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
'aria-current',
|
||||||
'page'
|
'page'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useMemo } from 'react';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { NAV_TABS, type NavTab } from '../../../config/navConfig';
|
import { NAV_TABS, type NavTab } from '../../../config/navConfig';
|
||||||
|
import { registry } from '../../../lib/commands/registry';
|
||||||
import { useT } from '../../../lib/i18n/I18nContext';
|
import { useT } from '../../../lib/i18n/I18nContext';
|
||||||
import { trackEvent } from '../../../services/analytics';
|
import { trackEvent } from '../../../services/analytics';
|
||||||
import { useAppSelector } from '../../../store/hooks';
|
import { useAppSelector } from '../../../store/hooks';
|
||||||
import { selectUnreadCount } from '../../../store/notificationSlice';
|
import { selectUnreadCount } from '../../../store/notificationSlice';
|
||||||
import { settingsNavState } from '../../settings/modal/settingsOverlay';
|
|
||||||
import { Tooltip } from '../../ui';
|
import { Tooltip } from '../../ui';
|
||||||
import { NavIcon } from './navIcons';
|
import { NavIcon } from './navIcons';
|
||||||
import { useHomeNav } from './useHomeNav';
|
import { useHomeNav } from './useHomeNav';
|
||||||
@@ -51,11 +51,7 @@ export default function CollapsedNavRail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const homeActive = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
|
const homeActive = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
|
||||||
// Settings defers to the more-specific Wallet rail item so the wallet sub-page
|
const settingsActive = matchActive('/settings', location.pathname);
|
||||||
// doesn't light up both icons at once.
|
|
||||||
const settingsActive =
|
|
||||||
matchActive('/settings', location.pathname) &&
|
|
||||||
!matchActive('/settings/wallet-balances', location.pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-col items-center gap-2" aria-label={t('nav.home')}>
|
<nav className="flex flex-col items-center gap-2" aria-label={t('nav.home')}>
|
||||||
@@ -75,22 +71,16 @@ export default function CollapsedNavRail() {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
|
{/* Keyboard shortcuts — mirrors SidebarHeader's shortcuts button for the
|
||||||
<Tooltip label={t('nav.wallet')}>
|
collapsed state. Opens the help directory (also reachable via ? / ⌘/). */}
|
||||||
|
<Tooltip label={t('shortcuts.title')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/settings/wallet-balances', settingsNavState(location))}
|
onClick={() => registry.runAction('meta.keyboard-shortcuts')}
|
||||||
aria-label={t('nav.wallet')}
|
aria-label={t('shortcuts.title')}
|
||||||
aria-current={
|
data-analytics-id="collapsed-rail-shortcuts"
|
||||||
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
|
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" />
|
||||||
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" />
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { Tooltip } from '../../ui';
|
|||||||
import CollapsedNavRail from './CollapsedNavRail';
|
import CollapsedNavRail from './CollapsedNavRail';
|
||||||
|
|
||||||
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
|
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
|
||||||
// fresh with the sidebar visible by default.
|
// fresh with the sidebar visible by default. Exported so the global command
|
||||||
const LAYOUT_ID = 'app-shell';
|
// 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 DEFAULT_WIDTH = 224;
|
||||||
const MIN_WIDTH = 188;
|
const MIN_WIDTH = 188;
|
||||||
const MAX_WIDTH = 420;
|
const MAX_WIDTH = 420;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { registry } from '../../../lib/commands/registry';
|
||||||
import { renderWithProviders } from '../../../test/test-utils';
|
import { renderWithProviders } from '../../../test/test-utils';
|
||||||
import SidebarHeader from './SidebarHeader';
|
import SidebarHeader from './SidebarHeader';
|
||||||
|
|
||||||
@@ -20,39 +21,37 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string)
|
|||||||
describe('SidebarHeader', () => {
|
describe('SidebarHeader', () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
it('renders Home, Wallet, Settings, and Collapse buttons', () => {
|
it('renders Home, Keyboard Shortcuts, Settings, and Collapse buttons', () => {
|
||||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||||
expect(screen.getByRole('button', { name: 'nav.home' })).toBeInTheDocument();
|
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: 'nav.settings' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'chat.hideSidebar' })).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'] });
|
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
|
fireEvent.click(screen.getByRole('button', { name: 'shortcuts.title' }));
|
||||||
// Carries the backgroundLocation so the desktop Settings modal renders over
|
expect(runAction).toHaveBeenCalledWith('meta.keyboard-shortcuts');
|
||||||
// the page it was opened from.
|
runAction.mockRestore();
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
|
|
||||||
state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wallet button has correct data-analytics-id', () => {
|
it('shortcuts button has correct data-analytics-id', () => {
|
||||||
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||||
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
|
expect(screen.getByRole('button', { name: 'shortcuts.title' })).toHaveAttribute(
|
||||||
'data-analytics-id',
|
'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'] });
|
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
|
||||||
const btn = screen.getByRole('button', { name: 'nav.wallet' });
|
const btn = screen.getByRole('button', { name: 'shortcuts.title' });
|
||||||
expect(btn).toHaveAttribute('aria-label', 'nav.wallet');
|
expect(btn).toHaveAttribute('aria-label', 'shortcuts.title');
|
||||||
// The styled <Tooltip> wrapper re-applies a native `title` fallback so the
|
// 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.
|
// 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', () => {
|
it('settings button navigates to /settings', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { registry } from '../../../lib/commands/registry';
|
||||||
import { useT } from '../../../lib/i18n/I18nContext';
|
import { useT } from '../../../lib/i18n/I18nContext';
|
||||||
import { settingsNavState } from '../../settings/modal/settingsOverlay';
|
import { settingsNavState } from '../../settings/modal/settingsOverlay';
|
||||||
import { Tooltip } from '../../ui';
|
import { Tooltip } from '../../ui';
|
||||||
@@ -42,20 +43,26 @@ export default function SidebarHeader() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Wallet shortcut — one-click access to wallet balances. */}
|
{/* Keyboard shortcuts — one-click open of the help directory (also ? / ⌘/). */}
|
||||||
<Tooltip label={t('nav.wallet')}>
|
<Tooltip label={t('shortcuts.title')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/settings/wallet-balances', settingsNavState(location))}
|
onClick={() => registry.runAction('meta.keyboard-shortcuts')}
|
||||||
className={ICON_BTN}
|
className={ICON_BTN}
|
||||||
data-analytics-id="sidebar-header-wallet"
|
data-analytics-id="sidebar-header-shortcuts"
|
||||||
aria-label={t('nav.wallet')}>
|
aria-label={t('shortcuts.title')}>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={1.8}
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -143,6 +143,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</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:
|
default:
|
||||||
return null;
|
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')),
|
'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')),
|
about: icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')),
|
||||||
usage: icon(
|
usage: icon(
|
||||||
stroke(
|
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 DevWorkflowPanel from './panels/DevWorkflowPanel';
|
||||||
import EventLogPanel from './panels/EventLogPanel';
|
import EventLogPanel from './panels/EventLogPanel';
|
||||||
import IntegrationsPanel from './panels/IntegrationsPanel';
|
import IntegrationsPanel from './panels/IntegrationsPanel';
|
||||||
|
import KeyboardShortcutsPanel from './panels/KeyboardShortcutsPanel';
|
||||||
import LocalModelDebugPanel from './panels/LocalModelDebugPanel';
|
import LocalModelDebugPanel from './panels/LocalModelDebugPanel';
|
||||||
import McpServerPanel from './panels/McpServerPanel';
|
import McpServerPanel from './panels/McpServerPanel';
|
||||||
import MeetingSettingsPanel from './panels/MeetingSettingsPanel';
|
import MeetingSettingsPanel from './panels/MeetingSettingsPanel';
|
||||||
@@ -150,6 +151,7 @@ export function settingsRouteElements(): ReactNode {
|
|||||||
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
|
||||||
|
|
||||||
{/* ── System ──────────────────────────────────────────────── */}
|
{/* ── System ──────────────────────────────────────────────── */}
|
||||||
|
<Route path="keyboard-shortcuts" element={wrapSettingsPage(<KeyboardShortcutsPanel />)} />
|
||||||
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
|
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
|
||||||
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
|
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,15 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
|
|||||||
// to /settings/notifications and /settings/wallet-balances.
|
// to /settings/notifications and /settings/wallet-balances.
|
||||||
|
|
||||||
// --- About ---
|
// --- 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',
|
id: 'about',
|
||||||
titleKey: 'settings.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 { 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 { hotkeyManager } from '../hotkeyManager';
|
||||||
import { registry } from '../registry';
|
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(() => {
|
beforeEach(() => {
|
||||||
hotkeyManager.teardown();
|
hotkeyManager.teardown();
|
||||||
registry.reset();
|
registry.reset();
|
||||||
@@ -17,10 +27,9 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('registerGlobalActions', () => {
|
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 frame = hotkeyManager.pushFrame('global', 'root');
|
||||||
const navigate = vi.fn() as unknown as NavigateFunction;
|
registerGlobalActions(makeHandlers(), frame);
|
||||||
registerGlobalActions(navigate, frame);
|
|
||||||
const ids = [
|
const ids = [
|
||||||
'nav.home',
|
'nav.home',
|
||||||
'nav.chat',
|
'nav.chat',
|
||||||
@@ -28,6 +37,10 @@ describe('registerGlobalActions', () => {
|
|||||||
'nav.skills',
|
'nav.skills',
|
||||||
'nav.activity',
|
'nav.activity',
|
||||||
'nav.settings',
|
'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);
|
for (const id of ids) expect(registry.getAction(id)?.id).toBe(id);
|
||||||
expect(registry.getAction('help.show')).toBeUndefined();
|
expect(registry.getAction('help.show')).toBeUndefined();
|
||||||
@@ -36,13 +49,70 @@ describe('registerGlobalActions', () => {
|
|||||||
it('nav.home handler calls navigate("/home")', () => {
|
it('nav.home handler calls navigate("/home")', () => {
|
||||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||||
const navigate = vi.fn();
|
const navigate = vi.fn();
|
||||||
registerGlobalActions(navigate as unknown as NavigateFunction, frame);
|
registerGlobalActions(makeHandlers({ navigate }), frame);
|
||||||
registry.setActiveStack([frame]);
|
registry.setActiveStack([frame]);
|
||||||
registry.runAction('nav.home');
|
registry.runAction('nav.home');
|
||||||
expect(navigate).toHaveBeenCalledWith('/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', () => {
|
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 { hotkeyManager } from './hotkeyManager';
|
||||||
import { registry } from './registry';
|
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,
|
* i18n keys for the group headings surfaced in the command palette / shortcuts
|
||||||
globalScopeSymbol: symbol
|
* directory. Grouping/ordering keys off the stable English `group` string; the
|
||||||
): () => void {
|
* 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) => () => {
|
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',
|
id: 'nav.home',
|
||||||
label: 'Go Home',
|
label: 'Go Home',
|
||||||
|
labelKey: 'shortcuts.action.home',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
shortcut: 'mod+1',
|
shortcut: `${NAV_MOD}+1`,
|
||||||
handler: nav('/home'),
|
handler: nav('/home'),
|
||||||
keywords: ['dashboard'],
|
keywords: ['dashboard'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav.chat',
|
id: 'nav.chat',
|
||||||
label: 'Go to Chat',
|
label: 'Go to Chat',
|
||||||
|
labelKey: 'shortcuts.action.chat',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
shortcut: 'mod+2',
|
shortcut: `${NAV_MOD}+2`,
|
||||||
handler: nav('/chat'),
|
handler: nav('/chat'),
|
||||||
keywords: ['conversations', 'messages', 'inbox'],
|
keywords: ['conversations', 'messages', 'inbox'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav.intelligence',
|
id: 'nav.intelligence',
|
||||||
label: 'Go to Knowledge & Memory',
|
label: 'Go to Knowledge & Memory',
|
||||||
|
labelKey: 'shortcuts.action.knowledge',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
shortcut: 'mod+3',
|
shortcut: `${NAV_MOD}+3`,
|
||||||
handler: nav('/settings/intelligence'),
|
handler: nav('/settings/intelligence'),
|
||||||
keywords: ['memory', 'knowledge', 'intelligence'],
|
keywords: ['memory', 'knowledge', 'intelligence'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav.skills',
|
id: 'nav.skills',
|
||||||
label: 'Go to Connections',
|
label: 'Go to Connections',
|
||||||
|
labelKey: 'shortcuts.action.connections',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
shortcut: 'mod+4',
|
shortcut: `${NAV_MOD}+4`,
|
||||||
handler: nav('/connections'),
|
handler: nav('/connections'),
|
||||||
keywords: ['plugins', 'tools', 'connections', 'apps', 'skills'],
|
keywords: ['plugins', 'tools', 'connections', 'apps', 'skills'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav.activity',
|
id: 'nav.activity',
|
||||||
label: 'Go to Activity',
|
label: 'Go to Activity',
|
||||||
|
labelKey: 'shortcuts.action.activity',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
shortcut: 'mod+5',
|
shortcut: `${NAV_MOD}+5`,
|
||||||
handler: nav('/activity'),
|
handler: nav('/activity'),
|
||||||
keywords: ['tasks', 'automations', 'alerts', 'background'],
|
keywords: ['tasks', 'automations', 'alerts', 'background'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav.settings',
|
id: 'nav.settings',
|
||||||
label: 'Open Settings',
|
label: 'Open Settings',
|
||||||
|
labelKey: 'shortcuts.action.settings',
|
||||||
group: 'Navigation',
|
group: 'Navigation',
|
||||||
|
// Settings keeps the conventional ⌘, (mod) — it isn't a numbered tab.
|
||||||
shortcut: 'mod+,',
|
shortcut: 'mod+,',
|
||||||
handler: nav('/settings'),
|
handler: nav('/settings'),
|
||||||
keywords: ['preferences', 'config'],
|
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> = [];
|
const disposers: Array<() => void> = [];
|
||||||
for (const a of actions) {
|
for (const a of actions) {
|
||||||
const disposeRegistry = registry.registerAction(a, globalScopeSymbol);
|
// `register: false` actions are still bound (wired) but stay out of the
|
||||||
const bindingSym = hotkeyManager.bind(globalScopeSymbol, {
|
// palette / help directory.
|
||||||
shortcut: a.shortcut,
|
const disposeRegistry =
|
||||||
handler: a.handler,
|
a.register === false
|
||||||
id: a.id,
|
? () => {}
|
||||||
});
|
: 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(() => {
|
disposers.push(() => {
|
||||||
disposeRegistry();
|
disposeRegistry();
|
||||||
hotkeyManager.unbind(globalScopeSymbol, bindingSym);
|
for (const sym of bindingSyms) hotkeyManager.unbind(globalScopeSymbol, sym);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export interface ParsedShortcut {
|
|||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** English fallback label (used for cmdk search + when no `labelKey`). */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** i18n key resolved through `useT()` at display time; falls back to `label`. */
|
||||||
|
labelKey?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
icon?: ComponentType<{ className?: string }>;
|
icon?: ComponentType<{ className?: string }>;
|
||||||
|
|||||||
@@ -2896,6 +2896,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'اضغط ؟ لعرض جميع الاختصارات',
|
'commandPalette.shortcutHint': 'اضغط ؟ لعرض جميع الاختصارات',
|
||||||
'commandPalette.title': 'لوحة الأوامر',
|
'commandPalette.title': 'لوحة الأوامر',
|
||||||
'kbd.ariaLabel': 'اختصار لوحة المفاتيح: {shortcut}',
|
'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.connectedTo': 'متصل بـ',
|
||||||
'iosMascot.defaultPairedLabel': 'سطح المكتب',
|
'iosMascot.defaultPairedLabel': 'سطح المكتب',
|
||||||
'iosMascot.disconnect': 'قطع الاتصال',
|
'iosMascot.disconnect': 'قطع الاتصال',
|
||||||
|
|||||||
@@ -2957,6 +2957,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'সব শর্টকাটের জন্য ? চাপুন',
|
'commandPalette.shortcutHint': 'সব শর্টকাটের জন্য ? চাপুন',
|
||||||
'commandPalette.title': 'কমান্ড প্যালেট',
|
'commandPalette.title': 'কমান্ড প্যালেট',
|
||||||
'kbd.ariaLabel': 'কীবোর্ড শর্টকাট: {shortcut}',
|
'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.connectedTo': '[[I18N_SEP_92731]] এর সাথে সংযুক্ত',
|
||||||
'iosMascot.defaultPairedLabel': 'ডেস্কটপ',
|
'iosMascot.defaultPairedLabel': 'ডেস্কটপ',
|
||||||
'iosMascot.disconnect': 'সংযোগ বিচ্ছিন্ন করুন',
|
'iosMascot.disconnect': 'সংযোগ বিচ্ছিন্ন করুন',
|
||||||
|
|||||||
@@ -3027,6 +3027,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Drücke ? für alle Verknüpfungen',
|
'commandPalette.shortcutHint': 'Drücke ? für alle Verknüpfungen',
|
||||||
'commandPalette.title': 'Befehlspalette',
|
'commandPalette.title': 'Befehlspalette',
|
||||||
'kbd.ariaLabel': 'Tastenkombination: {shortcut}',
|
'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.connectedTo': 'Verbunden mit',
|
||||||
'iosMascot.defaultPairedLabel': 'Desktop',
|
'iosMascot.defaultPairedLabel': 'Desktop',
|
||||||
'iosMascot.disconnect': 'Trennen',
|
'iosMascot.disconnect': 'Trennen',
|
||||||
|
|||||||
@@ -3493,6 +3493,26 @@ const en: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Press ? for all shortcuts',
|
'commandPalette.shortcutHint': 'Press ? for all shortcuts',
|
||||||
'commandPalette.title': 'Command palette',
|
'commandPalette.title': 'Command palette',
|
||||||
'kbd.ariaLabel': 'Keyboard shortcut: {shortcut}',
|
'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.connectedTo': 'Connected to',
|
||||||
'iosMascot.defaultPairedLabel': 'Desktop',
|
'iosMascot.defaultPairedLabel': 'Desktop',
|
||||||
'iosMascot.disconnect': 'Disconnect',
|
'iosMascot.disconnect': 'Disconnect',
|
||||||
|
|||||||
@@ -3006,6 +3006,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Pulsa ? para ver todos los atajos',
|
'commandPalette.shortcutHint': 'Pulsa ? para ver todos los atajos',
|
||||||
'commandPalette.title': 'Paleta de comandos',
|
'commandPalette.title': 'Paleta de comandos',
|
||||||
'kbd.ariaLabel': 'Atajo de teclado: {shortcut}',
|
'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.connectedTo': 'Conectado a',
|
||||||
'iosMascot.defaultPairedLabel': 'Escritorio',
|
'iosMascot.defaultPairedLabel': 'Escritorio',
|
||||||
'iosMascot.disconnect': 'Desconectar',
|
'iosMascot.disconnect': 'Desconectar',
|
||||||
|
|||||||
@@ -3021,6 +3021,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Appuyez sur ? pour tous les raccourcis',
|
'commandPalette.shortcutHint': 'Appuyez sur ? pour tous les raccourcis',
|
||||||
'commandPalette.title': 'Palette de commandes',
|
'commandPalette.title': 'Palette de commandes',
|
||||||
'kbd.ariaLabel': 'Raccourci clavier : {shortcut}',
|
'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.connectedTo': 'Connecté à',
|
||||||
'iosMascot.defaultPairedLabel': 'Bureau',
|
'iosMascot.defaultPairedLabel': 'Bureau',
|
||||||
'iosMascot.disconnect': 'Déconnecter',
|
'iosMascot.disconnect': 'Déconnecter',
|
||||||
|
|||||||
@@ -2958,6 +2958,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'सभी शॉर्टकट के लिए ? दबाएँ',
|
'commandPalette.shortcutHint': 'सभी शॉर्टकट के लिए ? दबाएँ',
|
||||||
'commandPalette.title': 'कमांड पैलेट',
|
'commandPalette.title': 'कमांड पैलेट',
|
||||||
'kbd.ariaLabel': 'कीबोर्ड शॉर्टकट: {shortcut}',
|
'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.connectedTo': 'से जुड़ा हुआ है',
|
||||||
'iosMascot.defaultPairedLabel': 'डेस्कटॉप',
|
'iosMascot.defaultPairedLabel': 'डेस्कटॉप',
|
||||||
'iosMascot.disconnect': 'डिस्कनेक्ट करें',
|
'iosMascot.disconnect': 'डिस्कनेक्ट करें',
|
||||||
|
|||||||
@@ -2962,6 +2962,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Tekan ? untuk semua pintasan',
|
'commandPalette.shortcutHint': 'Tekan ? untuk semua pintasan',
|
||||||
'commandPalette.title': 'Palet perintah',
|
'commandPalette.title': 'Palet perintah',
|
||||||
'kbd.ariaLabel': 'Pintasan keyboard: {shortcut}',
|
'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.connectedTo': 'Tersambung ke',
|
||||||
'iosMascot.defaultPairedLabel': 'Desktop',
|
'iosMascot.defaultPairedLabel': 'Desktop',
|
||||||
'iosMascot.disconnect': 'Putuskan sambungan',
|
'iosMascot.disconnect': 'Putuskan sambungan',
|
||||||
|
|||||||
@@ -2999,6 +2999,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Premi ? per tutte le scorciatoie',
|
'commandPalette.shortcutHint': 'Premi ? per tutte le scorciatoie',
|
||||||
'commandPalette.title': 'Palette dei comandi',
|
'commandPalette.title': 'Palette dei comandi',
|
||||||
'kbd.ariaLabel': 'Scorciatoia da tastiera: {shortcut}',
|
'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.connectedTo': 'Connesso a',
|
||||||
'iosMascot.defaultPairedLabel': 'Desktop',
|
'iosMascot.defaultPairedLabel': 'Desktop',
|
||||||
'iosMascot.disconnect': 'Disconnetti',
|
'iosMascot.disconnect': 'Disconnetti',
|
||||||
|
|||||||
@@ -2931,6 +2931,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': '모든 단축키를 보려면 ?를 누르세요',
|
'commandPalette.shortcutHint': '모든 단축키를 보려면 ?를 누르세요',
|
||||||
'commandPalette.title': '명령 팔레트',
|
'commandPalette.title': '명령 팔레트',
|
||||||
'kbd.ariaLabel': '키보드 단축키: {shortcut}',
|
'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.connectedTo': '연결됨',
|
||||||
'iosMascot.defaultPairedLabel': '데스크탑',
|
'iosMascot.defaultPairedLabel': '데스크탑',
|
||||||
'iosMascot.disconnect': '연결 끊기',
|
'iosMascot.disconnect': '연결 끊기',
|
||||||
|
|||||||
@@ -2987,6 +2987,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Wciśnij ?, aby zobaczyć wszystkie skróty',
|
'commandPalette.shortcutHint': 'Wciśnij ?, aby zobaczyć wszystkie skróty',
|
||||||
'commandPalette.title': 'Paleta poleceń',
|
'commandPalette.title': 'Paleta poleceń',
|
||||||
'kbd.ariaLabel': 'Skrót klawiszowy: {shortcut}',
|
'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.connectedTo': 'Połączono z',
|
||||||
'iosMascot.defaultPairedLabel': 'Komputer',
|
'iosMascot.defaultPairedLabel': 'Komputer',
|
||||||
'iosMascot.disconnect': 'Rozłącz',
|
'iosMascot.disconnect': 'Rozłącz',
|
||||||
|
|||||||
@@ -3004,6 +3004,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Pressione ? para ver todos os atalhos',
|
'commandPalette.shortcutHint': 'Pressione ? para ver todos os atalhos',
|
||||||
'commandPalette.title': 'Paleta de comandos',
|
'commandPalette.title': 'Paleta de comandos',
|
||||||
'kbd.ariaLabel': 'Atalho de teclado: {shortcut}',
|
'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.connectedTo': 'Conectado a',
|
||||||
'iosMascot.defaultPairedLabel': 'Desktop',
|
'iosMascot.defaultPairedLabel': 'Desktop',
|
||||||
'iosMascot.disconnect': 'Desconectar',
|
'iosMascot.disconnect': 'Desconectar',
|
||||||
|
|||||||
@@ -2979,6 +2979,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': 'Нажмите ?, чтобы увидеть все сочетания',
|
'commandPalette.shortcutHint': 'Нажмите ?, чтобы увидеть все сочетания',
|
||||||
'commandPalette.title': 'Палитра команд',
|
'commandPalette.title': 'Палитра команд',
|
||||||
'kbd.ariaLabel': 'Сочетание клавиш: {shortcut}',
|
'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.connectedTo': 'Подключен к',
|
||||||
'iosMascot.defaultPairedLabel': 'Рабочий стол',
|
'iosMascot.defaultPairedLabel': 'Рабочий стол',
|
||||||
'iosMascot.disconnect': 'Отключиться',
|
'iosMascot.disconnect': 'Отключиться',
|
||||||
|
|||||||
@@ -2817,6 +2817,26 @@ const messages: TranslationMap = {
|
|||||||
'commandPalette.shortcutHint': '按 ? 查看所有快捷键',
|
'commandPalette.shortcutHint': '按 ? 查看所有快捷键',
|
||||||
'commandPalette.title': '命令面板',
|
'commandPalette.title': '命令面板',
|
||||||
'kbd.ariaLabel': '键盘快捷键:{shortcut}',
|
'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.connectedTo': '连接到',
|
||||||
'iosMascot.defaultPairedLabel': '桌面',
|
'iosMascot.defaultPairedLabel': '桌面',
|
||||||
'iosMascot.disconnect': '断开连接',
|
'iosMascot.disconnect': '断开连接',
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ import {
|
|||||||
beginInferenceTurn,
|
beginInferenceTurn,
|
||||||
clearFollowupsForThread,
|
clearFollowupsForThread,
|
||||||
clearRuntimeForThread,
|
clearRuntimeForThread,
|
||||||
|
clearThreadSendPending,
|
||||||
enqueueFollowup,
|
enqueueFollowup,
|
||||||
fetchAndHydrateTurnState,
|
fetchAndHydrateTurnState,
|
||||||
markSubagentCancelled,
|
markSubagentCancelled,
|
||||||
|
markThreadSendPending,
|
||||||
type QueuedFollowup,
|
type QueuedFollowup,
|
||||||
registerParallelRequest,
|
registerParallelRequest,
|
||||||
setTaskBoardForThread,
|
setTaskBoardForThread,
|
||||||
@@ -287,22 +289,32 @@ const Conversations = ({
|
|||||||
const [pendingSendingThreadIds, setPendingSendingThreadIds] = useState<ReadonlySet<string>>(
|
const [pendingSendingThreadIds, setPendingSendingThreadIds] = useState<ReadonlySet<string>>(
|
||||||
() => new Set()
|
() => new Set()
|
||||||
);
|
);
|
||||||
const addPendingSendingThread = useCallback((threadId: string) => {
|
const addPendingSendingThread = useCallback(
|
||||||
setPendingSendingThreadIds(prev => {
|
(threadId: string) => {
|
||||||
if (prev.has(threadId)) return prev;
|
// Mirror to Redux so global surfaces (e.g. the New Chat shortcut) can see
|
||||||
const next = new Set(prev);
|
// an in-flight send before any message/streaming state exists.
|
||||||
next.add(threadId);
|
dispatch(markThreadSendPending({ threadId }));
|
||||||
return next;
|
setPendingSendingThreadIds(prev => {
|
||||||
});
|
if (prev.has(threadId)) return prev;
|
||||||
}, []);
|
const next = new Set(prev);
|
||||||
const removePendingSendingThread = useCallback((threadId: string) => {
|
next.add(threadId);
|
||||||
setPendingSendingThreadIds(prev => {
|
return next;
|
||||||
if (!prev.has(threadId)) return prev;
|
});
|
||||||
const next = new Set(prev);
|
},
|
||||||
next.delete(threadId);
|
[dispatch]
|
||||||
return next;
|
);
|
||||||
});
|
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 socketStatus = useAppSelector(selectSocketStatus);
|
||||||
const agentProfiles = useAppSelector(selectAgentProfiles);
|
const agentProfiles = useAppSelector(selectAgentProfiles);
|
||||||
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
|
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
|
||||||
|
|||||||
@@ -313,6 +313,13 @@ export type QueueMode = 'interrupt' | 'steer' | 'followup' | 'collect' | 'parall
|
|||||||
interface ChatRuntimeState {
|
interface ChatRuntimeState {
|
||||||
inferenceStatusByThread: Record<string, InferenceStatus>;
|
inferenceStatusByThread: Record<string, InferenceStatus>;
|
||||||
streamingAssistantByThread: Record<string, StreamingAssistantState>;
|
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
|
* Live streams for concurrent PARALLEL (forked) turns on a thread, nested
|
||||||
* `threadId -> requestId -> stream`. A separate lane from
|
* `threadId -> requestId -> stream`. A separate lane from
|
||||||
@@ -390,6 +397,7 @@ export interface QueuedFollowup {
|
|||||||
const initialState: ChatRuntimeState = {
|
const initialState: ChatRuntimeState = {
|
||||||
inferenceStatusByThread: {},
|
inferenceStatusByThread: {},
|
||||||
streamingAssistantByThread: {},
|
streamingAssistantByThread: {},
|
||||||
|
pendingSendThreadIds: {},
|
||||||
parallelStreamsByThread: {},
|
parallelStreamsByThread: {},
|
||||||
parallelRequestThreads: {},
|
parallelRequestThreads: {},
|
||||||
toolTimelineByThread: {},
|
toolTimelineByThread: {},
|
||||||
@@ -657,6 +665,14 @@ const chatRuntimeSlice = createSlice({
|
|||||||
clearStreamingAssistantForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
clearStreamingAssistantForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||||
delete state.streamingAssistantByThread[action.payload.threadId];
|
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
|
* Register a parallel (forked) turn so its socket events route to the
|
||||||
* parallel lane. Called when a `queueMode: 'parallel'` send is accepted.
|
* 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.pendingPlanReviewByThread[action.payload.threadId];
|
||||||
delete state.queueStatusByThread[action.payload.threadId];
|
delete state.queueStatusByThread[action.payload.threadId];
|
||||||
delete state.queuedFollowupsByThread[action.payload.threadId];
|
delete state.queuedFollowupsByThread[action.payload.threadId];
|
||||||
|
delete state.pendingSendThreadIds[action.payload.threadId];
|
||||||
// Note: artifactsByThread intentionally NOT cleared here. The
|
// Note: artifactsByThread intentionally NOT cleared here. The
|
||||||
// ArtifactCard renders inline in the message timeline, so the
|
// ArtifactCard renders inline in the message timeline, so the
|
||||||
// snapshot needs to survive turn boundaries — historic artifacts
|
// snapshot needs to survive turn boundaries — historic artifacts
|
||||||
@@ -1088,6 +1105,7 @@ const chatRuntimeSlice = createSlice({
|
|||||||
state.artifactsByThread = {};
|
state.artifactsByThread = {};
|
||||||
state.queueStatusByThread = {};
|
state.queueStatusByThread = {};
|
||||||
state.queuedFollowupsByThread = {};
|
state.queuedFollowupsByThread = {};
|
||||||
|
state.pendingSendThreadIds = {};
|
||||||
},
|
},
|
||||||
recordChatTurnUsage: (
|
recordChatTurnUsage: (
|
||||||
state,
|
state,
|
||||||
@@ -1224,6 +1242,8 @@ export const {
|
|||||||
clearInferenceStatusForThread,
|
clearInferenceStatusForThread,
|
||||||
setStreamingAssistantForThread,
|
setStreamingAssistantForThread,
|
||||||
clearStreamingAssistantForThread,
|
clearStreamingAssistantForThread,
|
||||||
|
markThreadSendPending,
|
||||||
|
clearThreadSendPending,
|
||||||
registerParallelRequest,
|
registerParallelRequest,
|
||||||
setParallelStream,
|
setParallelStream,
|
||||||
clearParallelRequest,
|
clearParallelRequest,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import personaReducer from '../store/personaSlice';
|
|||||||
import { pttReducer } from '../store/pttSlice';
|
import { pttReducer } from '../store/pttSlice';
|
||||||
import socketReducer from '../store/socketSlice';
|
import socketReducer from '../store/socketSlice';
|
||||||
import themeReducer from '../store/themeSlice';
|
import themeReducer from '../store/themeSlice';
|
||||||
|
import threadReducer from '../store/threadSlice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fresh Redux store for testing.
|
* Creates a fresh Redux store for testing.
|
||||||
@@ -53,6 +54,7 @@ const testRootReducer = combineReducers({
|
|||||||
ptt: pttReducer,
|
ptt: pttReducer,
|
||||||
socket: socketReducer,
|
socket: socketReducer,
|
||||||
theme: themeReducer,
|
theme: themeReducer,
|
||||||
|
thread: threadReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createTestStore(preloadedState?: Record<string, unknown>) {
|
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', () => {
|
describe('Command palette', () => {
|
||||||
before(async () => {
|
before(async () => {
|
||||||
// CommandProvider is mounted inside the auth-gated provider chain.
|
// 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.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.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
|
## Summary
|
||||||
|
|||||||
Reference in New Issue
Block a user