From 268e949f16a4f6fd6119a1259823a231dcd3d61f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 9 Feb 2026 18:07:08 +0000 Subject: [PATCH 1/2] fix(chat): reduce rendering jank during streaming The chat view stuttered during streaming responses due to several compounding issues: transition-all on the message container animated height changes as messages grew, smooth scroll was interrupted on every streaming chunk before the previous animation completed, the scrollend listener used anonymous functions so cleanup never matched and broke user scroll-away detection, and the URL regex /g flag made it stateful across split/test calls causing link flicker. Sending a message now always scrolls to bottom and re-enables auto-scroll so the agent response stays visible. --- chat/src/components/message-list.tsx | 65 ++++++++-------------------- 1 file changed, 17 insertions(+), 48 deletions(-) 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) { From 5035423d6d765691e120e60ee1c4eb7ee6ec1fd1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 9 Feb 2026 18:07:19 +0000 Subject: [PATCH 2/2] fix(chat): stop clearing messages on SSE reconnect Messages were reset to [] every time the EventSource reconnected, including on transient network errors. This blanked the entire conversation. Now we let the browser handle SSE reconnection natively and never clear the message list. Also removes the draft filter in sendMessage's finally block which caused the user's message to briefly disappear before the server confirmed it. Drafts are now replaced inline when the matching confirmed message arrives via SSE, and cleaned up on send failure. --- chat/src/components/chat-provider.tsx | 162 ++++++++++---------------- 1 file changed, 63 insertions(+), 99 deletions(-) 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); } }