import React, { useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ArrowRight } from 'lucide-react'; const PALETTE = [ { color: '#6366F1', bg: '#EEF2FF' }, // indigo { color: '#059669', bg: '#ECFDF5' }, // emerald { color: '#D97706', bg: '#FFFBEB' }, // amber { color: '#DC2626', bg: '#FEE2E2' }, // red { color: '#0891B2', bg: '#ECFEFF' }, // cyan { color: '#7C3AED', bg: '#F5F3FF' }, // violet { color: '#0D9488', bg: '#F0FDFA' }, // teal { color: '#DB2777', bg: '#FDF2F8' }, // pink { color: '#65A30D', bg: '#F7FEE7' }, // lime ]; function colorForIdx(idx) { return PALETTE[idx % PALETTE.length]; } /** * Generic participant bubble. The CCAI demo can have up to 9 active * participants, so we colorize by their index in the active roster * rather than the original A/B scheme. * * Conversation-tracking enhancements: * - "→ Addressee" arrow chip in the speaker line when this message * is aimed at a specific other participant. The chip is clickable * and scrolls the chat to the addressee's most recent message * before this one (with a brief flash highlight). * - "Replying to: X, Y" pill above the bubble whenever the orchestrator * told this participant they had open threads owed before they spoke. * - Light left-indent + thread line when a message is a direct reply to * the immediately previous bubble - cheap visual threading without * full nesting. */ // Green tone used for in-the-loop human participants. Overrides the // rotating palette so a human's bubble always reads as "the human // one" no matter where they fall in the active roster ordering. const HUMAN_TONE = { color: '#16A34A', bg: '#F0FDF4' }; export default function MessageBubble({ message, idx, messageIdx, prevMessage, participantNameById, showResponseTime, }) { const isHuman = message.kind === 'human' || (message.model_display === 'Human participant'); const tone = isHuman ? HUMAN_TONE : colorForIdx(idx); const initial = (message.speaker_name || '?').charAt(0).toUpperCase(); const elapsed = message.elapsed_seconds; const addresseeId = message.addressed_to || null; const addresseeName = addresseeId ? (participantNameById?.[addresseeId] || addresseeId) : null; const replyingToNames = (message.replying_to || []) .map((pid) => participantNameById?.[pid]) .filter(Boolean); // Direct reply to the immediately previous participant message gets // a light indent + thread line. Skips orchestrator messages. const isDirectReply = !!addresseeId && prevMessage && prevMessage.role === 'participant' && prevMessage.speaker_id === addresseeId; const onAddresseeClick = useCallback(() => { if (!addresseeId) return; const all = document.querySelectorAll('[data-msg-idx][data-speaker-id]'); let candidate = null; for (const el of all) { const eIdx = parseInt(el.getAttribute('data-msg-idx'), 10); if (Number.isNaN(eIdx)) continue; if (eIdx >= messageIdx) break; if (el.getAttribute('data-speaker-id') === addresseeId) { candidate = el; } } if (candidate) { candidate.scrollIntoView({ behavior: 'smooth', block: 'center' }); candidate.classList.remove('ccai-flash-highlight'); void candidate.offsetWidth; candidate.classList.add('ccai-flash-highlight'); setTimeout( () => candidate.classList.remove('ccai-flash-highlight'), 1500, ); } }, [addresseeId, messageIdx]); const rowClassName = 'message-row ccai-message-row' + (isDirectReply ? ' ccai-message-row-reply' : '') + (isHuman ? ' ccai-message-row-human' : ''); return (
{initial}
{replyingToNames.length > 0 && (
Replying to: {replyingToNames.join(', ')}
)}
{message.speaker_name} {addresseeName && ( )}
{message.text} {showResponseTime && elapsed > 0 && (
{elapsed.toFixed(1)}s
)}
); }