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

View File

@@ -23,7 +23,7 @@ export const useUnifiedVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true });
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 {
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
*
* @returns boolean indicating if the runtime is ready
*/
export const useRuntimeIsReady = (): boolean => {
export const useRuntimeIsReady = ({
allowAgentError = false,
}: UseRuntimeIsReadyOptions = {}): boolean => {
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
const inactiveStates = allowAgentError
? RUNTIME_STARTING_STATES
: RUNTIME_INACTIVE_STATES;
return (
conversation?.status === "RUNNING" &&
!RUNTIME_INACTIVE_STATES.includes(curAgentState)
!inactiveStates.includes(curAgentState)
);
};

View File

@@ -1,17 +1,17 @@
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 { 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 { 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 } = useUnifiedVSCodeUrl();
const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
@@ -39,7 +39,7 @@ function VSCodeTab() {
}
};
if (isRuntimeInactive) {
if (isRuntimeStarting) {
return <WaitingForRuntimeMessage />;
}

View File

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