From 4020448d64ca1dfe29a677bd7a98a46c2f2cd2b1 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:52:31 +0400 Subject: [PATCH] chore(frontend): Add unified hooks for V1 sandbox URLs (VSCode and served hosts) (#11511) Co-authored-by: openhands --- .../v1-conversation-service.api.ts | 27 ++++ .../v1-conversation-service.types.ts | 15 +++ .../query/use-batch-app-conversations.ts | 11 ++ .../src/hooks/query/use-batch-sandboxes.ts | 11 ++ .../hooks/query/use-unified-active-host.ts | 99 ++++++++++++++ .../src/hooks/query/use-unified-vscode-url.ts | 122 ++++++++++++++++++ frontend/src/routes/served-tab.tsx | 4 +- frontend/src/routes/vscode-tab.tsx | 14 +- 8 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/query/use-batch-app-conversations.ts create mode 100644 frontend/src/hooks/query/use-batch-sandboxes.ts create mode 100644 frontend/src/hooks/query/use-unified-active-host.ts create mode 100644 frontend/src/hooks/query/use-unified-vscode-url.ts diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 4ec039ee2d..59bf44b1d4 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -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} diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index 9ff3499652..f1206fc382 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -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; +} diff --git a/frontend/src/hooks/query/use-batch-app-conversations.ts b/frontend/src/hooks/query/use-batch-app-conversations.ts new file mode 100644 index 0000000000..0218359450 --- /dev/null +++ b/frontend/src/hooks/query/use-batch-app-conversations.ts @@ -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 + }); diff --git a/frontend/src/hooks/query/use-batch-sandboxes.ts b/frontend/src/hooks/query/use-batch-sandboxes.ts new file mode 100644 index 0000000000..bf4f456114 --- /dev/null +++ b/frontend/src/hooks/query/use-batch-sandboxes.ts @@ -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 + }); diff --git a/frontend/src/hooks/query/use-unified-active-host.ts b/frontend/src/hooks/query/use-unified-active-host.ts new file mode 100644 index 0000000000..cc9b8a1a3d --- /dev/null +++ b/frontend/src/hooks/query/use-unified-active-host.ts @@ -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(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 }; +}; diff --git a/frontend/src/hooks/query/use-unified-vscode-url.ts b/frontend/src/hooks/query/use-unified-vscode-url.ts new file mode 100644 index 0000000000..3355cf5cd9 --- /dev/null +++ b/frontend/src/hooks/query/use-unified-vscode-url.ts @@ -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({ + 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, + }; +}; diff --git a/frontend/src/routes/served-tab.tsx b/frontend/src/routes/served-tab.tsx index 74a5f7c2c1..f2f6b26883 100644 --- a/frontend/src/routes/served-tab.tsx +++ b/frontend/src/routes/served-tab.tsx @@ -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 diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx index fe72079e6f..0d64180c1d 100644 --- a/frontend/src/routes/vscode-tab.tsx +++ b/frontend/src/routes/vscode-tab.tsx @@ -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(null); @@ -39,10 +39,18 @@ function VSCodeTab() { } }; - if (isRuntimeInactive || isLoading) { + if (isRuntimeInactive) { return ; } + if (isLoading) { + return ( +
+ {t(I18nKey.VSCODE$LOADING)} +
+ ); + } + if (error || (data && data.error) || !data?.url || iframeError) { return (