Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 84 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -42,6 +46,7 @@ function App() {

return (
<>
<GlobalShortcuts />
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/chat" replace />} />
Expand All @@ -64,8 +69,85 @@ function App() {
</Route>
</Routes>
<NotificationToast />
<ShortcutsModal />
</>
);
}

/**
* 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<ShortcutDef[]>(() => [
{
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;
1 change: 1 addition & 0 deletions web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
/>

<textarea
id="nerve-chat-input"
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); handleInput(); }}
Expand Down
1 change: 1 addition & 0 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-faint" />
<input
id="nerve-sidebar-search"
ref={inputRef}
type="text"
value={localQuery}
Expand Down
122 changes: 122 additions & 0 deletions web/src/components/ShortcutsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
import { useUIStore } from '../stores/uiStore';
import { formatCombo, type ShortcutCombo } from '../utils/keyboard';

interface DisplayShortcut {
combo: ShortcutCombo;
description: string;
}

interface Section {
title: string;
items: DisplayShortcut[];
}

/**
* Static display of every keyboard binding. The runtime handlers live in
* App.tsx (global) and ChatPage.tsx (chat-scoped) — keep this list in sync
* with those when bindings change.
*/
const SECTIONS: Section[] = [
{
title: 'General',
items: [
{ combo: { mod: true, shift: true, key: 'o' }, description: 'New chat' },
{ combo: { mod: true, key: 'k' }, description: 'Focus session search' },
{ combo: { mod: true, key: '/' }, description: 'Show keyboard shortcuts' },
{ combo: { key: 'Escape' }, description: 'Close dialog · clear search · stop generation' },
],
},
{
title: 'Chat',
items: [
{ combo: { mod: true, shift: true, key: 's' }, description: 'Toggle session sidebar' },
{ combo: { mod: true, shift: true, key: ';' }, description: 'Focus message input' },
{ combo: { mod: true, shift: true, key: 'c' }, description: 'Copy last response' },
{ combo: { mod: true, shift: true, key: 'Backspace' }, description: 'Delete current conversation' },
{ combo: { mod: true, key: '\\' }, description: 'Toggle side panel' },
],
},
{
title: 'Message input',
items: [
{ combo: { key: 'Enter' }, description: 'Send message' },
{ combo: { shift: true, key: 'Enter' }, description: 'New line' },
],
},
];

export function ShortcutsModal() {
const open = useUIStore((s) => s.shortcutsModalOpen);
const close = useUIStore((s) => s.closeShortcutsModal);

// Local Esc handler — runs *before* the document-level shortcut listeners
// because modal mount captures it first when focus is inside.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
close();
}
};
document.addEventListener('keydown', onKey, true); // capture phase = wins
return () => document.removeEventListener('keydown', onKey, true);
}, [open, close]);

if (!open) return null;

return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={close}
>
<div
className="bg-surface-raised border border-border-subtle rounded-xl w-[520px] max-w-[90vw] max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h2 className="text-[15px] font-semibold">Keyboard shortcuts</h2>
<button
onClick={close}
className="text-text-faint hover:text-text-muted cursor-pointer p-1"
title="Close"
>
<X size={18} />
</button>
</div>

