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}
-
- >
- )}
-
- )}
-
- )}
-
-
-
- );
-};
-
-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}
+
+ >
+ )}
+
+ )}
+
+ )}
+
+
+
+ );
+};
+
+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";