Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down
58 changes: 57 additions & 1 deletion __tests__/hooks/use-unified-vscode-url.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<QueryClientProvider client={queryClient}>
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -336,16 +336,10 @@ export function ConversationTabs({
</div>
</div>
</div>
{/* Keep the ref'd wrapper mounted on local backends too — the
overflow measurement effect above bails if it's missing. */}
<div
ref={vscodeButtonRef}
className={cn(
"ml-auto shrink-0",
backend.kind === "cloud" && "pr-1",
)}
>
{backend.kind === "cloud" && <DrawerVSCodeLink />}
{/* The ref'd wrapper must stay mounted — the overflow measurement
effect above bails if it's missing. */}
<div ref={vscodeButtonRef} className="ml-auto shrink-0 pr-1">
<DrawerVSCodeLink />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 (
<button
type="button"
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/query/use-unified-vscode-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export const useUnifiedVSCodeUrl = () => {
enabled: !isCloud && runtimeIsReady && !!conversationId,
refetchOnMount: true,
retry: 3,
// `enable_vscode: false` answers 503, which is a deployment setting rather
// than a failure the user should see a toast about. The correct response is
// to offer no button (see `isUnavailable`), so suppress the global handler.
meta: { disableToast: true },
});

let data: VSCodeUrlResult | undefined;
Expand All @@ -68,6 +72,9 @@ export const useUnifiedVSCodeUrl = () => {
let status: typeof localQuery.status;
let error: unknown;
let refetch: () => Promise<{ data: VSCodeUrlResult | undefined }>;
// True once we know there is nothing to open, so callers can render nothing
// instead of a control whose activation is a no-op.
let isUnavailable: boolean;

if (isCloud) {
const sandbox = cloudSandboxQuery.data;
Expand All @@ -94,6 +101,10 @@ export const useUnifiedVSCodeUrl = () => {
: undefined,
};
};
// Cloud behavior is deliberately unchanged: a sandbox with no VSCODE
// entry in `exposed_urls` still surfaces the control. Narrowing this
// change to self-hosted keeps its blast radius off the cloud path.
isUnavailable = false;
} else {
data = localQuery.data;
isLoading = localQuery.isLoading;
Expand All @@ -105,6 +116,12 @@ export const useUnifiedVSCodeUrl = () => {
const result = await localQuery.refetch();
return { data: result.data };
};
// Two ways a self-hosted backend has no editor to offer, both final
// rather than transient (the query already retries three times):
// - `enable_vscode: false` — `GET /vscode/url` answers 503, so the
// query settles in `error` with no data at all.
// - the server reports no URL — settles successfully with `url: null`.
isUnavailable = isError || (isSuccess && !localQuery.data?.url);
}

// Derive the i18n'd "URL unavailable" message outside `queryFn` so the
Expand All @@ -118,6 +135,7 @@ export const useUnifiedVSCodeUrl = () => {
isLoading,
isError,
isSuccess,
isUnavailable,
status,
refetch,
};
Expand Down
Loading