"use client"; import { Check, Pencil, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { type SessionSummary } from "@/lib/session-api"; import { normalizeMessageContent, truncateText } from "@/lib/message-content"; import { SessionAvatar } from "@/components/sidebar/SessionAvatar"; import { formatRelativeTime, getDayGroupKey, type DayGroupKey, } from "@/lib/relative-time"; type SessionRuntimeStatus = | "idle" | "running" | "completed" | "failed" | "cancelled" | "rejected"; interface SessionListProps { sessions: SessionSummary[]; activeSessionId: string | null; loading?: boolean; compact?: boolean; onSelect: (sessionId: string) => void | Promise; onRename: (sessionId: string, title: string) => void | Promise; onDelete: (sessionId: string) => void | Promise; } function StatusIndicator({ status }: { status?: SessionRuntimeStatus }) { if (!status || status === "idle") return null; if (status === "running") { return ( ); } if (status === "completed") { return ( ); } if (status === "failed") { return ( ); } if (status === "rejected") { return ( ); } if (status === "cancelled") { return ( ); } return null; } export default function SessionList({ sessions, activeSessionId, loading = false, compact = false, onSelect, onRename, onDelete, }: SessionListProps) { const { t, i18n } = useTranslation(); const [editingId, setEditingId] = useState(null); const [draftTitle, setDraftTitle] = useState(""); // The sentinel the backend writes when a session is created and not // yet renamed by the LLM title generator. We swap it for a localized // "New chat" string with a breathing animation so the sidebar shows // something "alive" while the title is being generated in the // background instead of a literal English sentinel. const isPlaceholderTitle = (raw: string | null | undefined): boolean => { const value = (raw ?? "").trim(); return value === "" || value === "New conversation"; }; const placeholderLabel = t("New chat"); // The group-key tokens stay stable; only the translated labels change. const groupLabels = useMemo>( () => ({ today: t("Today"), yesterday: t("Yesterday"), last_7_days: t("Last 7 days"), earlier: t("Earlier"), }), [t], ); const grouped = useMemo(() => { const buckets = new Map(); for (const session of sessions) { const key = getDayGroupKey(session.updated_at); const current = buckets.get(key) ?? []; current.push(session); buckets.set(key, current); } return Array.from(buckets.entries()); }, [sessions]); const startEdit = (session: SessionSummary) => { setEditingId(session.session_id); setDraftTitle(session.title); }; const commitEdit = async () => { if (!editingId) return; const nextTitle = draftTitle.trim(); if (!nextTitle) { setEditingId(null); setDraftTitle(""); return; } await onRename(editingId, nextTitle); setEditingId(null); setDraftTitle(""); }; if (loading) { if (compact) { return (
{[1, 2, 3].map((i) => (
))}
); } return (
{[1, 2, 3].map((i) => (
))}
); } if (sessions.length === 0) { if (compact) { return (
{t("No conversations yet")}
); } return (
{t("No conversations yet")}
); } /* ---- Compact sidebar style (standalone chat history region) ---- */ if (compact) { return (
{sessions.map((session) => { const active = activeSessionId === session.session_id; const isEditing = editingId === session.session_id; return (
void onSelect(session.session_id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); void onSelect(session.session_id); } }} role="button" tabIndex={0} className={`group flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors ${ active ? "bg-[var(--background)]/50 text-[var(--foreground)]" : "text-[var(--muted-foreground)] hover:bg-[var(--background)]/40 hover:text-[var(--foreground)]" }`} > {isEditing ? ( setDraftTitle(event.target.value)} onBlur={() => void commitEdit()} onKeyDown={(event) => { if (event.key === "Enter") void commitEdit(); if (event.key === "Escape") { setEditingId(null); setDraftTitle(""); } }} onClick={(event) => event.stopPropagation()} className="min-w-0 flex-1 rounded border border-[var(--border)] bg-[var(--background)] px-1.5 py-px text-[12px] text-[var(--foreground)] outline-none focus:ring-1 focus:ring-[var(--primary)]/40" /> ) : isPlaceholderTitle(session.title) ? ( {placeholderLabel} ) : ( {session.title} )}
{isEditing ? ( ) : ( )}
); })}
); } /* ---- Classic style ---- */ return (
{grouped.map(([key, items]) => (
{groupLabels[key]}
{items.map((session) => { const active = activeSessionId === session.session_id; const isEditing = editingId === session.session_id; return (
void onSelect(session.session_id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); void onSelect(session.session_id); } }} role="button" tabIndex={0} className={`group relative w-full px-3 py-2.5 text-left transition-colors duration-150 ${ active ? "bg-[var(--background)]/70 text-[var(--foreground)]" : "text-[var(--muted-foreground)] hover:bg-[var(--background)]/50 hover:text-[var(--foreground)]" }`} > {active && ( )}
{isEditing ? ( setDraftTitle(event.target.value) } onBlur={() => void commitEdit()} onKeyDown={(event) => { if (event.key === "Enter") void commitEdit(); if (event.key === "Escape") { setEditingId(null); setDraftTitle(""); } }} onClick={(event) => event.stopPropagation()} className="w-full rounded border border-[var(--border)] bg-[var(--background)] px-2 py-0.5 text-[12px] text-[var(--foreground)] outline-none focus:ring-1 focus:ring-[var(--primary)]/40" /> ) : (
{isPlaceholderTitle(session.title) ? ( {placeholderLabel} ) : ( {session.title} )}
)} {!isEditing && (
{truncateText( normalizeMessageContent(session.last_message), 120, ) || formatRelativeTime( session.updated_at, i18n.language, )}
)}
{isEditing ? ( ) : ( )}
); })}
))}
); }