diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 21a2ee3f..52c39900 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -4,7 +4,6 @@ import { useSearchParams } from "next/navigation"; import { useState, useEffect, - useRef, createContext, PropsWithChildren, useContext, @@ -138,116 +137,80 @@ export function ChatProvider({ children }: PropsWithChildren) { const [loading, setLoading] = useState(false); const [serverStatus, setServerStatus] = useState("unknown"); const [agentType, setAgentType] = useState("custom"); - const eventSourceRef = useRef(null); const agentAPIUrl = useAgentAPIUrl(); - // Set up SSE connection to the events endpoint + // Set up SSE connection to the events endpoint. EventSource handles + // reconnection automatically, so we only create it once per URL and + // let the browser manage transient failures. Messages are NOT cleared + // on reconnect to avoid blanking the conversation on network blips. useEffect(() => { - // Function to create and set up EventSource - const setupEventSource = () => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } + if (!agentAPIUrl) { + console.warn( + "agentAPIUrl is not set, SSE connection cannot be established." + ); + setServerStatus("offline"); + return; + } - // Reset messages when establishing a new connection - setMessages([]); + const eventSource = new EventSource(`${agentAPIUrl}/events`); - if (!agentAPIUrl) { - console.warn( - "agentAPIUrl is not set, SSE connection cannot be established." + // Handle message updates + eventSource.addEventListener("message_update", (event) => { + const data: MessageUpdateEvent = JSON.parse(event.data); + const confirmed: Message = { + role: data.role, + content: data.message, + id: data.id, + }; + + setMessages((prevMessages) => { + // Check if message with this ID already exists + const existingIndex = prevMessages.findIndex( + (m) => m.id === data.id ); - setServerStatus("offline"); // Or some other appropriate status - return null; // Don't try to connect if URL is empty - } - const eventSource = new EventSource(`${agentAPIUrl}/events`); - eventSourceRef.current = eventSource; - - // Handle message updates - eventSource.addEventListener("message_update", (event) => { - const data: MessageUpdateEvent = JSON.parse(event.data); - - setMessages((prevMessages) => { - // Clean up draft messages - const updatedMessages = [...prevMessages].filter( - (m) => !isDraftMessage(m) - ); - - // Check if message with this ID already exists - const existingIndex = updatedMessages.findIndex( - (m) => m.id === data.id - ); - - if (existingIndex !== -1) { - // Update existing message - updatedMessages[existingIndex] = { - role: data.role, - content: data.message, - id: data.id, - }; - return updatedMessages; - } else { - // Add new message - return [ - ...updatedMessages, - { - role: data.role, - content: data.message, - id: data.id, - }, - ]; - } - }); - }); + if (existingIndex !== -1) { + // Update existing message + const updated = [...prevMessages]; + updated[existingIndex] = confirmed; + return updated; + } - // Handle status changes - eventSource.addEventListener("status_change", (event) => { - const data: StatusChangeEvent = JSON.parse(event.data); - if (data.status === "stable") { - setServerStatus("stable"); - } else if (data.status === "running") { - setServerStatus("running"); - } else { - setServerStatus("unknown"); + // New confirmed message: replace any trailing draft that matches + // the same role (the optimistic message we inserted on send). + const last = prevMessages[prevMessages.length - 1]; + if (last && isDraftMessage(last) && last.role === confirmed.role) { + return [...prevMessages.slice(0, -1), confirmed]; } - // Set agent type - setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType); + return [...prevMessages, confirmed]; }); + }); + + // Handle status changes + eventSource.addEventListener("status_change", (event) => { + const data: StatusChangeEvent = JSON.parse(event.data); + if (data.status === "stable") { + setServerStatus("stable"); + } else if (data.status === "running") { + setServerStatus("running"); + } else { + setServerStatus("unknown"); + } + // Set agent type + setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType); + }); - // Handle connection open (server is online) - eventSource.onopen = () => { - // Connection is established, but we'll wait for status_change event - // for the actual server status - console.log("EventSource connection established - messages reset"); - }; - - // Handle connection errors - eventSource.onerror = (error) => { - console.error("EventSource error:", error); - setServerStatus("offline"); - - // Try to reconnect after delay - setTimeout(() => { - if (eventSourceRef.current) { - setupEventSource(); - } - }, 3000); - }; - - return eventSource; + eventSource.onopen = () => { + console.log("EventSource connection established"); }; - // Initial setup - const eventSource = setupEventSource(); - - // Clean up on component unmount - return () => { - if (eventSource) { - // Check if eventSource was successfully created - eventSource.close(); - } + // Mark offline on error. The browser will retry automatically. + eventSource.onerror = () => { + setServerStatus("offline"); }; + + return () => eventSource.close(); }, [agentAPIUrl]); // Send a new message @@ -293,6 +256,8 @@ export function ChatProvider({ children }: PropsWithChildren) { toast.error(`Failed to send message`, { description: fullDetail, }); + // Remove the optimistic draft since the server rejected it. + setMessages((prev) => prev.filter((m) => !isDraftMessage(m))); } } catch (error) { @@ -302,11 +267,10 @@ export function ChatProvider({ children }: PropsWithChildren) { toast.error(`Error sending message`, { description: message, }); + // Remove the optimistic draft since the request failed. + setMessages((prev) => prev.filter((m) => !isDraftMessage(m))); } finally { if (type === "user") { - setMessages((prevMessages) => - prevMessages.filter((m) => !isDraftMessage(m)) - ); setLoading(false); } } diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index dc2f913b..6d658227 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -28,10 +28,6 @@ export default function MessageList({messages}: MessageListProps) { // Track if user is at bottom - default to true for initial scroll const isAtBottomRef = useRef(true); - // Track the last known scroll height to detect new content - const lastScrollHeightRef = useRef(0); - // Track if we're currently doing a programmatic scroll - const isProgrammaticScrollRef = useRef(false); const checkIfAtBottom = useCallback(() => { if (!scrollAreaRef) return false; @@ -60,58 +56,31 @@ export default function MessageList({messages}: MessageListProps) { }; }, []); - // Update isAtBottom on scroll + // Track whether the user is scrolled to the bottom. Every scroll event + // updates the ref so auto-scroll decisions are always based on the + // user's actual position. useEffect(() => { if (!scrollAreaRef) return; - const handleScroll = () => { - if (isProgrammaticScrollRef.current) return; isAtBottomRef.current = checkIfAtBottom(); }; - - // Initial check handleScroll(); - scrollAreaRef.addEventListener("scroll", handleScroll); - scrollAreaRef.addEventListener("scrollend", () => isProgrammaticScrollRef.current = false); - return () => { - scrollAreaRef.removeEventListener("scroll", handleScroll) - scrollAreaRef.removeEventListener("scrollend", () => isProgrammaticScrollRef.current = false); - - }; + return () => scrollAreaRef.removeEventListener("scroll", handleScroll); }, [checkIfAtBottom, scrollAreaRef]); - // Handle auto-scrolling when messages change + // Pin to bottom when new content arrives, but only if the user hasn't + // scrolled away. Always scroll when the latest message is from the user + // (they just sent it and should see it). Direct scrollTop assignment is + // synchronous and avoids the animation conflicts that smooth scrollTo + // causes during streaming. useLayoutEffect(() => { if (!scrollAreaRef) return; - - const currentScrollHeight = scrollAreaRef.scrollHeight; - - // Check if this is new content (scroll height increased) - const hasNewContent = currentScrollHeight > lastScrollHeightRef.current; - const isFirstRender = lastScrollHeightRef.current === 0; - const isNewUserMessage = - messages.length > 0 && messages[messages.length - 1].role === "user"; - - // Auto-scroll only if: - // 1. It's the first render, OR - // 2. There's new content AND user was at the bottom, OR - // 3. The user sent a new message - if ( - hasNewContent && - (isFirstRender || isAtBottomRef.current || isNewUserMessage) - ) { - isProgrammaticScrollRef.current = true; - scrollAreaRef.scrollTo({ - top: currentScrollHeight, - behavior: isFirstRender ? "instant" : "smooth", - }); - // After scrolling, we're at the bottom - isAtBottomRef.current = true; - } - - // Update the last known scroll height - lastScrollHeightRef.current = currentScrollHeight; + const lastMessage = messages[messages.length - 1]; + const isUserMessage = lastMessage && lastMessage.role === "user"; + if (!isAtBottomRef.current && !isUserMessage) return; + scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight; + isAtBottomRef.current = true; }, [messages, scrollAreaRef]); // If no messages, show a placeholder @@ -126,7 +95,7 @@ export default function MessageList({messages}: MessageListProps) { return (
+ className="p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0"> {messages.map((message, index) => (
(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g, []); + const urlRegex = useMemo(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/, []); const handleClick = (e: React.MouseEvent, url: string) => { if (e.metaKey || e.ctrlKey) {