'use client'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { api } from '@/lib/api'; import type { Conversation, Project } from '@/lib/types'; export interface ConversationSummary { id: string; title: string | null; projectId: string | null; updatedAt: string; archived?: boolean; } export interface ConversationSidebarRef { refresh: () => void; addConversation: (conversation: ConversationSummary) => void; } interface ConversationSidebarProps { isOpen: boolean; onClose: () => void; currentConversationId: string | null; onSelectConversation: (conversationId: string | null) => void; onNewConversation: (projectId?: string | null) => void; } interface GroupedConversations { key: string; label: string; projectId: string | null; conversations: ConversationSummary[]; } function toSummary(conversation: Conversation): ConversationSummary { return { id: conversation.id, title: conversation.title, projectId: conversation.projectId, updatedAt: conversation.updatedAt, archived: conversation.archived, }; } function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / 60_000); const diffHours = Math.floor(diffMs / 3_600_000); const diffDays = Math.floor(diffMs / 86_400_000); if (diffMinutes < 1) return 'Just now'; if (diffMinutes < 60) return `${diffMinutes}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays === 1) return 'Yesterday'; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } export const ConversationSidebar = forwardRef( function ConversationSidebar( { isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation }, ref, ): React.ReactElement { const [conversations, setConversations] = useState([]); const [projects, setProjects] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [hoveredId, setHoveredId] = useState(null); const renameInputRef = useRef(null); const loadSidebarData = useCallback(async (): Promise => { try { setIsLoading(true); setError(null); const [loadedConversations, loadedProjects] = await Promise.all([ api('/api/conversations'), api('/api/projects').catch(() => [] as Project[]), ]); setConversations( loadedConversations .filter((conversation) => !conversation.archived) .map(toSummary) .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)), ); setProjects(loadedProjects); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load conversations'); } finally { setIsLoading(false); } }, []); useEffect(() => { void loadSidebarData(); }, [loadSidebarData]); useEffect(() => { if (!renamingId) return; const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0); return () => window.clearTimeout(timer); }, [renamingId]); useImperativeHandle( ref, () => ({ refresh: () => { void loadSidebarData(); }, addConversation: (conversation) => { setConversations((prev) => { const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)]; return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); }); }, }), [loadSidebarData], ); const filteredConversations = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return conversations; return conversations.filter((conversation) => (conversation.title ?? 'Untitled conversation').toLowerCase().includes(query), ); }, [conversations, searchQuery]); const groupedConversations = useMemo(() => { if (projects.length === 0) { return [ { key: 'all', label: 'All conversations', projectId: null, conversations: filteredConversations, }, ]; } const byProject = new Map(); for (const conversation of filteredConversations) { const key = conversation.projectId ?? null; const items = byProject.get(key) ?? []; items.push(conversation); byProject.set(key, items); } const groups: GroupedConversations[] = []; for (const project of projects) { const projectConversations = byProject.get(project.id); if (!projectConversations?.length) continue; groups.push({ key: project.id, label: project.name, projectId: project.id, conversations: projectConversations, }); } const ungrouped = byProject.get(null); if (ungrouped?.length) { groups.push({ key: 'general', label: 'General', projectId: null, conversations: ungrouped, }); } if (groups.length === 0) { groups.push({ key: 'all', label: 'All conversations', projectId: null, conversations: filteredConversations, }); } return groups; }, [filteredConversations, projects]); const startRename = useCallback((conversation: ConversationSummary): void => { setPendingDeleteId(null); setRenamingId(conversation.id); setRenameValue(conversation.title ?? ''); }, []); const cancelRename = useCallback((): void => { setRenamingId(null); setRenameValue(''); }, []); const commitRename = useCallback(async (): Promise => { if (!renamingId) return; const title = renameValue.trim() || 'Untitled conversation'; try { const updated = await api(`/api/conversations/${renamingId}`, { method: 'PATCH', body: { title }, }); const summary = toSummary(updated); setConversations((prev) => prev .map((conversation) => (conversation.id === renamingId ? summary : conversation)) .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)), ); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to rename conversation'); } finally { setRenamingId(null); setRenameValue(''); } }, [renameValue, renamingId]); const deleteConversation = useCallback( async (conversationId: string): Promise => { try { await api(`/api/conversations/${conversationId}`, { method: 'DELETE' }); setConversations((prev) => prev.filter((conversation) => conversation.id !== conversationId), ); if (currentConversationId === conversationId) { onSelectConversation(null); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete conversation'); } finally { setPendingDeleteId(null); } }, [currentConversationId, onSelectConversation], ); return ( <> {isOpen ? (
setSearchQuery(event.target.value)} placeholder="Search conversations" className="w-full rounded-lg border px-9 py-2 text-sm outline-none" style={{ background: 'var(--surface)', borderColor: 'var(--border)', color: 'var(--text)', }} />
{isLoading ? (
Loading conversations...
) : error ? (

{error}

) : filteredConversations.length === 0 ? (

{searchQuery ? 'No matching conversations' : 'No conversations yet'}

{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}

) : (
{groupedConversations.map((group) => (
{projects.length > 0 ? (

{group.label}

) : null}
{group.conversations.map((conversation) => { const isActive = currentConversationId === conversation.id; const isRenaming = renamingId === conversation.id; const showActions = hoveredId === conversation.id || isRenaming || pendingDeleteId === conversation.id; return (
setHoveredId(conversation.id)} onMouseLeave={() => setHoveredId((current) => current === conversation.id ? null : current, ) } className="rounded-xl border p-2 transition-colors" style={{ borderColor: isActive ? 'color-mix(in srgb, var(--primary) 60%, var(--border))' : 'transparent', background: isActive ? 'var(--surface-2)' : 'transparent', }} > {isRenaming ? ( setRenameValue(event.target.value)} onBlur={() => void commitRename()} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void commitRename(); } if (event.key === 'Escape') { event.preventDefault(); cancelRename(); } }} maxLength={255} className="w-full rounded-md border px-2 py-1.5 text-sm outline-none" style={{ background: 'var(--surface)', borderColor: 'var(--ms-blue-500)', color: 'var(--text)', }} /> ) : (
) : null}
)} {pendingDeleteId === conversation.id ? (

Delete this conversation?

) : null}
); })}
))} )} ); }, );