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:
Jwalin Shah
2026-04-23 22:52:54 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 500ddc2a8a
commit 478c92e533
38 changed files with 5826 additions and 20 deletions
+74
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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>;
}
+29
View File
@@ -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(/\?/);
});
});
});
+41
View File
@@ -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 };
}
+34
View File
@@ -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;
}
}
+3
View File
@@ -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([]);
});
});
+76
View File
@@ -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();
};
}
+173
View File
@@ -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();
+170
View File
@@ -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();
+153
View File
@@ -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;
}
+56
View File
@@ -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;
}
+37
View File
@@ -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]);
}
+57
View File
@@ -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,
]);
}
+22 -11
View File
@@ -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) => (
+38
View File
@@ -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);
}
+14
View File
@@ -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) }));
+13
View File
@@ -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
View File
@@ -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, f1f12
// 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).
+218 -1
View File
@@ -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"