mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(commands): ⌘K palette + keyboard system + chat UX polish (#745)
- Implement command palette (⌘K) using cmdk and Radix Dialog with a global action registry. - Add a keyboard shortcut system with a capture-phase hotkey manager and scope stack. - Integrate seed navigation actions for Home, Chat, Intelligence, Skills, and Settings. - Improve chat UX with a `useStickToBottom` hook for auto-scroll and fixed hydration errors. - Refactor command UI to display shortcuts inline and remove the redundant help overlay. - Fix Vite HMR connection issues within the Tauri/CEF environment. Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
500ddc2a8a
commit
478c92e533
@@ -0,0 +1,74 @@
|
||||
# Phase 0 — Command Palette + Keyboard Shortcut System
|
||||
|
||||
One-page summary. Full spec: [`docs/superpowers/specs/2026-04-21-command-palette-design.md`](../docs/superpowers/specs/2026-04-21-command-palette-design.md)
|
||||
|
||||
**Branch:** `feat/frontend-reskin` · **Worktree:** `~/projects/openhuman-frontend`
|
||||
|
||||
## What
|
||||
|
||||
Superhuman/Linear-style `⌘K` palette + global keyboard shortcut system + `?` help overlay for OpenHuman. Additive keyboard layer — no existing page visuals touched, no feature flag, no new Redux slices.
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
lib/commands/
|
||||
├── types.ts · shortcut.ts · registry.ts (singleton)
|
||||
├── hotkeyManager.ts (singleton capture-phase listener + scope stack)
|
||||
├── useHotkey.ts (raw) · useRegisterAction.ts (palette, delegates to useHotkey)
|
||||
└── globalActions.ts
|
||||
|
||||
components/commands/
|
||||
├── CommandProvider.tsx (root mount, one instance)
|
||||
├── CommandScope.tsx (push/pop scope frame by symbol)
|
||||
├── CommandPalette.tsx (cmdk + Radix Dialog)
|
||||
├── HelpOverlay.tsx (Radix Dialog)
|
||||
└── Kbd.tsx
|
||||
```
|
||||
|
||||
## Non-obvious decisions (decisions log in full spec)
|
||||
|
||||
- **`<CommandScope>` primitive, NOT `useLocation().key`** — HashRouter is brittle, fails for tabbed/drawer surfaces.
|
||||
- **Scope frames keyed by `Symbol`** — nesting + StrictMode double-mount safe.
|
||||
- **Last-registered-wins** within a frame (iterate reversed at dispatch).
|
||||
- **`preventDefault` on match, NEVER `stopPropagation`** — don't break cmdk or native inputs.
|
||||
- **Version-counter memoized snapshots** for `useSyncExternalStore` — same array ref when unchanged.
|
||||
- **`handlerRef` pattern** — handler ref updated every render, binding re-registers only on shortcut/scope change.
|
||||
- **Palette and help mutually exclusive.**
|
||||
- **8 scoped `cmd-*` tokens only** — not a full design system; reskin brainstorm owns that later.
|
||||
- **Separate `useHotkey` / `useRegisterAction`** — prevents double-registration bug; raw vs palette-visible.
|
||||
|
||||
## Seed actions (v1 — six total)
|
||||
|
||||
| id | shortcut | group |
|
||||
|---|---|---|
|
||||
| `nav.home` | `mod+1` | Navigation |
|
||||
| `nav.conversations` | `mod+2` | Navigation |
|
||||
| `nav.intelligence` | `mod+3` | Navigation |
|
||||
| `nav.skills` | `mod+4` | Navigation |
|
||||
| `nav.settings` | `mod+,` | Navigation |
|
||||
| `help.show` | `?` | Help |
|
||||
|
||||
Meta hotkeys bound directly in `CommandProvider` (not in registry): `⌘K` open palette, `Esc` close overlay.
|
||||
|
||||
## Gates (must pass in order)
|
||||
|
||||
0. **Platform verify** — stub keydown listener, confirm `⌘1–⌘4` not swallowed by Tauri/CEF. Blocks everything.
|
||||
1. **Foundation** — types, shortcut, registry, hotkeyManager, hooks, `<CommandScope>`. Unit tests ≥95% on core.
|
||||
2. **Tokens** — 8 `cmd-*` in tailwind + CSS vars + `lint:commands-tokens` pre-push script.
|
||||
3. **Components** — Kbd, install cmdk + `@radix-ui/react-dialog`, Palette, HelpOverlay, CommandProvider, globalActions.
|
||||
4. **Wire** — one-line edit to `App.tsx` (pinned mount point inside HashRouter, outside routes).
|
||||
5. **E2E** — command-palette spec + regression probe on one pre-existing shortcut.
|
||||
6. **Pre-merge** — typecheck, lint, unit, token-lint, e2e, cargo fmt/check, manual smoke + a11y.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- Chord sequences (v2)
|
||||
- Full design-system semantic tokens (reskin brainstorm)
|
||||
- Sign Out / Toggle Theme / per-page actions (future PRs)
|
||||
- i18n
|
||||
- Go Back / Forward shortcuts
|
||||
|
||||
## New deps
|
||||
|
||||
- `cmdk` (palette UI)
|
||||
- `@radix-ui/react-dialog` (overlay wrapper, focus trap, a11y)
|
||||
+7
-1
@@ -36,8 +36,14 @@ yarn rust:check
|
||||
RUST_CHECK_EXIT=$?
|
||||
set -e
|
||||
|
||||
# Enforce scoped cmd-* tokens in components/commands/
|
||||
set +e
|
||||
yarn --cwd app lint:commands-tokens
|
||||
CMD_TOKENS_EXIT=$?
|
||||
set -e
|
||||
|
||||
# Exit with error if any command still fails after fixes
|
||||
if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ]; then
|
||||
if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then
|
||||
echo "Pre-push checks failed. Please fix format (Prettier + cargo fmt for core and Tauri), lint, TypeScript, and/or Rust errors before pushing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -49,12 +49,14 @@
|
||||
"format:check": "prettier --check . && yarn rust:format:check",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"lint:commands-tokens": "bash -c '! rg -nU \"(bg|text|border|ring|shadow)-(neutral|primary|sage|amber|canvas|stone|slate)\" src/components/commands/'",
|
||||
"knip": "knip --config knip.json",
|
||||
"knip:production": "knip --config knip.json --production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@radix-ui/react-dialog": "^1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@scure/bip32": "^2.0.1",
|
||||
"@scure/bip39": "^2.0.1",
|
||||
@@ -65,6 +67,7 @@
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"buffer": "^6.0.3",
|
||||
"cmdk": "^1",
|
||||
"debug": "^4.4.3",
|
||||
"lottie-react": "^2.4.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
@@ -89,6 +92,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^25.0.10",
|
||||
|
||||
+9
-6
@@ -5,6 +5,7 @@ import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import AppRoutes from './AppRoutes';
|
||||
import BottomTabBar from './components/BottomTabBar';
|
||||
import CommandProvider from './components/commands/CommandProvider';
|
||||
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
|
||||
import DictationHotkeyManager from './components/DictationHotkeyManager';
|
||||
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
@@ -47,12 +48,14 @@ function App() {
|
||||
<SocketProvider>
|
||||
<ChatRuntimeProvider>
|
||||
<Router>
|
||||
<ServiceBlockingGate>
|
||||
<AppShell />
|
||||
<OnboardingOverlay />
|
||||
<DictationHotkeyManager />
|
||||
<LocalAIDownloadSnackbar />
|
||||
</ServiceBlockingGate>
|
||||
<CommandProvider>
|
||||
<ServiceBlockingGate>
|
||||
<AppShell />
|
||||
<OnboardingOverlay />
|
||||
<DictationHotkeyManager />
|
||||
<LocalAIDownloadSnackbar />
|
||||
</ServiceBlockingGate>
|
||||
</CommandProvider>
|
||||
</Router>
|
||||
</ChatRuntimeProvider>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { Command } from 'cmdk';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
||||
import { registry } from '../../lib/commands/registry';
|
||||
import type { RegisteredAction } from '../../lib/commands/types';
|
||||
import Kbd from './Kbd';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
export default function CommandPalette({ open, onOpenChange }: Props) {
|
||||
const actions = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byGroup = new Map<string, RegisteredAction[]>();
|
||||
for (const a of actions) {
|
||||
const g = a.group ?? 'Actions';
|
||||
if (!byGroup.has(g)) byGroup.set(g, []);
|
||||
byGroup.get(g)!.push(a);
|
||||
}
|
||||
const order = ['Navigation', 'Help'];
|
||||
const keys = [...byGroup.keys()].sort((a, b) => {
|
||||
const ai = order.indexOf(a);
|
||||
const bi = 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(k => [k, byGroup.get(k)!] as const);
|
||||
}, [actions]);
|
||||
|
||||
function runAction(action: RegisteredAction): void {
|
||||
onOpenChange(false);
|
||||
window.requestAnimationFrame(() => {
|
||||
registry.runAction(action.id);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-cmd-overlay z-40" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-[20vh] -translate-x-1/2 w-[min(640px,calc(100vw-32px))] bg-cmd-surface text-cmd-foreground border border-cmd-border rounded-xl shadow-cmd-palette z-50 overflow-hidden"
|
||||
aria-label="Command palette">
|
||||
<Dialog.Title className="sr-only">Command palette</Dialog.Title>
|
||||
<Dialog.Description className="sr-only">
|
||||
Search and run commands. Use arrow keys to navigate, Enter to select, Escape to close.
|
||||
</Dialog.Description>
|
||||
<Command label="Commands" shouldFilter={true}>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Type a command or search…"
|
||||
className="w-full px-4 py-3 bg-transparent outline-none border-b border-cmd-border text-cmd-foreground placeholder:text-cmd-foreground-muted"
|
||||
aria-label="Search commands"
|
||||
/>
|
||||
<Command.List className="max-h-[50vh] overflow-auto py-2">
|
||||
<Command.Empty className="px-4 py-8 text-center text-cmd-foreground-muted">
|
||||
No results.
|
||||
</Command.Empty>
|
||||
{groups.map(([groupName, items]) => (
|
||||
<Command.Group
|
||||
key={groupName}
|
||||
heading={groupName}
|
||||
className="[&_[cmdk-group-heading]]:px-4 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:text-cmd-foreground-muted">
|
||||
{items.map(action => (
|
||||
<Command.Item
|
||||
key={action.id}
|
||||
value={action.id}
|
||||
keywords={[action.label, ...(action.keywords ?? [])]}
|
||||
onSelect={() => runAction(action)}
|
||||
className="flex items-center gap-3 px-4 py-2 cursor-pointer aria-selected:bg-cmd-surface-elevated">
|
||||
{action.icon ? (
|
||||
<action.icon className="w-4 h-4 text-cmd-foreground-muted" />
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{action.label}</span>
|
||||
{action.hint && (
|
||||
<span className="text-xs text-cmd-foreground-muted truncate">
|
||||
{action.hint}
|
||||
</span>
|
||||
)}
|
||||
{action.shortcut && <Kbd shortcut={action.shortcut} />}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
))}
|
||||
</Command.List>
|
||||
<div className="px-4 py-2 border-t border-cmd-border text-xs text-cmd-foreground-muted">
|
||||
Press ? for all shortcuts
|
||||
</div>
|
||||
</Command>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerGlobalActions } from '../../lib/commands/globalActions';
|
||||
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
||||
import { registry } from '../../lib/commands/registry';
|
||||
import { ScopeContext } from '../../lib/commands/ScopeContext';
|
||||
import CommandPalette from './CommandPalette';
|
||||
|
||||
let instanceCount = 0;
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function CommandProvider({ children }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [globalFrame, setGlobalFrame] = useState<symbol | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
instanceCount += 1;
|
||||
if (instanceCount > 1) {
|
||||
console.warn('[commands] CommandProvider mounted more than once — this is unsupported');
|
||||
}
|
||||
hotkeyManager.init();
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
||||
const disposeGlobalActions = registerGlobalActions(navigate, frame);
|
||||
const paletteBinding = hotkeyManager.bind(frame, {
|
||||
shortcut: 'mod+k',
|
||||
handler: () => {
|
||||
setPaletteOpen(o => !o);
|
||||
},
|
||||
allowInInput: true,
|
||||
id: 'meta.open-palette',
|
||||
});
|
||||
setGlobalFrame(frame);
|
||||
|
||||
return () => {
|
||||
disposeGlobalActions();
|
||||
hotkeyManager.unbind(frame, paletteBinding);
|
||||
hotkeyManager.popFrame(frame);
|
||||
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
||||
instanceCount -= 1;
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalFrame) return;
|
||||
registry.setActiveStack(hotkeyManager.getStackSymbols());
|
||||
}, [globalFrame]);
|
||||
|
||||
const value = useMemo(() => globalFrame, [globalFrame]);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopeContext.Provider value={value}>
|
||||
{children}
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
</ScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { hotkeyManager } from '../../lib/commands/hotkeyManager';
|
||||
import { ScopeContext } from '../../lib/commands/ScopeContext';
|
||||
import type { ScopeKind } from '../../lib/commands/types';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
kind?: ScopeKind;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function CommandScope({ id, kind = 'page', children }: Props) {
|
||||
const [frame] = useState(() => hotkeyManager.pushFrame(kind, id));
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
hotkeyManager.popFrame(frame);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => frame, [frame]);
|
||||
return <ScopeContext.Provider value={value}>{children}</ScopeContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { formatShortcut, isMac, parseShortcut } from '../../lib/commands/shortcut';
|
||||
|
||||
interface Props {
|
||||
shortcut: string;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Kbd({ shortcut, size = 'sm', className = '' }: Props) {
|
||||
const segments = useMemo(() => formatShortcut(parseShortcut(shortcut), isMac()), [shortcut]);
|
||||
const padding = size === 'md' ? 'px-2 py-1 text-sm' : 'px-1.5 py-0.5 text-xs';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 font-mono ${className}`}
|
||||
aria-label={`Keyboard shortcut: ${segments.join(' ')}`}>
|
||||
{segments.map((seg, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className={`${padding} rounded border border-cmd-border bg-cmd-surface-elevated text-cmd-foreground-muted`}>
|
||||
{seg}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Kbd);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
|
||||
import { registry } from '../../../lib/commands/registry';
|
||||
import { ScopeContext } from '../../../lib/commands/ScopeContext';
|
||||
import CommandPalette from '../CommandPalette';
|
||||
|
||||
let currentFrame: symbol;
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
registry.reset();
|
||||
hotkeyManager.init();
|
||||
currentFrame = hotkeyManager.pushFrame('global', 'root');
|
||||
registry.setActiveStack([currentFrame]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
registry.reset();
|
||||
});
|
||||
|
||||
function Harness({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
return (
|
||||
<ScopeContext.Provider value={currentFrame}>
|
||||
<CommandPalette open={open} onOpenChange={onOpenChange} />
|
||||
</ScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function registerSettingsAction(handler?: () => void): void {
|
||||
registry.registerAction(
|
||||
{
|
||||
id: 'nav.settings',
|
||||
label: 'Open Settings',
|
||||
handler: handler ?? vi.fn(),
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+,',
|
||||
},
|
||||
currentFrame
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
it('renders registered actions when open', () => {
|
||||
registerSettingsAction();
|
||||
render(<Harness open={true} onOpenChange={() => {}} />);
|
||||
expect(screen.getByText('Open Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by typed query', async () => {
|
||||
registerSettingsAction();
|
||||
const user = userEvent.setup();
|
||||
render(<Harness open={true} onOpenChange={() => {}} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.type(input, 'xyzzy');
|
||||
expect(screen.queryByText('Open Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires handler on Enter and calls onOpenChange(false)', async () => {
|
||||
registerSettingsAction();
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<Harness open={true} onOpenChange={onOpenChange} />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.type(input, 'settings');
|
||||
await user.keyboard('{Enter}');
|
||||
await act(async () => {
|
||||
await new Promise(r => requestAnimationFrame(() => r(null)));
|
||||
});
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('renders footer hint', () => {
|
||||
render(<Harness open={true} onOpenChange={() => {}} />);
|
||||
expect(screen.getByText(/Press \? for all shortcuts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
|
||||
import { pressKey } from '../../../test/commandTestUtils';
|
||||
import CommandProvider from '../CommandProvider';
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
});
|
||||
|
||||
describe('CommandProvider', () => {
|
||||
it('mounts and registers seed actions', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CommandProvider>
|
||||
<div>child</div>
|
||||
</CommandProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens palette on mod+K', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CommandProvider>
|
||||
<div>child</div>
|
||||
</CommandProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
act(() => {
|
||||
pressKey({ key: 'k', mod: true });
|
||||
});
|
||||
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('opens help on ? (disabled — help overlay temporarily off)', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CommandProvider>
|
||||
<div>child</div>
|
||||
</CommandProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
act(() => {
|
||||
pressKey({ key: '?' });
|
||||
});
|
||||
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Esc closes open overlay', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CommandProvider>
|
||||
<div>child</div>
|
||||
</CommandProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
act(() => {
|
||||
pressKey({ key: 'k', mod: true });
|
||||
});
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('palette and help mutually exclusive (disabled — help overlay temporarily off)', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CommandProvider>
|
||||
<div>child</div>
|
||||
</CommandProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
act(() => {
|
||||
pressKey({ key: 'k', mod: true });
|
||||
});
|
||||
expect(await screen.findByRole('dialog', { name: /Command palette/i })).toBeInTheDocument();
|
||||
act(() => {
|
||||
pressKey({ key: '?' });
|
||||
});
|
||||
expect(await screen.findByRole('dialog', { name: /Keyboard shortcuts/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /Command palette/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { StrictMode } from 'react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { hotkeyManager } from '../../../lib/commands/hotkeyManager';
|
||||
import CommandScope from '../CommandScope';
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
hotkeyManager.init();
|
||||
});
|
||||
|
||||
describe('CommandScope', () => {
|
||||
it('pushes frame on mount, pops on unmount', () => {
|
||||
const { unmount } = render(
|
||||
<CommandScope id="home">
|
||||
<div />
|
||||
</CommandScope>
|
||||
);
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(1);
|
||||
unmount();
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(0);
|
||||
});
|
||||
|
||||
it('StrictMode double-mount nets a single frame', () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<CommandScope id="home">
|
||||
<div />
|
||||
</CommandScope>
|
||||
</StrictMode>
|
||||
);
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(1);
|
||||
});
|
||||
|
||||
it('nested scopes push two frames', () => {
|
||||
render(
|
||||
<CommandScope id="page">
|
||||
<CommandScope id="modal" kind="modal">
|
||||
<div />
|
||||
</CommandScope>
|
||||
</CommandScope>
|
||||
);
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(2);
|
||||
});
|
||||
|
||||
it('pops by symbol (out-of-order unmount safe)', () => {
|
||||
function App({ showInner }: { showInner: boolean }) {
|
||||
return (
|
||||
<CommandScope id="outer">
|
||||
{showInner && (
|
||||
<CommandScope id="inner">
|
||||
<div />
|
||||
</CommandScope>
|
||||
)}
|
||||
</CommandScope>
|
||||
);
|
||||
}
|
||||
const { rerender, unmount } = render(<App showInner={true} />);
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(2);
|
||||
rerender(<App showInner={false} />);
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(1);
|
||||
unmount();
|
||||
expect(hotkeyManager.getStackSymbols().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import Kbd from '../Kbd';
|
||||
|
||||
function withPlatform(value: string, fn: () => void) {
|
||||
const orig = navigator.platform;
|
||||
Object.defineProperty(navigator, 'platform', { value, configurable: true });
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
Object.defineProperty(navigator, 'platform', { value: orig, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('Kbd', () => {
|
||||
it('renders mac glyphs for mod+shift+k', () => {
|
||||
withPlatform('MacIntel', () => {
|
||||
const { container } = render(<Kbd shortcut="shift+mod+k" />);
|
||||
expect(container.textContent).toMatch(/⇧/);
|
||||
expect(container.textContent).toMatch(/⌘/);
|
||||
expect(container.textContent).toMatch(/K/);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders PC labels on Win32', () => {
|
||||
withPlatform('Win32', () => {
|
||||
const { container } = render(<Kbd shortcut="shift+mod+k" />);
|
||||
expect(container.textContent).toMatch(/Shift/);
|
||||
expect(container.textContent).toMatch(/Ctrl/);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders single printable', () => {
|
||||
withPlatform('MacIntel', () => {
|
||||
const { container } = render(<Kbd shortcut="?" />);
|
||||
expect(container.textContent).toMatch(/\?/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
export function useStickToBottom(
|
||||
messages: readonly unknown[],
|
||||
threadKey: string | null | undefined,
|
||||
resetKey: string
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const didInitialScrollRef = useRef(false);
|
||||
const lastScrolledThreadRef = useRef<string | null>(null);
|
||||
const lastResetKeyRef = useRef(resetKey);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Reset is handled inside the same layout phase as the scroll so we never
|
||||
// read a stale `didInitialScrollRef` after a resetKey change (the previous
|
||||
// useEffect-based reset fired after paint, leaving this layout effect with
|
||||
// stale state on the first re-render and triggering an unwanted smooth
|
||||
// scroll animation on re-entry).
|
||||
if (lastResetKeyRef.current !== resetKey) {
|
||||
didInitialScrollRef.current = false;
|
||||
lastResetKeyRef.current = resetKey;
|
||||
}
|
||||
if (messages.length === 0) return;
|
||||
const container = containerRef.current;
|
||||
const threadChanged = lastScrolledThreadRef.current !== threadKey;
|
||||
const firstScroll = !didInitialScrollRef.current;
|
||||
const instant = firstScroll || threadChanged;
|
||||
if (instant) {
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
lastScrolledThreadRef.current = threadKey ?? null;
|
||||
didInitialScrollRef.current = true;
|
||||
}, [messages, threadKey, resetKey]);
|
||||
|
||||
return { containerRef, endRef };
|
||||
}
|
||||
@@ -399,3 +399,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Command palette + help overlay — scoped tokens. */
|
||||
:root {
|
||||
--cmd-accent: #2f6ef4;
|
||||
--cmd-surface: #ffffff;
|
||||
--cmd-surface-elevated: #f5f5f5;
|
||||
--cmd-foreground: #171717;
|
||||
--cmd-foreground-muted: #737373;
|
||||
--cmd-border: #e5e5e5;
|
||||
--cmd-ring: var(--cmd-accent);
|
||||
--cmd-overlay: rgba(0, 0, 0, 0.5);
|
||||
--cmd-shadow-palette: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--cmd-accent: #60a5fa;
|
||||
--cmd-surface: #171717;
|
||||
--cmd-surface-elevated: #262626;
|
||||
--cmd-foreground: #fafafa;
|
||||
--cmd-foreground-muted: #a3a3a3;
|
||||
--cmd-border: #404040;
|
||||
--cmd-overlay: rgba(0, 0, 0, 0.7);
|
||||
--cmd-shadow-palette: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cmd-palette-enter,
|
||||
.cmd-palette-exit,
|
||||
.cmd-help-enter,
|
||||
.cmd-help-exit {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ScopeContext = createContext<symbol>(Symbol('no-scope'));
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GROUP_ORDER, registerGlobalActions } from '../globalActions';
|
||||
import { hotkeyManager } from '../hotkeyManager';
|
||||
import { registry } from '../registry';
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
registry.reset();
|
||||
hotkeyManager.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
registry.reset();
|
||||
});
|
||||
|
||||
describe('registerGlobalActions', () => {
|
||||
it('registers the 5 seed nav actions into the global frame', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
const navigate = vi.fn() as unknown as NavigateFunction;
|
||||
registerGlobalActions(navigate, frame);
|
||||
const ids = ['nav.home', 'nav.chat', 'nav.intelligence', 'nav.skills', 'nav.settings'];
|
||||
for (const id of ids) expect(registry.getAction(id)?.id).toBe(id);
|
||||
expect(registry.getAction('help.show')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('nav.home handler calls navigate("/home")', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
const navigate = vi.fn();
|
||||
registerGlobalActions(navigate as unknown as NavigateFunction, frame);
|
||||
registry.setActiveStack([frame]);
|
||||
registry.runAction('nav.home');
|
||||
expect(navigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
|
||||
it('exports GROUP_ORDER', () => {
|
||||
expect(GROUP_ORDER).toEqual(['Navigation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createHotkeyManager } from '../hotkeyManager';
|
||||
|
||||
function dispatchKey(key: string, opts: Partial<KeyboardEventInit> = {}): KeyboardEvent {
|
||||
const e = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts });
|
||||
window.dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('hotkeyManager', () => {
|
||||
let mgr: ReturnType<typeof createHotkeyManager>;
|
||||
beforeEach(() => {
|
||||
mgr = createHotkeyManager();
|
||||
mgr.init();
|
||||
});
|
||||
afterEach(() => {
|
||||
mgr.teardown();
|
||||
});
|
||||
|
||||
it('init is idempotent', () => {
|
||||
const listenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
mgr.init();
|
||||
const keydownCalls = listenerSpy.mock.calls.filter(c => c[0] === 'keydown');
|
||||
expect(keydownCalls.length).toBe(0);
|
||||
listenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fires binding handler + preventDefault', () => {
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'escape', handler });
|
||||
const e = dispatchKey('Escape');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT stopPropagation / stopImmediatePropagation', () => {
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'escape', handler });
|
||||
// Register a downstream listener that should still fire, plus spy on the
|
||||
// event's propagation-stopping methods. The hotkey manager attaches at
|
||||
// capture phase; our listener runs at the bubble phase.
|
||||
const downstream = vi.fn();
|
||||
const listener = (e: Event) => {
|
||||
downstream();
|
||||
// Verify the hotkey manager did not stop propagation or immediate
|
||||
// propagation at any point.
|
||||
expect((e as KeyboardEvent & { cancelBubble: boolean }).cancelBubble).toBe(false);
|
||||
};
|
||||
window.addEventListener('keydown', listener);
|
||||
try {
|
||||
const e = dispatchKey('Escape');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(downstream).toHaveBeenCalledTimes(1);
|
||||
expect(e.cancelBubble).toBe(false);
|
||||
} finally {
|
||||
window.removeEventListener('keydown', listener);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips when isComposing', () => {
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'escape', handler });
|
||||
dispatchKey('Escape', { keyCode: 229 } as KeyboardEventInit & { keyCode: number });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips auto-repeat by default', () => {
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'escape', handler });
|
||||
dispatchKey('Escape', { repeat: true });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires on auto-repeat when repeat: true', () => {
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'escape', handler, repeat: true });
|
||||
dispatchKey('Escape', { repeat: true });
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses in input unless allowInInput', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'k', handler });
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true })
|
||||
);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('fires in input when allowInInput:true', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
const frame = mgr.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
mgr.bind(frame, { shortcut: 'k', handler, allowInInput: true });
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true })
|
||||
);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('modal shadows page+global for same shortcut', () => {
|
||||
const g = mgr.pushFrame('global', 'root');
|
||||
const p = mgr.pushFrame('page', 'home');
|
||||
const m = mgr.pushFrame('modal', 'dialog');
|
||||
const gh = vi.fn(),
|
||||
ph = vi.fn(),
|
||||
mh = vi.fn();
|
||||
mgr.bind(g, { shortcut: 'escape', handler: gh });
|
||||
mgr.bind(p, { shortcut: 'escape', handler: ph });
|
||||
mgr.bind(m, { shortcut: 'escape', handler: mh });
|
||||
dispatchKey('Escape');
|
||||
expect(mh).toHaveBeenCalled();
|
||||
expect(ph).not.toHaveBeenCalled();
|
||||
expect(gh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('last-registered wins within a frame', () => {
|
||||
const f = mgr.pushFrame('global', 'root');
|
||||
const first = vi.fn();
|
||||
const last = vi.fn();
|
||||
mgr.bind(f, { shortcut: 'k', handler: first });
|
||||
mgr.bind(f, { shortcut: 'k', handler: last });
|
||||
dispatchKey('k');
|
||||
expect(last).toHaveBeenCalled();
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('popFrame by symbol removes correctly even when not top', () => {
|
||||
const a = mgr.pushFrame('page', 'a');
|
||||
const b = mgr.pushFrame('page', 'b');
|
||||
mgr.popFrame(a);
|
||||
const stack = mgr.getStackSymbols();
|
||||
expect(stack).not.toContain(a);
|
||||
expect(stack).toContain(b);
|
||||
});
|
||||
|
||||
it('sync throw in handler does not break listener', () => {
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const f = mgr.pushFrame('global', 'root');
|
||||
mgr.bind(f, {
|
||||
shortcut: 'k',
|
||||
handler: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
const h2 = vi.fn();
|
||||
mgr.bind(f, { shortcut: 'j', handler: h2 });
|
||||
dispatchKey('k');
|
||||
dispatchKey('j');
|
||||
expect(err).toHaveBeenCalled();
|
||||
expect(h2).toHaveBeenCalled();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('rejected promise in handler does not break listener', async () => {
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const f = mgr.pushFrame('global', 'root');
|
||||
mgr.bind(f, { shortcut: 'k', handler: () => Promise.reject(new Error('boom')) });
|
||||
dispatchKey('k');
|
||||
// Flush microtasks so the .catch fires without relying on real timers.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(err).toHaveBeenCalled();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('unregister during dispatch does not crash or double-fire', () => {
|
||||
const f = mgr.pushFrame('global', 'root');
|
||||
const b = vi.fn();
|
||||
const bindBSym = mgr.bind(f, { shortcut: 'k', handler: b });
|
||||
mgr.bind(f, { shortcut: 'k', handler: () => mgr.unbind(f, bindBSym) });
|
||||
dispatchKey('k');
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pop frame during dispatch does not crash', () => {
|
||||
const f = mgr.pushFrame('modal', 'dialog');
|
||||
mgr.bind(f, { shortcut: 'escape', handler: () => mgr.popFrame(f) });
|
||||
expect(() => dispatchKey('Escape')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRegistry } from '../registry';
|
||||
import type { Action } from '../types';
|
||||
|
||||
const baseAction: Action = { id: 'a.test', label: 'Test', handler: vi.fn() };
|
||||
|
||||
describe('registry', () => {
|
||||
let reg: ReturnType<typeof createRegistry>;
|
||||
beforeEach(() => {
|
||||
reg = createRegistry();
|
||||
});
|
||||
|
||||
it('registers + getAction', () => {
|
||||
const frame = Symbol('global');
|
||||
reg.registerAction(baseAction, frame);
|
||||
expect(reg.getAction('a.test')?.id).toBe('a.test');
|
||||
});
|
||||
|
||||
it('dispose unregisters', () => {
|
||||
const frame = Symbol('global');
|
||||
const dispose = reg.registerAction(baseAction, frame);
|
||||
dispose();
|
||||
expect(reg.getAction('a.test')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('duplicate id same frame warns and replaces', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const frame = Symbol('global');
|
||||
reg.registerAction({ ...baseAction, label: 'A' }, frame);
|
||||
reg.registerAction({ ...baseAction, label: 'B' }, frame);
|
||||
expect(reg.getAction('a.test')?.label).toBe('B');
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('same id in two frames: top of stack wins', () => {
|
||||
const f1 = Symbol('global');
|
||||
const f2 = Symbol('page');
|
||||
reg.registerAction({ ...baseAction, label: 'global' }, f1);
|
||||
reg.registerAction({ ...baseAction, label: 'page' }, f2);
|
||||
const active = reg.getActiveActions([f1, f2]);
|
||||
expect(active.filter(a => a.id === 'a.test')).toHaveLength(1);
|
||||
expect(active.find(a => a.id === 'a.test')?.label).toBe('page');
|
||||
});
|
||||
|
||||
it('enabled:false excluded from active', () => {
|
||||
const frame = Symbol('global');
|
||||
reg.registerAction({ ...baseAction, enabled: () => false }, frame);
|
||||
expect(reg.getActiveActions([frame])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dedups by canonicalized shortcut (inner scope wins)', () => {
|
||||
const f1 = Symbol('global');
|
||||
const f2 = Symbol('page');
|
||||
reg.registerAction({ id: 'a', label: 'A', handler: vi.fn(), shortcut: 'mod+k' }, f1);
|
||||
reg.registerAction({ id: 'b', label: 'B', handler: vi.fn(), shortcut: 'mod+k' }, f2);
|
||||
const active = reg.getActiveActions([f1, f2]);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('subscribe fires on register/unregister', () => {
|
||||
const listener = vi.fn();
|
||||
const unsub = reg.subscribe(listener);
|
||||
const frame = Symbol('global');
|
||||
const dispose = reg.registerAction(baseAction, frame);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('version counter stable when unchanged', () => {
|
||||
const frame = Symbol('global');
|
||||
reg.registerAction(baseAction, frame);
|
||||
const a = reg.getActiveActions([frame]);
|
||||
const b = reg.getActiveActions([frame]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('version counter new ref on change', () => {
|
||||
const frame = Symbol('global');
|
||||
reg.registerAction(baseAction, frame);
|
||||
const a = reg.getActiveActions([frame]);
|
||||
reg.registerAction({ id: 'other', label: 'O', handler: vi.fn() }, frame);
|
||||
const b = reg.getActiveActions([frame]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
describe('runAction', () => {
|
||||
it('happy path fires + returns true', () => {
|
||||
const frame = Symbol('global');
|
||||
const handler = vi.fn();
|
||||
reg.registerAction({ id: 'x', label: 'X', handler }, frame);
|
||||
reg.setActiveStack([frame]);
|
||||
expect(reg.runAction('x')).toBe(true);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('disabled returns false without firing', () => {
|
||||
const frame = Symbol('global');
|
||||
const handler = vi.fn();
|
||||
reg.registerAction({ id: 'x', label: 'X', handler, enabled: () => false }, frame);
|
||||
reg.setActiveStack([frame]);
|
||||
expect(reg.runAction('x')).toBe(false);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
it('unknown id returns false without throwing', () => {
|
||||
expect(reg.runAction('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatShortcut, matchEvent, parseShortcut } from '../shortcut';
|
||||
|
||||
describe('parseShortcut', () => {
|
||||
it('parses mod+k', () => {
|
||||
expect(parseShortcut('mod+k')).toEqual({
|
||||
key: 'k',
|
||||
mod: true,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
});
|
||||
});
|
||||
it('parses shift+mod+p', () => {
|
||||
expect(parseShortcut('shift+mod+p')).toEqual({
|
||||
key: 'p',
|
||||
mod: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
});
|
||||
});
|
||||
it('parses ?', () => {
|
||||
expect(parseShortcut('?')).toEqual({
|
||||
key: '?',
|
||||
mod: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
});
|
||||
});
|
||||
it('parses escape and f1 and arrowup', () => {
|
||||
expect(parseShortcut('escape').key).toBe('escape');
|
||||
expect(parseShortcut('f1').key).toBe('f1');
|
||||
expect(parseShortcut('arrowup').key).toBe('arrowup');
|
||||
});
|
||||
it('parses mod+,', () => {
|
||||
expect(parseShortcut('mod+,')).toEqual({
|
||||
key: ',',
|
||||
mod: true,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
});
|
||||
});
|
||||
it('throws on empty', () => {
|
||||
expect(() => parseShortcut('')).toThrow();
|
||||
});
|
||||
it('throws on modifier-only', () => {
|
||||
expect(() => parseShortcut('mod')).toThrow();
|
||||
});
|
||||
it('throws on meta+k (must use mod)', () => {
|
||||
expect(() => parseShortcut('meta+k')).toThrow();
|
||||
});
|
||||
it('memoizes', () => {
|
||||
expect(parseShortcut('mod+k')).toBe(parseShortcut('mod+k'));
|
||||
});
|
||||
});
|
||||
|
||||
function ke(opts: Partial<KeyboardEventInit> & { key: string }): KeyboardEvent {
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: opts.key,
|
||||
metaKey: !!opts.metaKey,
|
||||
ctrlKey: !!opts.ctrlKey,
|
||||
shiftKey: !!opts.shiftKey,
|
||||
altKey: !!opts.altKey,
|
||||
});
|
||||
}
|
||||
|
||||
describe('matchEvent (mac)', () => {
|
||||
const origPlatform = navigator.platform;
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true });
|
||||
});
|
||||
afterAll(() => {
|
||||
Object.defineProperty(navigator, 'platform', { value: origPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it('mod+k matches metaKey+k', () => {
|
||||
expect(matchEvent(parseShortcut('mod+k'), ke({ key: 'k', metaKey: true }))).toBe(true);
|
||||
});
|
||||
it('mod+k does NOT match ctrlKey+k on mac', () => {
|
||||
expect(matchEvent(parseShortcut('mod+k'), ke({ key: 'k', ctrlKey: true }))).toBe(false);
|
||||
});
|
||||
it('k does not match shift+k', () => {
|
||||
expect(matchEvent(parseShortcut('k'), ke({ key: 'K', shiftKey: true }))).toBe(false);
|
||||
});
|
||||
it('? matches e.key === "?"', () => {
|
||||
expect(matchEvent(parseShortcut('?'), ke({ key: '?' }))).toBe(true);
|
||||
});
|
||||
it('escape matches Escape', () => {
|
||||
expect(matchEvent(parseShortcut('escape'), ke({ key: 'Escape' }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchEvent (non-mac)', () => {
|
||||
const origPlatform = navigator.platform;
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true });
|
||||
});
|
||||
afterAll(() => {
|
||||
Object.defineProperty(navigator, 'platform', { value: origPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it('mod+k matches ctrlKey+k', () => {
|
||||
expect(matchEvent(parseShortcut('mod+k'), ke({ key: 'k', ctrlKey: true }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortcut', () => {
|
||||
it('mac renders glyphs', () => {
|
||||
expect(formatShortcut(parseShortcut('shift+mod+k'), true)).toEqual(['⇧', '⌘', 'K']);
|
||||
});
|
||||
it('pc renders labels', () => {
|
||||
expect(formatShortcut(parseShortcut('shift+mod+k'), false)).toEqual(['Shift', 'Ctrl', 'K']);
|
||||
});
|
||||
it('single printable renders alone', () => {
|
||||
expect(formatShortcut(parseShortcut('?'), true)).toEqual(['?']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
import { __metaAssertPressKeyReachesCaptureListener } from '../../../test/commandTestUtils';
|
||||
|
||||
describe('commandTestUtils', () => {
|
||||
it('pressKey reaches capture-phase listeners', () => {
|
||||
__metaAssertPressKeyReachesCaptureListener();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { StrictMode, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { hotkeyManager } from '../hotkeyManager';
|
||||
import { ScopeContext } from '../ScopeContext';
|
||||
import { useHotkey } from '../useHotkey';
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
hotkeyManager.init();
|
||||
});
|
||||
|
||||
function Wrapper({ children, frame }: { children: React.ReactNode; frame: symbol }) {
|
||||
return <ScopeContext.Provider value={frame}>{children}</ScopeContext.Provider>;
|
||||
}
|
||||
|
||||
function TestHotkey({ shortcut, handler }: { shortcut: string; handler: () => void }) {
|
||||
useHotkey(shortcut, handler);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useHotkey', () => {
|
||||
it('binds on mount and unbinds on unmount', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
const { unmount } = render(
|
||||
<Wrapper frame={frame}>
|
||||
<TestHotkey shortcut="k" handler={handler} />
|
||||
</Wrapper>
|
||||
);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
hotkeyManager.popFrame(frame);
|
||||
});
|
||||
|
||||
it('StrictMode double-mount yields net 1 binding', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<StrictMode>
|
||||
<Wrapper frame={frame}>
|
||||
<TestHotkey shortcut="k" handler={handler} />
|
||||
</Wrapper>
|
||||
</StrictMode>
|
||||
);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
hotkeyManager.popFrame(frame);
|
||||
});
|
||||
|
||||
it('handler identity updates via ref without re-registration', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
const calls: string[] = [];
|
||||
function Inner() {
|
||||
const [n, setN] = useState(0);
|
||||
useHotkey('k', () => calls.push(`v${n}`));
|
||||
return <button onClick={() => setN(v => v + 1)}>bump</button>;
|
||||
}
|
||||
const { getByText } = render(
|
||||
<Wrapper frame={frame}>
|
||||
<Inner />
|
||||
</Wrapper>
|
||||
);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
act(() => {
|
||||
getByText('bump').click();
|
||||
});
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
expect(calls).toEqual(['v0', 'v1']);
|
||||
hotkeyManager.popFrame(frame);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { hotkeyManager } from '../hotkeyManager';
|
||||
import { registry } from '../registry';
|
||||
import { ScopeContext } from '../ScopeContext';
|
||||
import { useRegisterAction } from '../useRegisterAction';
|
||||
|
||||
beforeEach(() => {
|
||||
hotkeyManager.teardown();
|
||||
hotkeyManager.init();
|
||||
});
|
||||
|
||||
function Wrapper({ children, frame }: { children: React.ReactNode; frame: symbol }) {
|
||||
return <ScopeContext.Provider value={frame}>{children}</ScopeContext.Provider>;
|
||||
}
|
||||
|
||||
function TestAction({
|
||||
id,
|
||||
shortcut,
|
||||
handler,
|
||||
}: {
|
||||
id: string;
|
||||
shortcut?: string;
|
||||
handler: () => void;
|
||||
}) {
|
||||
useRegisterAction({ id, label: id, handler, shortcut });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useRegisterAction', () => {
|
||||
it('adds to registry on mount, removes on unmount', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
registry.setActiveStack([frame]);
|
||||
const handler = vi.fn();
|
||||
const { unmount } = render(
|
||||
<Wrapper frame={frame}>
|
||||
<TestAction id="x.y" handler={handler} />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(registry.getAction('x.y')?.id).toBe('x.y');
|
||||
unmount();
|
||||
expect(registry.getAction('x.y')).toBeUndefined();
|
||||
hotkeyManager.popFrame(frame);
|
||||
registry.setActiveStack([]);
|
||||
});
|
||||
|
||||
it('with shortcut: fires via keydown AND registers in registry', () => {
|
||||
const frame = hotkeyManager.pushFrame('global', 'root');
|
||||
registry.setActiveStack([frame]);
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<Wrapper frame={frame}>
|
||||
<TestAction id="x.y" shortcut="k" handler={handler} />
|
||||
</Wrapper>
|
||||
);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(registry.getAction('x.y')).toBeDefined();
|
||||
hotkeyManager.popFrame(frame);
|
||||
registry.setActiveStack([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { hotkeyManager } from './hotkeyManager';
|
||||
import { registry } from './registry';
|
||||
|
||||
export const GROUP_ORDER = ['Navigation'] as const;
|
||||
|
||||
export function registerGlobalActions(
|
||||
navigate: NavigateFunction,
|
||||
globalScopeSymbol: symbol
|
||||
): () => void {
|
||||
const nav = (path: string) => () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: 'nav.home',
|
||||
label: 'Go Home',
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+1',
|
||||
handler: nav('/home'),
|
||||
keywords: ['dashboard'],
|
||||
},
|
||||
{
|
||||
id: 'nav.chat',
|
||||
label: 'Go to Chat',
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+2',
|
||||
handler: nav('/chat'),
|
||||
keywords: ['conversations', 'messages', 'inbox'],
|
||||
},
|
||||
{
|
||||
id: 'nav.intelligence',
|
||||
label: 'Go to Intelligence',
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+3',
|
||||
handler: nav('/intelligence'),
|
||||
keywords: ['memory', 'knowledge'],
|
||||
},
|
||||
{
|
||||
id: 'nav.skills',
|
||||
label: 'Go to Skills',
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+4',
|
||||
handler: nav('/skills'),
|
||||
keywords: ['plugins', 'tools'],
|
||||
},
|
||||
{
|
||||
id: 'nav.settings',
|
||||
label: 'Open Settings',
|
||||
group: 'Navigation',
|
||||
shortcut: 'mod+,',
|
||||
handler: nav('/settings'),
|
||||
keywords: ['preferences', 'config'],
|
||||
},
|
||||
];
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
for (const a of actions) {
|
||||
const disposeRegistry = registry.registerAction(a, globalScopeSymbol);
|
||||
const bindingSym = hotkeyManager.bind(globalScopeSymbol, {
|
||||
shortcut: a.shortcut,
|
||||
handler: a.handler,
|
||||
id: a.id,
|
||||
});
|
||||
disposers.push(() => {
|
||||
disposeRegistry();
|
||||
hotkeyManager.unbind(globalScopeSymbol, bindingSym);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const d of disposers) d();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { matchEvent, parseShortcut } from './shortcut';
|
||||
import type { ActiveBinding, HotkeyBinding, ScopeFrame, ScopeKind } from './types';
|
||||
|
||||
interface FrameInternal extends ScopeFrame {
|
||||
bindings: Map<symbol, { binding: HotkeyBinding; parsed: ReturnType<typeof parseShortcut> }>;
|
||||
}
|
||||
|
||||
function isEditableTarget(e: KeyboardEvent): boolean {
|
||||
const path = (e.composedPath && e.composedPath()) || [];
|
||||
const nodes = path.length ? path : [e.target as EventTarget | null];
|
||||
for (const node of nodes) {
|
||||
if (!node || !(node as HTMLElement).tagName) continue;
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (el.isContentEditable === true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface HotkeyManager {
|
||||
init: () => void;
|
||||
teardown: () => void;
|
||||
pushFrame: (kind: ScopeKind, id: string) => symbol;
|
||||
popFrame: (sym: symbol) => void;
|
||||
bind: (frame: symbol, binding: HotkeyBinding) => symbol;
|
||||
unbind: (frame: symbol, bindingSymbol: symbol) => void;
|
||||
getStackSymbols: () => symbol[];
|
||||
getActiveBindings: () => ActiveBinding[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export function createHotkeyManager(): HotkeyManager {
|
||||
const stack: FrameInternal[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
let initialized = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const composing = (e as any).isComposing === true || (e as any).keyCode === 229;
|
||||
if (composing) return;
|
||||
|
||||
const inEditable = isEditableTarget(e);
|
||||
|
||||
// Snapshot frames + bindings so handlers that push/pop frames
|
||||
// or bind/unbind during dispatch can't corrupt iteration.
|
||||
const frames = stack.slice();
|
||||
for (let i = frames.length - 1; i >= 0; i--) {
|
||||
const frame = frames[i];
|
||||
const entries = [...frame.bindings.entries()];
|
||||
for (let j = entries.length - 1; j >= 0; j--) {
|
||||
const [, { binding, parsed }] = entries[j];
|
||||
if (!matchEvent(parsed, e)) continue;
|
||||
if (e.repeat && !binding.repeat) continue;
|
||||
if (inEditable && !binding.allowInInput) continue;
|
||||
if (binding.enabled && !binding.enabled()) continue;
|
||||
if (binding.preventDefault !== false) e.preventDefault();
|
||||
try {
|
||||
const r = binding.handler();
|
||||
if (r && typeof (r as Promise<unknown>).catch === 'function') {
|
||||
(r as Promise<unknown>).catch(err => console.error('[hotkey] handler rejected', err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[hotkey] handler threw', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
if (initialized) return;
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!initialized) return;
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
initialized = false;
|
||||
stack.length = 0;
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
function pushFrame(kind: ScopeKind, id: string): symbol {
|
||||
const sym = Symbol(`${kind}:${id}`);
|
||||
stack.push({ symbol: sym, id, kind, bindings: new Map() });
|
||||
notify();
|
||||
return sym;
|
||||
}
|
||||
|
||||
function popFrame(sym: symbol): void {
|
||||
const idx = stack.findIndex(f => f.symbol === sym);
|
||||
if (idx === -1) return;
|
||||
stack.splice(idx, 1);
|
||||
notify();
|
||||
}
|
||||
|
||||
function bind(frameSym: symbol, binding: HotkeyBinding): symbol {
|
||||
const frame = stack.find(f => f.symbol === frameSym);
|
||||
if (!frame) throw new Error('hotkeyManager.bind: unknown frame');
|
||||
const parsed = parseShortcut(binding.shortcut);
|
||||
const sym = Symbol(binding.id ?? binding.shortcut);
|
||||
frame.bindings.set(sym, { binding, parsed });
|
||||
notify();
|
||||
return sym;
|
||||
}
|
||||
|
||||
function unbind(frameSym: symbol, bindingSym: symbol): void {
|
||||
const frame = stack.find(f => f.symbol === frameSym);
|
||||
if (!frame) return;
|
||||
if (frame.bindings.delete(bindingSym)) notify();
|
||||
}
|
||||
|
||||
function getStackSymbols(): symbol[] {
|
||||
return stack.map(f => f.symbol);
|
||||
}
|
||||
|
||||
function bindingDedupKey(parsed: ReturnType<typeof parseShortcut>): string {
|
||||
const flags =
|
||||
(parsed.mod ? 'M' : '') +
|
||||
(parsed.ctrl ? 'C' : '') +
|
||||
(parsed.shift ? 'S' : '') +
|
||||
(parsed.alt ? 'A' : '');
|
||||
return `${flags}|${parsed.key}`;
|
||||
}
|
||||
|
||||
function getActiveBindings(): ActiveBinding[] {
|
||||
const out: ActiveBinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Walk top-of-stack downwards so inner scopes shadow outer ones.
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
const frame = stack[i];
|
||||
for (const { binding, parsed } of frame.bindings.values()) {
|
||||
if (binding.enabled && !binding.enabled()) continue;
|
||||
const key = bindingDedupKey(parsed);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
frame: { symbol: frame.symbol, id: frame.id, kind: frame.kind },
|
||||
binding,
|
||||
parsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
teardown,
|
||||
pushFrame,
|
||||
popFrame,
|
||||
bind,
|
||||
unbind,
|
||||
getStackSymbols,
|
||||
getActiveBindings,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
export const hotkeyManager = createHotkeyManager();
|
||||
@@ -0,0 +1,170 @@
|
||||
import { parseShortcut } from './shortcut';
|
||||
import type { Action, RegisteredAction } from './types';
|
||||
|
||||
export interface Registry {
|
||||
registerAction: (action: Action, scopeFrame: symbol) => () => void;
|
||||
getAction: (id: string) => RegisteredAction | undefined;
|
||||
getActiveActions: (scopeStack: symbol[]) => RegisteredAction[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
runAction: (id: string) => boolean;
|
||||
setActiveStack: (stack: symbol[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function shortcutDedupKey(shortcut: string): string {
|
||||
try {
|
||||
const p = parseShortcut(shortcut);
|
||||
const flags =
|
||||
(p.mod ? 'M' : '') + (p.ctrl ? 'C' : '') + (p.shift ? 'S' : '') + (p.alt ? 'A' : '');
|
||||
return `${flags}|${p.key}`;
|
||||
} catch {
|
||||
return `raw|${shortcut}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRegistry(): Registry {
|
||||
const byFrame = new Map<symbol, Map<string, RegisteredAction>>();
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
const snapshotCache = new Map<string, RegisteredAction[]>();
|
||||
let activeStack: symbol[] = [];
|
||||
const symbolIds = new Map<symbol, number>();
|
||||
let nextSymbolId = 1;
|
||||
|
||||
function getSymbolId(sym: symbol): number {
|
||||
let id = symbolIds.get(sym);
|
||||
if (!id) {
|
||||
id = nextSymbolId++;
|
||||
symbolIds.set(sym, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function bump(): void {
|
||||
version += 1;
|
||||
snapshotCache.clear();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function stackKey(stack: symbol[]): string {
|
||||
return `${version}:${stack.map(getSymbolId).join('>')}`;
|
||||
}
|
||||
|
||||
function registerAction(action: Action, scopeFrame: symbol): () => void {
|
||||
let frame = byFrame.get(scopeFrame);
|
||||
if (!frame) {
|
||||
frame = new Map();
|
||||
byFrame.set(scopeFrame, frame);
|
||||
}
|
||||
if (frame.has(action.id)) {
|
||||
console.warn(`[commands] duplicate action id "${action.id}" in the same scope — replacing`);
|
||||
}
|
||||
const registered: RegisteredAction = { ...action, scopeFrame };
|
||||
if (action.shortcut) parseShortcut(action.shortcut);
|
||||
frame.set(action.id, registered);
|
||||
bump();
|
||||
return () => {
|
||||
const f = byFrame.get(scopeFrame);
|
||||
if (!f) return;
|
||||
if (f.get(action.id) !== registered) return;
|
||||
if (f.delete(action.id)) {
|
||||
if (f.size === 0) byFrame.delete(scopeFrame);
|
||||
bump();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getAction(id: string): RegisteredAction | undefined {
|
||||
for (let i = activeStack.length - 1; i >= 0; i--) {
|
||||
const frame = byFrame.get(activeStack[i]);
|
||||
const hit = frame?.get(id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const frame of byFrame.values()) {
|
||||
const hit = frame.get(id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getActiveActions(scopeStack: symbol[]): RegisteredAction[] {
|
||||
const key = stackKey(scopeStack);
|
||||
const cached = snapshotCache.get(key);
|
||||
if (cached) return cached;
|
||||
const seenId = new Set<string>();
|
||||
const seenShortcut = new Set<string>();
|
||||
const out: RegisteredAction[] = [];
|
||||
for (let i = scopeStack.length - 1; i >= 0; i--) {
|
||||
const frame = byFrame.get(scopeStack[i]);
|
||||
if (!frame) continue;
|
||||
for (const action of frame.values()) {
|
||||
if (seenId.has(action.id)) continue;
|
||||
if (action.enabled && !action.enabled()) continue;
|
||||
if (action.shortcut) {
|
||||
const sk = shortcutDedupKey(action.shortcut);
|
||||
if (seenShortcut.has(sk)) {
|
||||
seenId.add(action.id);
|
||||
continue;
|
||||
}
|
||||
seenShortcut.add(sk);
|
||||
}
|
||||
seenId.add(action.id);
|
||||
out.push(action);
|
||||
}
|
||||
}
|
||||
snapshotCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function runAction(id: string): boolean {
|
||||
const action = getAction(id);
|
||||
if (!action) return false;
|
||||
if (action.enabled && !action.enabled()) return false;
|
||||
try {
|
||||
const r = action.handler();
|
||||
if (r instanceof Promise)
|
||||
r.catch(err => console.error('[commands] action rejected', id, err));
|
||||
} catch (err) {
|
||||
console.error('[commands] action threw', id, err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setActiveStack(stack: symbol[]): void {
|
||||
const changed =
|
||||
stack.length !== activeStack.length || stack.some((sym, index) => sym !== activeStack[index]);
|
||||
if (!changed) return;
|
||||
activeStack = [...stack];
|
||||
snapshotCache.clear();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
byFrame.clear();
|
||||
listeners.clear();
|
||||
snapshotCache.clear();
|
||||
activeStack = [];
|
||||
symbolIds.clear();
|
||||
nextSymbolId = 1;
|
||||
version = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
registerAction,
|
||||
getAction,
|
||||
getActiveActions,
|
||||
subscribe,
|
||||
runAction,
|
||||
setActiveStack,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const registry = createRegistry();
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ParsedShortcut, ShortcutString } from './types';
|
||||
|
||||
const MODIFIER_TOKENS = new Set(['mod', 'shift', 'alt', 'ctrl']);
|
||||
const NAMED_KEYS = new Set([
|
||||
'escape',
|
||||
'enter',
|
||||
'tab',
|
||||
'space',
|
||||
'arrowup',
|
||||
'arrowdown',
|
||||
'arrowleft',
|
||||
'arrowright',
|
||||
'backspace',
|
||||
'delete',
|
||||
'home',
|
||||
'end',
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12',
|
||||
]);
|
||||
|
||||
const parseCache = new Map<string, ParsedShortcut>();
|
||||
|
||||
export function parseShortcut(raw: ShortcutString): ParsedShortcut {
|
||||
const cached = parseCache.get(raw);
|
||||
if (cached) return cached;
|
||||
if (!raw) throw new Error('parseShortcut: empty shortcut string');
|
||||
const rawTokens = raw
|
||||
.toLowerCase()
|
||||
.split('+')
|
||||
.map(t => t.trim());
|
||||
// Reject malformed shortcuts explicitly instead of silently dropping empty
|
||||
// tokens: "mod++k", "mod+ +k", and trailing "+" all need to fail loudly.
|
||||
if (rawTokens.some(t => t.length === 0)) {
|
||||
throw new Error(`parseShortcut: empty token in "${raw}"`);
|
||||
}
|
||||
const tokens = rawTokens;
|
||||
if (tokens.length === 0) throw new Error(`parseShortcut: invalid shortcut "${raw}"`);
|
||||
const key = tokens[tokens.length - 1];
|
||||
if (MODIFIER_TOKENS.has(key)) throw new Error(`parseShortcut: shortcut "${raw}" has no key`);
|
||||
if (key === 'meta' || key === 'cmd' || key === 'command') {
|
||||
throw new Error(`parseShortcut: use "mod" instead of "${key}" in "${raw}"`);
|
||||
}
|
||||
if (
|
||||
key.length > 1 &&
|
||||
!NAMED_KEYS.has(key) &&
|
||||
!/^[a-z0-9]$/.test(key) &&
|
||||
!/^[\p{P}\p{S}]$/u.test(key)
|
||||
) {
|
||||
throw new Error(`parseShortcut: unknown key "${key}" in "${raw}"`);
|
||||
}
|
||||
const result: ParsedShortcut = { key, mod: false, shift: false, alt: false, ctrl: false };
|
||||
const seenModifiers = new Set<string>();
|
||||
for (let i = 0; i < tokens.length - 1; i++) {
|
||||
const m = tokens[i];
|
||||
if (m === 'mod' || m === 'shift' || m === 'alt' || m === 'ctrl') {
|
||||
if (seenModifiers.has(m)) {
|
||||
throw new Error(`parseShortcut: duplicate modifier "${m}" in "${raw}"`);
|
||||
}
|
||||
seenModifiers.add(m);
|
||||
result[m] = true;
|
||||
} else {
|
||||
throw new Error(`parseShortcut: unknown modifier "${m}" in "${raw}"`);
|
||||
}
|
||||
}
|
||||
parseCache.set(raw, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return navigator.platform.toLowerCase().includes('mac');
|
||||
}
|
||||
|
||||
export function matchEvent(parsed: ParsedShortcut, e: KeyboardEvent): boolean {
|
||||
const mac = isMac();
|
||||
const modPressed = mac ? e.metaKey : e.ctrlKey;
|
||||
if (parsed.mod !== modPressed) return false;
|
||||
// Explicit ctrl flag tracks e.ctrlKey on mac (where ctrl is independent of mod).
|
||||
// On non-mac, ctrl IS the mod, so explicit ctrl must equal mod.
|
||||
if (mac) {
|
||||
if (parsed.ctrl !== e.ctrlKey) return false;
|
||||
} else {
|
||||
// non-mac: don't double-check ctrl when mod is set (mod === ctrl)
|
||||
if (!parsed.mod && parsed.ctrl !== e.ctrlKey) return false;
|
||||
}
|
||||
// For shifted punctuation (e.g. '?'), e.key already encodes the shift layer,
|
||||
// so don't require an explicit `shift+` in the shortcut string.
|
||||
const keyIsShiftedPunct = parsed.key.length === 1 && /[^\p{L}\p{N}]/u.test(parsed.key);
|
||||
if (!keyIsShiftedPunct && parsed.shift !== e.shiftKey) return false;
|
||||
if (parsed.alt !== e.altKey) return false;
|
||||
const eventKey = e.key.toLowerCase();
|
||||
return eventKey === parsed.key;
|
||||
}
|
||||
|
||||
const MAC_GLYPHS: Record<string, string> = {
|
||||
mod: '⌘',
|
||||
shift: '⇧',
|
||||
alt: '⌥',
|
||||
ctrl: '⌃',
|
||||
escape: 'Esc',
|
||||
enter: '↵',
|
||||
tab: '⇥',
|
||||
space: '␣',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
backspace: '⌫',
|
||||
delete: '⌦',
|
||||
};
|
||||
const PC_LABELS: Record<string, string> = {
|
||||
mod: 'Ctrl',
|
||||
shift: 'Shift',
|
||||
alt: 'Alt',
|
||||
ctrl: 'Ctrl',
|
||||
escape: 'Esc',
|
||||
enter: 'Enter',
|
||||
tab: 'Tab',
|
||||
space: 'Space',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Delete',
|
||||
};
|
||||
|
||||
export function formatShortcut(parsed: ParsedShortcut, mac: boolean): string[] {
|
||||
const table = mac ? MAC_GLYPHS : PC_LABELS;
|
||||
const out: string[] = [];
|
||||
if (parsed.ctrl && !parsed.mod) out.push(table.ctrl);
|
||||
if (parsed.alt) out.push(table.alt);
|
||||
if (parsed.shift) out.push(table.shift);
|
||||
if (parsed.mod) out.push(table.mod);
|
||||
const k = parsed.key;
|
||||
if (table[k]) out.push(table[k]);
|
||||
else if (k.length === 1) out.push(k.toUpperCase());
|
||||
else if (/^f\d+$/.test(k)) out.push(k.toUpperCase());
|
||||
else out.push(k);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type ScopeKind = 'global' | 'page' | 'modal';
|
||||
export type ShortcutString = string;
|
||||
|
||||
export interface ParsedShortcut {
|
||||
key: string;
|
||||
mod: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
ctrl: boolean;
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
group?: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
shortcut?: ShortcutString;
|
||||
scope?: ScopeKind;
|
||||
enabled?: () => boolean;
|
||||
handler: () => void | Promise<void>;
|
||||
allowInInput?: boolean;
|
||||
repeat?: boolean;
|
||||
preventDefault?: boolean;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface RegisteredAction extends Action {
|
||||
scopeFrame: symbol;
|
||||
}
|
||||
|
||||
export interface HotkeyBinding {
|
||||
shortcut: ShortcutString;
|
||||
handler: () => void | Promise<void>;
|
||||
scope?: ScopeKind;
|
||||
enabled?: () => boolean;
|
||||
allowInInput?: boolean;
|
||||
repeat?: boolean;
|
||||
preventDefault?: boolean;
|
||||
description?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ScopeFrame {
|
||||
symbol: symbol;
|
||||
id: string;
|
||||
kind: ScopeKind;
|
||||
}
|
||||
|
||||
export interface ActiveBinding {
|
||||
frame: ScopeFrame;
|
||||
binding: HotkeyBinding;
|
||||
parsed: ParsedShortcut;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import { hotkeyManager } from './hotkeyManager';
|
||||
import { ScopeContext } from './ScopeContext';
|
||||
import type { HotkeyBinding } from './types';
|
||||
|
||||
type HotkeyOptions = Omit<HotkeyBinding, 'shortcut' | 'handler'>;
|
||||
|
||||
export function useHotkey(
|
||||
shortcut: string,
|
||||
handler: () => void,
|
||||
options: HotkeyOptions = {}
|
||||
): void {
|
||||
const frame = useContext(ScopeContext);
|
||||
const handlerRef = useRef(handler);
|
||||
const optsRef = useRef(options);
|
||||
handlerRef.current = handler;
|
||||
optsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
const stable = () => handlerRef.current();
|
||||
// Always route `enabled` through the ref; callers can toggle it at any
|
||||
// render without rebinding.
|
||||
const stableEnabled = () => optsRef.current.enabled?.() ?? true;
|
||||
const sym = hotkeyManager.bind(frame, {
|
||||
shortcut,
|
||||
handler: stable,
|
||||
allowInInput: optsRef.current.allowInInput,
|
||||
repeat: optsRef.current.repeat,
|
||||
preventDefault: optsRef.current.preventDefault,
|
||||
enabled: stableEnabled,
|
||||
description: optsRef.current.description,
|
||||
id: optsRef.current.id,
|
||||
});
|
||||
return () => hotkeyManager.unbind(frame, sym);
|
||||
}, [shortcut, frame]);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
|
||||
import { hotkeyManager } from './hotkeyManager';
|
||||
import { registry } from './registry';
|
||||
import { ScopeContext } from './ScopeContext';
|
||||
import { parseShortcut } from './shortcut';
|
||||
import type { Action } from './types';
|
||||
|
||||
export function useRegisterAction(action: Action): void {
|
||||
const frame = useContext(ScopeContext);
|
||||
const handlerRef = useRef(action.handler);
|
||||
const enabledRef = useRef(action.enabled);
|
||||
handlerRef.current = action.handler;
|
||||
enabledRef.current = action.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!frame) {
|
||||
throw new Error(
|
||||
'useRegisterAction: no ScopeContext frame. Wrap your tree in a ScopeProvider (e.g. CommandProvider).'
|
||||
);
|
||||
}
|
||||
const stable = () => {
|
||||
handlerRef.current();
|
||||
};
|
||||
// Always route enabled through the ref so flipping it between undefined
|
||||
// and a predicate takes effect without rebinding.
|
||||
const stableEnabled = () => enabledRef.current?.() ?? true;
|
||||
const disposeRegistry = registry.registerAction(
|
||||
{ ...action, handler: stable, enabled: stableEnabled },
|
||||
frame
|
||||
);
|
||||
let bindingSym: symbol | undefined;
|
||||
if (action.shortcut) {
|
||||
parseShortcut(action.shortcut);
|
||||
bindingSym = hotkeyManager.bind(frame, {
|
||||
shortcut: action.shortcut,
|
||||
handler: stable,
|
||||
allowInInput: action.allowInInput,
|
||||
repeat: action.repeat,
|
||||
preventDefault: action.preventDefault,
|
||||
enabled: stableEnabled,
|
||||
id: action.id,
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
disposeRegistry();
|
||||
if (bindingSym) hotkeyManager.unbind(frame, bindingSym);
|
||||
};
|
||||
}, [
|
||||
action.id,
|
||||
action.shortcut,
|
||||
action.allowInInput,
|
||||
action.repeat,
|
||||
action.preventDefault,
|
||||
frame,
|
||||
]);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
|
||||
import TokenUsagePill from '../components/chat/TokenUsagePill';
|
||||
import UpsellBanner from '../components/upsell/UpsellBanner';
|
||||
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
||||
import UsageLimitModal from '../components/upsell/UsageLimitModal';
|
||||
import { useStickToBottom } from '../hooks/useStickToBottom';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
|
||||
import {
|
||||
@@ -127,7 +128,6 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
} = useUsageState();
|
||||
const [showLimitModal, setShowLimitModal] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
@@ -195,11 +195,12 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
}
|
||||
}, [selectedThreadId, messages.length, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages]);
|
||||
const location = useLocation();
|
||||
const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom(
|
||||
messages,
|
||||
selectedThreadId,
|
||||
location.pathname
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onDictationInsert = (event: Event) => {
|
||||
@@ -739,13 +740,23 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
<p className="px-4 py-6 text-xs text-stone-400 text-center">No threads yet</p>
|
||||
) : (
|
||||
sortedThreads.map(thread => (
|
||||
<button
|
||||
<div
|
||||
key={thread.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
dispatch(setSelectedThread(thread.id));
|
||||
void dispatch(loadThreadMessages(thread.id));
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 border-b border-stone-50 transition-colors group ${
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dispatch(setSelectedThread(thread.id));
|
||||
void dispatch(loadThreadMessages(thread.id));
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 border-b border-stone-50 transition-colors group cursor-pointer ${
|
||||
selectedThreadId === thread.id
|
||||
? 'bg-primary-50 border-l-2 border-l-primary-500'
|
||||
: 'hover:bg-stone-50'
|
||||
@@ -790,7 +801,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
</span>
|
||||
)}
|
||||
</div> */}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -833,7 +844,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6]">
|
||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6]">
|
||||
{isLoadingMessages ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export interface PressKeyOptions {
|
||||
key: string;
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
ctrl?: boolean;
|
||||
target?: EventTarget;
|
||||
}
|
||||
|
||||
export function pressKey(opts: PressKeyOptions): KeyboardEvent {
|
||||
const mac = navigator.platform.toLowerCase().includes('mac');
|
||||
const modPair = opts.mod ? (mac ? { metaKey: true } : { ctrlKey: true }) : {};
|
||||
const target = opts.target ?? window;
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: opts.key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
shiftKey: !!opts.shift,
|
||||
altKey: !!opts.alt,
|
||||
ctrlKey: !!opts.ctrl,
|
||||
...modPair,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function __metaAssertPressKeyReachesCaptureListener(): void {
|
||||
let reached = false;
|
||||
const listener = (_e: KeyboardEvent) => {
|
||||
reached = true;
|
||||
};
|
||||
window.addEventListener('keydown', listener, { capture: true });
|
||||
pressKey({ key: 'z' });
|
||||
window.removeEventListener('keydown', listener, { capture: true });
|
||||
expect(reached).toBe(true);
|
||||
}
|
||||
@@ -24,6 +24,20 @@ import {
|
||||
vi.stubEnv('DEV', true);
|
||||
vi.stubEnv('MODE', 'test');
|
||||
|
||||
// Polyfill ResizeObserver for cmdk/Radix components in jsdom
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
// Polyfill scrollIntoView for cmdk in jsdom
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = function () {};
|
||||
}
|
||||
|
||||
// Mock Tauri APIs (not available in test env)
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) }));
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ module.exports = {
|
||||
|
||||
// Elevated color system - Clean, light, professional
|
||||
colors: {
|
||||
// Command surface tokens — scoped to the ⌘K palette / help overlay.
|
||||
// Expand this set only with intent; the full reskin design system
|
||||
// is a separate decision.
|
||||
'cmd-surface': 'var(--cmd-surface)',
|
||||
'cmd-surface-elevated': 'var(--cmd-surface-elevated)',
|
||||
'cmd-foreground': 'var(--cmd-foreground)',
|
||||
'cmd-foreground-muted': 'var(--cmd-foreground-muted)',
|
||||
'cmd-border': 'var(--cmd-border)',
|
||||
'cmd-ring': 'var(--cmd-ring)',
|
||||
'cmd-accent': 'var(--cmd-accent)',
|
||||
'cmd-overlay': 'var(--cmd-overlay)',
|
||||
|
||||
// Neutral - Light theme grayscale (from Figma design tokens)
|
||||
neutral: {
|
||||
0: '#FFFFFF', // Base / surface
|
||||
@@ -212,6 +224,7 @@ module.exports = {
|
||||
'large': '0 8px 24px -4px rgba(0, 0, 0, 0.10), 0 16px 32px -8px rgba(0, 0, 0, 0.10)',
|
||||
'float': '0 12px 32px -8px rgba(0, 0, 0, 0.12), 0 24px 48px -12px rgba(0, 0, 0, 0.12)',
|
||||
'crisp': '0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.08)',
|
||||
'cmd-palette': 'var(--cmd-shadow-palette)',
|
||||
},
|
||||
|
||||
// Premium animations for polished interactions
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { waitForWebView } from '../helpers/element-helpers';
|
||||
|
||||
// Dispatch a keydown on window (capture-phase hotkey listener lives there).
|
||||
// `browser.keys()` is unreliable on tauri-driver, so we synthesize the event
|
||||
// directly — this matches the manager's actual listener surface.
|
||||
async function dispatchKey(
|
||||
key: string,
|
||||
opts: { meta?: boolean; ctrl?: boolean; shift?: boolean } = {}
|
||||
): Promise<void> {
|
||||
await browser.execute(
|
||||
(k: string, meta: boolean, ctrl: boolean, shift: boolean) => {
|
||||
const ev = new KeyboardEvent('keydown', {
|
||||
key: k,
|
||||
metaKey: meta,
|
||||
ctrlKey: ctrl,
|
||||
shiftKey: shift,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
},
|
||||
key,
|
||||
!!opts.meta,
|
||||
!!opts.ctrl,
|
||||
!!opts.shift
|
||||
);
|
||||
}
|
||||
|
||||
describe('Command palette', () => {
|
||||
before(async () => {
|
||||
await waitForApp();
|
||||
await waitForWebView();
|
||||
});
|
||||
|
||||
it('opens via mod+K, runs an action, closes and navigates', async () => {
|
||||
await dispatchKey('k', { meta: true });
|
||||
|
||||
const input = await browser.$('input[role="combobox"]');
|
||||
await input.waitForExist({ timeout: 5000 });
|
||||
|
||||
await input.setValue('settings');
|
||||
await browser.keys('Enter');
|
||||
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const hash = (await browser.execute('return window.location.hash')) as string;
|
||||
return typeof hash === 'string' && hash.includes('/settings');
|
||||
},
|
||||
{ timeout: 5000, timeoutMsg: 'hash did not change to /settings' }
|
||||
);
|
||||
|
||||
await browser.waitUntil(async () => !(await input.isExisting()), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'palette did not close after Enter',
|
||||
});
|
||||
});
|
||||
|
||||
it('palette lists the 5 seed nav actions, Esc closes', async () => {
|
||||
await dispatchKey('k', { meta: true });
|
||||
const input = await browser.$('input[role="combobox"]');
|
||||
await input.waitForExist({ timeout: 5000 });
|
||||
|
||||
const seedLabels = [
|
||||
'Go Home',
|
||||
'Go to Chat',
|
||||
'Go to Intelligence',
|
||||
'Go to Skills',
|
||||
'Open Settings',
|
||||
];
|
||||
for (const label of seedLabels) {
|
||||
const el = await browser.$(`*=${label}`);
|
||||
await el.waitForExist({ timeout: 2000, timeoutMsg: `seed action "${label}" missing` });
|
||||
}
|
||||
|
||||
await dispatchKey('Escape');
|
||||
await browser.waitUntil(async () => !(await input.isExisting()), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'palette did not close on Escape',
|
||||
});
|
||||
});
|
||||
|
||||
it('regression probe: pre-existing keydown listeners still attached', async () => {
|
||||
// No dev-only handle is exposed by DictationHotkeyManager (Tauri OS-level
|
||||
// shortcut, not a DOM listener), so we probe window-level listener health
|
||||
// by asserting a fresh dispatch still reaches the command manager —
|
||||
// i.e. no prior test left the manager torn down / stack corrupted.
|
||||
await dispatchKey('k', { meta: true });
|
||||
const input = await browser.$('input[role="combobox"]');
|
||||
await input.waitForExist({ timeout: 5000 });
|
||||
await dispatchKey('Escape');
|
||||
await browser.waitUntil(async () => !(await input.isExisting()), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'palette did not close — hotkey stack may be corrupted',
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -95,7 +95,15 @@ export default defineConfig(async () => ({
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
: {
|
||||
// Tauri CEF loads the app from tauri.localhost; without this the
|
||||
// HMR client tries ws://tauri.localhost/ and gets ERR_CONNECTION_REFUSED.
|
||||
// Force the client to connect to the Vite dev server directly.
|
||||
protocol: "ws",
|
||||
host: "localhost",
|
||||
port: 1420,
|
||||
clientPort: 1420,
|
||||
},
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri` directory (includes src-tauri/ai)
|
||||
ignored: ["**/src-tauri/**"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
# Command Palette + Global Keyboard Shortcut System — Design Spec
|
||||
|
||||
**Phase:** 0 (Frontend reskin foundation)
|
||||
**Branch:** `feat/frontend-reskin`
|
||||
**Worktree:** `~/projects/openhuman-frontend`
|
||||
**Date:** 2026-04-21
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a Superhuman/Linear/Raycast-style command palette (`⌘K`), a global keyboard shortcut system, and a `?` help overlay for OpenHuman. Additive keyboard layer — no existing page visuals touched, no feature flag, no new Redux slices.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Chord sequences (Vim-style `g h`) — API shape allows v2 extension without breaking changes.
|
||||
- App-wide design-system semantic tokens — scoped `cmd-*` tokens only.
|
||||
- Sign Out / Toggle Theme / per-page actions — owned by future feature PRs.
|
||||
- i18n string externalization.
|
||||
- Go Back / Go Forward shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
app/src/lib/commands/
|
||||
├── types.ts # Action, ScopeKind, Shortcut, HotkeyBinding
|
||||
├── shortcut.ts # parseShortcut, matchEvent, formatShortcut
|
||||
├── registry.ts # singleton Map + version counter + subscribers
|
||||
├── hotkeyManager.ts # singleton capture-phase listener + scope stack
|
||||
├── useHotkey.ts # raw binding hook
|
||||
├── useRegisterAction.ts # palette-visible action hook (delegates to useHotkey)
|
||||
├── globalActions.ts # seed actions registered once at boot
|
||||
└── __tests__/ # vitest
|
||||
|
||||
app/src/components/commands/
|
||||
├── CommandProvider.tsx # root mount: init manager, register globals, render overlays
|
||||
├── CommandScope.tsx # push/pop scope frame, provide ScopeContext
|
||||
├── CommandPalette.tsx # cmdk UI, subscribes to registry
|
||||
├── HelpOverlay.tsx # active shortcuts list, grouped
|
||||
├── Kbd.tsx # renders one shortcut (⌘ K)
|
||||
└── __tests__/
|
||||
```
|
||||
|
||||
### Key invariants
|
||||
|
||||
- **Registry is a module singleton.** Components mutate only via hooks, never hold references. Reason: action lifecycle is per-mount, no persistence needed, no cross-slice dependencies.
|
||||
- **Hotkey manager owns exactly one `window.addEventListener('keydown')`** in capture phase. Second `init()` is a dev-mode no-op warning.
|
||||
- **Scope stack frames are keyed by `Symbol`** generated at push time; `id` is debug-only metadata. Nesting and StrictMode double-mount are robust.
|
||||
- **`preventDefault()` on match; never `stopPropagation()`.** Let events bubble so `cmdk` internals and native text inputs keep working.
|
||||
- **Snapshot stability via version counters.** `registry.version` bumps on register/unregister; `hotkeyManager.stackVersion` bumps on push/pop. `getActiveActions(stackSymbols)` is memoized by `(registryVersion, stackVersion)` and returns the same array reference when unchanged — required for `useSyncExternalStore` to avoid re-render churn.
|
||||
|
||||
### Scope model
|
||||
|
||||
`ScopeKind = 'global' | 'page' | 'modal'`. Stack priority: modal > page > global. Within a frame, **last-registered wins** (dispatch iterates bindings in reverse insertion order).
|
||||
|
||||
`<CommandScope id kind>` component:
|
||||
- On mount (ref-guarded for StrictMode): `sym = hotkeyManager.pushFrame(kind, id)`.
|
||||
- Provides `sym` via `ScopeContext`.
|
||||
- On unmount: `hotkeyManager.popFrame(sym)` by symbol (safe if not on top, handles out-of-order unmount).
|
||||
|
||||
Page scope uses `<CommandScope>` wrapping page content — **not** `useLocation().key`. HashRouter keys are inconsistent and miss tabbed/drawer states that are page-like without route change.
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```ts
|
||||
export type ScopeKind = 'global' | 'page' | 'modal';
|
||||
export type ShortcutString = string; // "mod+k", "shift+mod+p", "?", "escape", "g", "f1"
|
||||
|
||||
export interface Action {
|
||||
id: string; // stable, kebab-case ("nav.go-home")
|
||||
label: string;
|
||||
hint?: string;
|
||||
group?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
shortcut?: ShortcutString;
|
||||
scope?: ScopeKind; // default 'global'
|
||||
enabled?: () => boolean;
|
||||
handler: () => void | Promise<void>;
|
||||
allowInInput?: boolean; // default false
|
||||
repeat?: boolean; // default false
|
||||
preventDefault?: boolean; // default true
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface HotkeyBinding {
|
||||
shortcut: ShortcutString;
|
||||
handler: () => void;
|
||||
scope?: ScopeKind;
|
||||
enabled?: () => boolean;
|
||||
allowInInput?: boolean;
|
||||
repeat?: boolean;
|
||||
preventDefault?: boolean;
|
||||
description?: string; // shown in help overlay if present
|
||||
id?: string; // dev diagnostics
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shortcut parser
|
||||
|
||||
```ts
|
||||
parseShortcut(s): { key, mod, shift, alt, ctrl }
|
||||
// Tokens lowercased, split on '+'. Last token = key, rest = modifiers.
|
||||
// `mod` = Cmd on mac, Ctrl elsewhere.
|
||||
// Memoized by string via WeakMap/Map.
|
||||
|
||||
matchEvent(parsed, e): boolean
|
||||
// isMac = navigator.platform.includes('Mac')
|
||||
// modPressed = isMac ? e.metaKey : e.ctrlKey
|
||||
// Single-key + letter match on e.key.toLowerCase() (shift-layer aware: '?' not '/')
|
||||
// Named keys: escape, enter, tab, space, arrow{up|down|left|right}, backspace, delete, f1–f12
|
||||
// Exact modifier match: shift/alt match e.shiftKey/e.altKey.
|
||||
|
||||
formatShortcut(parsed, isMac): string[]
|
||||
// mac: ['⇧','⌘','K'] | ['⌘','K'] | ['?']
|
||||
// other: ['Shift','Ctrl','K']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registry API
|
||||
|
||||
```ts
|
||||
registry.registerAction(action, scopeFrame: symbol): () => void // dispose fn
|
||||
registry.getActiveActions(scopeStack: symbol[]): RegisteredAction[]
|
||||
registry.getAction(id: string): RegisteredAction | undefined // for future Tooltip use
|
||||
registry.subscribe(listener: () => void): () => void // feeds useSyncExternalStore
|
||||
registry.runAction(id: string): boolean // programmatic invoke; false if missing/disabled
|
||||
```
|
||||
|
||||
Semantics:
|
||||
- Registration keyed by `(id, scopeFrame)`. Same id in different frames → top-of-stack wins.
|
||||
- `getActiveActions`: walks stack top→bottom, dedups by id (first occurrence wins) and by canonicalized shortcut string.
|
||||
- Dev collision warnings when same shortcut is enabled in active frame twice; silent in prod.
|
||||
- Version-counter memoization: same array reference returned when `(registryVersion, stackVersion)` unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Hotkey manager pipeline
|
||||
|
||||
Capture-phase `keydown` listener. On each event:
|
||||
|
||||
1. Early-reject: `e.isComposing || e.keyCode === 229`.
|
||||
2. Compute `inEditable` via `composedPath()` walk — input/textarea/`[contenteditable]`, shadow-DOM-safe.
|
||||
3. Walk scope stack top → bottom. Within each frame, iterate bindings in **reverse insertion order** (last-registered wins).
|
||||
4. For each binding: `matchEvent`? then `e.repeat && !binding.repeat` skip. Then `inEditable && !allowInInput` skip. Then `enabled?.()` skip-if-false.
|
||||
5. On first match: `preventDefault()` (unless `preventDefault:false`), `try/catch` the handler (console.error on throw or rejected promise), return. **No `stopPropagation`.**
|
||||
|
||||
Edge cases handled explicitly:
|
||||
- IME composition (`isComposing` + `keyCode===229`)
|
||||
- Auto-repeat (`e.repeat`) — opt-in only
|
||||
- Shadow DOM (`composedPath`)
|
||||
- Tauri-killing shortcuts (`⌘R`/`⌘W`/`⌘Q`) — pass through if not registered; blocked via `preventDefault` if registered
|
||||
- Handler throwing sync / rejecting promise — logged, listener survives
|
||||
|
||||
---
|
||||
|
||||
## Hook APIs
|
||||
|
||||
```ts
|
||||
useHotkey(shortcut, handler, options?: {
|
||||
scope?, enabled?, allowInInput?, repeat?, preventDefault?, description?, id?
|
||||
// reserved for v2: sequence
|
||||
}): void
|
||||
|
||||
useRegisterAction(action: Action): void
|
||||
// Registers into scope from ScopeContext.
|
||||
// If action.shortcut present, internally calls useHotkey (single source of truth).
|
||||
```
|
||||
|
||||
**Stale-closure defense (handlerRef pattern):**
|
||||
|
||||
```ts
|
||||
const handlerRef = useRef(handler);
|
||||
useEffect(() => { handlerRef.current = handler; }); // every render
|
||||
useEffect(() => {
|
||||
const stable = () => handlerRef.current();
|
||||
const sym = hotkeyManager.bind(frameSymbol, { ...options, handler: stable });
|
||||
return () => hotkeyManager.unbind(frameSymbol, sym);
|
||||
}, [shortcut, frameSymbol, /* stable option primitives */]);
|
||||
```
|
||||
|
||||
Handler updates every render via ref; binding only re-registers when `shortcut` or `frameSymbol` changes.
|
||||
|
||||
---
|
||||
|
||||
## Component contracts
|
||||
|
||||
### `<CommandProvider>` (root mount, one instance)
|
||||
|
||||
Responsibilities:
|
||||
1. `hotkeyManager.init()` (idempotent).
|
||||
2. Register seed actions from `globalActions.ts`.
|
||||
3. Push root global `ScopeFrame`, provide symbol via `ScopeContext`.
|
||||
4. Render `<CommandPalette>` + `<HelpOverlay>` in a Radix Portal.
|
||||
5. Bind meta hotkeys: `⌘K` (open palette, `allowInInput: true`), `Esc` (close topmost overlay).
|
||||
6. Dev: one-instance warning via module flag.
|
||||
|
||||
**Palette and help are mutually exclusive** — opening one closes the other.
|
||||
|
||||
### `<CommandScope id kind?>`
|
||||
|
||||
Push/pop scope frame by symbol; StrictMode-safe via ref guard; provides `ScopeContext`.
|
||||
|
||||
### `<CommandPalette>` (cmdk + Radix Dialog)
|
||||
|
||||
- Open state owned by `CommandProvider`.
|
||||
- `useSyncExternalStore(registry.subscribe, () => registry.getActiveActions(hotkeyManager.getStackSymbols()))`.
|
||||
- Rows: icon + label + `hint` subtitle + `<Kbd>` on right.
|
||||
- Fuzzy search via cmdk's built-in. **Use `<Command.Item value={action.id} keywords={action.keywords}>`** — do NOT join keywords into `value` (breaks Enter-to-run-selected).
|
||||
- On Enter/click: close palette → `requestAnimationFrame` → fire handler (avoid cmdk close-animation race).
|
||||
- Focus trap: cmdk + Radix Dialog.
|
||||
- `prefers-reduced-motion`: skip open/close transitions.
|
||||
- A11y: `aria-label="Command palette"`; input `aria-label="Search commands"`.
|
||||
- **Footer:** `Press ? for all shortcuts` in `cmd-foreground-muted`.
|
||||
|
||||
### `<HelpOverlay>` (Radix Dialog)
|
||||
|
||||
- `useSyncExternalStore(hotkeyManager.subscribe, () => hotkeyManager.getActiveBindings())`.
|
||||
- Dedup by canonicalized shortcut string.
|
||||
- Two sections: **Actions** (bindings backed by `Action`) and **Shortcuts** (bare `HotkeyBinding` with `description`).
|
||||
- Within each, group by scope kind (Modal / Page / Global), alphabetical by label.
|
||||
- Esc closes; `prefers-reduced-motion` respected.
|
||||
|
||||
### `<Kbd shortcut size?>`
|
||||
|
||||
Pure presentational. Parses once, memoized. Renders segments in `<kbd>` tags with mac glyphs or PC labels.
|
||||
|
||||
### Group ordering (pinned)
|
||||
|
||||
```ts
|
||||
export const GROUP_ORDER = ['Navigation', 'Help'] as const;
|
||||
// Unknown groups from future useRegisterAction calls append alphabetically after.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Styling — 8 scoped `cmd-*` tokens
|
||||
|
||||
### Tailwind config additions
|
||||
|
||||
```js
|
||||
extend: {
|
||||
colors: {
|
||||
'cmd-surface': 'var(--cmd-surface)',
|
||||
'cmd-surface-elevated': 'var(--cmd-surface-elevated)',
|
||||
'cmd-foreground': 'var(--cmd-foreground)',
|
||||
'cmd-foreground-muted': 'var(--cmd-foreground-muted)',
|
||||
'cmd-border': 'var(--cmd-border)',
|
||||
'cmd-ring': 'var(--cmd-ring)',
|
||||
'cmd-accent': 'var(--cmd-accent)',
|
||||
'cmd-overlay': 'var(--cmd-overlay)',
|
||||
},
|
||||
boxShadow: {
|
||||
'cmd-palette': 'var(--cmd-shadow-palette)',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### CSS vars in `app/src/index.css`
|
||||
|
||||
```css
|
||||
:root {
|
||||
--cmd-surface: #FFFFFF;
|
||||
--cmd-surface-elevated: #F5F5F5;
|
||||
--cmd-foreground: #171717;
|
||||
--cmd-foreground-muted: #737373;
|
||||
--cmd-border: #E5E5E5;
|
||||
--cmd-ring: var(--cmd-accent);
|
||||
--cmd-accent: #2F6EF4;
|
||||
--cmd-overlay: rgba(0, 0, 0, 0.5);
|
||||
--cmd-shadow-palette: 0 20px 25px -5px rgba(0,0,0,0.1),
|
||||
0 10px 10px -5px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--cmd-surface: #171717;
|
||||
--cmd-surface-elevated: #262626;
|
||||
--cmd-foreground: #FAFAFA;
|
||||
--cmd-foreground-muted: #A3A3A3;
|
||||
--cmd-border: #404040;
|
||||
--cmd-accent: #60A5FA;
|
||||
--cmd-overlay: rgba(0, 0, 0, 0.7);
|
||||
--cmd-shadow-palette: 0 20px 25px -5px rgba(0,0,0,0.5),
|
||||
0 10px 10px -5px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cmd-palette-enter, .cmd-palette-exit,
|
||||
.cmd-help-enter, .cmd-help-exit {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discipline
|
||||
|
||||
- `components/commands/*` uses **only** `cmd-*` tokens. No raw color classes.
|
||||
- Package script `lint:commands-tokens`:
|
||||
```
|
||||
"lint:commands-tokens": "rg -n '(bg|text|border|ring|shadow)-(neutral|primary|sage|amber|canvas|stone|slate)' src/components/commands/ && exit 1 || exit 0"
|
||||
```
|
||||
Wired into pre-push Husky hook alongside existing lint.
|
||||
|
||||
---
|
||||
|
||||
## Seed actions (v1 — six total)
|
||||
|
||||
### Meta hotkeys (bound directly in `CommandProvider`, NOT in registry)
|
||||
|
||||
| Key | Effect | allowInInput |
|
||||
|-----|--------|--------------|
|
||||
| `⌘K` | Open palette | yes |
|
||||
| `Esc` | Close topmost overlay | — |
|
||||
|
||||
Rationale: "Open Command Palette" inside the palette is noise; `Esc` is meta plumbing.
|
||||
|
||||
### Global actions (registered at boot)
|
||||
|
||||
| id | label | group | shortcut | handler |
|
||||
|----|-------|-------|----------|---------|
|
||||
| `nav.home` | Go Home | Navigation | `mod+1` | `navigate('/home')` |
|
||||
| `nav.conversations` | Go to Conversations | Navigation | `mod+2` | `navigate('/conversations')` |
|
||||
| `nav.intelligence` | Go to Intelligence | Navigation | `mod+3` | `navigate('/intelligence')` |
|
||||
| `nav.skills` | Go to Skills | Navigation | `mod+4` | `navigate('/skills')` |
|
||||
| `nav.settings` | Open Settings | Navigation | `mod+,` | `navigate('/settings')` |
|
||||
| `help.show` | Show Keyboard Shortcuts | Help | `?` | `openHelpOverlay()` |
|
||||
|
||||
- All `allowInInput: false`.
|
||||
- `help.show` is an Action (not a raw binding) so it's palette-searchable — direct answer to "how do users discover `?`".
|
||||
- Keywords per nav action: `['navigate', 'open', <page synonyms>]`.
|
||||
- Icons reuse existing nav icons from `BottomTabBar`.
|
||||
|
||||
### `registerGlobalActions` signature
|
||||
|
||||
```ts
|
||||
export function registerGlobalActions(
|
||||
navigate: NavigateFunction,
|
||||
openHelpOverlay: () => void,
|
||||
globalScopeSymbol: symbol,
|
||||
): void
|
||||
```
|
||||
|
||||
Called once from `<CommandProvider>` after `useNavigate()` resolves. No dynamic registration; lifetime = app lifetime.
|
||||
|
||||
### Shortcut platform verification (GATE 0)
|
||||
|
||||
**⌘1–⌘4 must be verified against Tauri/CEF before PR opens.** Stub a `useEffect` keydown listener, run `yarn tauri dev`, press each. If any are swallowed by the webview, fall back to `⌘⌥1–⌘⌥4` and update the table. If `⌘K` is eaten, hard blocker — escalate.
|
||||
|
||||
---
|
||||
|
||||
## Mount point in `App.tsx`
|
||||
|
||||
Pinned position (inside `HashRouter`, outside individual routes):
|
||||
|
||||
```tsx
|
||||
<Provider store>
|
||||
<PersistGate>
|
||||
<CoreStateProvider>
|
||||
<SocketProvider>
|
||||
<ChatRuntimeProvider>
|
||||
<HashRouter>
|
||||
<CommandProvider> {/* ← here */}
|
||||
<ServiceBlockingGate>
|
||||
<AppRoutes />
|
||||
</ServiceBlockingGate>
|
||||
</CommandProvider>
|
||||
</HashRouter>
|
||||
```
|
||||
|
||||
One-line diff from current `App.tsx`. No Redux changes, no router changes.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit (Vitest, co-located `__tests__`)
|
||||
|
||||
**`shortcut.test.ts`** — parser + matcher
|
||||
- Parse: `mod+k`, `shift+mod+p`, `?`, `/`, `escape`, `f1`, `g`, `shift+?`
|
||||
- Rejects: `""`, `"mod"`, `"mod+mod+k"`, `"meta+k"`
|
||||
- mac: `mod+k` matches `metaKey+k`; non-mac: `ctrlKey+k`
|
||||
- Exact modifier match: `k` does NOT match `shift+k`
|
||||
- `?` matches `e.key === '?'` both platforms
|
||||
- `formatShortcut` mac glyphs vs PC text
|
||||
|
||||
**`registry.test.ts`** — registration
|
||||
- Register + `getAction`
|
||||
- Duplicate id same frame → dev warn + last-wins
|
||||
- Same id across frames → top-of-stack via `getActiveActions`
|
||||
- `subscribe` fires on register/unregister
|
||||
- `enabled: () => false` excludes from active list
|
||||
- Dedup: two frames register `⌘K` → one entry
|
||||
- Version counter: same `(regVer, stackVer)` returns same array ref
|
||||
- **`runAction(id)`**: happy path fires + returns true; disabled returns false; unknown id returns false without throw
|
||||
|
||||
**`hotkeyManager.test.ts`** — pipeline
|
||||
- `init()` attaches one listener; second is no-op
|
||||
- Push/pop by symbol; pop non-top removes correctly
|
||||
- Dispatch fires handler, calls preventDefault, NOT stopPropagation
|
||||
- `isComposing` skipped; `keyCode===229` skipped
|
||||
- `repeat` default skip; opt-in fires
|
||||
- Input focus: without `allowInInput` suppressed (input/textarea/contenteditable); with → fires
|
||||
- Scope priority: modal shadows page + global for same shortcut
|
||||
- **Last-registered wins** within a frame (iterate reversed)
|
||||
- Handler sync throw → console.error spy fires → listener survives
|
||||
- Handler rejected promise → console.error spy fires → listener survives
|
||||
- **Unregister-during-dispatch:** handler A unregisters B mid-walk → no crash, no double-fire
|
||||
- **Frame pop-during-dispatch:** modal closes self on Esc → no crash
|
||||
|
||||
**`useHotkey.test.ts` / `useRegisterAction.test.ts`** (RTL)
|
||||
- Mount registers, unmount unregisters
|
||||
- StrictMode double-mount: net registration count = 1
|
||||
- Handler identity: updating handler via render doesn't re-register; fresh handler runs on next keydown
|
||||
- Shortcut change re-registers
|
||||
- Nested `<CommandScope>` doesn't corrupt stack
|
||||
|
||||
**`CommandPalette.test.tsx`** (RTL)
|
||||
- `⌘K` opens, `Esc` closes
|
||||
- Renders active actions from `useSyncExternalStore`
|
||||
- Fuzzy filter by label AND keywords (via cmdk `keywords` prop)
|
||||
- Arrow keys move selection; Enter fires handler exactly once
|
||||
- Live-updates when action registered while open
|
||||
- Footer renders `Press ? for all shortcuts`
|
||||
|
||||
**`HelpOverlay.test.tsx`** (RTL)
|
||||
- `?` opens, `Esc` closes
|
||||
- Shows only active (not shadowed) shortcuts
|
||||
- Dedup displayed shortcuts
|
||||
- **Bare `HotkeyBinding` with `description`** renders in "Shortcuts" section separate from Actions
|
||||
- **Shortcut normalization dedup:** `mod+k` from global + `cmd+k` from page → one entry
|
||||
|
||||
**`Kbd.test.tsx`**
|
||||
- mac glyphs vs PC labels
|
||||
- Multi-segment separators
|
||||
|
||||
**`globalActions.test.ts`**
|
||||
- All 6 registered at boot into global frame
|
||||
- `useNavigate` stability: navigate via palette after re-render still works (catches react-router regressions)
|
||||
|
||||
**Test utilities (`app/src/test/commandTestUtils.ts`):**
|
||||
- `renderWithCommands(ui, { scopes?, actions? })` wrapper
|
||||
- `pressKey(target, { key, mod, shift, alt, ... })` synthetic KeyboardEvent helper routed through capture-phase listener
|
||||
- **Meta-test:** `pressKey` util asserts the event actually reaches the manager's listener. Prevents silent util breakage.
|
||||
|
||||
### E2E (WDIO, `app/test/e2e/specs/command-palette.spec.ts`)
|
||||
|
||||
- Launch at `/home`. Press `⌘K` → palette focused.
|
||||
- Type `"settings"` → row highlighted.
|
||||
- Press `Enter` → palette closes, hash is `#/settings`.
|
||||
- Open help (`?`) → assert 6 actions listed → `Esc` closes.
|
||||
- **Regression probe:** exercise one pre-existing shortcut (audit repo first; candidates: chat composer Enter-to-send, modal-close Escape) → assert still works. Prevents global listener from silently swallowing keys.
|
||||
|
||||
### Coverage gates
|
||||
- `registry.ts`, `hotkeyManager.ts`, `shortcut.ts`: ≥95% line, ≥90% branch.
|
||||
- Hooks + components: ≥80% line floor + behavior coverage.
|
||||
|
||||
### Not tested (explicit)
|
||||
- Non-US keyboard layouts (jsdom limitation)
|
||||
- Cross-Tauri-window behavior (single-window app)
|
||||
- Chord sequences (v2)
|
||||
|
||||
---
|
||||
|
||||
## Implementation order (gated)
|
||||
|
||||
### Gate 0 — Platform verify (BLOCKS EVERYTHING)
|
||||
1. Stub `useEffect` in `App.tsx` logging keydown + modifiers.
|
||||
2. `yarn tauri dev`. Press `⌘1..⌘4`, `⌘,`, `?`, `⌘K`.
|
||||
3. Pass: all reach listener + `preventDefault` blocks native side effect.
|
||||
4. Fail on ⌘1–⌘4: fall back to `⌘⌥1..⌘⌥4`, update spec.
|
||||
5. Fail on ⌘K: hard blocker, escalate.
|
||||
|
||||
### Gate 1 — Foundation (no UI)
|
||||
types → shortcut → registry → hotkeyManager → useHotkey → useRegisterAction → CommandScope. Unit tests to ≥95% on core.
|
||||
|
||||
### Gate 2 — Tokens
|
||||
Tailwind config + CSS vars + `lint:commands-tokens` script + pre-push wiring.
|
||||
|
||||
### Gate 3 — Components
|
||||
Kbd → install cmdk + `@radix-ui/react-dialog` → CommandPalette → HelpOverlay → CommandProvider → globalActions.
|
||||
|
||||
### Gate 4 — Wire into app
|
||||
One-line `App.tsx` edit; grep existing `window.addEventListener('keydown')` for conflicts.
|
||||
|
||||
### Gate 5 — E2E
|
||||
`command-palette.spec.ts` including regression probe.
|
||||
|
||||
### Gate 6 — Pre-merge
|
||||
- `yarn typecheck && yarn lint && yarn test:unit && yarn lint:commands-tokens` green.
|
||||
- `cargo fmt --check && cargo check --manifest-path app/src-tauri/Cargo.toml` green (no drift).
|
||||
- Manual smoke + a11y + reduced-motion.
|
||||
- Diff audit: changes only in `lib/commands/`, `components/commands/`, `tailwind.config.js`, `index.css`, `App.tsx`, `package.json`, test files.
|
||||
|
||||
---
|
||||
|
||||
## Scope summary
|
||||
|
||||
**Ships:**
|
||||
- 4 components (CommandProvider, CommandPalette, HelpOverlay, Kbd) + CommandScope primitive
|
||||
- 7 `lib/commands/*` modules
|
||||
- 8 scoped `cmd-*` semantic tokens
|
||||
- 6 global actions
|
||||
- ~25 unit test files + 1 E2E spec + token-lint CI script
|
||||
- 2 new deps: `cmdk`, `@radix-ui/react-dialog`
|
||||
|
||||
**Does not ship:**
|
||||
- Chord sequences (v2)
|
||||
- Full design-system tokens (reskin brainstorm)
|
||||
- Sign Out / Toggle Theme / per-page actions (future PRs)
|
||||
- i18n
|
||||
- Go Back / Forward
|
||||
|
||||
---
|
||||
|
||||
## Decisions log
|
||||
|
||||
| # | Decision | Alternative considered |
|
||||
|---|----------|------------------------|
|
||||
| 1 | Hybrid registry (static + dynamic) | Pure static; pure dynamic |
|
||||
| 2 | `cmdk` library | `kbar`; hand-roll |
|
||||
| 3 | Scope priority modal > page > global | Last-registered-wins global; explicit priority numbers |
|
||||
| 4 | v1 single-key + modifiers only | Ship chords now |
|
||||
| 5 | Contextual `?` overlay (active shortcuts only) | Full static list |
|
||||
| 6 | Scoped `cmd-*` tokens only | Full semantic system now |
|
||||
| 7 | `<CommandScope>` primitive, not `useLocation().key` | Route-derived scope (HashRouter brittle) |
|
||||
| 8 | Separate `useHotkey` / `useRegisterAction` | Unified hook with optional palette flag |
|
||||
| 9 | `handlerRef` pattern for stale closures | Re-register on every render |
|
||||
| 10 | Version-counter memoized snapshots | New array per call (causes re-render churn) |
|
||||
| 11 | Last-registered-wins within a frame | First-registered-wins |
|
||||
| 12 | `preventDefault` on match, never `stopPropagation` | Aggressive `stopPropagation` |
|
||||
| 13 | Palette and help mutually exclusive | Stacked overlays |
|
||||
| 14 | Radix Dialog for overlay wrappers | Headless UI; hand-roll |
|
||||
| 15 | Six seed actions (5 nav + 1 help) | More; fewer |
|
||||
| 16 | Gate 0 platform verify first | Trust shortcuts work |
|
||||
|
||||
---
|
||||
|
||||
## External reviews
|
||||
|
||||
Second opinions from Codex (gpt-5.2-codex) and Gemini CLI (2.5) incorporated. Key contributions:
|
||||
- Codex: `useSyncExternalStore`, `handlerRef` stability pattern, explicit `enabled` predicate, separate raw/action hooks.
|
||||
- Gemini: drop `useLocation().key` in favor of `<CommandScope>` primitive, aggressive `preventDefault` for Tauri-killing chords, registry queryable by id for future Tooltip integration.
|
||||
|
||||
Divergence: Codex suggested route-derived page scope; Gemini's `<CommandScope>` argument prevailed (HashRouter quirks + non-route surfaces).
|
||||
@@ -1190,6 +1190,149 @@
|
||||
tar-fs "^3.1.1"
|
||||
yargs "^17.7.2"
|
||||
|
||||
"@radix-ui/primitive@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
|
||||
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
|
||||
|
||||
"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
|
||||
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
|
||||
|
||||
"@radix-ui/react-context@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
|
||||
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
|
||||
|
||||
"@radix-ui/react-dialog@^1", "@radix-ui/react-dialog@^1.1.6":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
|
||||
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-dismissable-layer" "1.1.11"
|
||||
"@radix-ui/react-focus-guards" "1.1.3"
|
||||
"@radix-ui/react-focus-scope" "1.1.7"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-portal" "1.1.9"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.1.11":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz#e33ab6f6bdaa00f8f7327c408d9f631376b88b37"
|
||||
integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.1.1"
|
||||
|
||||
"@radix-ui/react-focus-guards@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f"
|
||||
integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==
|
||||
|
||||
"@radix-ui/react-focus-scope@1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d"
|
||||
integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
|
||||
"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
|
||||
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-portal@1.1.9":
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
|
||||
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-presence@1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
|
||||
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-primitive@2.1.3":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"
|
||||
integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-primitive@^2.0.2":
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz#2626ea309ebd63bf5767d3e7fc4081f81b993df0"
|
||||
integrity sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.2.4"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-slot@1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
|
||||
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40"
|
||||
integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==
|
||||
|
||||
"@radix-ui/react-use-controllable-state@1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190"
|
||||
integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-effect-event" "0.0.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-use-effect-event@0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907"
|
||||
integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29"
|
||||
integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
|
||||
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
|
||||
|
||||
"@reduxjs/toolkit@^2.11.2":
|
||||
version "2.11.2"
|
||||
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz"
|
||||
@@ -1687,6 +1830,11 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
"@testing-library/user-event@^14.6.1":
|
||||
version "14.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149"
|
||||
integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
|
||||
|
||||
"@tootallnate/quickjs-emscripten@^0.23.0":
|
||||
version "0.23.0"
|
||||
resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz"
|
||||
@@ -2502,6 +2650,13 @@ argparse@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
|
||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||
|
||||
aria-hidden@^1.2.4:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a"
|
||||
integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
aria-query@5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz"
|
||||
@@ -3118,6 +3273,16 @@ clone@^1.0.2:
|
||||
resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz"
|
||||
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
|
||||
|
||||
cmdk@^1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.1.1.tgz#b8524272699ccaa37aaf07f36850b376bf3d58e5"
|
||||
integrity sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "^1.1.1"
|
||||
"@radix-ui/react-dialog" "^1.1.6"
|
||||
"@radix-ui/react-id" "^1.1.0"
|
||||
"@radix-ui/react-primitive" "^2.0.2"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
|
||||
@@ -3488,6 +3653,11 @@ des.js@^1.0.0:
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
|
||||
detect-node-es@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
|
||||
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
|
||||
|
||||
devlop@^1.0.0, devlop@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz"
|
||||
@@ -4462,6 +4632,11 @@ get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@
|
||||
hasown "^2.0.2"
|
||||
math-intrinsics "^1.1.0"
|
||||
|
||||
get-nonce@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||
|
||||
get-port@^7.0.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz"
|
||||
@@ -6919,6 +7094,25 @@ react-refresh@^0.17.0:
|
||||
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz"
|
||||
integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==
|
||||
|
||||
react-remove-scroll-bar@^2.3.7:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223"
|
||||
integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==
|
||||
dependencies:
|
||||
react-style-singleton "^2.2.2"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react-remove-scroll@^2.6.3:
|
||||
version "2.7.2"
|
||||
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz#6442da56791117661978ae99cd29be9026fecca0"
|
||||
integrity sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==
|
||||
dependencies:
|
||||
react-remove-scroll-bar "^2.3.7"
|
||||
react-style-singleton "^2.2.3"
|
||||
tslib "^2.1.0"
|
||||
use-callback-ref "^1.3.3"
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-router-dom@^7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz"
|
||||
@@ -6934,6 +7128,14 @@ react-router@7.13.0:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
|
||||
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
||||
integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==
|
||||
dependencies:
|
||||
get-nonce "^1.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react@^19.1.0:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz"
|
||||
@@ -7996,7 +8198,7 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0:
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
@@ -8217,6 +8419,21 @@ urlpattern-polyfill@^10.0.0:
|
||||
resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz"
|
||||
integrity sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==
|
||||
|
||||
use-callback-ref@^1.3.3:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf"
|
||||
integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sidecar@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
|
||||
integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==
|
||||
dependencies:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@^1.4.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user