import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from 'react-markdown';
import './ChatWindow.css';
import { FaRegCopy, FaCopy, FaVolumeUp, FaPlay, FaPause, FaDownload, FaSyncAlt } from 'react-icons/fa'; // Import the icons
// Individual message component
const ChatMessage = ({ message, index, onSynthesize, featureName = "default", activePlayingId, onPlayStateChange }) => {
const [isReasoningExpanded, setIsReasoningExpanded] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef(null);
const isVoiceChat = featureName === "voice_chat";
// Unique ID for this message's audio
const currentMsgId = message.id || `msg-${index}`;
useEffect(() => {
if (message.audioBlob) {
const url = URL.createObjectURL(message.audioBlob);
setAudioUrl(url);
return () => URL.revokeObjectURL(url);
}
}, [message.audioBlob]);
// Removed auto-expand behavior to keep UI clean during long orchestration tasks.
// The user can manually expand the trace if they wish to see inner-turn details.
// Handle exclusive playback: stop if someone else starts playing
useEffect(() => {
if (activePlayingId && activePlayingId !== currentMsgId && isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
}
}, [activePlayingId, currentMsgId, isPlaying]);
// Stop audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = ""; // Clear source to ensure it stops immediately
}
};
}, []);
const handlePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
onPlayStateChange(null);
} else {
audioRef.current.play();
onPlayStateChange(currentMsgId);
}
setIsPlaying(!isPlaying);
}
};
const handleDownload = () => {
if (audioUrl) {
const a = document.createElement("a");
a.href = audioUrl;
a.download = `voice_chat_${Date.now()}.wav`;
a.click();
}
};
const handleReplay = () => {
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play();
setIsPlaying(true);
onPlayStateChange(currentMsgId);
}
};
const toggleReasoning = () => {
setIsReasoningExpanded(!isReasoningExpanded);
};
// Function to copy text to clipboard
const handleCopy = async () => {
if (message.text) {
try {
await navigator.clipboard.writeText(message.text);
// Optional: Add a state or a toast notification to show "Copied!"
} catch (err) {
console.error('Failed to copy text: ', err);
}
}
};
const assistantMessageClasses = `p-4 rounded-2xl shadow-lg max-w-[95%] assistant-message mr-auto border border-gray-300 dark:border-gray-700/50 text-gray-900 dark:text-gray-100`;
const userMessageClasses = `max-w-[90%] p-4 rounded-2xl shadow-md text-white ml-auto user-message-container`;
const formatTime = (iso) => {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
};
return (
<div className={message.isUser ? userMessageClasses : assistantMessageClasses}>
{/* Status indicator moved to top/bottom for better visibility */}
{(message.reasoning || (message.status === "Thinking")) && (
<div className="mb-3">
<button
onClick={toggleReasoning}
className="text-xs font-bold text-indigo-600 dark:text-indigo-400 hover:opacity-80 focus:outline-none flex items-center gap-2 px-2 py-1 rounded-md hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
>
<span className="text-[10px] transform transition-transform duration-200" style={{ transform: isReasoningExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
{message.status && message.status.startsWith("Thought for") ? message.status.toUpperCase() : "THOUGHT TRACE"}
{message.reasoning && (() => {
const count = (message.reasoning.match(/Sub-Agent \[/g) || []).length;
return count > 0 ? (
<span className="ml-1 px-1.5 py-0.5 text-[9px] bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-full font-semibold">
{count} steps
</span>
) : null;
})()}
</button>
<div
className={`mt-2 text-xs transition-all duration-500 ease-in-out overflow-hidden thought-panel pl-4 pr-2 py-1 ${isReasoningExpanded ? "max-h-[800px] opacity-100 mb-2" : "max-h-0 opacity-0"
} text-gray-600 dark:text-gray-400 font-light leading-relaxed`}
>
<ReactMarkdown>{message.reasoning}</ReactMarkdown>
</div>
</div>
)}
<div className="prose dark:prose-invert max-w-none text-sm leading-relaxed mb-2">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
{!message.isUser && message.status && (
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-800 transition-all duration-300">
<div className="status-chip text-[10px] sm:text-xs text-indigo-600 dark:text-indigo-300 font-semibold italic flex items-center gap-2">
<div className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
<span className={
message.status &&
!message.status.includes("finished in") &&
!message.status.startsWith("Thought for")
? "streaming-dots" : ""
}>
{message.status}
</span>
</div>
</div>
)}
{(message.isPureAnswer || !message.isUser) && (
<div className="justify-end items-center mt-2 group-audio-container">
{/* Horizontal line - only for voice chat to separate from voice controls */}
{isVoiceChat && (
<div className="border-b border-gray-400 dark:border-gray-600 opacity-20 my-1.5"></div>
)}
<div className="flex justify-end items-center gap-1">
{/* Audio Controls - strictly limited to voice chat feature */}
{isVoiceChat && (message.audioBlob ? (
<div className="flex items-center gap-1 mr-auto text-gray-500 dark:text-gray-400">
<audio
ref={audioRef}
src={audioUrl}
onEnded={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
className="hidden"
/>
<button
onClick={handlePlayPause}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <FaPause size={14} /> : <FaPlay size={14} />}
</button>
<button
onClick={handleReplay}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
title="Replay"
>
<FaSyncAlt size={14} />
</button>
<button
onClick={handleDownload}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
title="Download"
>
<FaDownload size={14} />
</button>
<div className="h-3 w-px bg-gray-400 dark:bg-gray-600 mx-1 opacity-30"></div>
<FaVolumeUp size={14} className="opacity-60" />
</div>
) : (!message.isUser && (message.isFromHistory || (message.audioProgress && message.audioProgress > 0)) && (
<div className="mr-auto flex items-center gap-2 text-[10px] text-gray-500 italic">
{message.isFromHistory && !message.audioProgress ? (
<button
onClick={() => onSynthesize(index, message.text)}
className="flex items-center gap-1.5 px-2 py-0.5 bg-gray-300 dark:bg-gray-700 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 rounded text-gray-600 dark:text-gray-400 hover:text-indigo-600 transition-colors not-italic font-medium"
>
<FaVolumeUp size={10} />
Synthesize Audio
</button>
) : (message.audioProgress && (
<>
<div className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
Audio generating {message.audioProgress || 0}%...
</>
))}
</div>
)))}
{/* Timestamp */}
<span className={`text-[10px] opacity-40 font-medium ${message.isUser ? 'text-white' : 'text-gray-500'}`}>
{formatTime(message.timestamp)}
</span>
{/* Copy Icon - positioned above the bottom line */}
<button
onClick={handleCopy}
className="relative p-2 rounded-right-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors z-10 group"
aria-label="Copy message text"
>
{/* Outline icon */}
<FaRegCopy size={16} className="transition-opacity opacity-100 group-hover:opacity-0 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
{/* Solid icon (initially hidden) */}
<FaCopy size={16} className="transition-opacity opacity-0 group-hover:opacity-100 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
</button>
</div>
</div>
)}
</div>
);
};
// Main ChatWindow component with dynamic height calculation
const ChatWindow = ({ chatHistory, maxHeight, onSynthesize, featureName, isStreamingPlaying, onAudioPlay }) => {
const containerRef = useRef(null);
const [activePlayingId, setActivePlayingId] = useState(null);
useEffect(() => {
// If a new stream starts playing, stop any ongoing historical audio
if (isStreamingPlaying) {
setActivePlayingId(null);
}
}, [isStreamingPlaying]);
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [chatHistory]);
return (
<div
ref={containerRef}
style={{ maxHeight: maxHeight, overflowY: 'auto' }}
className="px-2 py-4 space-y-4 bg-transparent"
>
{chatHistory.map((message, index) => (
<div
key={index}
className={`flex ${message.isUser ? "justify-end" : "justify-start"} w-full`}
>
<ChatMessage
message={message}
index={index}
onSynthesize={onSynthesize}
featureName={featureName}
activePlayingId={activePlayingId}
onPlayStateChange={(id) => {
setActivePlayingId(id);
if (id && onAudioPlay) {
onAudioPlay(); // Notify parent to stop streaming (to prevent overlap)
}
}}
/>
</div>
))}
</div>
);
};
export default ChatWindow;