From ef8ecd503d86dd68142c3f118738d0e9b781d41e Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Thu, 18 Jun 2026 19:40:05 +0200 Subject: [PATCH 1/4] feat(chat): render sidebar sessions as with per-chat URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session rows used to be
, so Vimium couldn't hint them and there was no shareable URL per chat — the address bar always read /chat. - SessionSidebar: wrap each session row (conversations + system) in a react-router . Menu buttons inside the row now preventDefault on click so opening the kebab doesn't navigate. - ChatPage: drop the onSelect prop; add a navigate() effect that mirrors activeSession into the URL (replace), so createSession / WS-driven switches / deleteSession auto-pick all update the address bar too. --- web/src/components/Chat/SessionSidebar.tsx | 32 ++++++++++------------ web/src/pages/ChatPage.tsx | 16 +++++++++-- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index cc0c49c..38bff4a 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useRef, useEffect, useCallback, useLayoutEffect } from 'react'; +import { Link } from 'react-router-dom'; import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search, Hammer, MoreHorizontal, Star, Pencil, Trash2 } from 'lucide-react'; import type { Session, AgentStatus } from '../../types/chat'; import { groupByDate } from '../../utils/dateGroups'; @@ -31,11 +32,10 @@ function formatShortDate(dateStr: string): string { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); } -export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, onCreate, onDelete, collapsed }: { +export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed }: { sessions: Session[]; activeSession: string; agentStatus: AgentStatus; - onSelect: (id: string) => void; onCreate: () => void; onDelete: (id: string) => void; collapsed?: boolean; @@ -185,7 +185,6 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, session={s} isActive={s.id === activeSession} isRunning={s.id === activeSession ? activeIsRunning : !!s.is_running} - onSelect={onSelect} onDelete={onDelete} onRename={renameSession} onToggleStar={toggleStar} @@ -210,7 +209,6 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, session={s} isActive={s.id === activeSession} isRunning - onSelect={onSelect} onDelete={onDelete} onRename={renameSession} onToggleStar={toggleStar} @@ -231,7 +229,6 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, session={s} isActive={s.id === activeSession} isRunning={false} - onSelect={onSelect} onDelete={onDelete} onRename={renameSession} onToggleStar={toggleStar} @@ -256,7 +253,6 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, session={s} isActive={s.id === activeSession} isRunning={s.id === activeSession ? activeIsRunning : !!s.is_running} - onSelect={onSelect} onDelete={onDelete} onRename={renameSession} onToggleStar={toggleStar} @@ -292,10 +288,10 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, {systemExpanded && systemSessions.map((s) => ( -
onSelect(s.id)} - className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-[12px] transition-colors + to={`/chat/${s.id}`} + className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-[12px] transition-colors no-underline ${s.id === activeSession ? 'bg-accent/10 text-text-muted' : 'text-text-faint hover:bg-surface-raised hover:text-text-muted' @@ -310,7 +306,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, isActive={s.id === activeSession} isRunning={s.id === activeSession ? activeIsRunning : !!s.is_running} /> -
+ ))}
)} @@ -370,11 +366,10 @@ function StatusIndicator({ session, isActive, isRunning }: { } -function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRename, onToggleStar, showDate }: { +function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, showDate }: { session: Session; isActive: boolean; isRunning: boolean; - onSelect: (id: string) => void; onDelete: (id: string) => void; onRename: (id: string, title: string) => Promise; onToggleStar: (id: string) => Promise; @@ -431,9 +426,9 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam } return ( -
onSelect(session.id)} - className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-sm transition-colors +
)} - + ); } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 43eff42..130cf7a 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { useChatStore } from '../stores/chatStore'; import { SessionSidebar } from '../components/Chat/SessionSidebar'; import { MessageList } from '../components/Chat/MessageList'; @@ -29,6 +29,7 @@ function formatModelLabel(model: string): string { export function ChatPage() { const { sessionId } = useParams(); + const navigate = useNavigate(); const { sessions, activeSession, messages, streamingBlocks, isStreaming, loading, @@ -79,6 +80,16 @@ export function ChatPage() { }); }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps + // Keep the URL in sync with activeSession. Covers createSession (which sets + // activeSession but can't navigate from inside the store), server-driven + // session_switched WS messages, and deleteSession's auto-pick fallback. + // `replace` avoids polluting browser history with intra-chat hops. + useEffect(() => { + if (activeSession && activeSession !== sessionId) { + navigate(`/chat/${activeSession}`, { replace: true }); + } + }, [activeSession, sessionId, navigate]); + const statusLabel = agentStatus.state === 'tool' ? `Using ${agentStatus.toolName}...` : STATUS_LABELS[agentStatus.state] || null; @@ -92,7 +103,6 @@ export function ChatPage() { sessions={sessions} activeSession={activeSession} agentStatus={agentStatus} - onSelect={switchSession} onCreate={() => createSession()} onDelete={deleteSession} collapsed={sidebarCollapsed} From 1ab4bd879d961a852f601a64a157c8eaae82af9a Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Thu, 18 Jun 2026 19:47:06 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(chat):=20stop=20URL=20=E2=86=94=20activ?= =?UTF-8?q?eSession=20ping-pong=20loop=20on=20session=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "mirror activeSession into the URL" effect ran on every click: the set sessionId=A instantly, but activeSession was still B until switchSession() resolved a tick later. The effect saw the mismatch and yanked the URL back to /chat/B, which then re-triggered switchSession, which re-triggered the mirror — infinite flip. Guard the mirror: only push activeSession into the URL when the URL can't be the source of truth (no sessionId, or sessionId points to a session that's no longer in the list — i.e. just deleted). On a normal click the URL is valid, so the effect stays out of the way and lets switchSession catch up naturally. --- web/src/pages/ChatPage.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 130cf7a..064577a 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -80,15 +80,19 @@ export function ChatPage() { }); }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps - // Keep the URL in sync with activeSession. Covers createSession (which sets - // activeSession but can't navigate from inside the store), server-driven - // session_switched WS messages, and deleteSession's auto-pick fallback. - // `replace` avoids polluting browser history with intra-chat hops. + // Sync activeSession → URL, but only when the URL itself can't be source + // of truth: it has no sessionId yet (createSession, fresh load) or it + // points to a session that no longer exists (deleteSession auto-pick). + // Bare `activeSession !== sessionId` would fight the URL→switchSession + // effect above and loop on every click — the click sets sessionId + // instantly while activeSession only catches up after switchSession(). useEffect(() => { - if (activeSession && activeSession !== sessionId) { + if (!activeSession) return; + const urlPointsToValid = !!sessionId && sessions.some(s => s.id === sessionId); + if (!urlPointsToValid && activeSession !== sessionId) { navigate(`/chat/${activeSession}`, { replace: true }); } - }, [activeSession, sessionId, navigate]); + }, [activeSession, sessionId, sessions, navigate]); const statusLabel = agentStatus.state === 'tool' ? `Using ${agentStatus.toolName}...` From 54effc6b7c5803cf492547203dc7cf17c47d8468 Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Thu, 18 Jun 2026 22:45:06 +0200 Subject: [PATCH 3/4] fix(chat): cmd+click opens the correct chat in a new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh tab opened with /chat/A would briefly bounce to a different chat because two effects raced before loadSessions() resolved: - the URL-mirror useEffect ran with sessions=[], decided "URL points to unknown session", and yanked URL → /chat/ - the WS session_switched handler also ran on connect with activeSession="" and switched to the server-side default, which then propagated to URL - ChatPage: drop the mirror effect entirely; instead navigate explicitly from handleCreateSession and handleDeleteSession (the two places that change activeSession without a URL change). useCallback for stability. - App.tsx: global new-chat shortcut awaits createSession() and navigates to /chat/ with replace so the URL reflects the just-made chat. - sessionHandlers: handleSessionSwitched no longer overrides when the URL already names a specific chat — ChatPage's useEffect[sessionId] will switch to it. Prevents the new-tab flash through the server default. --- web/src/App.tsx | 80 ++++++++++++++++++++++ web/src/pages/ChatPage.tsx | 39 ++++++----- web/src/stores/handlers/sessionHandlers.ts | 11 +-- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c195ef..6469480 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -68,4 +68,84 @@ function App() { ); } +<<<<<<< HEAD +======= +/** + * 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: async () => { + navigate('/chat'); + await useChatStore.getState().createSession(); + const next = useChatStore.getState().activeSession; + if (next) navigate(`/chat/${next}`, { replace: true }); + }, + }, + { + 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; +} + +>>>>>>> bd63a27 (fix(chat): cmd+click opens the correct chat in a new tab) export default App; diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 064577a..913fa85 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useChatStore } from '../stores/chatStore'; import { SessionSidebar } from '../components/Chat/SessionSidebar'; @@ -52,6 +52,26 @@ export function ChatPage() { return () => document.removeEventListener('keydown', handleKeyDown); }, []); + // URL → activeSession is handled by the useEffect[sessionId] below. + // activeSession → URL is intentionally NOT done as a mirror effect — + // that races with `loadSessions()` (which starts with sessions=[], so any + // "URL is unknown to us" check is unreliable on a fresh tab) and with the + // server's `session_switched` WS message that fires before our store + // knows the URL's session exists. Instead we navigate explicitly from + // each call-site that changes the active session without a URL change. + const handleCreateSession = useCallback(async () => { + await createSession(); + const next = useChatStore.getState().activeSession; + if (next) navigate(`/chat/${next}`, { replace: true }); + }, [createSession, navigate]); + + const handleDeleteSession = useCallback(async (id: string) => { + await deleteSession(id); + const next = useChatStore.getState().activeSession; + if (next) navigate(`/chat/${next}`, { replace: true }); + else navigate('/chat', { replace: true }); + }, [deleteSession, navigate]); + // Langfuse deep-link status — fetched once. Shows a small "external link" // icon when observability is enabled so we can jump from a session to // its trace in Langfuse. @@ -80,19 +100,6 @@ export function ChatPage() { }); }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps - // Sync activeSession → URL, but only when the URL itself can't be source - // of truth: it has no sessionId yet (createSession, fresh load) or it - // points to a session that no longer exists (deleteSession auto-pick). - // Bare `activeSession !== sessionId` would fight the URL→switchSession - // effect above and loop on every click — the click sets sessionId - // instantly while activeSession only catches up after switchSession(). - useEffect(() => { - if (!activeSession) return; - const urlPointsToValid = !!sessionId && sessions.some(s => s.id === sessionId); - if (!urlPointsToValid && activeSession !== sessionId) { - navigate(`/chat/${activeSession}`, { replace: true }); - } - }, [activeSession, sessionId, sessions, navigate]); const statusLabel = agentStatus.state === 'tool' ? `Using ${agentStatus.toolName}...` @@ -107,8 +114,8 @@ export function ChatPage() { sessions={sessions} activeSession={activeSession} agentStatus={agentStatus} - onCreate={() => createSession()} - onDelete={deleteSession} + onCreate={handleCreateSession} + onDelete={handleDeleteSession} collapsed={sidebarCollapsed} /> diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index 3b7d50d..a27cc8f 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -123,10 +123,13 @@ export function handleSessionSwitched( get: Get, _set: Set, ): void { - // Server assigned a session (e.g., on WebSocket connect via auto-session) - if (msg.session_id && !get().activeSession) { - get().switchSession(msg.session_id); - } + // Server assigned a session (e.g., on WebSocket connect via auto-session). + // Don't override if the URL already names a specific chat — ChatPage's + // useEffect[sessionId] will switch to it. Otherwise cmd+click on a chat + // would briefly land on the server's default and then flip to the URL. + if (!msg.session_id || get().activeSession) return; + if (typeof window !== 'undefined' && /^\/chat\/.+/.test(window.location.pathname)) return; + get().switchSession(msg.session_id); } export function handleSessionForked( From 3418bebb2006cb4fb13fbf564e783c0da921f256 Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Thu, 18 Jun 2026 22:47:38 +0200 Subject: [PATCH 4/4] feat(chat): mirror active session title into the browser tab Sets document.title to the cleaned session title (same strip rules as the sidebar: leading '#' and 'Implement:' removed). Falls back to plain "Nerve" when there is no active session or when ChatPage unmounts, so other pages don't inherit a stale chat title. --- web/src/App.tsx | 80 -------------------------------------- web/src/pages/ChatPage.tsx | 16 ++++++++ 2 files changed, 16 insertions(+), 80 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 6469480..9c195ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -68,84 +68,4 @@ function App() { ); } -<<<<<<< HEAD -======= -/** - * 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: async () => { - navigate('/chat'); - await useChatStore.getState().createSession(); - const next = useChatStore.getState().activeSession; - if (next) navigate(`/chat/${next}`, { replace: true }); - }, - }, - { - 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; -} - ->>>>>>> bd63a27 (fix(chat): cmd+click opens the correct chat in a new tab) export default App; diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 913fa85..d142dca 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -72,6 +72,22 @@ export function ChatPage() { else navigate('/chat', { replace: true }); }, [deleteSession, navigate]); + // Mirror the active session's title into the browser tab. Same cleaning + // rules as the sidebar (strip leading '#' and 'Implement:' prefix). + // Restored to plain "Nerve" when leaving the chat page or when there's + // no active session yet. + useEffect(() => { + const session = sessions.find(s => s.id === activeSession); + if (!session) { + document.title = 'Nerve'; + return; + } + const raw = session.title || session.id; + const clean = raw.replace(/^#+\s*/, '').replace(/^Implement:\s*/i, ''); + document.title = clean; + return () => { document.title = 'Nerve'; }; + }, [activeSession, sessions]); + // Langfuse deep-link status — fetched once. Shows a small "external link" // icon when observability is enabled so we can jump from a session to // its trace in Langfuse.