diff --git a/ui/litellm-dashboard/src/components/chat/ConversationList.tsx b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx new file mode 100644 index 00000000000..1b9cdfc7939 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ConversationList.tsx @@ -0,0 +1,483 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { + Button, + Input, + Modal, + Popconfirm, + Tooltip, + Avatar, + Typography, +} from "antd"; +import { + EditOutlined, + DeleteOutlined, + PlusOutlined, + SearchOutlined, + UserOutlined, + MessageOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import { Conversation } from "./types"; + +const { Text } = Typography; + +interface Props { + conversations: Conversation[]; + activeConversationId: string | null; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + onNewChat: () => void; + onRename: (id: string, newTitle: string) => void; +} + +// ---- Date grouping helpers ---- + +type DateGroup = "Today" | "Yesterday" | "Last 7 Days" | "Older"; + +const getDateGroup = (timestamp: number): DateGroup => { + const now = dayjs(); + const date = dayjs(timestamp); + + if (date.isSame(now, "day")) return "Today"; + if (date.isSame(now.subtract(1, "day"), "day")) return "Yesterday"; + if (date.isAfter(now.subtract(7, "day"))) return "Last 7 Days"; + return "Older"; +}; + +const DATE_GROUP_ORDER: DateGroup[] = ["Today", "Yesterday", "Last 7 Days", "Older"]; + +interface GroupedConversations { + group: DateGroup; + items: Conversation[]; +} + +const groupConversations = (conversations: Conversation[]): GroupedConversations[] => { + const map = new Map(); + + for (const conv of conversations) { + const group = getDateGroup(conv.updatedAt); + if (!map.has(group)) map.set(group, []); + map.get(group)!.push(conv); + } + + return DATE_GROUP_ORDER.filter((g) => map.has(g)).map((g) => ({ + group: g, + items: map.get(g)!, + })); +}; + +// ---- Single conversation row ---- + +interface ConversationRowProps { + conv: Conversation; + isActive: boolean; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + onRename: (id: string, newTitle: string) => void; +} + +const ConversationRow: React.FC = ({ + conv, + isActive, + onSelect, + onDelete, + onRename, +}) => { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(conv.title); + const inputRef = useRef(null); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editing]); + + const startEditing = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditValue(conv.title); + setEditing(true); + }; + + const commitRename = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== conv.title) { + onRename(conv.id, trimmed); + } + setEditing(false); + }; + + const cancelEditing = () => { + setEditValue(conv.title); + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEditing(); + } + }; + + const truncatedTitle = + conv.title.length > 40 ? conv.title.slice(0, 40) + "…" : conv.title; + + return ( +
!editing && onSelect(conv.id)} + className="conversation-row group" + style={{ + display: "flex", + alignItems: "center", + padding: "6px 8px", + borderRadius: 6, + cursor: editing ? "default" : "pointer", + backgroundColor: isActive ? "#e6f4ff" : "transparent", + transition: "background-color 0.15s", + minHeight: 34, + position: "relative", + }} + onMouseEnter={(e) => { + if (!isActive) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = "#f5f5f5"; + } + }} + onMouseLeave={(e) => { + if (!isActive) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent"; + } + }} + > + {editing ? ( + { + inputRef.current = node?.input ?? null; + }} + size="small" + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={commitRename} + onClick={(e) => e.stopPropagation()} + style={{ flex: 1, fontSize: 13 }} + /> + ) : ( + <> + + {truncatedTitle} + + + {/* Action icons — visible only on hover via CSS opacity */} +
e.stopPropagation()} + > + +
+ + )} +
+ ); +}; + +// ---- Cmd+K search modal ---- + +interface SearchModalProps { + open: boolean; + conversations: Conversation[]; + onSelect: (id: string) => void; + onClose: () => void; +} + +const SearchModal: React.FC = ({ + open, + conversations, + onSelect, + onClose, +}) => { + const [query, setQuery] = useState(""); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + + const filtered = query.trim() + ? conversations.filter((c) => + c.title.toLowerCase().includes(query.trim().toLowerCase()) + ) + : conversations; + + const handleSelect = (id: string) => { + onSelect(id); + onClose(); + }; + + return ( + + } + placeholder="Search conversations…" + value={query} + onChange={(e) => setQuery(e.target.value)} + style={{ marginBottom: 12 }} + allowClear + /> + +
+ {filtered.length === 0 ? ( +
+ No conversations found +
+ ) : ( + filtered.map((conv) => { + const truncated = + conv.title.length > 55 ? conv.title.slice(0, 55) + "…" : conv.title; + return ( +
handleSelect(conv.id)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 10px", + borderRadius: 6, + cursor: "pointer", + transition: "background-color 0.1s", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = "#f0f5ff"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent"; + }} + > + + {truncated} + + {dayjs(conv.updatedAt).format("MMM D")} + +
+ ); + }) + )} +
+
+ ); +}; + +// ---- Main ConversationList component ---- + +const ConversationList: React.FC = ({ + conversations, + activeConversationId, + onSelect, + onDelete, + onNewChat, + onRename, +}) => { + const [searchModalOpen, setSearchModalOpen] = useState(false); + + // Cmd+K / Ctrl+K listener + const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setSearchModalOpen((prev) => !prev); + } + }, []); + + useEffect(() => { + document.addEventListener("keydown", handleGlobalKeyDown); + return () => document.removeEventListener("keydown", handleGlobalKeyDown); + }, [handleGlobalKeyDown]); + + const grouped = groupConversations(conversations); + + return ( + <> + {/* Hover-reveal CSS for action icons */} + + +
+ {/* Top: New Chat button */} +
+ + + +
+ + {/* Conversation list (scrollable) */} +
+ {grouped.length === 0 ? ( +
+ No conversations yet. +
+ Start a new chat above. +
+ ) : ( + grouped.map(({ group, items }) => ( +
+
+ {group} +
+ {items.map((conv) => ( + + ))} +
+ )) + )} +
+ + {/* Bottom: user avatar placeholder */} +
+ } + style={{ backgroundColor: "#e0e7ff", color: "#4f46e5", flexShrink: 0 }} + /> + + My Account + +
+
+ + {/* Cmd+K search modal */} + setSearchModalOpen(false)} + /> + + ); +}; + +export default ConversationList;