/** * ConversationHistory.jsx — Chat-style conversation display * * Features: * - Real-time conversation messages from /social/conversation_text * - User speech input (STT) and robot responses (TTS) * - Timestamped messages with speaker labels * - Auto-scroll to latest message * - Message grouping by speaker * - Scrollback history with max limit */ import { useEffect, useRef, useState } from 'react'; const MAX_MESSAGES = 100; // Keep last 100 messages const AUTO_SCROLL_THRESHOLD = 100; // pixels from bottom function formatTimestamp(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } function MessageBubble({ message, isUser }) { const bgClass = isUser ? 'bg-blue-950 border-blue-800' : 'bg-green-950 border-green-800'; const textColorClass = isUser ? 'text-blue-300' : 'text-green-300'; const labelColorClass = isUser ? 'text-blue-500' : 'text-green-500'; return (
{isUser ? 'USER' : 'ROBOT'} {formatTimestamp(message.timestamp)}

{message.text}

); } export function ConversationHistory({ subscribe }) { const [messages, setMessages] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const messageIdRef = useRef(0); // Auto-scroll to latest message useEffect(() => { if (autoScroll && messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages, autoScroll]); // Handle scroll detection for auto-scroll toggle const handleScroll = () => { if (!scrollContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; // Auto-scroll if within threshold setAutoScroll(distanceFromBottom < AUTO_SCROLL_THRESHOLD); }; // Subscribe to conversation text topic useEffect(() => { const unsubConversation = subscribe( '/social/conversation_text', 'saltybot_social_msgs/ConversationMessage', (msg) => { try { // Parse message data let speaker = 'unknown'; let text = ''; if (msg.speaker !== undefined) { // speaker: 0 = user, 1 = robot speaker = msg.speaker === 0 ? 'user' : 'robot'; } else if (msg.is_user !== undefined) { speaker = msg.is_user ? 'user' : 'robot'; } if (msg.text) { text = msg.text; } if (!text) return; // Skip empty messages const newMessage = { id: ++messageIdRef.current, text, speaker, isUser: speaker === 'user', timestamp: msg.timestamp || Date.now(), }; setMessages((prev) => [...prev, newMessage].slice(-MAX_MESSAGES)); } catch (e) { console.error('Error parsing conversation message:', e); } } ); return unsubConversation; }, [subscribe]); const clearHistory = () => { setMessages([]); messageIdRef.current = 0; }; const userMessageCount = messages.filter((m) => m.isUser).length; const robotMessageCount = messages.filter((m) => !m.isUser).length; return (
{/* Controls */}
CONVERSATION HISTORY
{messages.length} messages ({userMessageCount} user, {robotMessageCount} robot)
{/* Messages Container */}
{messages.length === 0 ? (
No conversation yet
Messages from /social/conversation_text will appear here
) : ( <> {messages.map((message) => ( ))}
)}
{/* Auto-scroll indicator */}
Auto-scroll to latest: {autoScroll ? '✓ ON' : '○ OFF'} {!autoScroll && ( )}
{/* Topic Info */}
Topic: /social/conversation_text
Message Type: saltybot_social_msgs/ConversationMessage
Max History: {MAX_MESSAGES} messages
); }