Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 63 additions & 99 deletions chat/src/components/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useSearchParams } from "next/navigation";
import {
useState,
useEffect,
useRef,
createContext,
PropsWithChildren,
useContext,
Expand Down Expand Up @@ -138,116 +137,80 @@ export function ChatProvider({ children }: PropsWithChildren) {
const [loading, setLoading] = useState<boolean>(false);
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
const [agentType, setAgentType] = useState<AgentType>("custom");
const eventSourceRef = useRef<EventSource | null>(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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Expand Down
65 changes: 17 additions & 48 deletions chat/src/components/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -126,7 +95,7 @@ export default function MessageList({messages}: MessageListProps) {
return (
<div className="overflow-y-auto flex-1" ref={setScrollAreaRef}>
<div
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto transition-all duration-300 ease-in-out min-h-0">
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto min-h-0">
{messages.map((message, index) => (
<div
key={message.id ?? "draft"}
Expand All @@ -137,7 +106,7 @@ export default function MessageList({messages}: MessageListProps) {
message.role === "user"
? "bg-accent-foreground rounded-lg max-w-[90%] px-4 py-3 text-accent"
: "max-w-[80ch]"
} ${message.id === undefined ? "animate-pulse" : ""}`}
}`}
>
<div
className={`whitespace-pre-wrap break-words text-left text-xs md:text-sm leading-relaxed md:leading-normal ${
Expand Down Expand Up @@ -186,7 +155,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({
}: ProcessedMessageProps) {
// Regex to find URLs
// https://stackoverflow.com/a/17773849
const urlRegex = useMemo<RegExp>(() => /(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<RegExp>(() => /(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<HTMLAnchorElement>, url: string) => {
if (e.metaKey || e.ctrlKey) {
Expand Down
Loading