claw-web-v2 / client /src /components /Sidebar.tsx
Claw Web
feat: JSON repair, exponential backoff, sidebar date groups, parity audit fixes
33e6f65
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>
);
}