/**
* ConversationLog.jsx — Real-time transcript with speaker labels.
*
* Subscribes:
* /social/speech/transcript (SpeechTranscript) — human utterances
* /social/conversation/response (ConversationResponse) — bot replies
*/
import { useEffect, useRef, useState } from 'react';
const MAX_ENTRIES = 200;
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function HumanBubble({ entry }) {
return (
{formatTime(entry.ts)}
{entry.speaker || 'unknown'}
{entry.confidence != null && (
({Math.round(entry.confidence * 100)}%)
)}
{entry.partial && partial}
{entry.text}
);
}
function BotBubble({ entry }) {
return (
Salty
{entry.speaker && → {entry.speaker}}
{formatTime(entry.ts)}
{entry.partial && streaming…}
{entry.text}
);
}
export function ConversationLog({ subscribe }) {
const [entries, setEntries] = useState([]);
const [autoScroll, setAutoScroll] = useState(true);
const bottomRef = useRef(null);
const scrollRef = useRef(null);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (autoScroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [entries, autoScroll]);
// Human transcript
useEffect(() => {
const unsub = subscribe(
'/social/speech/transcript',
'saltybot_social_msgs/SpeechTranscript',
(msg) => {
setEntries((prev) => {
const entry = {
id: `h-${msg.header?.stamp?.sec ?? Date.now()}-${msg.turn_id ?? Math.random()}`,
type: 'human',
text: msg.text,
speaker: msg.speaker_id,
confidence: msg.confidence,
partial: msg.is_partial,
ts: Date.now(),
};
// Replace partial entry with same turn if exists
if (msg.is_partial) {
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
} else {
// Replace any trailing partial for same speaker
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = { ...entry, id: prev[idx].id };
return updated.slice(-MAX_ENTRIES);
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
// Bot response
useEffect(() => {
const unsub = subscribe(
'/social/conversation/response',
'saltybot_social_msgs/ConversationResponse',
(msg) => {
setEntries((prev) => {
const entry = {
id: `b-${msg.turn_id ?? Math.random()}`,
type: 'bot',
text: msg.text,
speaker: msg.speaker_id,
partial: msg.is_partial,
turnId: msg.turn_id,
ts: Date.now(),
};
// Replace streaming partial with same turn_id
if (msg.turn_id != null) {
const idx = prev.findLastIndex(
(e) => e.type === 'bot' && e.turnId === msg.turn_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
setAutoScroll(atBottom);
};
const handleClear = () => setEntries([]);
return (
{/* Toolbar */}
{/* Scroll container */}
{entries.length === 0 ? (
Waiting for conversation…
) : (
entries.map((entry) =>
entry.type === 'human' ? (
) : (
)
)
)}
{/* Legend */}
);
}