chore(frontend): Add unified hooks for V1 sandbox URLs (VSCode and served hosts) (#11511)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
sp.wack 2025-10-29 18:52:31 +04:00 committed by GitHub
parent 2fdd4d084a
commit 4020448d64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 298 additions and 5 deletions

View File

@ -11,6 +11,7 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
V1SandboxInfo,
} from "./v1-conversation-service.types";
class V1ConversationService {
@ -268,6 +269,32 @@ class V1ConversationService {
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*
* @param ids Array of sandbox IDs (max 100)
* @returns Array of sandboxes or null for missing ones
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}

View File

@ -98,3 +98,18 @@ export interface V1AppConversation {
conversation_url: string | null;
session_api_key: string | null;
}
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}

View File

@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchAppConversations = (ids: string[]) =>
useQuery({
queryKey: ["v1-batch-get-app-conversations", ids],
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchSandboxes = (ids: string[]) =>
useQuery({
queryKey: ["sandboxes", "batch", ids],
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@ -0,0 +1,99 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useBatchSandboxes } from "./use-batch-sandboxes";
import { useConversationConfig } from "./use-conversation-config";
/**
* Unified hook to get active web host for both legacy (V0) and V1 conversations
* - V0: Uses the legacy getWebHosts API endpoint and polls them
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
*/
export const useUnifiedActiveHost = () => {
const [activeHost, setActiveHost] = React.useState<string | null>(null);
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
const { data: conversation } = useActiveConversation();
const { data: conversationConfig, isLoading: isLoadingConfig } =
useConversationConfig();
const isV1Conversation = conversation?.conversation_version === "V1";
const sandboxId = conversationConfig?.runtime_id;
// Fetch sandbox data for V1 conversations
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
// Get worker URLs from V1 sandbox or legacy web hosts from V0
const { data, isLoading: hostsQueryLoading } = useQuery({
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
queryFn: async () => {
// V1: Get worker URLs from sandbox exposed_urls
if (isV1Conversation) {
if (
!sandboxesQuery.data ||
sandboxesQuery.data.length === 0 ||
!sandboxesQuery.data[0]
) {
return { hosts: [] };
}
const sandbox = sandboxesQuery.data[0];
const workerUrls =
sandbox.exposed_urls
?.filter((url) => url.name.startsWith("WORKER_"))
.map((url) => url.url) || [];
return { hosts: workerUrls };
}
// V0 (Legacy): Use the legacy API endpoint
const hosts = await ConversationService.getWebHosts(conversationId);
return { hosts };
},
enabled:
runtimeIsReady &&
!!conversationId &&
(!isV1Conversation || !!sandboxesQuery.data),
initialData: { hosts: [] },
meta: {
disableToast: true,
},
});
// Poll all hosts to find which one is active
const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "unified", "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
meta: {
disableToast: true,
},
})),
});
const appsData = apps.map((app) => app.data);
React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);
// Calculate overall loading state including dependent queries for V1
const isLoading = isV1Conversation
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
: hostsQueryLoading;
return { activeHost, isLoading };
};

View File

@ -0,0 +1,122 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { I18nKey } from "#/i18n/declaration";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useBatchAppConversations } from "./use-batch-app-conversations";
import { useBatchSandboxes } from "./use-batch-sandboxes";
interface VSCodeUrlResult {
url: string | null;
error: string | null;
}
/**
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
* - V0: Uses the legacy getVSCodeUrl API endpoint
* - V1: Gets the VSCode URL from sandbox exposed_urls
*/
export const useUnifiedVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
// Fetch V1 app conversation to get sandbox_id
const appConversationsQuery = useBatchAppConversations(
isV1Conversation && conversationId ? [conversationId] : [],
);
const appConversation = appConversationsQuery.data?.[0];
const sandboxId = appConversation?.sandbox_id;
// Fetch sandbox data for V1 conversations
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
const mainQuery = useQuery<VSCodeUrlResult>({
queryKey: [
"unified",
"vscode_url",
conversationId,
isV1Conversation,
sandboxId,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Get VSCode URL from sandbox exposed_urls
if (isV1Conversation) {
if (
!sandboxesQuery.data ||
sandboxesQuery.data.length === 0 ||
!sandboxesQuery.data[0]
) {
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
}
const sandbox = sandboxesQuery.data[0];
const vscodeUrl = sandbox.exposed_urls?.find(
(url) => url.name === "VSCODE",
);
if (!vscodeUrl) {
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
}
return {
url: transformVSCodeUrl(vscodeUrl.url),
error: null,
};
}
// V0 (Legacy): Use the legacy API endpoint
const data = await ConversationService.getVSCodeUrl(conversationId);
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
error: null,
};
}
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
},
enabled:
runtimeIsReady &&
!!conversationId &&
(!isV1Conversation || !!sandboxesQuery.data),
refetchOnMount: true,
retry: 3,
});
// Calculate overall loading state including dependent queries for V1
const isLoading = isV1Conversation
? appConversationsQuery.isLoading ||
sandboxesQuery.isLoading ||
mainQuery.isLoading
: mainQuery.isLoading;
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
return {
data: mainQuery.data,
error: mainQuery.error,
isLoading,
isError: mainQuery.isError,
isSuccess: mainQuery.isSuccess,
status: mainQuery.status,
refetch: mainQuery.refetch,
};
};

View File

@ -2,14 +2,14 @@ import React from "react";
import { FaArrowRotateRight } from "react-icons/fa6";
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { useActiveHost } from "#/hooks/query/use-active-host";
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
import { PathForm } from "#/components/features/served-host/path-form";
import { I18nKey } from "#/i18n/declaration";
import ServerProcessIcon from "#/icons/server-process.svg?react";
function ServedApp() {
const { t } = useTranslation();
const { activeHost } = useActiveHost();
const { activeHost } = useUnifiedActiveHost();
const [refreshKey, setRefreshKey] = React.useState(0);
const [currentActiveHost, setCurrentActiveHost] = React.useState<
string | null

View File

@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
import { useAgentState } from "#/hooks/use-agent-state";
function VSCodeTab() {
const { t } = useTranslation();
const { data, isLoading, error } = useVSCodeUrl();
const { data, isLoading, error } = useUnifiedVSCodeUrl();
const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
@ -39,10 +39,18 @@ function VSCodeTab() {
}
};
if (isRuntimeInactive || isLoading) {
if (isRuntimeInactive) {
return <WaitingForRuntimeMessage />;
}
if (isLoading) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t(I18nKey.VSCODE$LOADING)}
</div>
);
}
if (error || (data && data.error) || !data?.url || iframeError) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">