"use client"; import { MessageSquare, Pencil, Pin, PinOff, Plus, Search, Trash2 } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, } from "motion/react"; import { type CSSProperties, type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { EmptyState } from "@/components/motion/empty-state"; import { InlineEdit } from "@/components/motion/inline-edit"; import { ScrollArea } from "@/components/motion/scroll-area"; import { Skeleton } from "@/components/motion/skeleton"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface Conversation { id: string; title: string; /** Last activity. ISO string, epoch ms or Date. */ updatedAt: string | number | Date; pinned?: boolean; /** The title is still being generated: shows a blinking caret after it. */ streaming?: boolean; } export type ConversationGroup = "pinned" | "today" | "yesterday" | "week" | "older"; export interface ConversationHistoryProps { conversations?: Conversation[]; defaultConversations?: Conversation[]; onConversationsChange?: (conversations: Conversation[]) => void; activeId?: string | null; defaultActiveId?: string | null; onActiveIdChange?: (id: string) => void; /** Called after a rename commits; return a promise to keep the editor's spinner until it settles. */ onRename?: (id: string, title: string) => unknown; onPinChange?: (id: string, pinned: boolean) => void; onDelete?: (id: string) => void; /** Renders the "New chat" button. The parent inserts the conversation (usually `streaming`). */ onNewChat?: () => void; /** Override "today" (otherwise read after mount so server and client markup match). */ now?: Date; searchable?: boolean; labels?: Partial>; /** Max height of the scrolling list. Default 320. */ maxHeight?: number; emptyState?: ReactNode; className?: string; } const GROUP_LABELS: Record = { pinned: "Pinned", today: "Today", yesterday: "Yesterday", week: "Previous 7 days", older: "Older", }; const GROUP_ORDER: ConversationGroup[] = ["pinned", "today", "yesterday", "week", "older"]; const DAY_MS = 86_400_000; function toTime(value: Conversation["updatedAt"]) { return value instanceof Date ? value.getTime() : new Date(value).getTime(); } /** Bucket a conversation by calendar day relative to `now` (local time). */ export function groupConversation(conversation: Conversation, now: Date): ConversationGroup { if (conversation.pinned) return "pinned"; const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const t = toTime(conversation.updatedAt); if (t >= startOfToday) return "today"; if (t >= startOfToday - DAY_MS) return "yesterday"; if (t >= startOfToday - 7 * DAY_MS) return "week"; return "older"; } function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } export function ConversationHistory({ conversations: conversationsProp, defaultConversations = [], onConversationsChange, activeId: activeIdProp, defaultActiveId = null, onActiveIdChange, onRename, onPinChange, onDelete, onNewChat, now: nowProp, searchable = true, labels, maxHeight = 320, emptyState, className, }: ConversationHistoryProps) { const uid = useId(); const reduce = useReducedMotion() ?? false; const [conversations, setConversations] = useControllable( conversationsProp, defaultConversations, onConversationsChange, ); const [activeId, setActiveId] = useControllable( activeIdProp, defaultActiveId, onActiveIdChange as ((id: string | null) => void) | undefined, ); const [query, setQuery] = useState(""); const [renamingId, setRenamingId] = useState(null); const listRef = useRef(null); // "Today" is only known on the client; grouping waits for it so the server // render never disagrees with the first client render. const [mountedNow, setMountedNow] = useState(null); useEffect(() => { if (!nowProp) setMountedNow(new Date()); }, [nowProp]); const now = nowProp ?? mountedNow; const groups = useMemo(() => { if (!now) return []; const q = query.trim().toLowerCase(); const buckets = new Map(); const sorted = [...conversations].sort((a, b) => toTime(b.updatedAt) - toTime(a.updatedAt)); for (const c of sorted) { if (q && !c.title.toLowerCase().includes(q)) continue; const g = groupConversation(c, now); const list = buckets.get(g) ?? []; list.push(c); buckets.set(g, list); } return GROUP_ORDER.filter((g) => buckets.has(g)).map((g) => ({ id: g, items: buckets.get(g) ?? [], })); }, [conversations, now, query]); const focusRow = (id: string) => { requestAnimationFrame(() => { listRef.current ?.querySelector(`[data-conversation-id="${CSS.escape(id)}"]`) ?.focus(); }); }; const update = (id: string, patch: Partial) => setConversations(conversations.map((c) => (c.id === id ? { ...c, ...patch } : c))); const remove = (id: string) => { const flat = groups.flatMap((g) => g.items); const index = flat.findIndex((c) => c.id === id); const neighbour = flat[index + 1] ?? flat[index - 1]; setConversations(conversations.filter((c) => c.id !== id)); onDelete?.(id); if (neighbour) focusRow(neighbour.id); }; // Up/Down (and Home/End) move between conversation rows. const onListKeyDown = (event: KeyboardEvent) => { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const target = event.target as HTMLElement; if (!target.dataset.conversationId) return; const rows = Array.from( listRef.current?.querySelectorAll("[data-conversation-id]") ?? [], ); const i = rows.indexOf(target); const next = event.key === "Home" ? rows[0] : event.key === "End" ? rows[rows.length - 1] : rows[i + (event.key === "ArrowDown" ? 1 : -1)]; if (next) { event.preventDefault(); next.focus(); } }; const total = groups.reduce((n, g) => n + g.items.length, 0); return (
{(onNewChat || searchable) && (
{searchable && ( )} {onNewChat && ( )}
)} {/* biome-ignore lint/a11y/noStaticElementInteractions: delegates arrow keys to the row buttons inside */}
{!now ? (
{[72, 58, 80, 64].map((w) => ( ))}
) : total === 0 ? ( (emptyState ?? (

{query && now ? `${total} conversation${total === 1 ? "" : "s"} found` : ""}

); } interface ConversationRowProps { uid: string; conversation: Conversation; active: boolean; renaming: boolean; reduce: boolean; onSelect: () => void; onStartRename: () => void; onEndRename: () => void; onRename: (title: string) => unknown; onTogglePin: () => void; onDelete: () => void; } function ConversationRow({ uid, conversation, active, renaming, reduce, onSelect, onStartRename, onEndRename, onRename, onTogglePin, onDelete, }: ConversationRowProps) { const editRef = useRef(null); // InlineEdit opens from its own display button; press it once so the // Rename action lands straight in the editor. useEffect(() => { if (renaming) editRef.current?.querySelector("button")?.click(); }, [renaming]); return ( {active && ( ); } function RowAction({ label, destructive, onClick, children, }: { label: string; destructive?: boolean; onClick: () => void; children: ReactNode; }) { return ( ); }