"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Check, History as HistoryIcon, Loader2, MessageSquare, Search, Sparkles, UserRound, } from "lucide-react"; import { useTranslation } from "react-i18next"; import PickerShell from "@/components/common/PickerShell"; import PickerHeader from "@/components/common/PickerHeader"; import { getSession, listSessions, type SessionDetail, type SessionSummary, } from "@/lib/session-api"; import { normalizeMessageContent, truncateText } from "@/lib/message-content"; export interface SelectedHistorySession { sessionId: string; title: string; } interface HistorySessionPickerProps { open: boolean; onClose: () => void; onApply: (sessions: SelectedHistorySession[]) => void; } /** * Format a backend session timestamp (stored as float seconds via time.time()) * into a localized string. Returns an empty string when the timestamp is * missing or non-positive so we don't render nonsensical 1970 dates. */ function formatSessionTimestamp(value?: number): string { if (!value || value <= 0) return ""; // Backend stores REAL seconds. JS Date expects milliseconds. return new Date(value * 1000).toLocaleString(); } function sessionKey(session: SessionSummary): string { return session.session_id || session.id; } export default function HistorySessionPicker({ open, onClose, onApply, }: HistorySessionPickerProps) { const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [query, setQuery] = useState(""); const [loading, setLoading] = useState(false); // The session shown in the right-hand preview pane. Driven by hover/focus // and click so the preview tracks wherever the user's attention is — the // list reads like a mail client: glide over a row, see its conversation. const [activeId, setActiveId] = useState(null); // Fetched session transcripts, cached so re-hovering a row is instant. const [details, setDetails] = useState>({}); const detailsRef = useRef(details); useEffect(() => { detailsRef.current = details; }, [details]); const [previewLoadingId, setPreviewLoadingId] = useState(null); useEffect(() => { if (!open) return; let mounted = true; const load = async () => { setLoading(true); try { const data = await listSessions(200, 0, { force: true }); if (!mounted) return; setSessions(data); // Default the preview to the most recent session so the right pane is // never blank on open. setActiveId((prev) => { if (prev && data.some((s) => sessionKey(s) === prev)) return prev; return data.length ? sessionKey(data[0]) : null; }); } catch { if (!mounted) return; setSessions([]); } finally { if (mounted) setLoading(false); } }; void load(); return () => { mounted = false; }; }, [open]); // Lazily fetch the active session's transcript for the preview pane. useEffect(() => { if (!open || !activeId) return; if (detailsRef.current[activeId]) return; let cancelled = false; setPreviewLoadingId(activeId); getSession(activeId) .then((detail) => { if (cancelled) return; setDetails((prev) => ({ ...prev, [activeId]: detail })); }) .catch(() => { /* preview is best-effort; the list still works without it */ }) .finally(() => { if (cancelled) return; setPreviewLoadingId((cur) => (cur === activeId ? null : cur)); }); return () => { cancelled = true; }; }, [activeId, open]); const filteredSessions = useMemo(() => { const keyword = query.trim().toLowerCase(); if (!keyword) return sessions; return sessions.filter((session) => { const title = String(session.title || "").toLowerCase(); const lastMessage = normalizeMessageContent( session.last_message, ).toLowerCase(); return title.includes(keyword) || lastMessage.includes(keyword); }); }, [query, sessions]); const toggleSession = (id: string) => { setSelectedIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], ); }; const handleApply = () => { const selected = sessions .filter((session) => selectedIds.includes(sessionKey(session))) .map((session) => ({ sessionId: sessionKey(session), title: session.title || t("Untitled session"), })); onApply(selected); onClose(); }; const activeSession = activeId ? sessions.find((s) => sessionKey(s) === activeId) : undefined; const activeDetail = activeId ? details[activeId] : undefined; const activeLoading = previewLoadingId !== null && previewLoadingId === activeId; return (
{/* ── Left: searchable, selectable session list ── */}
setQuery(event.target.value)} placeholder={t("Search sessions by title or last message")} className="w-full rounded-xl border border-[var(--border)] bg-[var(--card)] py-2.5 pl-9 pr-3 text-[13px] text-[var(--foreground)] outline-none transition focus:border-[var(--primary)]/50 focus:ring-2 focus:ring-[var(--primary)]/15" />
{selectedIds.length > 0 && ( )}
{loading ? (
) : filteredSessions.length ? (
{filteredSessions.map((session) => { const id = sessionKey(session); const selected = selectedIds.includes(id); const active = id === activeId; return ( ); })}
) : (
{t("No matching sessions found.")}
)}
{/* ── Right: live preview of the focused session ── */}
{activeSession ? ( <>
{activeSession.title || t("Untitled session")}
{activeSession.message_count ?? 0} {t("messages")} {formatSessionTimestamp( activeSession.updated_at || activeSession.created_at, ) && ( {formatSessionTimestamp( activeSession.updated_at || activeSession.created_at, )} )}
{selectedIds.includes(activeId!) && ( {t("Selected")} )}
{activeLoading && !activeDetail ? (
{t("Loading preview…")}
) : activeDetail ? ( ) : (
{t("No messages in this session.")}
)}
) : (

{t("Select a session to preview it here.")}

)}
{selectedIds.length === 1 ? t("1 session selected") : t("{{n}} sessions selected", { n: selectedIds.length })}
); } /** * Read-only transcript rendering for the preview pane. We render the stored * final `content` of each message (not the streaming trace) as plain, * role-labelled text — enough to recognize a conversation at a glance without * pulling in the full markdown/KaTeX renderer. */ function ConversationPreview({ detail }: { detail: SessionDetail }) { const { t } = useTranslation(); const turns = useMemo( () => (detail.messages || []) .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => ({ id: m.id, role: m.role, text: truncateText(normalizeMessageContent(m.content), 1400), })) .filter((m) => m.text.trim().length > 0), [detail.messages], ); if (turns.length === 0) { return (
{t("No messages in this session.")}
); } return (
{turns.map((turn) => { const isUser = turn.role === "user"; return (
{isUser ? ( ) : ( )} {isUser ? t("You") : t("Assistant")}
{turn.text}
); })}
); }