Skip to content

Commit 301c968

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.
1 parent 7af0f43 commit 301c968

File tree

2 files changed

+68
-98
lines changed

2 files changed

+68
-98
lines changed

chat/src/components/chat-provider.tsx

Lines changed: 60 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -141,112 +141,80 @@ export function ChatProvider({ children }: PropsWithChildren) {
141141
const eventSourceRef = useRef<EventSource | null>(null);
142142
const agentAPIUrl = useAgentAPIUrl();
143143

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

152-
// Reset messages when establishing a new connection
153-
setMessages([]);
157+
const eventSource = new EventSource(`${agentAPIUrl}/events`);
158+
eventSourceRef.current = eventSource;
154159

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

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-
});
175+
if (existingIndex !== -1) {
176+
// Update in place without copying the whole array prefix/suffix.
177+
const updated = [...prevMessages];
178+
updated[existingIndex] = confirmed;
179+
return updated;
180+
}
202181

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");
182+
// New confirmed message: replace any trailing draft that matches
183+
// the same role (the optimistic message we inserted on send).
184+
const last = prevMessages[prevMessages.length - 1];
185+
if (last && isDraftMessage(last) && last.role === confirmed.role) {
186+
return [...prevMessages.slice(0, -1), confirmed];
212187
}
213188

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

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;
206+
eventSource.onopen = () => {
207+
console.log("EventSource connection established");
239208
};
240209

241-
// Initial setup
242-
const eventSource = setupEventSource();
210+
// Mark offline on error. The browser will retry automatically.
211+
eventSource.onerror = () => {
212+
setServerStatus("offline");
213+
};
243214

244-
// Clean up on component unmount
245215
return () => {
246-
if (eventSource) {
247-
// Check if eventSource was successfully created
248-
eventSource.close();
249-
}
216+
eventSource.close();
217+
eventSourceRef.current = null;
250218
};
251219
}, [agentAPIUrl]);
252220

@@ -304,9 +272,6 @@ export function ChatProvider({ children }: PropsWithChildren) {
304272
});
305273
} finally {
306274
if (type === "user") {
307-
setMessages((prevMessages) =>
308-
prevMessages.filter((m) => !isDraftMessage(m))
309-
);
310275
setLoading(false);
311276
}
312277
}

chat/src/components/message-list.tsx

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

6969
// Pin to bottom when new content arrives, but only if the user hasn't
70-
// scrolled away. Direct scrollTop assignment is synchronous and avoids
71-
// the animation conflicts that smooth scrollTo causes during streaming.
70+
// scrolled away. Always scroll when the latest message is from the user
71+
// (they just sent it and should see it). Direct scrollTop assignment is
72+
// synchronous and avoids the animation conflicts that smooth scrollTo
73+
// causes during streaming.
7274
useLayoutEffect(() => {
7375
if (!scrollAreaRef) return;
74-
if (!isAtBottomRef.current) return;
76+
const lastMessage = messages[messages.length - 1];
77+
const isUserMessage = lastMessage && lastMessage.role === "user";
78+
if (!isAtBottomRef.current && !isUserMessage) return;
7579
scrollAreaRef.scrollTop = scrollAreaRef.scrollHeight;
80+
isAtBottomRef.current = true;
7681
}, [messages, scrollAreaRef]);
7782

7883
// If no messages, show a placeholder

0 commit comments

Comments
 (0)