Skip to content
Open
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
121 changes: 90 additions & 31 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,50 @@ 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);
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 isSearching = localQuery.trim().length > 0;
const shouldShowSearch = searchHovered || searchFocused || isSearching;

// 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]);

// 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 +163,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