Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useMemo } from "react"; | |
| import { cn } from "@/lib/utils"; | |
| import { trpc } from "@/lib/trpc"; | |
| import { | |
| Plus, | |
| MessageSquare, | |
| Settings, | |
| Trash2, | |
| Edit3, | |
| Check, | |
| X, | |
| ChevronLeft, | |
| ChevronRight, | |
| DollarSign, | |
| Zap, | |
| LogOut, | |
| Search, | |
| ChevronDown, | |
| } from "lucide-react"; | |
| import { useAuth } from "@/_core/hooks/useAuth"; | |
| import { getLoginUrl } from "@/const"; | |
| interface SidebarProps { | |
| currentSessionId: number | null; | |
| onSelectSession: (id: number) => void; | |
| onNewSession: () => void; | |
| onOpenSettings: () => void; | |
| collapsed: boolean; | |
| onToggleCollapse: () => void; | |
| } | |
| type DateGroup = "Today" | "Yesterday" | "This Week" | "This Month" | "Older"; | |
| function getDateGroup(dateStr: string): DateGroup { | |
| const date = new Date(dateStr); | |
| const now = new Date(); | |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| const yesterday = new Date(today); | |
| yesterday.setDate(yesterday.getDate() - 1); | |
| const weekAgo = new Date(today); | |
| weekAgo.setDate(weekAgo.getDate() - 7); | |
| const monthAgo = new Date(today); | |
| monthAgo.setMonth(monthAgo.getMonth() - 1); | |
| if (date >= today) return "Today"; | |
| if (date >= yesterday) return "Yesterday"; | |
| if (date >= weekAgo) return "This Week"; | |
| if (date >= monthAgo) return "This Month"; | |
| return "Older"; | |
| } | |
| const GROUP_ORDER: DateGroup[] = ["Today", "Yesterday", "This Week", "This Month", "Older"]; | |
| export function Sidebar({ | |
| currentSessionId, | |
| onSelectSession, | |
| onNewSession, | |
| onOpenSettings, | |
| collapsed, | |
| onToggleCollapse, | |
| }: SidebarProps) { | |
| const { user } = useAuth(); | |
| const utils = trpc.useUtils(); | |
| const { data: sessions, isLoading } = trpc.sessions.list.useQuery(); | |
| const { data: costSummary } = trpc.costs.summary.useQuery(undefined, { | |
| enabled: !!user, | |
| }); | |
| const deleteMutation = trpc.sessions.delete.useMutation({ | |
| onSuccess: () => utils.sessions.list.invalidate(), | |
| }); | |
| const updateMutation = trpc.sessions.update.useMutation({ | |
| onSuccess: () => utils.sessions.list.invalidate(), | |
| }); | |
| const logoutMutation = trpc.auth.logout.useMutation({ | |
| onSuccess: () => window.location.reload(), | |
| }); | |
| const [editingId, setEditingId] = useState<number | null>(null); | |
| const [editTitle, setEditTitle] = useState(""); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [collapsedGroups, setCollapsedGroups] = useState<Set<DateGroup>>(new Set()); | |
| // Filter sessions by search | |
| const filteredSessions = useMemo(() => { | |
| if (!sessions) return []; | |
| if (!searchQuery.trim()) return sessions; | |
| const q = searchQuery.toLowerCase(); | |
| return sessions.filter( | |
| (s) => | |
| s.title.toLowerCase().includes(q) || | |
| (s.model && s.model.toLowerCase().includes(q)) | |
| ); | |
| }, [sessions, searchQuery]); | |
| // Group sessions by date | |
| const groupedSessions = useMemo(() => { | |
| const groups = new Map<DateGroup, typeof filteredSessions>(); | |
| for (const session of filteredSessions) { | |
| const group = getDateGroup(session.updatedAt || session.createdAt); | |
| if (!groups.has(group)) groups.set(group, []); | |
| groups.get(group)!.push(session); | |
| } | |
| return groups; | |
| }, [filteredSessions]); | |
| const toggleGroup = (group: DateGroup) => { | |
| setCollapsedGroups((prev) => { | |
| const next = new Set(prev); | |
| if (next.has(group)) next.delete(group); | |
| else next.add(group); | |
| return next; | |
| }); | |
| }; | |
| // Listen for title updates from SSE | |
| useEffect(() => { | |
| const handler = () => { | |
| utils.sessions.list.invalidate(); | |
| }; | |
| window.addEventListener("session-title-update", handler); | |
| return () => window.removeEventListener("session-title-update", handler); | |
| }, [utils]); | |
| const startEdit = (id: number, title: string) => { | |
| setEditingId(id); | |
| setEditTitle(title); | |
| }; | |
| const saveEdit = () => { | |
| if (editingId && editTitle.trim()) { | |
| updateMutation.mutate({ id: editingId, title: editTitle.trim() }); | |
| } | |
| setEditingId(null); | |
| }; | |
| if (collapsed) { | |
| return ( | |
| <div className="w-12 bg-sidebar border-r border-sidebar-border flex flex-col items-center py-3 gap-2"> | |
| <button | |
| onClick={onToggleCollapse} | |
| className="size-8 rounded-lg hover:bg-sidebar-accent flex items-center justify-center text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors" | |
| > | |
| <ChevronRight className="size-4" /> | |
| </button> | |
| <button | |
| onClick={onNewSession} | |
| className="size-8 rounded-lg bg-primary/20 hover:bg-primary/30 flex items-center justify-center text-primary transition-colors" | |
| title="New session" | |
| > | |
| <Plus className="size-4" /> | |
| </button> | |
| <div className="flex-1" /> | |
| <button | |
| onClick={onOpenSettings} | |
| className="size-8 rounded-lg hover:bg-sidebar-accent flex items-center justify-center text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors" | |
| title="Settings" | |
| > | |
| <Settings className="size-4" /> | |
| </button> | |
| </div> | |
| ); | |
| } | |
| const renderSession = (session: (typeof filteredSessions)[0]) => ( | |
| <div | |
| key={session.id} | |
| className={cn( | |
| "group flex items-center gap-2 rounded-lg px-2.5 py-2 cursor-pointer transition-colors", | |
| session.id === currentSessionId | |
| ? "bg-sidebar-accent text-sidebar-accent-foreground" | |
| : "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground" | |
| )} | |
| onClick={() => onSelectSession(session.id)} | |
| > | |
| <MessageSquare className="size-3.5 shrink-0 opacity-60" /> | |
| {editingId === session.id ? ( | |
| <div className="flex-1 flex items-center gap-1"> | |
| <input | |
| value={editTitle} | |
| onChange={(e) => setEditTitle(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") saveEdit(); | |
| if (e.key === "Escape") setEditingId(null); | |
| }} | |
| className="flex-1 bg-background text-xs rounded px-1.5 py-0.5 outline-none border border-border" | |
| autoFocus | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| saveEdit(); | |
| }} | |
| className="text-green-400 hover:text-green-300" | |
| > | |
| <Check className="size-3" /> | |
| </button> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setEditingId(null); | |
| }} | |
| className="text-muted-foreground hover:text-foreground" | |
| > | |
| <X className="size-3" /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <> | |
| <span className="flex-1 text-xs truncate">{session.title}</span> | |
| <div className="hidden group-hover:flex items-center gap-0.5"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| startEdit(session.id, session.title); | |
| }} | |
| className="size-5 rounded hover:bg-background/50 flex items-center justify-center" | |
| > | |
| <Edit3 className="size-3" /> | |
| </button> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (confirm("Delete this session?")) { | |
| deleteMutation.mutate({ id: session.id }); | |
| } | |
| }} | |
| className="size-5 rounded hover:bg-destructive/20 text-destructive flex items-center justify-center" | |
| > | |
| <Trash2 className="size-3" /> | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| return ( | |
| <div className="w-64 bg-sidebar border-r border-sidebar-border flex flex-col h-full"> | |
| {/* Header */} | |
| <div className="p-3 flex items-center justify-between border-b border-sidebar-border"> | |
| <div className="flex items-center gap-2"> | |
| <Zap className="size-5 text-primary" /> | |
| <span className="font-semibold text-sm text-sidebar-foreground"> | |
| Claw | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <button | |
| onClick={onNewSession} | |
| className="size-7 rounded-md hover:bg-sidebar-accent flex items-center justify-center text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors" | |
| title="New session" | |
| > | |
| <Plus className="size-4" /> | |
| </button> | |
| <button | |
| onClick={onToggleCollapse} | |
| className="size-7 rounded-md hover:bg-sidebar-accent flex items-center justify-center text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors" | |
| > | |
| <ChevronLeft className="size-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Search */} | |
| <div className="px-2 pt-2"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" /> | |
| <input | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| placeholder="Search sessions..." | |
| className="w-full bg-sidebar-accent/50 border border-sidebar-border rounded-md pl-7 pr-2 py-1.5 text-xs outline-none focus:border-primary placeholder:text-muted-foreground/50" | |
| /> | |
| </div> | |
| </div> | |
| {/* Sessions list — grouped by date */} | |
| <div className="flex-1 overflow-y-auto py-2 px-2"> | |
| {isLoading && ( | |
| <div className="text-xs text-muted-foreground text-center py-4"> | |
| Loading sessions... | |
| </div> | |
| )} | |
| {filteredSessions.length === 0 && !isLoading && ( | |
| <div className="text-xs text-muted-foreground text-center py-4"> | |
| {searchQuery ? "No matching sessions" : "No sessions yet"} | |
| </div> | |
| )} | |
| {GROUP_ORDER.map((group) => { | |
| const groupSessions = groupedSessions.get(group); | |
| if (!groupSessions || groupSessions.length === 0) return null; | |
| const isCollapsed = collapsedGroups.has(group); | |
| return ( | |
| <div key={group} className="mb-2"> | |
| <button | |
| onClick={() => toggleGroup(group)} | |
| className="w-full flex items-center gap-1.5 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 hover:text-muted-foreground transition-colors" | |
| > | |
| <ChevronDown | |
| className={cn( | |
| "size-3 transition-transform", | |
| isCollapsed && "-rotate-90" | |
| )} | |
| /> | |
| <span>{group}</span> | |
| <span className="ml-auto text-[9px] font-normal opacity-50"> | |
| {groupSessions.length} | |
| </span> | |
| </button> | |
| {!isCollapsed && ( | |
| <div className="space-y-0.5"> | |
| {groupSessions.map(renderSession)} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Cost widget */} | |
| {costSummary && ( | |
| <div className="mx-2 mb-2 px-2.5 py-2 rounded-lg bg-sidebar-accent/30 border border-sidebar-border"> | |
| <div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-0.5"> | |
| <DollarSign className="size-3" /> | |
| <span>Total Cost</span> | |
| </div> | |
| <div className="text-sm font-mono font-semibold text-primary"> | |
| ${Number(costSummary.totalCost || 0).toFixed(4)} | |
| </div> | |
| <div className="text-[10px] text-muted-foreground"> | |
| {Number(costSummary.totalPromptTokens || 0).toLocaleString()} +{" "} | |
| {Number(costSummary.totalCompletionTokens || 0).toLocaleString()}{" "} | |
| tokens | |
| </div> | |
| </div> | |
| )} | |
| {/* Bottom section */} | |
| <div className="border-t border-sidebar-border p-2 space-y-0.5"> | |
| <button | |
| onClick={onOpenSettings} | |
| className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground transition-colors" | |
| > | |
| <Settings className="size-3.5" /> | |
| <span className="text-xs">Settings</span> | |
| </button> | |
| {user ? ( | |
| <div className="flex items-center gap-2 px-2.5 py-2"> | |
| <div className="size-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-[10px] font-bold"> | |
| {user.name?.[0]?.toUpperCase() || "U"} | |
| </div> | |
| <span className="flex-1 text-xs text-sidebar-foreground/70 truncate"> | |
| {user.name || "User"} | |
| </span> | |
| <button | |
| onClick={() => logoutMutation.mutate()} | |
| className="size-6 rounded hover:bg-sidebar-accent flex items-center justify-center text-sidebar-foreground/40 hover:text-sidebar-foreground transition-colors" | |
| title="Logout" | |
| > | |
| <LogOut className="size-3" /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <a | |
| href={getLoginUrl()} | |
| className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-primary hover:bg-sidebar-accent/50 transition-colors" | |
| > | |
| <span className="text-xs font-medium">Sign in</span> | |
| </a> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |