From 0f38b2946de5c1e813e8d9a7d4cc4f0820c088f7 Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Mon, 15 Jun 2026 23:48:23 +0200 Subject: [PATCH] feat(web): keyboard shortcuts (claude.ai parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small shortcut system around a single document-level keydown listener plus a help modal. Mirrors the bindings claude.ai/chat exposes, skipping ones that don't map (artifacts, force-send, settings). Global: ⌘⇧O new chat, ⌘K focus search, ⌘/ shortcuts modal, Esc cascades to modal → search → stop generation. Chat: ⌘⇧S sidebar, ⌘⇧; focus input, ⌘⇧C copy last response, ⌘⇧⌫ delete current (confirmed), ⌘\ side panel (already existed). Mac/Linux labels and Backspace/Delete aliases render automatically. Co-Authored-By: Claude Opus 4.7 --- web/src/App.tsx | 86 ++++++++++++++- web/src/components/Chat/ChatInput.tsx | 1 + web/src/components/Chat/SessionSidebar.tsx | 1 + web/src/components/ShortcutsModal.tsx | 122 +++++++++++++++++++++ web/src/hooks/useKeyboardShortcuts.ts | 34 ++++++ web/src/pages/ChatPage.tsx | 84 ++++++++++++-- web/src/stores/uiStore.ts | 19 ++++ web/src/utils/keyboard.ts | 95 ++++++++++++++++ 8 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 web/src/components/ShortcutsModal.tsx create mode 100644 web/src/hooks/useKeyboardShortcuts.ts create mode 100644 web/src/stores/uiStore.ts create mode 100644 web/src/utils/keyboard.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c195ef..9f4ebfa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,11 @@ -import { useEffect } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; +import { useEffect, useMemo } from 'react'; +import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import { ws } from './api/websocket'; import { useChatStore } from './stores/chatStore'; +import { useUIStore } from './stores/uiStore'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import type { ShortcutDef } from './utils/keyboard'; import { LoginPage } from './components/Auth/LoginPage'; import { AppShell } from './components/Layout/AppShell'; import { ChatPage } from './pages/ChatPage'; @@ -22,6 +25,7 @@ import { HouseOfAgentsPage } from './pages/HouseOfAgentsPage'; import { McpServerDetailPage } from './pages/McpServerDetailPage'; import { NotificationsPage } from './pages/NotificationsPage'; import { NotificationToast } from './components/Notifications/NotificationToast'; +import { ShortcutsModal } from './components/ShortcutsModal'; function App() { const { authenticated, checking, checkAuth } = useAuthStore(); @@ -42,6 +46,7 @@ function App() { return ( <> + }> } /> @@ -64,8 +69,85 @@ function App() { + ); } +/** + * Global keyboard shortcuts — work on every page. Page-scoped chat shortcuts + * live in ChatPage so they only activate while the chat view is mounted. + * + * Esc behavior is intentionally cascaded: + * 1. ShortcutsModal swallows Esc first via capture-phase listener. + * 2. SessionSidebar's own listener clears search when active. + * 3. This handler stops generation only if streaming and nothing else + * is claiming Esc (modal closed, search empty). + */ +function GlobalShortcuts() { + const navigate = useNavigate(); + + const shortcuts = useMemo(() => [ + { + id: 'global-new-chat', + combo: { mod: true, shift: true, key: 'o' }, + description: 'New chat', + section: 'global', + action: () => { + navigate('/chat'); + void useChatStore.getState().createSession(); + }, + }, + { + id: 'global-focus-search', + combo: { mod: true, key: 'k' }, + description: 'Focus session search', + section: 'global', + allowInInput: true, // allow even when focus is in chat textarea + action: () => { + if (!window.location.pathname.startsWith('/chat')) { + navigate('/chat'); + } + // Defer focus until after route change paints the sidebar input. + setTimeout(() => { + if (useChatStore.getState().sidebarCollapsed) { + useChatStore.getState().toggleSidebar(); + } + const el = document.getElementById('nerve-sidebar-search'); + if (el instanceof HTMLInputElement) { + el.focus(); + el.select(); + } + }, 0); + }, + }, + { + id: 'global-shortcuts-modal', + combo: { mod: true, key: '/' }, + description: 'Show keyboard shortcuts', + section: 'global', + allowInInput: true, + action: () => useUIStore.getState().toggleShortcutsModal(), + }, + { + id: 'global-esc-stop', + combo: { key: 'Escape' }, + description: 'Stop generation', + section: 'global', + // Only fire when nothing else is claiming Esc: + // - modal handles its own Esc in capture phase + // - sidebar handles Esc only while searching + when: () => { + if (useUIStore.getState().shortcutsModalOpen) return false; + if (!useChatStore.getState().isStreaming) return false; + return true; + }, + action: () => useChatStore.getState().stopSession(), + }, + ], [navigate]); + + useKeyboardShortcuts(shortcuts); + return null; +} + export default App; diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 7d0217a..cc86669 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -433,6 +433,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { />