diff --git a/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx b/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx new file mode 100644 index 0000000000..86bc8d82f7 --- /dev/null +++ b/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx @@ -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(value: Partial): 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>({ + 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); + }); +}); diff --git a/frontend/__tests__/routes/vscode-tab.test.tsx b/frontend/__tests__/routes/vscode-tab.test.tsx new file mode 100644 index 0000000000..8c84678603 --- /dev/null +++ b/frontend/__tests__/routes/vscode-tab.test.tsx @@ -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>, +) { + 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); +} + +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(); + + 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(); + + expect( + screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"), + ).toBeInTheDocument(); + expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx index 07509ab19d..08e7879fad 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx @@ -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 (
{t(I18nKey.COMMON$CODE)} - {!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? ( + {!isRuntimeStarting ? ( { const { t } = useTranslation(); const { conversationId } = useConversationId(); const { data: conversation } = useActiveConversation(); - const runtimeIsReady = useRuntimeIsReady(); + const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true }); const isV1Conversation = conversation?.conversation_version === "V1"; diff --git a/frontend/src/hooks/use-runtime-is-ready.ts b/frontend/src/hooks/use-runtime-is-ready.ts index 914b3624c4..e09af98872 100644 --- a/frontend/src/hooks/use-runtime-is-ready.ts +++ b/frontend/src/hooks/use-runtime-is-ready.ts @@ -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) ); }; diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx index e1bb2e8fe4..fe60a52dac 100644 --- a/frontend/src/routes/vscode-tab.tsx +++ b/frontend/src/routes/vscode-tab.tsx @@ -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(null); const [isCrossProtocol, setIsCrossProtocol] = useState(false); const [iframeError, setIframeError] = useState(null); @@ -39,7 +39,7 @@ function VSCodeTab() { } }; - if (isRuntimeInactive) { + if (isRuntimeStarting) { return ; } diff --git a/frontend/src/types/agent-state.tsx b/frontend/src/types/agent-state.tsx index 9309ef5e41..ab05ea89df 100644 --- a/frontend/src/types/agent-state.tsx +++ b/frontend/src/types/agent-state.tsx @@ -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, ];