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
32 changes: 15 additions & 17 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand All @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -292,10 +288,10 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
</button>

{systemExpanded && systemSessions.map((s) => (
<div
<Link
key={s.id}
onClick={() => 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'
Expand All @@ -310,7 +306,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
isActive={s.id === activeSession}
isRunning={s.id === activeSession ? activeIsRunning : !!s.is_running}
/>
</div>
</Link>
))}
</div>
)}
Expand Down Expand Up @@ -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<void>;
onToggleStar: (id: string) => Promise<void>;
Expand Down Expand Up @@ -431,9 +426,9 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
}

return (
<div
onClick={() => 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
<Link
to={`/chat/${session.id}`}
className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-sm transition-colors no-underline
${isActive
? 'bg-accent/10 text-text'
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
Expand All @@ -460,7 +455,7 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
{/* Menu trigger: starred → show star, on hover → three dots; unstarred → three dots on hover */}
<div className="relative shrink-0" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setMenuOpen(!menuOpen); }}
className={`p-0.5 cursor-pointer transition-opacity ${
session.starred
? 'text-hue-yellow opacity-100 [&>*:first-child]:block [&>*:last-child]:hidden hover:[&>*:first-child]:hidden hover:[&>*:last-child]:block hover:text-text-muted'
Expand All @@ -481,6 +476,7 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
<div className="absolute right-0 top-full mt-1 z-50 bg-surface-raised border border-border-subtle rounded-lg shadow-xl py-1 min-w-[140px]">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleStar(session.id);
setMenuOpen(false);
Expand All @@ -492,6 +488,7 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRenameValue(cleanTitle(session));
setRenaming(true);
Expand All @@ -505,6 +502,7 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
<div className="border-t border-border my-1" />
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
onDelete(session.id);
Expand All @@ -517,6 +515,6 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
</div>
)}
</div>
</div>
</Link>
);
}
47 changes: 42 additions & 5 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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}
/>

Expand Down
11 changes: 7 additions & 4 deletions web/src/stores/handlers/sessionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down