import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from 'react-markdown';
import './ChatWindow.css';
import FileListComponent from "./FileList";
import DiffViewer from "./DiffViewer";
import CodeChangePlan from "./CodeChangePlan";
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 [selectedFile, setSelectedFile] = useState(null);
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]);
// 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);
};
const handleCloseDiff = () => {
setSelectedFile(null);
};
const handleFileClick = (file) => {
setSelectedFile(file);
};
// 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-3 pb-2 rounded-2xl shadow-sm max-w-[85%] bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 assistant-message mr-auto border border-gray-300 dark:border-gray-700/50`;
const userMessageClasses = `max-w-[80%] p-3 pb-2 rounded-2xl shadow-sm bg-indigo-600 text-white ml-auto`;
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}>
{message.reasoning && (
<div className="mb-2">
<button
onClick={toggleReasoning}
className="text-sm font-semibold text-indigo-700 hover:text-indigo-800 dark:text-gray-300 dark:hover:text-gray-100 focus:outline-none"
>
{isReasoningExpanded ? "Hide Reasoning ▲" : "Show Reasoning ▼"}
</button>
<div
className={`mt-2 text-xs transition-max-h duration-500 ease-in-out overflow-hidden ${isReasoningExpanded ? "max-h-96" : "max-h-0"
} text-gray-700 dark:text-gray-300`}
>
<ReactMarkdown>{message.reasoning}</ReactMarkdown>
</div>
</div>
)}
<ReactMarkdown>{message.text}</ReactMarkdown>
{(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>
)}
{message.code_changes && (
<FileListComponent code_changes={message.code_changes} onFileClick={handleFileClick} />
)}
{message.steps && (
<CodeChangePlan steps={message.steps} />
)}
{selectedFile && <DiffViewer oldContent={selectedFile.old} newContent={selectedFile.new} filePath={selectedFile.filepath} onClose={handleCloseDiff} />}
</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;