fix(frontend): prevent auto sandbox resume behavior (#13133)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
HeyItsChloe
2026-03-16 09:22:23 -07:00
committed by GitHub
parent 238cab4d08
commit a0e777503e
8 changed files with 1091 additions and 204 deletions

View File

@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import * as customToastHandlers from "#/utils/custom-toast-handlers";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [{ provider: "github", token: "test-token" }],
}),
}));
vi.mock("#/utils/custom-toast-handlers");
vi.mock("#/hooks/mutation/use-unified-start-conversation");
describe("useSandboxRecovery", () => {
let mockMutate: ReturnType<typeof vi.fn>;
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
vi.clearAllMocks();
mockMutate = vi.fn();
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
isIdle: true,
data: undefined,
error: null,
reset: vi.fn(),
status: "idle",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
// Reset document.visibilityState
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial load recovery", () => {
it("should call resumeSandbox on initial load when conversation is STOPPED", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "conv-123",
providers: [{ provider: "github", token: "test-token" }],
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it("should NOT call resumeSandbox on initial load when conversation is RUNNING", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when conversationId is undefined", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: undefined,
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when conversationStatus is undefined", () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: undefined,
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should only call resumeSandbox once per conversation on initial load", () => {
const { rerender } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(mockMutate).toHaveBeenCalledTimes(1);
// Rerender with same props - should not trigger again
rerender();
expect(mockMutate).toHaveBeenCalledTimes(1);
});
it("should call resumeSandbox for a new conversation after navigating", async () => {
const { rerender } = renderHook(
({ conversationId }) =>
useSandboxRecovery({
conversationId,
conversationStatus: "STOPPED",
}),
{
wrapper: createWrapper(),
initialProps: { conversationId: "conv-123" },
},
);
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: "conv-123" }),
expect.any(Object),
);
// Navigate to a different conversation
rerender({ conversationId: "conv-456" });
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledTimes(2);
});
expect(mockMutate).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: "conv-456" }),
expect.any(Object),
);
});
});
describe("tab focus recovery", () => {
it("should call resumeSandbox when tab becomes visible and refetch returns STOPPED", async () => {
// Start with tab hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING", // Cached status is RUNNING
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// No initial recovery for RUNNING
expect(mockMutate).not.toHaveBeenCalled();
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch should be called to get fresh status
expect(mockRefetch).toHaveBeenCalledTimes(1);
// Recovery should trigger because fresh status is STOPPED
expect(mockMutate).toHaveBeenCalledTimes(1);
});
it("should NOT call resumeSandbox when tab becomes visible and refetch returns RUNNING", async () => {
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "RUNNING" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// No initial recovery for RUNNING
expect(mockMutate).not.toHaveBeenCalled();
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch was called but status is still RUNNING
expect(mockRefetch).toHaveBeenCalledTimes(1);
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when tab becomes visible but refetchConversation is not provided", async () => {
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
// No refetchConversation provided
}),
{ wrapper: createWrapper() },
);
// Initial load triggers recovery
expect(mockMutate).toHaveBeenCalledTimes(1);
mockMutate.mockClear();
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// No recovery on tab focus without refetchConversation
expect(mockMutate).not.toHaveBeenCalled();
});
it("should NOT call resumeSandbox when tab becomes hidden", async () => {
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Initial load triggers recovery
expect(mockMutate).toHaveBeenCalledTimes(1);
mockMutate.mockClear();
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch should NOT be called when tab is hidden
expect(mockRefetch).not.toHaveBeenCalled();
expect(mockMutate).not.toHaveBeenCalled();
});
it("should clean up visibility event listener on unmount", () => {
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(addEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("should NOT call resumeSandbox when tab becomes visible while isPending is true", async () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
const mockRefetch = vi.fn().mockResolvedValue({
data: { status: "STOPPED" },
});
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch will be called when isPending is true
expect(mockRefetch).toHaveBeenCalledTimes(1);
// resumeSandbox should NOT be called
expect(mockMutate).not.toHaveBeenCalled();
});
it("should handle refetch errors gracefully without crashing", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockRefetch = vi.fn().mockRejectedValue(new Error("Network error"));
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
refetchConversation: mockRefetch,
}),
{ wrapper: createWrapper() },
);
// Simulate tab becoming visible
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
// Refetch was called
expect(mockRefetch).toHaveBeenCalledTimes(1);
// Error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to refetch conversation on visibility change:",
expect.any(Error),
);
// No recovery attempt was made (due to error)
expect(mockMutate).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe("recovery callbacks", () => {
it("should return isResuming=false when no recovery is in progress", () => {
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
expect(result.current.isResuming).toBe(false);
});
it("should return isResuming=true when mutation is pending", () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
expect(result.current.isResuming).toBe(true);
});
it("should call onSuccess callback when recovery succeeds", () => {
const onSuccess = vi.fn();
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
onSuccess,
}),
{ wrapper: createWrapper() },
);
// Get the onSuccess callback passed to mutate
const mutateCall = mockMutate.mock.calls[0];
const options = mutateCall[1];
// Simulate successful mutation
act(() => {
options.onSuccess();
});
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("should call onError callback and display toast when recovery fails", () => {
const onError = vi.fn();
const testError = new Error("Resume failed");
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
onError,
}),
{ wrapper: createWrapper() },
);
// Get the onError callback passed to mutate
const mutateCall = mockMutate.mock.calls[0];
const options = mutateCall[1];
// Simulate failed mutation
act(() => {
options.onError(testError);
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(testError);
expect(vi.mocked(customToastHandlers.displayErrorToast)).toHaveBeenCalled();
});
it("should NOT call resumeSandbox when isPending is true", () => {
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
isIdle: false,
data: undefined,
error: null,
reset: vi.fn(),
status: "pending",
variables: undefined,
failureCount: 0,
failureReason: null,
submittedAt: 0,
context: undefined,
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "STOPPED",
}),
{ wrapper: createWrapper() },
);
// Should not call mutate because isPending is true
expect(mockMutate).not.toHaveBeenCalled();
});
});
describe("WebSocket disconnect (negative test)", () => {
it("should NOT have any mechanism to auto-resume on WebSocket disconnect", () => {
// This test documents the intended behavior: the hook does NOT
// listen for WebSocket disconnects. Recovery only happens on:
// 1. Initial page load (STOPPED status)
// 2. Tab focus (visibilitychange event)
//
// There is intentionally NO onDisconnect handler or WebSocket listener.
const { result } = renderHook(
() =>
useSandboxRecovery({
conversationId: "conv-123",
conversationStatus: "RUNNING",
}),
{ wrapper: createWrapper() },
);
// The hook should only expose isResuming - no disconnect-related functionality
expect(result.current).toEqual({
isResuming: expect.any(Boolean),
});
// No calls should have been made for RUNNING status
expect(mockMutate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,286 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useVisibilityChange } from "#/hooks/use-visibility-change";
describe("useVisibilityChange", () => {
beforeEach(() => {
// Reset document.visibilityState to visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("should return isVisible=true when document is visible", () => {
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(true);
});
it("should return isVisible=false when document is hidden", () => {
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(false);
});
});
describe("visibility change events", () => {
it("should update isVisible when visibility changes to hidden", () => {
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(true);
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.isVisible).toBe(false);
});
it("should update isVisible when visibility changes to visible", () => {
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
const { result } = renderHook(() => useVisibilityChange());
expect(result.current.isVisible).toBe(false);
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.isVisible).toBe(true);
});
});
describe("callbacks", () => {
it("should call onVisibilityChange with the new state", () => {
const onVisibilityChange = vi.fn();
renderHook(() => useVisibilityChange({ onVisibilityChange }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisibilityChange).toHaveBeenCalledWith("hidden");
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisibilityChange).toHaveBeenCalledWith("visible");
});
it("should call onVisible only when tab becomes visible", () => {
const onVisible = vi.fn();
const onHidden = vi.fn();
renderHook(() => useVisibilityChange({ onVisible, onHidden }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
expect(onHidden).toHaveBeenCalledTimes(1);
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).toHaveBeenCalledTimes(1);
expect(onHidden).toHaveBeenCalledTimes(1);
});
it("should call onHidden only when tab becomes hidden", () => {
const onHidden = vi.fn();
renderHook(() => useVisibilityChange({ onHidden }));
// Simulate tab becoming hidden
Object.defineProperty(document, "visibilityState", {
value: "hidden",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onHidden).toHaveBeenCalledTimes(1);
// Simulate tab becoming visible (should not call onHidden)
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onHidden).toHaveBeenCalledTimes(1);
});
});
describe("enabled option", () => {
it("should not listen for events when enabled=false", () => {
const onVisible = vi.fn();
renderHook(() => useVisibilityChange({ onVisible, enabled: false }));
// Simulate tab becoming visible
Object.defineProperty(document, "visibilityState", {
value: "visible",
writable: true,
});
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
});
it("should start listening when enabled changes from false to true", () => {
const onVisible = vi.fn();
const { rerender } = renderHook(
({ enabled }) => useVisibilityChange({ onVisible, enabled }),
{ initialProps: { enabled: false } },
);
// Simulate event while disabled
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).not.toHaveBeenCalled();
// Enable the hook
rerender({ enabled: true });
// Now events should be captured
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible).toHaveBeenCalledTimes(1);
});
});
describe("cleanup", () => {
it("should remove event listener on unmount", () => {
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useVisibilityChange());
expect(addEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("should remove event listener when enabled changes to false", () => {
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { rerender } = renderHook(
({ enabled }) => useVisibilityChange({ enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
});
describe("callback stability", () => {
it("should handle callback updates without missing events", () => {
const onVisible1 = vi.fn();
const onVisible2 = vi.fn();
const { rerender } = renderHook(
({ onVisible }) => useVisibilityChange({ onVisible }),
{ initialProps: { onVisible: onVisible1 } },
);
// Update callback
rerender({ onVisible: onVisible2 });
// Simulate visibility change
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(onVisible1).not.toHaveBeenCalled();
expect(onVisible2).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -78,7 +78,6 @@ export function ConversationWebSocketProvider({
sessionApiKey, sessionApiKey,
subConversations, subConversations,
subConversationIds, subConversationIds,
onDisconnect,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
conversationId?: string; conversationId?: string;
@@ -86,7 +85,6 @@ export function ConversationWebSocketProvider({
sessionApiKey?: string | null; sessionApiKey?: string | null;
subConversations?: V1AppConversation[]; subConversations?: V1AppConversation[];
subConversationIds?: string[]; subConversationIds?: string[];
onDisconnect?: () => void;
}) { }) {
// Separate connection state tracking for each WebSocket // Separate connection state tracking for each WebSocket
const [mainConnectionState, setMainConnectionState] = const [mainConnectionState, setMainConnectionState] =
@@ -714,13 +712,10 @@ export function ConversationWebSocketProvider({
} }
} }
}, },
onClose: (event: CloseEvent) => { onClose: () => {
setMainConnectionState("CLOSED"); setMainConnectionState("CLOSED");
// Trigger silent recovery on unexpected disconnect // Recovery is handled by useSandboxRecovery on tab focus/page refresh
// Do NOT show error message - recovery happens automatically // No error message needed - silent recovery provides better UX
if (event.code !== 1000 && hasConnectedRefMain.current) {
onDisconnect?.();
}
}, },
onError: () => { onError: () => {
setMainConnectionState("CLOSED"); setMainConnectionState("CLOSED");
@@ -738,7 +733,6 @@ export function ConversationWebSocketProvider({
sessionApiKey, sessionApiKey,
conversationId, conversationId,
conversationUrl, conversationUrl,
onDisconnect,
]); ]);
// Separate WebSocket options for planning agent connection // Separate WebSocket options for planning agent connection
@@ -785,13 +779,10 @@ export function ConversationWebSocketProvider({
} }
} }
}, },
onClose: (event: CloseEvent) => { onClose: () => {
setPlanningConnectionState("CLOSED"); setPlanningConnectionState("CLOSED");
// Trigger silent recovery on unexpected disconnect // Recovery is handled by useSandboxRecovery on tab focus/page refresh
// Do NOT show error message - recovery happens automatically // No error message needed - silent recovery provides better UX
if (event.code !== 1000 && hasConnectedRefPlanning.current) {
onDisconnect?.();
}
}, },
onError: () => { onError: () => {
setPlanningConnectionState("CLOSED"); setPlanningConnectionState("CLOSED");
@@ -808,7 +799,6 @@ export function ConversationWebSocketProvider({
removeErrorMessage, removeErrorMessage,
sessionApiKey, sessionApiKey,
subConversations, subConversations,
onDisconnect,
]); ]);
// Only attempt WebSocket connection when we have a valid URL // Only attempt WebSocket connection when we have a valid URL

View File

@@ -3,7 +3,8 @@ import { WsClientProvider } from "#/context/ws-client-provider";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSubConversations } from "#/hooks/query/use-sub-conversations"; import { useSubConversations } from "#/hooks/query/use-sub-conversations";
import { useWebSocketRecovery } from "#/hooks/use-websocket-recovery"; import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
import { isTaskConversationId } from "#/utils/conversation-local-storage";
interface WebSocketProviderWrapperProps { interface WebSocketProviderWrapperProps {
children: React.ReactNode; children: React.ReactNode;
@@ -18,18 +19,6 @@ interface WebSocketProviderWrapperProps {
* @param version - 0 for old WsClientProvider, 1 for new ConversationWebSocketProvider * @param version - 0 for old WsClientProvider, 1 for new ConversationWebSocketProvider
* @param conversationId - The conversation ID to pass to the provider * @param conversationId - The conversation ID to pass to the provider
* @param children - The child components to wrap * @param children - The child components to wrap
*
* @example
* // Use the old v0 provider
* <WebSocketProviderWrapper version={0} conversationId="conv-123">
* <ChatComponent />
* </WebSocketProviderWrapper>
*
* @example
* // Use the new v1 provider
* <WebSocketProviderWrapper version={1} conversationId="conv-123">
* <ChatComponent />
* </WebSocketProviderWrapper>
*/ */
export function WebSocketProviderWrapper({ export function WebSocketProviderWrapper({
children, children,
@@ -37,7 +26,11 @@ export function WebSocketProviderWrapper({
version, version,
}: WebSocketProviderWrapperProps) { }: WebSocketProviderWrapperProps) {
// Get conversation data for V1 provider // Get conversation data for V1 provider
const { data: conversation } = useActiveConversation(); const {
data: conversation,
refetch: refetchConversation,
isFetched,
} = useActiveConversation();
// Get sub-conversation data for V1 provider // Get sub-conversation data for V1 provider
const { data: subConversations } = useSubConversations( const { data: subConversations } = useSubConversations(
conversation?.sub_conversation_ids ?? [], conversation?.sub_conversation_ids ?? [],
@@ -48,9 +41,15 @@ export function WebSocketProviderWrapper({
(subConversation) => subConversation !== null, (subConversation) => subConversation !== null,
); );
// Silent recovery for V1 WebSocket disconnections const isConversationReady =
const { reconnectKey, handleDisconnect } = !isTaskConversationId(conversationId) && isFetched && !!conversation;
useWebSocketRecovery(conversationId); // Recovery for V1 conversations - handles page refresh and tab focus
// Does NOT resume on WebSocket disconnect (server pauses after 20 min inactivity)
useSandboxRecovery({
conversationId,
conversationStatus: conversation?.status,
refetchConversation: isConversationReady ? refetchConversation : undefined,
});
if (version === 0) { if (version === 0) {
return ( return (
@@ -63,13 +62,11 @@ export function WebSocketProviderWrapper({
if (version === 1) { if (version === 1) {
return ( return (
<ConversationWebSocketProvider <ConversationWebSocketProvider
key={reconnectKey}
conversationId={conversationId} conversationId={conversationId}
conversationUrl={conversation?.url} conversationUrl={conversation?.url}
sessionApiKey={conversation?.session_api_key} sessionApiKey={conversation?.session_api_key}
subConversationIds={conversation?.sub_conversation_ids} subConversationIds={conversation?.sub_conversation_ids}
subConversations={filteredSubConversations} subConversations={filteredSubConversations}
onDisconnect={handleDisconnect}
> >
{children} {children}
</ConversationWebSocketProvider> </ConversationWebSocketProvider>

View File

@@ -0,0 +1,138 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useUnifiedResumeConversationSandbox } from "./mutation/use-unified-start-conversation";
import { useUserProviders } from "./use-user-providers";
import { useVisibilityChange } from "./use-visibility-change";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import type { ConversationStatus } from "#/types/conversation-status";
import type { Conversation } from "#/api/open-hands.types";
interface UseSandboxRecoveryOptions {
conversationId: string | undefined;
conversationStatus: ConversationStatus | undefined;
/** Function to refetch the conversation data - used to get fresh status on tab focus */
refetchConversation?: () => Promise<{
data: Conversation | null | undefined;
}>;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
/**
* Hook that handles sandbox recovery based on user intent.
*
* Recovery triggers:
* - Page refresh: Resumes the sandbox on initial load if it was paused/stopped
* - Tab gains focus: Resumes the sandbox if it was paused/stopped
*
* What does NOT trigger recovery:
* - WebSocket disconnect: Does NOT automatically resume the sandbox
* (The server pauses sandboxes after 20 minutes of inactivity,
* and sandboxes should only be resumed when the user explicitly shows intent)
*
* @param options.conversationId - The conversation ID to recover
* @param options.conversationStatus - The current conversation status
* @param options.refetchConversation - Function to refetch conversation data on tab focus
* @param options.onSuccess - Callback when recovery succeeds
* @param options.onError - Callback when recovery fails
* @returns isResuming - Whether a recovery is in progress
*/
export function useSandboxRecovery({
conversationId,
conversationStatus,
refetchConversation,
onSuccess,
onError,
}: UseSandboxRecoveryOptions) {
const { t } = useTranslation();
const { providers } = useUserProviders();
const { mutate: resumeSandbox, isPending: isResuming } =
useUnifiedResumeConversationSandbox();
// Track which conversation ID we've already processed for initial load recovery
const processedConversationIdRef = React.useRef<string | null>(null);
const attemptRecovery = React.useCallback(
(statusOverride?: ConversationStatus) => {
const status = statusOverride ?? conversationStatus;
/**
* Only recover if sandbox is paused (status === STOPPED) and not already resuming
*
* Note: ConversationStatus uses different terminology than SandboxStatus:
* - SandboxStatus.PAUSED → ConversationStatus.STOPPED : the runtime is not running but may be restarted
* - SandboxStatus.MISSING → ConversationStatus.ARCHIVED : the runtime is not running and will not restart due to deleted files.
*/
if (!conversationId || status !== "STOPPED" || isResuming) {
return;
}
resumeSandbox(
{ conversationId, providers },
{
onSuccess: () => {
onSuccess?.();
},
onError: (error) => {
displayErrorToast(
t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
error: error.message,
}),
);
onError?.(error);
},
},
);
},
[
conversationId,
conversationStatus,
isResuming,
providers,
resumeSandbox,
onSuccess,
onError,
t,
],
);
// Handle page refresh (initial load) and conversation navigation
React.useEffect(() => {
if (!conversationId || !conversationStatus) return;
// Only attempt recovery once per conversation (handles both initial load and navigation)
if (processedConversationIdRef.current === conversationId) return;
processedConversationIdRef.current = conversationId;
if (conversationStatus === "STOPPED") {
attemptRecovery();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, conversationStatus]);
const handleVisible = React.useCallback(async () => {
// Skip if no conversation or refetch function
if (!conversationId || !refetchConversation) return;
try {
// Refetch to get fresh status - cached status may be stale if sandbox was paused while tab was inactive
const { data } = await refetchConversation();
attemptRecovery(data?.status);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
"Failed to refetch conversation on visibility change:",
error,
);
}
}, [conversationId, refetchConversation, isResuming, attemptRecovery]);
// Handle tab focus (visibility change) - refetch conversation status and resume if needed
useVisibilityChange({
enabled: !!conversationId,
onVisible: handleVisible,
});
return { isResuming };
}

View File

@@ -0,0 +1,64 @@
import React from "react";
type VisibilityState = "visible" | "hidden";
interface UseVisibilityChangeOptions {
/** Callback fired when visibility changes to the specified state */
onVisibilityChange?: (state: VisibilityState) => void;
/** Callback fired only when tab becomes visible */
onVisible?: () => void;
/** Callback fired only when tab becomes hidden */
onHidden?: () => void;
/** Whether to listen for visibility changes (default: true) */
enabled?: boolean;
}
/**
* Hook that listens for browser tab visibility changes.
*
* Useful for:
* - Resuming operations when user returns to the tab
* - Pausing expensive operations when tab is hidden
* - Tracking user engagement
*
* @param options.onVisibilityChange - Callback with the new visibility state
* @param options.onVisible - Callback fired only when tab becomes visible
* @param options.onHidden - Callback fired only when tab becomes hidden
* @param options.enabled - Whether to listen for changes (default: true)
* @returns isVisible - Current visibility state of the tab
*/
export function useVisibilityChange({
onVisibilityChange,
onVisible,
onHidden,
enabled = true,
}: UseVisibilityChangeOptions = {}) {
const [isVisible, setIsVisible] = React.useState(
() => document.visibilityState === "visible",
);
React.useEffect(() => {
if (!enabled) return undefined;
const handleVisibilityChange = () => {
const state = document.visibilityState as VisibilityState;
setIsVisible(state === "visible");
onVisibilityChange?.(state);
if (state === "visible") {
onVisible?.();
} else {
onHidden?.();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [enabled, onVisibilityChange, onVisible, onHidden]);
return { isVisible };
}

View File

@@ -1,110 +0,0 @@
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 };
}

View File

@@ -18,7 +18,6 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useIsAuthed } from "#/hooks/query/use-is-authed"; import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider"; import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { useUserProviders } from "#/hooks/use-user-providers";
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main"; import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status"; import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
@@ -26,7 +25,6 @@ import { ConversationNameWithStatus } from "#/components/features/conversation/c
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs"; import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper"; import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
import { useErrorMessageStore } from "#/stores/error-message-store"; import { useErrorMessageStore } from "#/stores/error-message-store";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import { useEventStore } from "#/stores/use-event-store"; import { useEventStore } from "#/stores/use-event-store";
@@ -39,11 +37,8 @@ function AppContent() {
// Handle both task IDs (task-{uuid}) and regular conversation IDs // Handle both task IDs (task-{uuid}) and regular conversation IDs
const { isTask, taskStatus, taskDetail } = useTaskPolling(); const { isTask, taskStatus, taskDetail } = useTaskPolling();
const { data: conversation, isFetched, refetch } = useActiveConversation(); const { data: conversation, isFetched } = useActiveConversation();
const { mutate: startConversation, isPending: isStarting } =
useUnifiedResumeConversationSandbox();
const { data: isAuthed } = useIsAuthed(); const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const { resetConversationState } = useConversationStore(); const { resetConversationState } = useConversationStore();
const navigate = useNavigate(); const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal); const clearTerminal = useCommandStore((state) => state.clearTerminal);
@@ -54,9 +49,6 @@ function AppContent() {
(state) => state.removeErrorMessage, (state) => state.removeErrorMessage,
); );
// Track which conversation ID we've auto-started to prevent auto-restart after manual stop
const processedConversationId = React.useRef<string | null>(null);
// Fetch batch feedback data when conversation is loaded // Fetch batch feedback data when conversation is loaded
useBatchFeedback(); useBatchFeedback();
@@ -67,12 +59,6 @@ function AppContent() {
setCurrentAgentState(AgentState.LOADING); setCurrentAgentState(AgentState.LOADING);
removeErrorMessage(); removeErrorMessage();
clearEvents(); clearEvents();
// Reset tracking ONLY if we're navigating to a DIFFERENT conversation
// Don't reset on StrictMode remounts (conversationId is the same)
if (processedConversationId.current !== conversationId) {
processedConversationId.current = null;
}
}, [ }, [
conversationId, conversationId,
clearTerminal, clearTerminal,
@@ -91,7 +77,8 @@ function AppContent() {
} }
}, [isTask, taskStatus, taskDetail, t]); }, [isTask, taskStatus, taskDetail, t]);
// 3. Auto-start Effect - handles conversation not found and auto-starting STOPPED conversations // 3. Handle conversation not found
// NOTE: Resuming STOPPED conversations is handled by useSandboxRecovery in WebSocketProviderWrapper
React.useEffect(() => { React.useEffect(() => {
// Wait for data to be fetched // Wait for data to be fetched
if (!isFetched || !isAuthed) return; if (!isFetched || !isAuthed) return;
@@ -100,50 +87,8 @@ function AppContent() {
if (!conversation) { if (!conversation) {
displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION)); displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));
navigate("/"); navigate("/");
return;
} }
}, [conversation, isFetched, isAuthed, navigate, t]);
const currentConversationId = conversation.conversation_id;
const currentStatus = conversation.status;
// Skip if we've already processed this conversation
if (processedConversationId.current === currentConversationId) {
return;
}
// Mark as processed immediately to prevent duplicate calls
processedConversationId.current = currentConversationId;
// Auto-start STOPPED conversations on initial load only
if (currentStatus === "STOPPED" && !isStarting) {
startConversation(
{ conversationId: currentConversationId, providers },
{
onError: (error) => {
displayErrorToast(
t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
error: error.message,
}),
);
refetch();
},
},
);
}
// NOTE: conversation?.status is intentionally NOT in dependencies
// We only want to run when conversation ID changes, not when status changes
// This prevents duplicate calls when stale cache data is replaced with fresh data
}, [
conversation?.conversation_id,
isFetched,
isAuthed,
isStarting,
providers,
startConversation,
navigate,
refetch,
t,
]);
const isV0Conversation = conversation?.conversation_version === "V0"; const isV0Conversation = conversation?.conversation_version === "V0";