Fix VS Code iframe reload issue (#8243)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2025-05-06 00:07:20 +08:00
committed by GitHub
parent 27878a2200
commit d9c10b0164
4 changed files with 123 additions and 45 deletions

View File

@@ -0,0 +1,72 @@
import React, { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { LoadingSpinner } from "../shared/loading-spinner";
// Lazy load all tab components
const EditorTab = lazy(() => import("#/routes/changes-tab"));
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const TerminalTab = lazy(() => import("#/routes/terminal-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
interface TabContentProps {
conversationPath: string;
}
export function TabContent({ conversationPath }: TabContentProps) {
const location = useLocation();
const currentPath = location.pathname;
// Determine which tab is active based on the current path
const isEditorActive = currentPath === conversationPath;
const isBrowserActive = currentPath === `${conversationPath}/browser`;
const isJupyterActive = currentPath === `${conversationPath}/jupyter`;
const isServedActive = currentPath === `${conversationPath}/served`;
const isTerminalActive = currentPath === `${conversationPath}/terminal`;
const isVSCodeActive = currentPath === `${conversationPath}/vscode`;
return (
<div className="h-full w-full relative">
{/* Each tab content is always loaded but only visible when active */}
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<LoadingSpinner size="large" />
</div>
}
>
<div
className={`absolute inset-0 ${isEditorActive ? "block" : "hidden"}`}
>
<EditorTab />
</div>
<div
className={`absolute inset-0 ${isBrowserActive ? "block" : "hidden"}`}
>
<BrowserTab />
</div>
<div
className={`absolute inset-0 ${isJupyterActive ? "block" : "hidden"}`}
>
<JupyterTab />
</div>
<div
className={`absolute inset-0 ${isServedActive ? "block" : "hidden"}`}
>
<ServedTab />
</div>
<div
className={`absolute inset-0 ${isTerminalActive ? "block" : "hidden"}`}
>
<TerminalTab />
</div>
<div
className={`absolute inset-0 ${isVSCodeActive ? "block" : "hidden"}`}
>
<VSCodeTab />
</div>
</Suspense>
</div>
);
}

View File

@@ -1,19 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
export const useVSCodeUrl = (config: { enabled: boolean }) => {
// Define the return type for the VS Code URL query
interface VSCodeUrlResult {
url: string | null;
error: string | null;
}
export const useVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const data = useQuery({
return useQuery<VSCodeUrlResult>({
queryKey: ["vscode_url", conversationId],
queryFn: () => {
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
return OpenHands.getVSCodeUrl(conversationId);
const data = await OpenHands.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: !!conversationId && config.enabled,
enabled: !!conversationId && !isRuntimeInactive,
refetchOnMount: true,
retry: 3,
});
return data;
};

View File

@@ -41,6 +41,7 @@ import { RootState } from "#/store";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { TabContent } from "#/components/layout/tab-content";
function AppContent() {
useConversationConfig();
@@ -113,6 +114,8 @@ function AppContent() {
} = useDisclosure();
function renderMain() {
const basePath = `/conversations/${conversationId}`;
if (width <= 640) {
return (
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full bg-base-secondary">
@@ -197,7 +200,15 @@ function AppContent() {
},
]}
>
<Outlet />
{/* Use both Outlet and TabContent */}
<div className="h-full w-full">
{/* Keep the Outlet for React Router to work properly */}
<div className="hidden">
<Outlet />
</div>
{/* Use TabContent to keep all tabs loaded but only show the active one */}
<TabContent conversationPath={basePath} />
</div>
</Container>
}
/>

View File

@@ -1,48 +1,17 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { useConversation } from "#/context/conversation-context";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
function VSCodeTab() {
const { t } = useTranslation();
const { conversationId } = useConversation();
const [vsCodeUrl, setVsCodeUrl] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { data, isLoading, error } = useVSCodeUrl();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
React.useEffect(() => {
async function fetchVSCodeUrl() {
if (!conversationId || isRuntimeInactive) return;
try {
setIsLoading(true);
const response = await fetch(
`/api/conversations/${conversationId}/vscode-url`,
);
const data = await response.json();
if (data.vscode_url) {
const transformedUrl = transformVSCodeUrl(data.vscode_url);
setVsCodeUrl(transformedUrl);
} else {
setError(t(I18nKey.VSCODE$URL_NOT_AVAILABLE));
}
} catch (err) {
setError(t(I18nKey.VSCODE$FETCH_ERROR));
// Error is handled by setting the error state
} finally {
setIsLoading(false);
}
}
fetchVSCodeUrl();
}, [conversationId, isRuntimeInactive, t]);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
if (isRuntimeInactive) {
return (
@@ -60,10 +29,10 @@ function VSCodeTab() {
);
}
if (error || !vsCodeUrl) {
if (error || (data && data.error) || !data?.url) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{error || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
{data?.error || String(error) || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
</div>
);
}
@@ -71,8 +40,9 @@ function VSCodeTab() {
return (
<div className="h-full w-full">
<iframe
ref={iframeRef}
title={t(I18nKey.VSCODE$TITLE)}
src={vsCodeUrl}
src={data.url}
className="w-full h-full border-0"
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
/>
@@ -80,4 +50,5 @@ function VSCodeTab() {
);
}
// Export the VSCodeTab directly since we're using the provider at a higher level
export default VSCodeTab;