diff --git a/frontend/src/components/ChatArea.css b/frontend/src/components/ChatArea.css deleted file mode 100644 index 0d86e34..0000000 --- a/frontend/src/components/ChatArea.css +++ /dev/null @@ -1,4 +0,0 @@ -/* ChatArea styles moved to inline Tailwind where possible */ -.custom-scrollbar::-webkit-scrollbar { - width: 6px; -} \ No newline at end of file diff --git a/frontend/src/components/ChatArea.js b/frontend/src/components/ChatArea.js deleted file mode 100644 index 71b1280..0000000 --- a/frontend/src/components/ChatArea.js +++ /dev/null @@ -1,139 +0,0 @@ -import React, { useState, useRef, useEffect } from "react"; -import ChatWindow from "./ChatWindow"; -import './ChatArea.css'; - -const ChatArea = ({ - chatHistory, - onSendMessage, - onCancel, - isProcessing, - featureName = "default", - workspaceId = null, - syncConfig = null, - isSourceDisconnected = false, - autoCollapse = false -}) => { - const [inputValue, setInputValue] = useState(""); - const inputRef = useRef(null); - const chatScrollRef = useRef(null); - - const handleSendMessage = (e) => { - e.preventDefault(); - if (inputValue.trim() !== "" && !isSourceDisconnected) { - onSendMessage(inputValue); - setInputValue(""); - } - }; - - const handleKeyDown = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(e); - } - }; - - // Scroll chat to bottom on new message - useEffect(() => { - if (chatScrollRef.current) { - chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; - } - }, [chatHistory]); - - return ( -
-
- -
- - {/* Sticky Input */} -
- {featureName === "coding_assistant" && workspaceId && ( -
-
-
-
-
-
-
- - {isSourceDisconnected ? 'Source Node Disconnected' : 'Workspace Sync Active'} - -
- - {workspaceId} - -
- - {syncConfig && ( -
-
- Source: - {syncConfig.source === 'node_local' ? 'Node Local' : syncConfig.source === 'server' ? 'Hub' : 'Empty'} -
- {syncConfig.source === 'node_local' && ( - <> -
-
- ⚠️ SOURCE NODE: - {syncConfig.source_node_id} -
-
-
- Path: - {syncConfig.path} -
- - )} -
- )} -
- )} -
- -
- - {isProcessing && ( - - )} -
-
-
-
- ); -}; - -export default ChatArea; \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.css b/frontend/src/components/ChatWindow.css deleted file mode 100644 index 7046008..0000000 --- a/frontend/src/components/ChatWindow.css +++ /dev/null @@ -1,157 +0,0 @@ -/* Modern AI Tool Styles */ -:root { - --user-bubble-bg: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); - --assistant-bubble-bg: #ffffff; - --reasoning-bg: #f8fafc; - --border-subtle: #e2e8f0; - --chat-bg: #f1f5f9; -} - -.dark { - --assistant-bubble-bg: #1e293b; - --reasoning-bg: rgba(15, 23, 42, 0.3); - --border-subtle: rgba(255, 255, 255, 0.05); - --chat-bg: #111827; -} - -.assistant-message { - background: var(--assistant-bubble-bg) !important; - backdrop-filter: blur(8px); - border: 1px solid var(--border-subtle) !important; - border-radius: 1.25rem !important; - font-family: 'Inter', sans-serif; - animation: slideInUp 0.3s ease-out; - overflow-wrap: anywhere; - word-break: break-word; - white-space: pre-wrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); -} - -.dark .assistant-message { - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); -} - -.user-message-container { - background: var(--user-bubble-bg) !important; - border-radius: 1.25rem !important; - font-family: 'Inter', sans-serif; - animation: slideInUp 0.3s ease-out; - overflow-wrap: anywhere; - word-break: break-word; - white-space: pre-wrap; -} - -@keyframes slideInUp { - from { - opacity: 0; - transform: translateY(10px) scale(0.98); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.thought-panel { - background: var(--reasoning-bg); - border-left: 3px solid #6366f1; - border-radius: 0 0.75rem 0.75rem 0; - margin: 0.5rem 0; -} - -.status-chip { - padding: 0.25rem 0.75rem; - border-radius: 9999px; - background: rgba(99, 102, 241, 0.1); - border: 1px solid rgba(99, 102, 241, 0.2); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -/* Scoped to .assistant-message */ -.assistant-message code { - color: #818cf8; - font-size: 90%; - background-color: rgba(99, 102, 241, 0.1); - border-radius: 4px; - padding: 2px 4px; -} - -.assistant-message pre { - background-color: #0f172a; - border: 1px solid rgba(255, 255, 255, 0.1); - color: #e2e8f0; - padding: 1.25rem; - border-radius: 0.75rem; - max-width: 100%; - overflow-x: auto; -} - -.streaming-dots::after { - content: ''; - animation: dots 1.5s steps(5, end) infinite; -} - -@keyframes dots { - - 0%, - 20% { - content: ''; - } - - 40% { - content: '.'; - } - - 60% { - content: '..'; - } - - 80%, - 100% { - content: '...'; - } -} - -.assistant-message strong { - font-weight: 700; - color: #6366f1; -} - -/* Sub-agent thought blocks */ -.thought-panel blockquote { - border-left: 2px solid rgba(99, 102, 241, 0.4) !important; - background: rgba(99, 102, 241, 0.05) !important; - margin: 0.75rem 0 0.75rem 1rem !important; - padding: 0.6rem 0.8rem !important; - border-radius: 0.5rem !important; - font-size: 0.7rem !important; - line-height: 1.4 !important; - color: #4f46e5 !important; -} - -.dark .thought-panel blockquote { - color: #818cf8 !important; - background: rgba(129, 140, 248, 0.05) !important; -} - -.thought-panel blockquote p { - margin: 0 !important; -} - -.thought-panel blockquote strong { - color: #4338ca; - text-transform: uppercase; - letter-spacing: 0.025em; - font-size: 0.65rem; -} - -.dark .thought-panel blockquote strong { - color: #a5b4fc; -} - -.chat-history-container { - background-color: var(--chat-bg) !important; -} \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.js b/frontend/src/components/ChatWindow.js deleted file mode 100644 index ff572ca..0000000 --- a/frontend/src/components/ChatWindow.js +++ /dev/null @@ -1,351 +0,0 @@ -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 ( -
- {/* Status indicator moved to top/bottom for better visibility */} - {(message.reasoning || (message.status === "Thinking")) && ( -
- - -
- {message.reasoning} -
-
- )} - - -
- {message.text} -
- - {!message.isUser && message.status && ( -
-
-
- - {message.status} - -
-
- )} - - {(message.isPureAnswer || !message.isUser) && ( -
- {/* Horizontal line - only for voice chat to separate from voice controls */} - {isVoiceChat && ( -
- )} -
- - {/* Audio Controls - strictly limited to voice chat feature */} - {isVoiceChat && (message.audioBlob ? ( -
-
- ) : (!message.isUser && (message.isFromHistory || (message.audioProgress && message.audioProgress > 0)) && ( -
- {message.isFromHistory && !message.audioProgress ? ( - - ) : (message.audioProgress && ( - <> -
- Audio generating {message.audioProgress || 0}%... - - ))} -
- )))} - - {/* Timestamp */} - - {formatTime(message.timestamp)} - - - {/* Copy Icon - positioned above the bottom line */} - -
-
- )} -
- ); -}; - -// Main ChatWindow component with dynamic height calculation -const ChatWindow = ({ chatHistory, maxHeight, onSynthesize, featureName, isStreamingPlaying, onAudioPlay, autoCollapse = false }) => { - const containerRef = useRef(null); - const [activePlayingId, setActivePlayingId] = useState(null); - const [expandedIndices, setExpandedIndices] = useState({}); - - 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]); - - // Handle auto-scroll when thought trace content changes (expanding or streaming) - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let isNearBottom = true; - const handleScroll = () => { - const threshold = 150; - isNearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < threshold; - }; - - container.addEventListener('scroll', handleScroll); - - const observer = new ResizeObserver(() => { - if (isNearBottom) { - container.scrollTop = container.scrollHeight; - } - }); - - // Observe children for height changes - Array.from(container.children).forEach(child => observer.observe(child)); - - return () => { - container.removeEventListener('scroll', handleScroll); - observer.disconnect(); - }; - }, [chatHistory]); - - return ( -
- {chatHistory.map((message, index) => { - const isLastMessage = index === chatHistory.length - 1; - const shouldCollapse = autoCollapse && !isLastMessage && !message.isUser && !expandedIndices[index]; - - return ( -
- {shouldCollapse ? ( - - ) : ( -
-
- { - setActivePlayingId(id); - if (id && onAudioPlay) { - onAudioPlay(); // Notify parent to stop streaming (to prevent overlap) - } - }} - /> -
- {autoCollapse && !isLastMessage && !message.isUser && expandedIndices[index] && ( - - )} -
- )} -
- ); - })} -
- ); -}; - -export default ChatWindow; diff --git a/frontend/src/features/chat/components/ChatArea.css b/frontend/src/features/chat/components/ChatArea.css new file mode 100644 index 0000000..0d86e34 --- /dev/null +++ b/frontend/src/features/chat/components/ChatArea.css @@ -0,0 +1,4 @@ +/* ChatArea styles moved to inline Tailwind where possible */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} \ No newline at end of file diff --git a/frontend/src/features/chat/components/ChatArea.js b/frontend/src/features/chat/components/ChatArea.js new file mode 100644 index 0000000..71b1280 --- /dev/null +++ b/frontend/src/features/chat/components/ChatArea.js @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect } from "react"; +import ChatWindow from "./ChatWindow"; +import './ChatArea.css'; + +const ChatArea = ({ + chatHistory, + onSendMessage, + onCancel, + isProcessing, + featureName = "default", + workspaceId = null, + syncConfig = null, + isSourceDisconnected = false, + autoCollapse = false +}) => { + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(null); + const chatScrollRef = useRef(null); + + const handleSendMessage = (e) => { + e.preventDefault(); + if (inputValue.trim() !== "" && !isSourceDisconnected) { + onSendMessage(inputValue); + setInputValue(""); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(e); + } + }; + + // Scroll chat to bottom on new message + useEffect(() => { + if (chatScrollRef.current) { + chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; + } + }, [chatHistory]); + + return ( +
+
+ +
+ + {/* Sticky Input */} +
+ {featureName === "coding_assistant" && workspaceId && ( +
+
+
+
+
+
+
+ + {isSourceDisconnected ? 'Source Node Disconnected' : 'Workspace Sync Active'} + +
+ + {workspaceId} + +
+ + {syncConfig && ( +
+
+ Source: + {syncConfig.source === 'node_local' ? 'Node Local' : syncConfig.source === 'server' ? 'Hub' : 'Empty'} +
+ {syncConfig.source === 'node_local' && ( + <> +
+
+ ⚠️ SOURCE NODE: + {syncConfig.source_node_id} +
+
+
+ Path: + {syncConfig.path} +
+ + )} +
+ )} +
+ )} +
+ +
+ + {isProcessing && ( + + )} +
+
+
+
+ ); +}; + +export default ChatArea; \ No newline at end of file diff --git a/frontend/src/features/chat/components/ChatWindow.css b/frontend/src/features/chat/components/ChatWindow.css new file mode 100644 index 0000000..7046008 --- /dev/null +++ b/frontend/src/features/chat/components/ChatWindow.css @@ -0,0 +1,157 @@ +/* Modern AI Tool Styles */ +:root { + --user-bubble-bg: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); + --assistant-bubble-bg: #ffffff; + --reasoning-bg: #f8fafc; + --border-subtle: #e2e8f0; + --chat-bg: #f1f5f9; +} + +.dark { + --assistant-bubble-bg: #1e293b; + --reasoning-bg: rgba(15, 23, 42, 0.3); + --border-subtle: rgba(255, 255, 255, 0.05); + --chat-bg: #111827; +} + +.assistant-message { + background: var(--assistant-bubble-bg) !important; + backdrop-filter: blur(8px); + border: 1px solid var(--border-subtle) !important; + border-radius: 1.25rem !important; + font-family: 'Inter', sans-serif; + animation: slideInUp 0.3s ease-out; + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03); +} + +.dark .assistant-message { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.user-message-container { + background: var(--user-bubble-bg) !important; + border-radius: 1.25rem !important; + font-family: 'Inter', sans-serif; + animation: slideInUp 0.3s ease-out; + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.thought-panel { + background: var(--reasoning-bg); + border-left: 3px solid #6366f1; + border-radius: 0 0.75rem 0.75rem 0; + margin: 0.5rem 0; +} + +.status-chip { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + background: rgba(99, 102, 241, 0.1); + border: 1px solid rgba(99, 102, 241, 0.2); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* Scoped to .assistant-message */ +.assistant-message code { + color: #818cf8; + font-size: 90%; + background-color: rgba(99, 102, 241, 0.1); + border-radius: 4px; + padding: 2px 4px; +} + +.assistant-message pre { + background-color: #0f172a; + border: 1px solid rgba(255, 255, 255, 0.1); + color: #e2e8f0; + padding: 1.25rem; + border-radius: 0.75rem; + max-width: 100%; + overflow-x: auto; +} + +.streaming-dots::after { + content: ''; + animation: dots 1.5s steps(5, end) infinite; +} + +@keyframes dots { + + 0%, + 20% { + content: ''; + } + + 40% { + content: '.'; + } + + 60% { + content: '..'; + } + + 80%, + 100% { + content: '...'; + } +} + +.assistant-message strong { + font-weight: 700; + color: #6366f1; +} + +/* Sub-agent thought blocks */ +.thought-panel blockquote { + border-left: 2px solid rgba(99, 102, 241, 0.4) !important; + background: rgba(99, 102, 241, 0.05) !important; + margin: 0.75rem 0 0.75rem 1rem !important; + padding: 0.6rem 0.8rem !important; + border-radius: 0.5rem !important; + font-size: 0.7rem !important; + line-height: 1.4 !important; + color: #4f46e5 !important; +} + +.dark .thought-panel blockquote { + color: #818cf8 !important; + background: rgba(129, 140, 248, 0.05) !important; +} + +.thought-panel blockquote p { + margin: 0 !important; +} + +.thought-panel blockquote strong { + color: #4338ca; + text-transform: uppercase; + letter-spacing: 0.025em; + font-size: 0.65rem; +} + +.dark .thought-panel blockquote strong { + color: #a5b4fc; +} + +.chat-history-container { + background-color: var(--chat-bg) !important; +} \ No newline at end of file diff --git a/frontend/src/features/chat/components/ChatWindow.js b/frontend/src/features/chat/components/ChatWindow.js new file mode 100644 index 0000000..ff572ca --- /dev/null +++ b/frontend/src/features/chat/components/ChatWindow.js @@ -0,0 +1,351 @@ +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 ( +
+ {/* Status indicator moved to top/bottom for better visibility */} + {(message.reasoning || (message.status === "Thinking")) && ( +
+ + +
+ {message.reasoning} +
+
+ )} + + +
+ {message.text} +
+ + {!message.isUser && message.status && ( +
+
+
+ + {message.status} + +
+
+ )} + + {(message.isPureAnswer || !message.isUser) && ( +
+ {/* Horizontal line - only for voice chat to separate from voice controls */} + {isVoiceChat && ( +
+ )} +
+ + {/* Audio Controls - strictly limited to voice chat feature */} + {isVoiceChat && (message.audioBlob ? ( +
+
+ ) : (!message.isUser && (message.isFromHistory || (message.audioProgress && message.audioProgress > 0)) && ( +
+ {message.isFromHistory && !message.audioProgress ? ( + + ) : (message.audioProgress && ( + <> +
+ Audio generating {message.audioProgress || 0}%... + + ))} +
+ )))} + + {/* Timestamp */} + + {formatTime(message.timestamp)} + + + {/* Copy Icon - positioned above the bottom line */} + +
+
+ )} +
+ ); +}; + +// Main ChatWindow component with dynamic height calculation +const ChatWindow = ({ chatHistory, maxHeight, onSynthesize, featureName, isStreamingPlaying, onAudioPlay, autoCollapse = false }) => { + const containerRef = useRef(null); + const [activePlayingId, setActivePlayingId] = useState(null); + const [expandedIndices, setExpandedIndices] = useState({}); + + 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]); + + // Handle auto-scroll when thought trace content changes (expanding or streaming) + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let isNearBottom = true; + const handleScroll = () => { + const threshold = 150; + isNearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < threshold; + }; + + container.addEventListener('scroll', handleScroll); + + const observer = new ResizeObserver(() => { + if (isNearBottom) { + container.scrollTop = container.scrollHeight; + } + }); + + // Observe children for height changes + Array.from(container.children).forEach(child => observer.observe(child)); + + return () => { + container.removeEventListener('scroll', handleScroll); + observer.disconnect(); + }; + }, [chatHistory]); + + return ( +
+ {chatHistory.map((message, index) => { + const isLastMessage = index === chatHistory.length - 1; + const shouldCollapse = autoCollapse && !isLastMessage && !message.isUser && !expandedIndices[index]; + + return ( +
+ {shouldCollapse ? ( + + ) : ( +
+
+ { + setActivePlayingId(id); + if (id && onAudioPlay) { + onAudioPlay(); // Notify parent to stop streaming (to prevent overlap) + } + }} + /> +
+ {autoCollapse && !isLastMessage && !message.isUser && expandedIndices[index] && ( + + )} +
+ )} +
+ ); + })} +
+ ); +}; + +export default ChatWindow; diff --git a/frontend/src/features/chat/index.js b/frontend/src/features/chat/index.js new file mode 100644 index 0000000..bf817ea --- /dev/null +++ b/frontend/src/features/chat/index.js @@ -0,0 +1,5 @@ +// Feature entry point for chat-related UI and logic. +// This allows other parts of the app to import chat components via a single module. + +export { default as ChatArea } from "./components/ChatArea"; +export { default as ChatWindow } from "./components/ChatWindow"; diff --git a/frontend/src/pages/SwarmControlPage.js b/frontend/src/pages/SwarmControlPage.js index 777cdb9..71fd1a2 100644 --- a/frontend/src/pages/SwarmControlPage.js +++ b/frontend/src/pages/SwarmControlPage.js @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; -import ChatArea from "../components/ChatArea"; +import { ChatArea } from "../features/chat"; import SessionSidebar from "../components/SessionSidebar"; import MultiNodeConsole from "../components/MultiNodeConsole"; import useSwarmControl from "../hooks/useSwarmControl"; diff --git a/frontend/src/pages/VoiceChatPage.js b/frontend/src/pages/VoiceChatPage.js index 7f0562f..8a98adf 100644 --- a/frontend/src/pages/VoiceChatPage.js +++ b/frontend/src/pages/VoiceChatPage.js @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; import useVoiceChat from "../hooks/useVoiceChat"; -import ChatWindow from "../components/ChatWindow"; +import { ChatWindow } from "../features/chat"; import Controls from "../components/VoiceControls"; import SessionSidebar from "../components/SessionSidebar"; import { updateSession } from "../services/apiService";