diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 0059b7d..8e4be5f 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -21,12 +21,13 @@ import { useState, useCallback } from 'react'; import { useRosbridge } from './hooks/useRosbridge.js'; // Social panels -import { StatusPanel } from './components/StatusPanel.jsx'; -import { FaceGallery } from './components/FaceGallery.jsx'; -import { ConversationLog } from './components/ConversationLog.jsx'; -import { PersonalityTuner } from './components/PersonalityTuner.jsx'; -import { NavModeSelector } from './components/NavModeSelector.jsx'; -import { AudioMeter } from './components/AudioMeter.jsx'; +import { StatusPanel } from './components/StatusPanel.jsx'; +import { FaceGallery } from './components/FaceGallery.jsx'; +import { ConversationLog } from './components/ConversationLog.jsx'; +import { ConversationHistory } from './components/ConversationHistory.jsx'; +import { PersonalityTuner } from './components/PersonalityTuner.jsx'; +import { NavModeSelector } from './components/NavModeSelector.jsx'; +import { AudioMeter } from './components/AudioMeter.jsx'; // Telemetry panels import PoseViewer from './components/PoseViewer.jsx'; @@ -65,6 +66,7 @@ const TAB_GROUPS = [ { id: 'status', label: 'Status', }, { id: 'faces', label: 'Faces', }, { id: 'conversation', label: 'Convo', }, + { id: 'history', label: 'History', }, { id: 'personality', label: 'Personality', }, { id: 'navigation', label: 'Nav Mode', }, { id: 'audio', label: 'Audio', }, @@ -223,6 +225,7 @@ export default function App() { {activeTab === 'status' && } {activeTab === 'faces' && } {activeTab === 'conversation' && } + {activeTab === 'history' && } {activeTab === 'personality' && } {activeTab === 'navigation' && } {activeTab === 'audio' && } diff --git a/ui/social-bot/src/components/ConversationHistory.jsx b/ui/social-bot/src/components/ConversationHistory.jsx new file mode 100644 index 0000000..497dc9e --- /dev/null +++ b/ui/social-bot/src/components/ConversationHistory.jsx @@ -0,0 +1,209 @@ +/** + * 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 +
+
+
+ ); +}