<div className="overflow-y-auto p-5 space-y-5">
{SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-[11px] uppercase tracking-wider text-text-faint font-medium mb-2">
{section.title}
</h3>
<div className="space-y-1.5">
{section.items.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-4 py-1"
>
<span className="text-[13px] text-text-secondary">{item.description}</span>
<Kbd combo={item.combo} />
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}

function Kbd({ combo }: { combo: ShortcutCombo }) {
return (
<kbd className="px-2 py-1 text-[11px] font-mono text-text-secondary bg-surface border border-border-subtle rounded shrink-0 tabular-nums">
{formatCombo(combo)}
</kbd>
);
}
34 changes: 34 additions & 0 deletions web/src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { isTypingTarget, matchesCombo, type ShortcutDef } from '../utils/keyboard';

/**
* Attach a `document`-level keydown listener that fires the first shortcut
* whose combo matches the event. Order matters: more specific shortcuts
* should come first.
*
* Skips when focus is inside an editable element unless the shortcut sets
* `allowInInput: true`. Skips when the shortcut's `when()` predicate is
* defined and returns false.
*
* Pass the same array reference across renders if you can — otherwise we
* re-register the listener on every render. In practice the callers below
* build a fresh array each render but the cost is one removeEventListener +
* addEventListener, which is negligible.
*/
export function useKeyboardShortcuts(shortcuts: ShortcutDef[]): void {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const typing = isTypingTarget(e.target);
for (const sc of shortcuts) {
if (typing && !sc.allowInInput) continue;
if (sc.when && !sc.when()) continue;
if (!matchesCombo(e, sc.combo)) continue;
e.preventDefault();
sc.action(e);
return;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [shortcuts]);
}
84 changes: 72 additions & 12 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useChatStore } from '../stores/chatStore';
import { SessionSidebar } from '../components/Chat/SessionSidebar';
Expand All @@ -10,6 +10,9 @@ import { SidePanel } from '../components/Chat/SidePanel';
import { BackgroundJobs } from '../components/Chat/BackgroundJobs';
import { Loader2, PanelLeftOpen, PanelLeftClose, Files, ExternalLink } from 'lucide-react';
import { api } from '../api/client';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import type { ShortcutDef } from '../utils/keyboard';
import type { ChatMessage, TextBlockData } from '../types/chat';

const STATUS_LABELS: Record<string, string> = {
thinking: 'Thinking...',
Expand Down Expand Up @@ -39,17 +42,60 @@ export function ChatPage() {
sendMessage, stopSession, toggleSidebar, openFilesPanel,
} = useChatStore();

// Cmd/Ctrl + \ toggles side panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === '\\') {
e.preventDefault();
useChatStore.getState().togglePanel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Chat-scoped keyboard shortcuts. Global ones (new chat, search, modal,
// Esc cascade) live in <GlobalShortcuts /> in App.tsx.
const chatShortcuts = useMemo<ShortcutDef[]>(() => [
{
id: 'chat-toggle-panel',
combo: { mod: true, key: '\\' },
description: 'Toggle side panel',
section: 'chat',
action: () => useChatStore.getState().togglePanel(),
},
{
id: 'chat-toggle-sidebar',
combo: { mod: true, shift: true, key: 's' },
description: 'Toggle session sidebar',
section: 'chat',
action: () => useChatStore.getState().toggleSidebar(),
},
{
id: 'chat-focus-input',
combo: { mod: true, shift: true, key: ';' },
description: 'Focus message input',
section: 'chat',
allowInInput: true,
action: () => {
const el = document.getElementById('nerve-chat-input');
if (el instanceof HTMLTextAreaElement) el.focus();
},
},
{
id: 'chat-copy-last',
combo: { mod: true, shift: true, key: 'c' },
description: 'Copy last response',
section: 'chat',
action: () => {
const text = getLastAssistantText(useChatStore.getState().messages);
if (text) void navigator.clipboard.writeText(text);
},
},
{
id: 'chat-delete-current',
combo: { mod: true, shift: true, key: 'Backspace' },
description: 'Delete current conversation',
section: 'chat',
action: () => {
const id = useChatStore.getState().activeSession;
if (!id) return;
if (window.confirm('Delete this conversation?')) {
void useChatStore.getState().deleteSession(id);
}
},
},
], []);

useKeyboardShortcuts(chatShortcuts);

// Langfuse deep-link status — fetched once. Shows a small "external link"
// icon when observability is enabled so we can jump from a session to
Expand Down Expand Up @@ -192,3 +238,17 @@ export function ChatPage() {
</div>
);
}

/** Walk messages backwards, return the joined text of the most recent assistant turn. */
function getLastAssistantText(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== 'assistant') continue;
const text = m.blocks
.filter((b): b is TextBlockData => b.type === 'text')
.map((b) => b.content)
.join('\n');
return text || null;
}
return null;
}
Loading