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 }: { />