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..d142dca 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 { useCallback, useEffect, 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,
@@ -51,6 +52,42 @@ 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]);
+
+ // 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.
@@ -79,6 +116,7 @@ export function ChatPage() {
});
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
+
const statusLabel = agentStatus.state === 'tool'
? `Using ${agentStatus.toolName}...`
: STATUS_LABELS[agentStatus.state] || null;
@@ -92,9 +130,8 @@ export function ChatPage() {
sessions={sessions}
activeSession={activeSession}
agentStatus={agentStatus}
- onSelect={switchSession}
- 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(