saltylab-firmware/ui/social-bot/src/components/ConversationHistory.jsx
sl-webui 04652c73f8 feat(webui): conversation history panel (Issue #240)
Implements a chat-style conversation viewer that subscribes to
/social/conversation_text and displays user speech (STT) and robot
responses (TTS) with timestamps and speaker labels. Includes auto-scroll
to latest message, manual scroll detection, and message history limiting.

- New component: ConversationHistory.jsx (chat-style message bubbles)
  - User messages in blue, robot responses in green
  - Auto-scrolling with manual scroll detection toggle
  - Timestamp formatting (HH:MM:SS)
  - Message history limiting (max 100 messages)
  - Clear history button
- Integrated into SOCIAL tab group as "History" tab
- Subscribes to /social/conversation_text topic
  (saltybot_social_msgs/ConversationMessage)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 12:21:38 -05:00

210 lines
6.8 KiB
JavaScript

/**
* 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 (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div className={`max-w-xs lg:max-w-md rounded-lg border ${bgClass} p-3 space-y-1`}>
<div className="flex items-center justify-between gap-2">
<span className={`text-xs font-bold tracking-widest ${labelColorClass}`}>
{isUser ? 'USER' : 'ROBOT'}
</span>
<span className="text-xs text-gray-600">
{formatTimestamp(message.timestamp)}
</span>
</div>
<p className={`text-sm ${textColorClass} break-words leading-relaxed`}>
{message.text}
</p>
</div>
</div>
);
}
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 (
<div className="flex flex-col h-full space-y-3">
{/* Controls */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
CONVERSATION HISTORY
</div>
<div className="text-gray-600 text-xs">
{messages.length} messages ({userMessageCount} user, {robotMessageCount} robot)
</div>
</div>
<button
onClick={clearHistory}
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-700 text-gray-400 hover:text-red-400 hover:border-red-700 transition-colors"
>
CLEAR
</button>
</div>
{/* Messages Container */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-1"
>
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-600">
<div className="text-center">
<div className="text-sm mb-2">No conversation yet</div>
<div className="text-xs text-gray-700">
Messages from /social/conversation_text will appear here
</div>
</div>
</div>
) : (
<>
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isUser={message.isUser}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Auto-scroll indicator */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 flex items-center justify-between text-xs text-gray-600">
<span>Auto-scroll to latest: {autoScroll ? '✓ ON' : '○ OFF'}</span>
{!autoScroll && (
<button
onClick={() => setAutoScroll(true)}
className="text-cyan-500 hover:text-cyan-400 transition-colors"
>
Jump to latest
</button>
)}
</div>
{/* Topic Info */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Topic:</span>
<span className="text-gray-500">/social/conversation_text</span>
</div>
<div className="flex justify-between">
<span>Message Type:</span>
<span className="text-gray-500">saltybot_social_msgs/ConversationMessage</span>
</div>
<div className="flex justify-between">
<span>Max History:</span>
<span className="text-gray-500">{MAX_MESSAGES} messages</span>
</div>
</div>
</div>
);
}