mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Fix VS Code iframe reload issue (#8243)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
72
frontend/src/components/layout/tab-content.tsx
Normal file
72
frontend/src/components/layout/tab-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user