Skip to content

Commit 47a6285

Browse files
committed
feat: customizable keyboard shortcuts with Gmail and Superhuman presets
Shortcuts were hardcoded in three separate keydown listeners, and the `?` cheat sheet was a hand-maintained array that had already drifted — it listed `c` for compose while the handler that owned that key lived in a different component. One registry now owns every global binding. The handlers, the cheat sheet and the new settings editor all read from it, so they cannot disagree. PRESETS. Three, all fully editable: Comms (single keys, nothing to learn), Gmail-style (e archives, b snoozes, l labels, / searches) and Superhuman-style (e done, h snooze, ⌘K for everything). They are modelled on those apps rather than copied — the bindings people actually have in muscle memory, with anything Comms-specific keeping its own key. SEQUENCES. Bindings can be multi-chord, so `g i`, `g m`, `g u` and `g c` jump between views the way both apps do. A half-typed sequence is swallowed rather than passed through, and a sequence that goes nowhere retries its last key on its own — press `g` then `j` and you still move down the list. CUSTOMIZATION. Settings → Shortcuts records the keys you press instead of asking you to type a binding string, which is the only way to guarantee the stored chord is the one the matcher will later see. Conflicts are detected and explained, including prefix collisions: binding `g` outright means `g i` can never complete, which is invisible until you wonder why a key stopped working. Preferences store a preset plus a diff, not a full map, so an action added in a later version picks up its default rather than silently having no key. Also: Enter-sends is now a setting rather than an assumption, and the hint under the composer follows it instead of always claiming Enter sends. ⌘↵ sends either way. Two of the three old listeners are gone — the palette and compose dialog now open by event. A second global keydown listener would double-fire actions and race the sequence buffer. 38 tests covering chord normalization (shift is recorded for named keys but not for printable ones, or `?` would have two spellings and never match), sequence matching, conflict and prefix detection, and the override round-trip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuKkB7AfSNCcwvubV99pDU
1 parent 99c2b88 commit 47a6285

15 files changed

Lines changed: 1279 additions & 159 deletions

File tree

apps/web/src/app/(app)/inbox/layout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { requireUser } from '@/lib/session';
22
import { listConversations, listInboxes, listTags, listAgents } from '@/server/queries';
33
import { ConversationListPane } from '@/components/inbox/conversation-list';
4-
import { KeyboardShortcuts } from '@/components/app/keyboard-shortcuts';
54
import { TagQuickPicker } from '@/components/inbox/tag-quick-picker';
65

76
export const dynamic = 'force-dynamic';
@@ -29,7 +28,6 @@ export default async function InboxLayout({ children }: { children: React.ReactN
2928
inboxes={inboxRows.map((i) => ({ id: i.id, name: i.name }))}
3029
/>
3130
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
32-
<KeyboardShortcuts />
3331
<TagQuickPicker
3432
allTags={tagRows.map((t) => ({ id: t.id, name: t.name, color: t.color }))}
3533
/>

apps/web/src/app/(app)/layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ import { Sidebar } from '@/components/app/sidebar';
1010
import { RealtimeProvider } from '@/components/app/realtime-provider';
1111
import { ChannelHealthBanner } from '@/components/app/channel-health-banner';
1212
import { CommandPalette } from '@/components/app/command-palette';
13+
import { KeymapProvider } from '@/components/app/keymap-provider';
14+
import { KeyboardShortcuts } from '@/components/app/keyboard-shortcuts';
1315
import { NewConversation } from '@/components/inbox/new-conversation';
1416
import { MobileTopBar, SidebarShell } from '@/components/app/mobile-shell';
17+
import type { KeymapPreference } from '@/lib/keymap';
1518

1619
export const dynamic = 'force-dynamic';
1720

@@ -54,6 +57,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
5457
<RealtimeProvider
5558
currentUser={{ id: user.id, name: user.name ?? null, image: user.image ?? null }}
5659
>
60+
<KeymapProvider preference={user.preferences?.keymap as KeymapPreference | undefined}>
5761
<div className="flex h-dvh flex-col overflow-hidden">
5862
<ChannelHealthBanner initial={unhealthy} />
5963
<MobileTopBar />
@@ -79,6 +83,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
7983
isAdmin={isAdminRole(user.role)}
8084
/>
8185
<NewConversation />
86+
{/* One listener for the whole app: `c`, ⌘K and the jump keys should work
87+
from settings too, not only inside the inbox. */}
88+
<KeyboardShortcuts />
89+
</KeymapProvider>
8290
</RealtimeProvider>
8391
);
8492
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { requireDbUser } from '@/lib/session';
2+
import { KeyboardForm } from '@/components/settings/keyboard-form';
3+
import type { KeymapPreference } from '@/lib/keymap';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export default async function KeyboardSettingsPage() {
8+
const me = await requireDbUser();
9+
10+
return (
11+
<div className="space-y-6">
12+
<div>
13+
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
14+
<p className="text-sm text-muted-foreground">
15+
Start from a preset, then rebind anything. Press{' '}
16+
<kbd className="rounded border bg-secondary px-1 py-px font-sans text-[11px]">?</kbd>{' '}
17+
anywhere in the app to see your current keys.
18+
</p>
19+
</div>
20+
21+
<KeyboardForm
22+
preference={(me.preferences?.keymap as KeymapPreference | undefined) ?? null}
23+
/>
24+
</div>
25+
);
26+
}

apps/web/src/components/app/command-palette.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,18 @@ export function CommandPalette({
3636
const [active, setActive] = useState(0);
3737
const [, startSearch] = useTransition();
3838

39-
// Global ⌘K / Ctrl+K, and an in-app open event (from the sidebar button).
39+
// The keyboard binding lives in the user's keymap, not here — a second
40+
// keydown listener would double-fire and break sequence shortcuts. This
41+
// component just responds to being asked to open (by key, or by the sidebar
42+
// button).
4043
useEffect(() => {
41-
const onKey = (e: KeyboardEvent) => {
42-
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
43-
e.preventDefault();
44-
setOpen((o) => !o);
45-
}
46-
};
4744
const onOpen = () => setOpen(true);
48-
window.addEventListener('keydown', onKey);
45+
const onToggle = () => setOpen((o) => !o);
4946
window.addEventListener('comms:open-command', onOpen);
47+
window.addEventListener('comms:open-palette', onToggle);
5048
return () => {
51-
window.removeEventListener('keydown', onKey);
5249
window.removeEventListener('comms:open-command', onOpen);
50+
window.removeEventListener('comms:open-palette', onToggle);
5351
};
5452
}, []);
5553

0 commit comments

Comments
 (0)