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(