(
+ `/api/v1/events/count?${params.toString()}`,
+ );
+
+ return data;
+ }
}
export default V1ConversationService;
diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx
index 800bb37762..040cd8f522 100644
--- a/frontend/src/components/features/chat/chat-interface.tsx
+++ b/frontend/src/components/features/chat/chat-interface.tsx
@@ -48,6 +48,7 @@ import {
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
+import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
function getEntryPoint(
hasRepository: boolean | null,
@@ -64,6 +65,7 @@ export function ChatInterface() {
const { errorMessage } = useErrorMessageStore();
const { isLoadingMessages } = useWsClient();
const { isTask } = useTaskPolling();
+ const conversationWebSocket = useConversationWebSocket();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
@@ -94,6 +96,25 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
+ // Instantly scroll to bottom when history loading completes
+ const prevLoadingHistoryRef = React.useRef(
+ conversationWebSocket?.isLoadingHistory,
+ );
+ React.useEffect(() => {
+ const wasLoading = prevLoadingHistoryRef.current;
+ const isLoading = conversationWebSocket?.isLoadingHistory;
+
+ // When history loading transitions from true to false, instantly scroll to bottom
+ if (wasLoading && !isLoading && scrollRef.current) {
+ scrollRef.current.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: "instant",
+ });
+ }
+
+ prevLoadingHistoryRef.current = isLoading;
+ }, [conversationWebSocket?.isLoadingHistory, scrollRef]);
+
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
@@ -228,6 +249,14 @@ export function ChatInterface() {
)}
+ {conversationWebSocket?.isLoadingHistory &&
+ isV1Conversation &&
+ !isTask && (
+
+
+
+ )}
+
{!isLoadingMessages && v0UserEventsExist && (
)}
- {v1UserEventsExist && }
+ {!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
+
+ )}
diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx
index 3de57ad8d0..0be6e75393 100644
--- a/frontend/src/contexts/conversation-websocket-context.tsx
+++ b/frontend/src/contexts/conversation-websocket-context.tsx
@@ -5,6 +5,7 @@ import React, {
useState,
useCallback,
useMemo,
+ useRef,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
@@ -27,6 +28,7 @@ import {
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState =
interface ConversationWebSocketContextType {
connectionState: V1_WebSocketConnectionState;
sendMessage: (message: V1SendMessageRequest) => Promise;
+ isLoadingHistory: boolean;
}
const ConversationWebSocketContext = createContext<
@@ -67,6 +70,13 @@ export function ConversationWebSocketProvider({
const { setAgentStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
+ // History loading state
+ const [isLoadingHistory, setIsLoadingHistory] = useState(true);
+ const [expectedEventCount, setExpectedEventCount] = useState(
+ null,
+ );
+ const receivedEventCountRef = useRef(0);
+
// Build WebSocket URL from props
// Only build URL if we have both conversationId and conversationUrl
// This prevents connection attempts during task polling phase
@@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({
return buildWebSocketUrl(conversationId, conversationUrl);
}, [conversationId, conversationUrl]);
- // Reset hasConnected flag when conversation changes
+ // Reset hasConnected flag and history loading state when conversation changes
useEffect(() => {
hasConnectedRef.current = false;
+ setIsLoadingHistory(true);
+ setExpectedEventCount(null);
+ receivedEventCountRef.current = 0;
}, [conversationId]);
+ // Check if we've received all events when expectedEventCount becomes available
+ useEffect(() => {
+ if (
+ expectedEventCount !== null &&
+ receivedEventCountRef.current >= expectedEventCount &&
+ isLoadingHistory
+ ) {
+ setIsLoadingHistory(false);
+ }
+ }, [expectedEventCount, isLoadingHistory]);
+
const handleMessage = useCallback(
(messageEvent: MessageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
+ // Track received events for history loading (count ALL events from WebSocket)
+ // Always count when loading, even if we don't have the expected count yet
+ if (isLoadingHistory) {
+ receivedEventCountRef.current += 1;
+
+ if (
+ expectedEventCount !== null &&
+ receivedEventCountRef.current >= expectedEventCount
+ ) {
+ setIsLoadingHistory(false);
+ }
+ }
+
// Use type guard to validate v1 event structure
if (isV1Event(event)) {
addEvent(event);
@@ -141,6 +178,8 @@ export function ConversationWebSocketProvider({
},
[
addEvent,
+ isLoadingHistory,
+ expectedEventCount,
setErrorMessage,
removeOptimisticUserMessage,
queryClient,
@@ -164,10 +203,27 @@ export function ConversationWebSocketProvider({
return {
queryParams,
reconnect: { enabled: true },
- onOpen: () => {
+ onOpen: async () => {
setConnectionState("OPEN");
hasConnectedRef.current = true; // Mark that we've successfully connected
removeErrorMessage(); // Clear any previous error messages on successful connection
+
+ // Fetch expected event count for history loading detection
+ if (conversationId) {
+ try {
+ const count =
+ await V1ConversationService.getEventCount(conversationId);
+ setExpectedEventCount(count);
+
+ // If no events expected, mark as loaded immediately
+ if (count === 0) {
+ setIsLoadingHistory(false);
+ }
+ } catch (error) {
+ // Fall back to marking as loaded to avoid infinite loading state
+ setIsLoadingHistory(false);
+ }
+ }
},
onClose: (event: CloseEvent) => {
setConnectionState("CLOSED");
@@ -188,7 +244,13 @@ export function ConversationWebSocketProvider({
},
onMessage: handleMessage,
};
- }, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
+ }, [
+ handleMessage,
+ setErrorMessage,
+ removeErrorMessage,
+ sessionApiKey,
+ conversationId,
+ ]);
// Only attempt WebSocket connection when we have a valid URL
// This prevents connection attempts during task polling phase
@@ -246,8 +308,8 @@ export function ConversationWebSocketProvider({
}, [socket, wsUrl]);
const contextValue = useMemo(
- () => ({ connectionState, sendMessage }),
- [connectionState, sendMessage],
+ () => ({ connectionState, sendMessage, isLoadingHistory }),
+ [connectionState, sendMessage, isLoadingHistory],
);
return (