/** * 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 (
Loading...
); } // ----- First-time welcome (brand new persona, no conversations yet) ----- if (isFirstTime) { return (
{/* Welcome header */}
{'\u2728'}

{projectName}

Ready to meet you

{/* First-time prompt */}

Start your first conversation — pick voice or text below.

{/* Primary action buttons */}
); } // ----- Returning user (has real conversation history) ----- return (
{/* Header */}

{projectName}

{ageLabel}

{memoryCount > 0 && (

{memoryCount} {memoryCount === 1 ? 'memory' : 'memories'} stored

)}
{/* Primary Actions */}
{/* Continue Conversation — glowing primary action */} {hasRealActiveSession && ()} {/* Talk by Voice */} {/* Chat by Text */} {/* Start Fresh — secondary action, subdued */}
{/* Memories Section — expandable list with per-item delete + Forget All */} {memoryCount > 0 && (
{memoriesExpanded && (
{memories.map((mem) => (
{mem.value}
{mem.category} {mem.source_type === 'user_statement' && (<> · )}
))}
)}
)} {/* Conversation History — grouped by day */} {dayGroups.length > 0 && (

Conversation History

{dayGroups.map((group) => { const isExpanded = expandedDays.has(group.sortKey); return (
{/* Day header — clickable to toggle */} {/* Expanded sessions */} {isExpanded && (
{group.sessions.map((session) => ())}
)}
); })}
{/* Micro-session toggle */} {!showMicro && microSessions.length > 0 && ()} {showMicro && microSessions.length > 0 && ()}
)} {/* Confirmation Dialog */} {confirmDialog && ( { if (confirmDialog.mode === 'all') { handleForgetAll(); } else if (confirmDialog.memory) { handleForgetSingle(confirmDialog.memory); } }} onCancel={() => { setConfirmDialog(null); setConfirmLoading(false); }}/>)}
); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatTimeAgo(dateStr) { try { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); if (diffMin < 1) return 'just now'; if (diffMin < 60) return `${diffMin}m ago`; const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.floor(diffHr / 24); return `${diffDay}d ago`; } catch { return dateStr; } } function formatTime(dateStr) { try { const date = new Date(dateStr); return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } catch { return dateStr; } } function formatSortKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function getDaySortKey(dateStr) { try { return formatSortKey(new Date(dateStr)); } catch { return '0000-00-00'; } } function getDayLabel(sortKey, todayKey, yesterdayKey) { if (sortKey === todayKey) return 'Today'; if (sortKey === yesterdayKey) return 'Yesterday'; try { const [y, m, d] = sortKey.split('-').map(Number); const date = new Date(y, m - 1, d); const now = new Date(); const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000); if (diffDays < 7) { return date.toLocaleDateString(undefined, { weekday: 'long' }); } return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return sortKey; } }