diff --git a/__tests__/components/features/conversation/conversation-tabs.test.tsx b/__tests__/components/features/conversation/conversation-tabs.test.tsx index fd09411bf..15d2ef8d1 100644 --- a/__tests__/components/features/conversation/conversation-tabs.test.tsx +++ b/__tests__/components/features/conversation/conversation-tabs.test.tsx @@ -510,7 +510,7 @@ describe("ConversationTabs localStorage behavior", () => { mockConversationId = REAL_CONVERSATION_ID; }); - it("should hide the vscode link when the active backend is local", () => { + it("should show the vscode link when the active backend is local", () => { // Arrange seedActiveBackend({ id: "local-test", @@ -525,10 +525,10 @@ describe("ConversationTabs localStorage behavior", () => { wrapper: createWrapper(REAL_CONVERSATION_ID), }); - // Assert - expect( - screen.queryByTestId("drawer-vscode-link"), - ).not.toBeInTheDocument(); + // Assert — self-hosted backends serve VSCode too; the URL comes from + // the agent server's /api/vscode/url via useUnifiedVSCodeUrl's local + // branch, rather than from cloud `exposed_urls`. + expect(screen.getByTestId("drawer-vscode-link")).toBeInTheDocument(); }); it("should show the vscode link when the active backend is cloud", () => { diff --git a/__tests__/hooks/use-unified-vscode-url.test.tsx b/__tests__/hooks/use-unified-vscode-url.test.tsx index 888d2456b..13d52c048 100644 --- a/__tests__/hooks/use-unified-vscode-url.test.tsx +++ b/__tests__/hooks/use-unified-vscode-url.test.tsx @@ -108,7 +108,10 @@ function makeSandbox( function createWrapper() { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + // `retry` is overridden per-query by the hook's own `retry: 3`, so error + // paths do retry here; `retryDelay: 0` keeps them from spending the + // default exponential backoff before the query settles. + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, }); return ({ children }: { children: React.ReactNode }) => ( @@ -172,6 +175,10 @@ describe("useUnifiedVSCodeUrl", () => { // Assert await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.url).toBeNull(); + // Cloud is deliberately excluded from `isUnavailable`: a sandbox that is + // still STARTING will populate exposed_urls shortly, so the control stays + // visible. Only self-hosted backends can report a final "no editor". + expect(result.current.isUnavailable).toBe(false); }); it("falls through to AgentServerConversationService.getVSCodeUrl in local mode", async () => { @@ -199,5 +206,54 @@ describe("useUnifiedVSCodeUrl", () => { ); expect(batchGetCloudSandboxes).not.toHaveBeenCalled(); expect(ConversationService.getVSCodeUrl).not.toHaveBeenCalled(); + // A backend that hands back a usable URL is available, so consumers + // render the control. + expect(result.current.isUnavailable).toBe(false); + }); + + it("reports isUnavailable in local mode when the backend has VSCode disabled", async () => { + // Arrange — `enable_vscode: false` makes agent-server answer + // `GET /vscode/url` with 503, so both resolvers reject and the query + // settles in `error` with no data. This is a permanent property of the + // deployment, not a transient failure, so consumers must be able to + // drop the control rather than offer a click that cannot do anything. + vi.mocked(useActiveBackend).mockReturnValue(localBackend); + vi.mocked(AgentServerConversationService.getVSCodeUrl).mockRejectedValue( + new Error("Request failed with status code 503"), + ); + vi.mocked(ConversationService.getVSCodeUrl).mockRejectedValue( + new Error("Request failed with status code 503"), + ); + + // Act + const { result } = renderHook(() => useUnifiedVSCodeUrl(), { + wrapper: createWrapper(), + }); + + // Assert + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.isUnavailable).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + it("reports isUnavailable in local mode when the backend reports no URL", async () => { + // Arrange — the request succeeds but carries no URL (VSCode enabled and + // yet nothing to point at, e.g. no connection token). Distinct code path + // from the 503 above: this settles in `success`, so `isError` alone + // would miss it. + vi.mocked(useActiveBackend).mockReturnValue(localBackend); + vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({ + vscode_url: null, + }); + + // Act + const { result } = renderHook(() => useUnifiedVSCodeUrl(), { + wrapper: createWrapper(), + }); + + // Assert + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.url).toBeNull(); + expect(result.current.isUnavailable).toBe(true); }); }); diff --git a/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx b/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx index 2b030251b..4d87cc224 100644 --- a/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx +++ b/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx @@ -336,16 +336,10 @@ export function ConversationTabs({ - {/* Keep the ref'd wrapper mounted on local backends too — the - overflow measurement effect above bails if it's missing. */} -
- {backend.kind === "cloud" && } + {/* The ref'd wrapper must stay mounted — the overflow measurement + effect above bails if it's missing. */} +
+
diff --git a/src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx b/src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx index fb2b61e16..f1bc12fda 100644 --- a/src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx +++ b/src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx @@ -10,7 +10,7 @@ import { cn } from "#/utils/utils"; export function DrawerVSCodeLink() { const { t } = useTranslation("openhands"); const { curAgentState } = useAgentState(); - const { data, refetch, isLoading } = useUnifiedVSCodeUrl(); + const { data, refetch, isLoading, isUnavailable } = useUnifiedVSCodeUrl(); const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState); const handleClick = async () => { @@ -26,6 +26,12 @@ export function DrawerVSCodeLink() { } }; + // Backends that have no editor to open (`enable_vscode: false`, or no URL + // reported) get no button rather than one that does nothing when clicked. + if (isUnavailable) { + return null; + } + return (