mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Keep VSCode accessible when agent errors (#13492)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal file
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal file
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user