Newer
Older
cortex-hub / ui / client-app / src / components / ChatWindow.js
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;