Keep VSCode accessible when agent errors (#13492)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Engel Nyst
2026-03-19 14:46:56 +01:00
committed by GitHub
parent e02dbb8974
commit 3a9f00aa37
7 changed files with 156 additions and 13 deletions

View File

@@ -0,0 +1,64 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Conversation } from "#/api/open-hands.types";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useAgentState } from "#/hooks/use-agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
vi.mock("#/hooks/use-agent-state");
vi.mock("#/hooks/query/use-active-conversation");
function asMockReturnValue<T>(value: Partial<T>): T {
return value as T;
}
function makeConversation(): Conversation {
return {
conversation_id: "conv-123",
title: "Test Conversation",
selected_repository: null,
selected_branch: null,
git_provider: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: null,
url: null,
session_api_key: null,
};
}
describe("useRuntimeIsReady", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useActiveConversation).mockReturnValue(
asMockReturnValue<ReturnType<typeof useActiveConversation>>({
data: makeConversation(),
}),
);
});
it("treats agent errors as not ready by default", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() => useRuntimeIsReady());
expect(result.current).toBe(false);
});
it("allows runtime-backed tabs to stay ready when the agent errors", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() =>
useRuntimeIsReady({ allowAgentError: true }),
);
expect(result.current).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import VSCodeTab from "#/routes/vscode-tab";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
vi.mock("#/hooks/query/use-unified-vscode-url");
vi.mock("#/hooks/use-agent-state");
vi.mock("#/utils/feature-flags", () => ({
VSCODE_IN_NEW_TAB: () => false,
}));
function mockVSCodeUrlHook(
value: Partial<ReturnType<typeof useUnifiedVSCodeUrl>>,
) {
vi.mocked(useUnifiedVSCodeUrl).mockReturnValue({
data: { url: "http://localhost:3000/vscode", error: null },
error: null,
isLoading: false,
isError: false,
isSuccess: true,
status: "success",
refetch: vi.fn(),
...value,
} as ReturnType<typeof useUnifiedVSCodeUrl>);
}
describe("VSCodeTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps VSCode accessible when the agent is in an error state", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
mockVSCodeUrlHook({});
renderWithProviders(<VSCodeTab />);
expect(
screen.queryByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
).not.toBeInTheDocument();
expect(screen.getByTitle("VSCODE$TITLE")).toHaveAttribute(
"src",
"http://localhost:3000/vscode",
);
});
it("still waits while the runtime is starting", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.LOADING,
});
mockVSCodeUrlHook({});
renderWithProviders(<VSCodeTab />);
expect(
screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
).toBeInTheDocument();
expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument();
});
});

View File

@@ -1,14 +1,15 @@
import { FaExternalLinkAlt } from "react-icons/fa"; import { FaExternalLinkAlt } from "react-icons/fa";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state"; import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
export function VSCodeTooltipContent() { export function VSCodeTooltipContent() {
const { curAgentState } = useAgentState(); const { curAgentState } = useAgentState();
const { t } = useTranslation(); const { t } = useTranslation();
const { data, refetch } = useUnifiedVSCodeUrl(); const { data, refetch } = useUnifiedVSCodeUrl();
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
const handleVSCodeClick = async (e: React.MouseEvent) => { const handleVSCodeClick = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -29,7 +30,7 @@ export function VSCodeTooltipContent() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{t(I18nKey.COMMON$CODE)}</span> <span>{t(I18nKey.COMMON$CODE)}</span>
{!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? ( {!isRuntimeStarting ? (
<FaExternalLinkAlt <FaExternalLinkAlt
className="w-3 h-3 text-inherit cursor-pointer" className="w-3 h-3 text-inherit cursor-pointer"
onClick={handleVSCodeClick} onClick={handleVSCodeClick}

View File

@@ -23,7 +23,7 @@ export const useUnifiedVSCodeUrl = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { conversationId } = useConversationId(); const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation(); const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady(); const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true });
const isV1Conversation = conversation?.conversation_version === "V1"; const isV1Conversation = conversation?.conversation_version === "V1";

View File

@@ -1,18 +1,30 @@
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useActiveConversation } from "./query/use-active-conversation";
import { useAgentState } from "#/hooks/use-agent-state"; import { useAgentState } from "#/hooks/use-agent-state";
import {
RUNTIME_INACTIVE_STATES,
RUNTIME_STARTING_STATES,
} from "#/types/agent-state";
import { useActiveConversation } from "./query/use-active-conversation";
interface UseRuntimeIsReadyOptions {
allowAgentError?: boolean;
}
/** /**
* Hook to determine if the runtime is ready for operations * Hook to determine if the runtime is ready for operations
* *
* @returns boolean indicating if the runtime is ready * @returns boolean indicating if the runtime is ready
*/ */
export const useRuntimeIsReady = (): boolean => { export const useRuntimeIsReady = ({
allowAgentError = false,
}: UseRuntimeIsReadyOptions = {}): boolean => {
const { data: conversation } = useActiveConversation(); const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState(); const { curAgentState } = useAgentState();
const inactiveStates = allowAgentError
? RUNTIME_STARTING_STATES
: RUNTIME_INACTIVE_STATES;
return ( return (
conversation?.status === "RUNNING" && conversation?.status === "RUNNING" &&
!RUNTIME_INACTIVE_STATES.includes(curAgentState) !inactiveStates.includes(curAgentState)
); );
}; };

View File

@@ -1,17 +1,17 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { useAgentState } from "#/hooks/use-agent-state";
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags"; import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message"; import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
import { useAgentState } from "#/hooks/use-agent-state";
function VSCodeTab() { function VSCodeTab() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, error } = useUnifiedVSCodeUrl(); const { data, isLoading, error } = useUnifiedVSCodeUrl();
const { curAgentState } = useAgentState(); const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState); const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null); const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false); const [isCrossProtocol, setIsCrossProtocol] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null); const [iframeError, setIframeError] = useState<string | null>(null);
@@ -39,7 +39,7 @@ function VSCodeTab() {
} }
}; };
if (isRuntimeInactive) { if (isRuntimeStarting) {
return <WaitingForRuntimeMessage />; return <WaitingForRuntimeMessage />;
} }

View File

@@ -14,9 +14,10 @@ export enum AgentState {
USER_REJECTED = "user_rejected", USER_REJECTED = "user_rejected",
} }
export const RUNTIME_STARTING_STATES = [AgentState.INIT, AgentState.LOADING];
export const RUNTIME_INACTIVE_STATES = [ export const RUNTIME_INACTIVE_STATES = [
AgentState.INIT, ...RUNTIME_STARTING_STATES,
AgentState.LOADING,
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped // Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
AgentState.ERROR, AgentState.ERROR,
]; ];