diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index e24a53f..82e1042 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -1,69 +1,106 @@ -import React from "react"; +import React, { memo, useCallback } from "react"; import LLMResponse from "./LLMResponse"; import MessageBubble from "./MessageBubble"; import LoadingIndicator from "./LoadingIndicator"; -function safeParse(str) { - try { - return JSON.parse(str); - } catch (err) { - console.error("safeParse error:", err, "Original string:", str); - return {}; - } +class ChatErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + console.error("ChatWindow error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+ Something went wrong. Please refresh the page. +
+ ); + } + return this.props.children; + } } -export default function ChatWindow({ conversation, loading, onConfirm }) { +const safeParse = (str) => { + try { + return typeof str === 'string' ? JSON.parse(str) : str; + } catch (err) { + console.error("safeParse error:", err, "Original string:", str); + return str; + } +}; - if (!Array.isArray(conversation)) { - console.error("ChatWindow expected conversation to be an array, got:", conversation); - return null; - } - - const filtered = conversation.filter((msg) => { +const Message = memo(({ msg, idx, isLastMessage, onConfirm, onContentChange }) => { const { actor, response } = msg; - + if (actor === "user") { - return true; + return ; } + if (actor === "agent") { - const parsed = typeof response === "string" ? safeParse(response) : response; - return true; // Adjust this logic based on your "next" field. - } - return false; - }); - - return ( -
- {/* Main message container */} -
- {filtered.map((msg, idx) => { - const { actor, response } = msg; - - if (actor === "user") { - return ; - } else if (actor === "agent") { - const data = - typeof response === "string" ? safeParse(response) : response; - const isLastMessage = idx === filtered.length - 1; - return ( - - ); - } - return null; // Fallback for unsupported actors. - })} - {/* Loading indicator */} - {loading && ( -
- -
- )} -
-
- ); - -} + onHeightChange={onContentChange} + /> + ); + } + + return null; +}); + +Message.displayName = 'Message'; + +const ChatWindow = memo(({ conversation, loading, onConfirm, onContentChange }) => { + const validateConversation = useCallback((conv) => { + if (!Array.isArray(conv)) { + console.error("ChatWindow expected conversation to be an array, got:", conv); + return []; + } + return conv; + }, []); + + const filtered = validateConversation(conversation).filter((msg) => { + const { actor } = msg; + return actor === "user" || actor === "agent"; + }); + + return ( + +
+
+ {filtered.map((msg, idx) => ( + + ))} + {loading && ( +
+ +
+ )} +
+
+
+ ); +}); + +ChatWindow.displayName = 'ChatWindow'; + +export default ChatWindow; diff --git a/frontend/src/components/ConfirmInline.jsx b/frontend/src/components/ConfirmInline.jsx index bb7c573..c126fee 100644 --- a/frontend/src/components/ConfirmInline.jsx +++ b/frontend/src/components/ConfirmInline.jsx @@ -1,59 +1,65 @@ -import React from "react"; +import React, { memo } from "react"; -export default function ConfirmInline({ data, confirmed, onConfirm }) { - const { args, tool } = data || {}; +const ConfirmInline = memo(({ data, confirmed, onConfirm }) => { + const { args, tool } = data || {}; - if (confirmed) { - // Once confirmed, show "Running..." state in the same container - return ( -
-
-
- Tool: {tool ?? "Unknown"} -
- {args && ( + const renderArgs = () => { + if (!args) return null; + + return (
- Args: -
-                {JSON.stringify(args, null, 2)}
-              
+ Args: +
+                    {JSON.stringify(args, null, 2)}
+                
- )} -
-
- Running {tool}... -
-
- ); - } + ); + }; - // Not confirmed yet → show confirmation UI - return ( -
-
-
- Agent is ready to run the tool: {tool ?? "Unknown"} + if (confirmed) { + return ( +
+
+
+ Tool: {tool ?? "Unknown"} +
+ {renderArgs()} +
+
+ Running {tool}... +
+
+ ); + } + + return ( +
+
+
+ Agent is ready to run the tool: {tool ?? "Unknown"} +
+ {renderArgs()} +
+ Please confirm to proceed. +
+
+
+ +
- {args && ( -
- With the following parameters -
-              {JSON.stringify(args, null, 2)}
-            
-
- )} -
- Please confirm to proceed. -
-
-
- -
-
- ); -} + ); +}); + +ConfirmInline.displayName = 'ConfirmInline'; + +export default ConfirmInline; diff --git a/frontend/src/components/LLMResponse.jsx b/frontend/src/components/LLMResponse.jsx index 9c0866a..3e4f361 100644 --- a/frontend/src/components/LLMResponse.jsx +++ b/frontend/src/components/LLMResponse.jsx @@ -1,44 +1,60 @@ -import React, { useState } from "react"; +import React, { memo, useEffect } from "react"; import MessageBubble from "./MessageBubble"; import ConfirmInline from "./ConfirmInline"; -export default function LLMResponse({ data, onConfirm, isLastMessage }) { - const [isConfirmed, setIsConfirmed] = useState(false); +const LLMResponse = memo(({ data, onConfirm, isLastMessage, onHeightChange }) => { + const [isConfirmed, setIsConfirmed] = React.useState(false); + const responseRef = React.useRef(null); - const handleConfirm = async () => { - if (onConfirm) await onConfirm(); - setIsConfirmed(true); - }; + // Notify parent of height changes when confirm UI appears/changes + useEffect(() => { + if (isLastMessage && responseRef.current && onHeightChange) { + onHeightChange(); + } + }, [isLastMessage, isConfirmed, onHeightChange]); - // Only requires confirm if data.next === "confirm" AND it's the last message - const requiresConfirm = data.next === "confirm" && isLastMessage; + const handleConfirm = async () => { + try { + if (onConfirm) await onConfirm(); + setIsConfirmed(true); + } catch (error) { + console.error('Error confirming action:', error); + } + }; - if (typeof data.response === "object") { - data.response = data.response.response; - } + const response = typeof data?.response === 'object' + ? data.response.response + : data?.response; - let displayText = (data.response || "").trim(); - if (!displayText && requiresConfirm) { - displayText = `Agent is ready to run "${data.tool}". Please confirm.`; - } + const displayText = (response || '').trim(); + const requiresConfirm = data.next === "confirm" && isLastMessage; + const defaultText = requiresConfirm + ? `Agent is ready to run "${data.tool}". Please confirm.` + : ''; - return ( -
- - {requiresConfirm && ( - - )} - {!requiresConfirm && data.tool && data.next === "confirm" && ( -
-
- Agent chose tool: {data.tool ?? "Unknown"} -
+ return ( +
+ + {requiresConfirm && ( + + )} + {!requiresConfirm && data.tool && data.next === "confirm" && ( +
+
+ Agent chose tool: {data.tool ?? "Unknown"} +
+
+ )}
- )} -
- ); -} + ); +}); + +LLMResponse.displayName = 'LLMResponse'; + +export default LLMResponse; diff --git a/frontend/src/components/LoadingIndicator.jsx b/frontend/src/components/LoadingIndicator.jsx index 46eeb02..dfb38c6 100644 --- a/frontend/src/components/LoadingIndicator.jsx +++ b/frontend/src/components/LoadingIndicator.jsx @@ -1,11 +1,24 @@ -import React from "react"; +import React, { memo } from "react"; -export default function LoadingIndicator() { - return ( -
-
-
-
-
- ); -} +const LoadingIndicator = memo(() => { + return ( +
+ {[0, 1, 2].map((i) => ( +
0 ? `delay-${i}00` : ''}`} + /> + ))} + Loading... +
+ ); +}); + +LoadingIndicator.displayName = 'LoadingIndicator'; + +export default LoadingIndicator; diff --git a/frontend/src/components/MessageBubble.jsx b/frontend/src/components/MessageBubble.jsx index 59c6b84..f3245a1 100644 --- a/frontend/src/components/MessageBubble.jsx +++ b/frontend/src/components/MessageBubble.jsx @@ -1,50 +1,53 @@ -import React from "react"; +import React, { memo } from "react"; -export default function MessageBubble({ message, fallback = "", isUser = false }) { - const bubbleStyle = isUser - ? "bg-blue-600 text-white self-end" - : "bg-gray-300 text-gray-900 dark:bg-gray-600 dark:text-gray-100"; +const MessageBubble = memo(({ message, fallback = "", isUser = false }) => { + const displayText = message.response?.trim() ? message.response : fallback; - const displayText = message.response?.trim() ? message.response : fallback; + if (displayText.startsWith("###")) { + return null; + } - if (displayText.startsWith("###")) { - return null; - } + const renderTextWithLinks = (text) => { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const parts = text.split(urlRegex); - // Function to detect and render URLs as links - const renderTextWithLinks = (text) => { - const urlRegex = /(https?:\/\/[^\s]+)/g; - const parts = text.split(urlRegex); + return parts.map((part, index) => { + if (urlRegex.test(part)) { + return ( + + {part} + + ); + } + return part; + }); + }; - return parts.map((part, index) => { - if (urlRegex.test(part)) { - return ( - - {part} - - ); - } - return part; - }); - }; + return ( +
+ {renderTextWithLinks(displayText)} +
+ ); +}); - return ( -
- {renderTextWithLinks(displayText)} -
- ); -} +MessageBubble.displayName = 'MessageBubble'; + +export default MessageBubble; diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 693f313..e6d6387 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -1,11 +1,20 @@ -import React from "react"; +import React, { memo } from "react"; -export default function NavBar({ title }) { - return ( -
-

{title}

- {/* ...any additional nav items... */} -
- ); -} +const NavBar = memo(({ title }) => { + return ( +
+

+ {title} +

+
+ ); +}); + +NavBar.displayName = 'NavBar'; + +export default NavBar; diff --git a/frontend/src/pages/App.jsx b/frontend/src/pages/App.jsx index 7e53144..94ef16a 100644 --- a/frontend/src/pages/App.jsx +++ b/frontend/src/pages/App.jsx @@ -1,74 +1,153 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef, useCallback } from "react"; import NavBar from "../components/NavBar"; import ChatWindow from "../components/ChatWindow"; +import { apiService } from "../services/api"; const POLL_INTERVAL = 500; // 0.5 seconds +const INITIAL_ERROR_STATE = { visible: false, message: '' }; +const DEBOUNCE_DELAY = 300; // 300ms debounce for user input + +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} export default function App() { const containerRef = useRef(null); const inputRef = useRef(null); + const pollingRef = useRef(null); + const scrollTimeoutRef = useRef(null); + const [conversation, setConversation] = useState([]); - const [lastMessage, setLastMessage] = useState(null); // New state for tracking the last message + const [lastMessage, setLastMessage] = useState(null); const [userInput, setUserInput] = useState(""); const [loading, setLoading] = useState(false); + const [error, setError] = useState(INITIAL_ERROR_STATE); const [done, setDone] = useState(true); - useEffect(() => { - // Poll /get-conversation-history every 0.5 seconds - const intervalId = setInterval(async () => { - try { - const res = await fetch("http://127.0.0.1:8000/get-conversation-history"); - if (res.ok) { - const data = await res.json(); - const newConversation = data.messages || []; - setConversation(newConversation); + const debouncedUserInput = useDebounce(userInput, DEBOUNCE_DELAY); - if (newConversation.length > 0) { - const lastMsg = newConversation[newConversation.length - 1]; - setLoading(lastMsg.actor !== "agent"); - setDone(lastMsg.response.next === "done"); + // Error handling utility with auto-dismiss + const handleError = useCallback((error, context) => { + console.error(`${context}:`, error); + const errorMessage = error.status === 400 + ? error.message + : `Error ${context.toLowerCase()}. Please try again.`; + + setError({ + visible: true, + message: errorMessage + }); + + const timer = setTimeout(() => setError(INITIAL_ERROR_STATE), 3000); + return () => clearTimeout(timer); + }, []); - // Only scroll if the last message changes - if (!lastMessage || lastMsg.response.response !== lastMessage.response.response) { - setLastMessage(lastMsg); // Update the last message - } - } else { - setLoading(false); - setDone(true); - setLastMessage(null); // Clear last message if no messages - } + const fetchConversationHistory = useCallback(async () => { + try { + const data = await apiService.getConversationHistory(); + const newConversation = data.messages || []; + + setConversation(prevConversation => { + // Only update if there are actual changes + if (JSON.stringify(prevConversation) !== JSON.stringify(newConversation)) { + return newConversation; } - } catch (err) { - console.error("Error fetching conversation history:", err); + return prevConversation; + }); + + if (newConversation.length > 0) { + const lastMsg = newConversation[newConversation.length - 1]; + const isAgentMessage = lastMsg.actor === "agent"; + + setLoading(!isAgentMessage); + setDone(lastMsg.response.next === "done"); + + setLastMessage(prevLastMessage => { + if (!prevLastMessage || lastMsg.response.response !== prevLastMessage.response.response) { + return lastMsg; + } + return prevLastMessage; + }); + } else { + setLoading(false); + setDone(true); + setLastMessage(null); } - }, POLL_INTERVAL); + } catch (err) { + handleError(err, "fetching conversation"); + } + }, [handleError]); - return () => clearInterval(intervalId); - }, [lastMessage]); + // Setup polling with cleanup + useEffect(() => { + pollingRef.current = setInterval(fetchConversationHistory, POLL_INTERVAL); + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, [fetchConversationHistory]); + + const scrollToBottom = useCallback(() => { + if (containerRef.current) { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + const element = containerRef.current; + element.scrollTop = element.scrollHeight; + scrollTimeoutRef.current = null; + }, 100); + } + }, []); + + const handleContentChange = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); useEffect(() => { - if (containerRef.current && lastMessage) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; + if (lastMessage) { + scrollToBottom(); } - }, [lastMessage]); // Scroll only when the last message changes + }, [lastMessage, scrollToBottom]); useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); // Ensure the input box retains focus + if (inputRef.current && !loading && !done) { + inputRef.current.focus(); } - }, [userInput, loading, done]); + + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, [loading, done]); const handleSendMessage = async () => { - if (!userInput.trim()) return; + const trimmedInput = userInput.trim(); + if (!trimmedInput) return; + try { setLoading(true); - await fetch( - `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent(userInput)}`, - { method: "POST" } - ); + setError(INITIAL_ERROR_STATE); + await apiService.sendMessage(trimmedInput); setUserInput(""); } catch (err) { - console.error("Error sending prompt:", err); + handleError(err, "sending message"); setLoading(false); } }; @@ -76,29 +155,25 @@ export default function App() { const handleConfirm = async () => { try { setLoading(true); - await fetch("http://127.0.0.1:8000/confirm", { method: "POST" }); + setError(INITIAL_ERROR_STATE); + await apiService.confirm(); } catch (err) { - console.error("Confirm error:", err); + handleError(err, "confirming action"); setLoading(false); } }; const handleStartNewChat = async () => { try { - await fetch( - `http://127.0.0.1:8000/send-prompt?prompt=${encodeURIComponent("I'd like to travel for an event.")}`, - { method: "POST" } - ); - setConversation([]); // Clear local state - setLastMessage(null); // Reset last message + setError(INITIAL_ERROR_STATE); + setLoading(true); + await apiService.sendMessage("I'd like to travel for an event."); + setConversation([]); + setLastMessage(null); } catch (err) { - console.error("Error ending chat:", err); - } - }; - - const handleKeyPress = (e) => { - if (e.key === "Enter") { - handleSendMessage(); + handleError(err, "starting new chat"); + } finally { + setLoading(false); } }; @@ -106,19 +181,28 @@ export default function App() {
- {/* Centered content, but no manual bottom margin */} + {error.visible && ( +
+ {error.message} +
+ )} +
- {/* Scrollable chat area */} -
+ flex flex-col overflow-hidden"> +
{done && ( -
+
Chat ended
)} @@ -126,41 +210,49 @@ export default function App() {
- {/* Fixed bottom input */} -
-
+ border-t border-gray-300 dark:border-gray-700 shadow-lg + transition-all duration-200" + style={{ zIndex: 10 }}> +
{ + e.preventDefault(); + handleSendMessage(); + }} className="flex items-center"> setUserInput(e.target.value)} - onKeyPress={handleKeyPress} disabled={loading || done} + aria-label="Type your message" /> -
+ +
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..af93d49 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,75 @@ +const API_BASE_URL = 'http://127.0.0.1:8000'; + +class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + this.name = 'ApiError'; + } +} + +async function handleResponse(response) { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.message || 'An error occurred', + response.status + ); + } + return response.json(); +} + +export const apiService = { + async getConversationHistory() { + try { + const res = await fetch(`${API_BASE_URL}/get-conversation-history`); + return handleResponse(res); + } catch (error) { + throw new ApiError( + 'Failed to fetch conversation history', + error.status || 500 + ); + } + }, + + async sendMessage(message) { + if (!message?.trim()) { + throw new ApiError('Message cannot be empty', 400); + } + + try { + const res = await fetch( + `${API_BASE_URL}/send-prompt?prompt=${encodeURIComponent(message)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + } + ); + return handleResponse(res); + } catch (error) { + throw new ApiError( + 'Failed to send message', + error.status || 500 + ); + } + }, + + async confirm() { + try { + const res = await fetch(`${API_BASE_URL}/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + return handleResponse(res); + } catch (error) { + throw new ApiError( + 'Failed to confirm action', + error.status || 500 + ); + } + } +}; \ No newline at end of file