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
85 changes: 83 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,84 @@ 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',
action: () => {
const focusNow = () => {
const store = useChatStore.getState();
if (store.sidebarCollapsed) store.toggleSidebar();
// The sidebar search input is unmounted until something asks for it.
// requestSearchFocus bumps a nonce the sidebar subscribes to.
store.requestSearchFocus();
};
if (!window.location.pathname.startsWith('/chat')) {
navigate('/chat');
// Wait one tick for ChatPage + SessionSidebar to mount.
setTimeout(focusNow, 0);
} else {
focusNow();
}
},
},
{
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;
8 changes: 8 additions & 0 deletions web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
setPrevQuoteCount(quotes.length);
}, [quotes.length, prevQuoteCount, quotes]);

// Auto-focus textarea when active session changes (new chat or session switch)
useEffect(() => {
if (activeSession && !disabled && textareaRef.current) {
textareaRef.current.focus();
}
}, [activeSession, disabled]);

// Cleanup object URLs on unmount
useEffect(() => {
return () => {
Expand Down Expand Up @@ -433,6 +440,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
148 changes: 117 additions & 31 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,77 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
}) {
const [systemExpanded, setSystemExpanded] = useState(false);
const [localQuery, setLocalQuery] = useState('');
const [searchHovered, setSearchHovered] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const [searchMounted, setSearchMounted] = useState(false);
const [searchVisible, setSearchVisible] = useState(false);
// Programmatic mount trigger — set true when something (e.g. Cmd+K) wants
// the search input visible without a mouse hover or focus event.
const [searchPinned, setSearchPinned] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar } = useChatStore();
const searchFocusNonce = useChatStore(s => s.searchFocusNonce);

const isSearching = localQuery.trim().length > 0;
const shouldShowSearch = searchHovered || searchFocused || isSearching || searchPinned;

// Mount/unmount the search input with a fade transition (200ms).
useEffect(() => {
if (shouldShowSearch) {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setSearchMounted(true);
} else if (searchMounted) {
setSearchVisible(false);
closeTimerRef.current = setTimeout(() => {
setSearchMounted(false);
closeTimerRef.current = null;
}, 200);
}
}, [shouldShowSearch, searchMounted]);

// After mount, flip to visible on next frame so the CSS transition runs.
useEffect(() => {
if (searchMounted && !searchVisible) {
const id = requestAnimationFrame(() => setSearchVisible(true));
return () => cancelAnimationFrame(id);
}
}, [searchMounted, searchVisible]);

// External "focus the search" request (e.g. Cmd+K). Pin the input so it
// mounts; the focus effect below takes over once it's in the DOM.
useEffect(() => {
if (searchFocusNonce > 0) setSearchPinned(true);
}, [searchFocusNonce]);

// Once the pinned input is in the DOM, focus + select it. We focus as soon
// as it's mounted (not after the fade-in) so the browser's focus event
// races less; the CSS transition still runs for the visual fade.
useEffect(() => {
if (searchPinned && searchMounted && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [searchPinned, searchMounted]);

// Release the pin only after onFocus has confirmed the focus landed —
// dropping it earlier risks a brief render with pinned=false AND
// focused=false, which collapses shouldShowSearch and fades the input out.
useEffect(() => {
if (searchPinned && searchFocused) setSearchPinned(false);
}, [searchPinned, searchFocused]);

// Clean up pending close timer on unmount.
useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
};
}, []);

// Debounced search
const handleSearchChange = useCallback((value: string) => {
Expand Down Expand Up @@ -125,37 +190,58 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,

return (
<div className={`bg-surface border-r border-border-subtle flex flex-col shrink-0 transition-all duration-200 overflow-hidden ${collapsed ? 'w-0 border-r-0' : 'w-60'}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
<span className="text-[10px] uppercase tracking-wider text-text-faint font-medium">Conversations</span>
<button
onClick={onCreate}
className="w-5 h-5 rounded flex items-center justify-center text-text-faint hover:text-text-muted hover:bg-surface-hover cursor-pointer"
title="New session"
>
<Plus size={12} />
</button>
</div>

{/* Search */}
<div className="px-2 py-1.5">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-faint" />
<input
ref={inputRef}
type="text"
value={localQuery}
onChange={e => handleSearchChange(e.target.value)}
placeholder="Search sessions..."
className="w-full bg-surface-raised border border-border rounded-md text-[12px] text-text-secondary placeholder-text-faint pl-7 pr-7 py-1.5 outline-none focus:border-text-faint transition-colors"
/>
{isSearching && (
<button
onClick={() => { setLocalQuery(''); clearSearch(); }}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 text-text-faint hover:text-text-muted cursor-pointer"
>
<X size={12} />
</button>
{/* Search + New chat */}
<div className="px-2 py-1.5 border-b border-border-subtle">
<div className="relative h-7">
{/* Search pill (always visible, hover-zone trigger) */}
<button
type="button"
onMouseEnter={() => setSearchHovered(true)}
onMouseLeave={() => setSearchHovered(false)}
className="absolute left-0 top-1/2 -translate-y-1/2 h-6 pl-1.5 pr-2.5 rounded-full border border-border-subtle flex items-center gap-1 text-[11px] text-text-faint hover:text-text-muted hover:bg-surface-hover cursor-pointer z-10"
>
<Search size={11} className="pointer-events-none" />
<span>Search sessions</span>
</button>

{/* New chat pill (hidden under input when open) */}
<button
onClick={onCreate}
title="New chat"
className="absolute right-0 top-1/2 -translate-y-1/2 h-6 pl-1.5 pr-2.5 rounded-full border border-border-subtle flex items-center gap-1 text-[11px] text-text-faint hover:text-text-muted hover:bg-surface-hover cursor-pointer"
>
<Plus size={11} />
<span>New chat</span>
</button>

{searchMounted && (
<>
<input
id="nerve-sidebar-search"
ref={inputRef}
type="text"
value={localQuery}
onChange={e => handleSearchChange(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
onMouseEnter={() => setSearchHovered(true)}
onMouseLeave={() => setSearchHovered(false)}
placeholder="Search sessions..."
className={`absolute inset-0 w-full h-full bg-surface-raised border border-border rounded-md text-[12px] text-text-secondary placeholder-text-faint pl-7 pr-7 outline-none focus:border-text-faint transition-all duration-200 ease-out z-20 ${
searchVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
/>
{isSearching && (
<button
onClick={() => { setLocalQuery(''); clearSearch(); }}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 text-text-faint hover:text-text-muted cursor-pointer transition-opacity duration-200 z-30 ${
searchVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<X size={12} />
</button>
)}
</>
)}
</div>
</div>
Expand Down
Loading