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
+
+
+
+ );
+}