feat(frontend): add silent WebSocket recovery for V1 conversations (#12677)

This commit is contained in:
Hiep Le
2026-02-02 19:53:33 +07:00
committed by GitHub
parent f5a9d28999
commit d1f0a01a57
3 changed files with 127 additions and 13 deletions

View File

@@ -7,7 +7,6 @@ import React, {
useMemo,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
import { useEventStore } from "#/stores/use-event-store";
@@ -72,6 +71,7 @@ export function ConversationWebSocketProvider({
sessionApiKey,
subConversations,
subConversationIds,
onDisconnect,
}: {
children: React.ReactNode;
conversationId?: string;
@@ -79,6 +79,7 @@ export function ConversationWebSocketProvider({
sessionApiKey?: string | null;
subConversations?: V1AppConversation[];
subConversationIds?: string[];
onDisconnect?: () => void;
}) {
// Separate connection state tracking for each WebSocket
const [mainConnectionState, setMainConnectionState] =
@@ -126,8 +127,6 @@ export function ConversationWebSocketProvider({
conversationId: string;
} | null>(null);
const { t } = useTranslation();
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
(event: ConversationStateUpdateEventStats) => {
@@ -631,12 +630,10 @@ export function ConversationWebSocketProvider({
},
onClose: (event: CloseEvent) => {
setMainConnectionState("CLOSED");
// Only show error message if we've previously connected successfully
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
// Trigger silent recovery on unexpected disconnect
// Do NOT show error message - recovery happens automatically
if (event.code !== 1000 && hasConnectedRefMain.current) {
setErrorMessage(
`${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`,
);
onDisconnect?.();
}
},
onError: () => {
@@ -655,6 +652,7 @@ export function ConversationWebSocketProvider({
sessionApiKey,
conversationId,
conversationUrl,
onDisconnect,
]);
// Separate WebSocket options for planning agent connection
@@ -703,12 +701,10 @@ export function ConversationWebSocketProvider({
},
onClose: (event: CloseEvent) => {
setPlanningConnectionState("CLOSED");
// Only show error message if we've previously connected successfully
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
// Trigger silent recovery on unexpected disconnect
// Do NOT show error message - recovery happens automatically
if (event.code !== 1000 && hasConnectedRefPlanning.current) {
setErrorMessage(
`${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`,
);
onDisconnect?.();
}
},
onError: () => {
@@ -726,6 +722,7 @@ export function ConversationWebSocketProvider({
removeErrorMessage,
sessionApiKey,
subConversations,
onDisconnect,
]);
// Only attempt WebSocket connection when we have a valid URL

View File

@@ -3,6 +3,7 @@ import { WsClientProvider } from "#/context/ws-client-provider";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSubConversations } from "#/hooks/query/use-sub-conversations";
import { useWebSocketRecovery } from "#/hooks/use-websocket-recovery";
interface WebSocketProviderWrapperProps {
children: React.ReactNode;
@@ -47,6 +48,10 @@ export function WebSocketProviderWrapper({
(subConversation) => subConversation !== null,
);
// Silent recovery for V1 WebSocket disconnections
const { reconnectKey, handleDisconnect } =
useWebSocketRecovery(conversationId);
if (version === 0) {
return (
<WsClientProvider conversationId={conversationId}>
@@ -58,11 +63,13 @@ export function WebSocketProviderWrapper({
if (version === 1) {
return (
<ConversationWebSocketProvider
key={reconnectKey}
conversationId={conversationId}
conversationUrl={conversation?.url}
sessionApiKey={conversation?.session_api_key}
subConversationIds={conversation?.sub_conversation_ids}
subConversations={filteredSubConversations}
onDisconnect={handleDisconnect}
>
{children}
</ConversationWebSocketProvider>

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { I18nKey } from "#/i18n/declaration";
const MAX_RECOVERY_ATTEMPTS = 3;
const RECOVERY_COOLDOWN_MS = 5000;
const RECOVERY_SETTLED_DELAY_MS = 2000;
/**
* Hook that handles silent WebSocket recovery by resuming the sandbox
* when a WebSocket disconnection is detected.
*
* @param conversationId - The conversation ID to recover
* @returns reconnectKey - Key to force provider remount (resets connection state)
* @returns handleDisconnect - Callback to trigger recovery on WebSocket disconnect
*/
export function useWebSocketRecovery(conversationId: string) {
// Recovery state (refs to avoid re-renders)
const recoveryAttemptsRef = React.useRef(0);
const recoveryInProgressRef = React.useRef(false);
const lastRecoveryAttemptRef = React.useRef<number | null>(null);
// Key to force remount of provider after recovery (resets connection state to "CONNECTING")
const [reconnectKey, setReconnectKey] = React.useState(0);
const queryClient = useQueryClient();
const { mutate: resumeConversation } = useUnifiedResumeConversationSandbox();
const { providers } = useUserProviders();
const setErrorMessage = useErrorMessageStore(
(state) => state.setErrorMessage,
);
// Reset recovery state when conversation changes
React.useEffect(() => {
recoveryAttemptsRef.current = 0;
recoveryInProgressRef.current = false;
lastRecoveryAttemptRef.current = null;
}, [conversationId]);
// Silent recovery callback - resumes sandbox when WebSocket disconnects
const handleDisconnect = React.useCallback(() => {
// Prevent concurrent recovery attempts
if (recoveryInProgressRef.current) return;
// Check cooldown
const now = Date.now();
if (
lastRecoveryAttemptRef.current &&
now - lastRecoveryAttemptRef.current < RECOVERY_COOLDOWN_MS
) {
return;
}
// Check max attempts - notify user when recovery is exhausted
if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
setErrorMessage(I18nKey.STATUS$CONNECTION_LOST);
return;
}
// Start silent recovery
recoveryInProgressRef.current = true;
lastRecoveryAttemptRef.current = now;
recoveryAttemptsRef.current += 1;
resumeConversation(
{ conversationId, providers },
{
onSuccess: async () => {
// Invalidate and wait for refetch to complete before remounting
// This ensures the provider remounts with fresh data (url: null during startup)
await queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
// Force remount to reset connection state to "CONNECTING"
setReconnectKey((k) => k + 1);
// Reset recovery state on success
recoveryAttemptsRef.current = 0;
recoveryInProgressRef.current = false;
lastRecoveryAttemptRef.current = null;
},
onError: () => {
// If this was the last attempt, show error to user
if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
setErrorMessage(I18nKey.STATUS$CONNECTION_LOST);
}
// recoveryInProgressRef will be reset by onSettled
},
onSettled: () => {
// Allow next attempt after a delay (covers both success and error)
setTimeout(() => {
recoveryInProgressRef.current = false;
}, RECOVERY_SETTLED_DELAY_MS);
},
},
);
}, [
conversationId,
providers,
resumeConversation,
queryClient,
setErrorMessage,
]);
return { reconnectKey, handleDisconnect };
}