"use client"; import { ChevronDown, Filter, GitFork, Loader2, MessageSquare, PanelLeftOpen, Pencil, Pin, Plus, Search, Settings, Trash2, } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSidebar } from "@/components/ui/sidebar"; import { normalizeTitle } from "@/components/utils"; import type { SessionThread, UseSessionHistoryResult, } from "@/hooks/use-session-history"; import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history"; import { cn } from "@/lib/utils"; type Thread = SessionThread; const filterOptions = ["All", "Running", "Recent", "Pinned"] as const; type FilterOption = (typeof filterOptions)[number]; const INITIAL_VISIBLE_THREAD_COUNT = 10; export function AgentSidebar({ onNewThread, setView, activeSessionId, sessionHistory, }: { onNewThread?: () => void; setView: (view: "chat" | "sessions" | "settings") => void; activeSessionId?: string | null; sessionHistory: UseSessionHistoryResult; }) { const { isMobile, setOpen, state } = useSidebar(); const isCollapsed = !isMobile && state === "collapsed"; const { deleteThread: deleteHistoryThread, forkThread: forkHistoryThread, isLoadingHistory, isLoadingMore, loadMoreSessions, mayHaveMoreSessions, openThread: openHistoryThread, pendingAction, renameThread, threads, unreadSessionIds, } = sessionHistory; const activeThread = activeSessionId ?? ""; const [filter, setFilter] = useState("All"); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showMoreCount, setShowMoreCount] = useState( INITIAL_VISIBLE_THREAD_COUNT, ); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(""); const [deleteConfirmThread, setDeleteConfirmThread] = useState( null, ); useEffect(() => { if (isCollapsed && searchOpen) { setSearchOpen(false); } }, [isCollapsed, searchOpen]); const filteredThreads = useMemo(() => { let filtered = threads; if (searchQuery) { const q = searchQuery.toLowerCase(); filtered = filtered.filter( (t) => t.title.toLowerCase().includes(q) || t.codebase.toLowerCase().includes(q), ); } switch (filter) { case "Running": return filtered.filter((t) => t.status === "running"); case "Recent": return filtered.slice(0, 8); case "Pinned": return filtered.filter((t) => t.pinned); default: return filtered; } }, [filter, searchQuery, threads]); const openThread = useCallback( (threadId: string) => { setView("chat"); openHistoryThread(threadId); }, [openHistoryThread, setView], ); const openNewThread = useCallback(() => { setView("chat"); onNewThread?.(); }, [onNewThread, setView]); const startRenameThread = useCallback((thread: Thread) => { setEditingSessionId(thread.id); setEditingTitle(normalizeTitle(thread.title)); }, []); const cancelRenameThread = useCallback(() => { setEditingSessionId(null); setEditingTitle(""); }, []); const commitRenameThread = useCallback( async (thread: Thread) => { const renamed = await renameThread(thread.id, editingTitle); if (renamed) { cancelRenameThread(); } }, [cancelRenameThread, editingTitle, renameThread], ); const forkThread = useCallback( async (thread: Thread) => { await forkHistoryThread(thread.id); }, [forkHistoryThread], ); const requestDeleteThread = useCallback((thread: Thread) => { setDeleteConfirmThread(thread); }, []); const deleteThread = useCallback( async (thread: Thread) => { await deleteHistoryThread(thread.id); setDeleteConfirmThread(null); }, [deleteHistoryThread], ); const pinnedThreads = useMemo( () => filteredThreads.filter((t) => t.pinned), [filteredThreads], ); const sessionThreads = useMemo( () => filteredThreads.filter((t) => !t.pinned), [filteredThreads], ); const displayedThreads = useMemo( () => filter === "All" ? [...pinnedThreads, ...sessionThreads.slice(0, showMoreCount)] : [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount), [filter, pinnedThreads, sessionThreads, showMoreCount], ); const showShowMore = sessionThreads.length > showMoreCount || mayHaveMoreSessions; const filterMenu = ( { setFilter(value as FilterOption); setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT); }} value={filter} > {filterOptions.map((opt) => ( {opt} ))} ); return ( <>
{isCollapsed ? ( ) : null}
{!isCollapsed ? (
{searchOpen ? (
{ if (!searchQuery) setSearchOpen(false); }} autoFocus={true} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search sessions..." value={searchQuery} />
) : ( )}
) : null} {!isCollapsed ? (
{isLoadingHistory && threads.length === 0 ? (
Loading session history...
) : ( <> {displayedThreads.length > 0 && ( setView("sessions")} > {displayedThreads.map((thread) => ( openThread(thread.id)} onCommitRename={() => void commitRenameThread(thread) } onDelete={() => requestDeleteThread(thread)} onEditTitleChange={setEditingTitle} onFork={() => void forkThread(thread)} onRename={() => startRenameThread(thread)} pendingAction={ pendingAction?.sessionId === thread.id ? pendingAction.action : null } thread={thread} unread={unreadSessionIds.has(thread.id)} /> ))} )} {displayedThreads.length === 0 && (
{searchQuery ? "No sessions match your search." : "No sessions found in history."}
)} )} {showShowMore && ( )}
) : (
)}
{ if (!open && pendingAction?.action !== "delete") { setDeleteConfirmThread(null); } }} > Delete session? This removes " {normalizeTitle(deleteConfirmThread?.title ?? "this session")}" from local history. Cancel { event.preventDefault(); if (deleteConfirmThread) { void deleteThread(deleteConfirmThread); } }} > {pendingAction?.action === "delete" ? ( <> Deleting... ) : ( "Delete" )} ); } function ThreadSection({ label, action, onClick, children, }: { label: string; action?: ReactNode; onClick?: () => void; children: ReactNode; }) { return (
{action ? (
{action}
) : null}
{children}
); } function ThreadItem({ thread, editTitle, editing, isActive, onClick, onCancelRename, onCommitRename, onEditTitleChange, onRename, onFork, onDelete, pendingAction, unread, }: { thread: Thread; editTitle: string; editing: boolean; isActive: boolean; onClick: () => void; onCancelRename: () => void; onCommitRename: () => void; onEditTitleChange: (title: string) => void; onRename: () => void; onFork: () => void; onDelete: () => void; pendingAction: "rename" | "fork" | "delete" | null; unread: boolean; }) { const tokenLabel = formatTokenCount(thread.inputTokens, thread.outputTokens); const costLabel = formatCostUsd(thread.totalCostUsd); const title = normalizeTitle(thread.title); const pending = pendingAction !== null; const statusDotClass = pending ? "bg-yellow-400" : thread.status === "running" ? "bg-green-500" : unread ? "bg-blue-500" : ""; const infoItems: Array<[string, string | null | undefined]> = [ ["ID", thread.id], ["Workspace", thread.codebase], ["Status", thread.status], ["Updated", thread.time], ["Provider", thread.provider], ["Model", thread.model], ["Tokens", tokenLabel], ["Cost", costLabel], ].filter((item): item is [string, string] => Boolean(item[1])); if (editing) { return (
{pendingAction === "rename" ? ( ) : null}
); } return (
{title}
{infoItems.map(([label, value]) => (
{label} {value}
))}
); } function EditableSessionTitle({ value, disabled, onChange, onCommit, onCancel, }: { value: string; disabled: boolean; onChange: (value: string) => void; onCommit: () => void; onCancel: () => void; }) { const inputRef = useRef(null); useEffect(() => { const input = inputRef.current; if (!input) { return; } input.focus(); input.setSelectionRange(0, 0); input.scrollLeft = 0; }, []); return ( { if (!disabled) { onCommit(); } }} onChange={(event) => onChange(event.target.value)} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); onCommit(); } if (event.key === "Escape") { event.preventDefault(); onCancel(); } }} value={value} /> ); } function SessionContextMenuContent({ onRename, onFork, onDelete, pendingAction, }: { onRename: () => void; onFork: () => void; onDelete: () => void; pendingAction: "rename" | "fork" | "delete" | null; }) { const pending = pendingAction !== null; return ( {pendingAction === "rename" ? ( ) : ( )} {pendingAction === "rename" ? "Renaming..." : "Rename"} {pendingAction === "fork" ? ( ) : ( )} {pendingAction === "fork" ? "Forking..." : "Fork"} {pendingAction === "delete" ? ( ) : ( )} {pendingAction === "delete" ? "Deleting..." : "Delete"} ); }