/** * SessionPanel — Companion-Grade Conversation Management * * Relationship-first UX: sessions are internal transport, users see * "Conversations" grouped by day with micro-session hiding. * * Key UX principles: * - "Talk by Voice" / "Chat by Text" reuse the active conversation * - "Start Fresh" explicitly creates a new conversation (secondary action) * - Past conversations grouped by day, collapsed beyond yesterday * - Micro-sessions (< 3 messages) hidden by default * - No "session" language in user-facing copy */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { Trash2, ChevronDown, ChevronUp, Pin, RotateCcw } from 'lucide-react'; import { resolveSession, createSession, listSessions, endSession, getMemories, forgetMemory, } from './sessionsApi'; import ConfirmForgetDialog from '../components/ConfirmForgetDialog'; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function SessionPanel({ projectId, projectName, projectCreatedAt, onOpenSession, onOpenVoiceSession, }) { const [sessions, setSessions] = useState([]); const [activeSession, setActiveSession] = useState(null); const [memoryCount, setMemoryCount] = useState(0); const [memories, setMemories] = useState([]); const [memoriesExpanded, setMemoriesExpanded] = useState(false); const [loading, setLoading] = useState(true); const [showMicro, setShowMicro] = useState(false); const [expandedDays, setExpandedDays] = useState(new Set()); // Confirmation dialog state const [confirmDialog, setConfirmDialog] = useState(null); const [confirmLoading, setConfirmLoading] = useState(false); // Load sessions + memory count on mount const loadData = useCallback(async () => { setLoading(true); try { const [sessionList, memData] = await Promise.all([ listSessions(projectId, 50), getMemories(projectId).catch(() => ({ memories: [], count: 0 })), ]); setSessions(sessionList); setMemoryCount(memData.count); setMemories(memData.memories); // Find active session (first non-ended) const active = sessionList.find((s) => !s.ended_at) || null; setActiveSession(active); } catch (err) { console.warn('[SessionPanel] Failed to load sessions:', err); } finally { setLoading(false); } }, [projectId]); useEffect(() => { loadData(); }, [loadData]); // ── Handlers ────────────────────────────────────────────────────────── /** Continue the current conversation (reuse active session) */ const handleContinue = useCallback(async () => { try { const session = await resolveSession(projectId, 'text'); if (session.mode === 'voice') { onOpenVoiceSession(session); } else { onOpenSession(session); } } catch (err) { console.error('[SessionPanel] Failed to resolve session:', err); } }, [projectId, onOpenSession, onOpenVoiceSession]); /** Talk by voice — reuses current conversation, just switches to voice UI */ const handleTalkVoice = useCallback(async () => { try { const session = await resolveSession(projectId, 'voice'); onOpenVoiceSession(session); } catch (err) { console.error('[SessionPanel] Failed to resolve voice session:', err); } }, [projectId, onOpenVoiceSession]); /** Chat by text — reuses current conversation, opens text UI */ const handleTalkText = useCallback(async () => { try { const session = await resolveSession(projectId, 'text'); onOpenSession(session); } catch (err) { console.error('[SessionPanel] Failed to resolve text session:', err); } }, [projectId, onOpenSession]); /** Start a truly fresh conversation (explicit user action) */ const handleStartFresh = useCallback(async (mode) => { try { if (activeSession && !activeSession.ended_at) { await endSession(activeSession.id); } const session = await createSession(projectId, mode, undefined, true); mode === 'voice' ? onOpenVoiceSession(session) : onOpenSession(session); } catch (err) { console.error('[SessionPanel] Failed to start fresh conversation:', err); } }, [projectId, activeSession, onOpenVoiceSession, onOpenSession]); // Memory deletion handlers const handleForgetSingle = useCallback(async (mem) => { setConfirmLoading(true); try { await forgetMemory(projectId, mem.category, mem.key); setMemories((prev) => prev.filter((m) => m.id !== mem.id)); setMemoryCount((prev) => Math.max(0, prev - 1)); setConfirmDialog(null); } catch (err) { console.error('[SessionPanel] Failed to forget memory:', err); } finally { setConfirmLoading(false); } }, [projectId]); const handleForgetAll = useCallback(async () => { setConfirmLoading(true); try { await forgetMemory(projectId); setMemories([]); setMemoryCount(0); setConfirmDialog(null); setMemoriesExpanded(false); } catch (err) { console.error('[SessionPanel] Failed to forget all memories:', err); } finally { setConfirmLoading(false); } }, [projectId]); const handleOpenPast = useCallback((session) => { if (session.mode === 'voice') { onOpenVoiceSession(session); } else { onOpenSession(session); } }, [onOpenSession, onOpenVoiceSession]); const toggleDay = useCallback((sortKey) => { setExpandedDays((prev) => { const next = new Set(prev); if (next.has(sortKey)) next.delete(sortKey); else next.add(sortKey); return next; }); }, []); // ── Derived data ────────────────────────────────────────────────────── // Relationship age const ageDays = projectCreatedAt ? Math.max(0, Math.floor((Date.now() / 1000 - projectCreatedAt) / 86400)) : 0; const ageLabel = ageDays === 0 ? 'Just created today' : ageDays === 1 ? '1 day together' : `${ageDays} days together`; // Filter real sessions (> 0 messages) const realSessions = useMemo(() => sessions.filter((s) => s.message_count > 0), [sessions]); const hasRealActiveSession = activeSession && activeSession.message_count > 0; const isFirstTime = realSessions.length === 0; // Split into meaningful (>= 3 msgs) and micro (< 3 msgs) const meaningfulSessions = useMemo(() => realSessions.filter((s) => s.message_count >= 3), [realSessions]); const microSessions = useMemo(() => realSessions.filter((s) => s.message_count > 0 && s.message_count < 3), [realSessions]); // Group by day const dayGroups = useMemo(() => { const visible = showMicro ? realSessions : meaningfulSessions; const grouped = {}; for (const s of visible) { const key = getDaySortKey(s.started_at); if (!grouped[key]) grouped[key] = []; grouped[key].push(s); } const now = new Date(); const todayKey = formatSortKey(now); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const yesterdayKey = formatSortKey(yesterday); return Object.entries(grouped) .sort(([a], [b]) => b.localeCompare(a)) // newest first .map(([sortKey, sess]) => ({ label: getDayLabel(sortKey, todayKey, yesterdayKey), sortKey, sessions: sess, })); }, [realSessions, meaningfulSessions, showMicro]); // Conversation history always starts collapsed — user toggles manually // (no auto-expand) // ── Render ──────────────────────────────────────────────────────────── if (loading) { return (
Ready to meet you
Start your first conversation — pick voice or text below.
{/* Primary action buttons */}{ageLabel}
{memoryCount > 0 && ({memoryCount} {memoryCount === 1 ? 'memory' : 'memories'} stored
)}