Skip to content

Commit 9ea76c5

Browse files
committed
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. Scrolling to bottom is now forced when the user sends a message, re-enabling auto-scroll so the agent response stays visible.
1 parent 3a64610 commit 9ea76c5

2 files changed

Lines changed: 71 additions & 102 deletions

File tree

chat/src/components/chat-provider.tsx

Lines changed: 63 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useSearchParams } from "next/navigation";
44
import {
55
useState,
66
useEffect,
7-
useRef,
87
createContext,
98
PropsWithChildren,
109
useContext,
@@ -138,116 +137,80 @@ export function ChatProvider({ children }: PropsWithChildren) {
138137
const [loading, setLoading] = useState<boolean>(false);
139138
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
140139
const [agentType, setAgentType] = useState<AgentType>("custom");
141-
const eventSourceRef = useRef<EventSource | null>(null);
142140
const agentAPIUrl = useAgentAPIUrl();
143141

144-
// Set up SSE connection to the events endpoint
142+
// Set up SSE connection to the events endpoint. EventSource handles
143+
// reconnection automatically, so we only create it once per URL and
144+
// let the browser manage transient failures. Messages are NOT cleared
145+
// on reconnect to avoid blanking the conversation on network blips.
145146
useEffect(() => {
146-
// Function to create and set up EventSource
147-
const setupEventSource = () => {
148-
if (eventSourceRef.current) {
149-
eventSourceRef.current.close();
150-
}
147+
if (!agentAPIUrl) {
148+
console.warn(
149+
"agentAPIUrl is not set, SSE connection cannot be established."
150+
);
151+
setServerStatus("offline");
152+
return;
153+
}
151154

152-
// Reset messages when establishing a new connection
153-
setMessages([]);
155+
const eventSource = new EventSource(`${agentAPIUrl}/events`);
154156

155-
if (!agentAPIUrl) {
156-
console.warn(
157-
"agentAPIUrl is not set, SSE connection cannot be established."
157+
// Handle message updates
158+
eventSource.addEventListener("message_update", (event) => {
159+
const data: MessageUpdateEvent = JSON.parse(event.data);
160+
const confirmed: Message = {
161+
role: data.role,
162+
content: data.message,
163+
id: data.id,
164+
};
165+
166+
setMessages((prevMessages) => {
167+
// Check if message with this ID already exists
168+
const existingIndex = prevMessages.findIndex(
169+
(m) => m.id === data.id
158170
);
159-
setServerStatus("offline"); // Or some other appropriate status
160-
return null; // Don't try to connect if URL is empty
161-
}
162171

163-
const eventSource = new EventSource(`${agentAPIUrl}/events`);
164-
eventSourceRef.current = eventSource;
165-
166-
// Handle message updates
167-
eventSource.addEventListener("message_update", (event) => {
168-
const data: MessageUpdateEvent = JSON.parse(event.data);
169-
170-
setMessages((prevMessages) => {
171-
// Clean up draft messages
172-
const updatedMessages = [...prevMessages].filter(
173-
(m) => !isDraftMessage(m)
174-
);
175-
176-
// Check if message with this ID already exists
177-
const existingIndex = updatedMessages.findIndex(
178-
(m) => m.id === data.id
179-
);
180-
181-
if (existingIndex !== -1) {
182-
// Update existing message
183-
updatedMessages[existingIndex] = {
184-
role: data.role,
185-
content: data.message,
186-
id: data.id,
187-
};
188-
return updatedMessages;
189-
} else {
190-
// Add new message
191-
return [
192-
...updatedMessages,
193-
{
194-
role: data.role,
195-
content: data.message,
196-
id: data.id,
197-
},
198-
];
199-
}
200-
});
201-
});
172+
if (existingIndex !== -1) {
173+
// Update in place without copying the whole array prefix/suffix.
174+
const updated = [...prevMessages];
175+
updated[existingIndex] = confirmed;
176+
return updated;
177+
}
202178

203-
// Handle status changes
204-
eventSource.addEventListener("status_change", (event) => {
205-
const data: StatusChangeEvent = JSON.parse(event.data);
206-
if (data.status === "stable") {
207-
setServerStatus("stable");
208-
} else if (data.status === "running") {
209-
setServerStatus("running");
210-
} else {
211-
setServerStatus("unknown");
179+
// New confirmed message: replace any trailing draft that matches
180+
// the same role (the optimistic message we inserted on send).
181+
const last = prevMessages[prevMessages.length - 1];
182+
if (last && isDraftMessage(last) && last.role === confirmed.role) {
183+
return [...prevMessages.slice(0, -1), confirmed];
212184
}
213185

214-
// Set agent type
215-
setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType);
186+
return [...prevMessages, confirmed];
216187
});
188+
});
189+
190+
// Handle status changes
191+
eventSource.addEventListener("status_change", (event) => {
192+
const data: StatusChangeEvent = JSON.parse(event.data);
193+
if (data.status === "stable") {
194+
setServerStatus("stable");
195+
} else if (data.status === "running") {
196+
setServerStatus("running");
197+
} else {
198+
setServerStatus("unknown");
199+
}
200+
// Set agent type
201+
setAgentType(data.agent_type === "" ? "unknown" : data.agent_type as AgentType);
202+
});
217203

218-
// Handle connection open (server is online)
219-
eventSource.onopen = () => {
220-
// Connection is established, but we'll wait for status_change event
221-
// for the actual server status
222-
console.log("EventSource connection established - messages reset");
223-
};
224-
225-
// Handle connection errors
226-
eventSource.onerror = (error) => {
227-
console.error("EventSource error:", error);
228-
setServerStatus("offline");
229-
230-
// Try to reconnect after delay
231-
setTimeout(() => {
232-
if (eventSourceRef.current) {
233-
setupEventSource();
234-
}
235-
}, 3000);
236-
};
237-
238-
return eventSource;
204+
eventSource.onopen = () => {
205+
console.log("EventSource connection established");
239206
};
240207

241-
// Initial setup
242-
const eventSource = setupEventSource();
243-
244-
// Clean up on component unmount
245-
return () => {
246-
if (eventSource) {
247-
// Check if eventSource was successfully created
248-
eventSource.close();
249-
}
208+
// Mark offline on error. The browser will retry automatically.
209+
eventSource.onerror = () => {
210+
setServerStatus("offline");
250211
};
212+
213+
return () => eventSource.close();
251214
}, [agentAPIUrl]);
252215

253216
// Send a new message
@@ -293,6 +256,8 @@ export function ChatProvider({ children }: PropsWithChildren) {
293256
toast.error(`Failed to send message`, {
294257
description: fullDetail,
295258
});
259+
// Remove the optimistic draft since the server rejected it.
260+
setMessages((prev) => prev.filter((m) => !isDraftMessage(m)));
296261
}
297262

298263
} catch (error) {
@@ -302,11 +267,10 @@ export function ChatProvider({ children }: PropsWithChildren) {
302267
toast.error(`Error sending message`, {
303268
description: message,
304269
});
270+
// Remove the optimistic draft since the request failed.
271+
setMessages((prev) => prev.filter((m) => !isDraftMessage(m)));
305272
} finally {
306273
if (type === "user") {
307-
setMessages((prevMessages) =>
308-
prevMessages.filter((m) => !isDraftMessage(m))
309-
);
310274
setLoading(false);
311275
}
312276
}

chat/src/components/message-list.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,17 @@ export default function MessageList({messages}: MessageListProps) {
7070
}, [checkIfAtBottom, scrollAreaRef]);
7171

7272
// Pin to bottom when new content arrives, but only if the user hasn't
73-
// scrolled away. Direct scrollTop assignment is synchronous and avoids
74-
// the animation conflicts that smooth scrollTo causes during streaming.
73+
// scrolled away. Always scroll when the latest message is from the user
74+
// (they just sent it and should see it). Direct scrollTop assignment is
75+
// synchronous and avoids the animation conflicts that smooth scrollTo
76+
// causes during streaming.
7577
useLayoutEffect(() => {
7678
if (!scrollAreaRef) return;
77-
if (!isAtBottomRef.current) return;
79+
const lastMessage = messages[messages.length - 1];
80+
const isUserMessage = lastMessage && lastMessage.role === "user";
81+
if (!isAtBottomRef.current && !isUserMessage) return;
7882
scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight;
83+
isAtBottomRef.current = true;
7984
}, [messages, scrollAreaRef]);
8085

8186
// If no messages, show a placeholder

0 commit comments

Comments
 (0)