diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx index 523738305..fc9810b02 100644 --- a/__tests__/components/backends/add-backend-modal.test.tsx +++ b/__tests__/components/backends/add-backend-modal.test.tsx @@ -84,7 +84,7 @@ describe("AddBackendModal – two-column layout", () => { expect(submit).not.toBeDisabled(); }); - it("allows submitting a local backend with a blank API key", async () => { + it("allows submitting a remote agent server with a blank API key", async () => { const onClose = vi.fn(); renderWithProviders(); @@ -107,7 +107,7 @@ describe("AddBackendModal – two-column layout", () => { name: "Local Extra", host: "http://127.0.0.1:18002", apiKey: "", - kind: "local", + kind: "remote", }); }); @@ -131,6 +131,37 @@ describe("AddBackendModal – two-column layout", () => { expect(submit).not.toBeDisabled(); }); + it("treats hosted non-cloud agent server URLs as remote", async () => { + renderWithProviders(); + + const submit = screen.getByTestId( + "add-backend-submit", + ) as HTMLButtonElement; + const user = userEvent.setup(); + + await user.type(screen.getByTestId("add-backend-name"), "Remote Work Host"); + await user.type( + screen.getByTestId("add-backend-host"), + "https://work-2-pmmkfqeesqroywhw.prod-runtime.all-hands.dev", + ); + expect(submit).not.toBeDisabled(); + + await user.click(submit); + + const stored = JSON.parse( + window.localStorage.getItem("openhands-backends") ?? "[]", + ); + const added = stored.find( + (b: { name: string }) => b.name === "Remote Work Host", + ); + expect(added).toMatchObject({ + name: "Remote Work Host", + host: "https://work-2-pmmkfqeesqroywhw.prod-runtime.all-hands.dev", + apiKey: "", + kind: "remote", + }); + }); + it("saves the backend, switches to it, and closes", async () => { const onClose = vi.fn(); renderWithProviders(); @@ -156,7 +187,7 @@ describe("AddBackendModal – two-column layout", () => { name: "Local 1", host: "http://localhost:9000", apiKey: "k", - kind: "local", + kind: "remote", }); // Active selection must point at the newly added backend. diff --git a/__tests__/hooks/query/use-automation-health.test.tsx b/__tests__/hooks/query/use-automation-health.test.tsx index ba712dbee..166138a93 100644 --- a/__tests__/hooks/query/use-automation-health.test.tsx +++ b/__tests__/hooks/query/use-automation-health.test.tsx @@ -4,6 +4,13 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { useAutomationHealth } from "#/hooks/query/use-automation-health"; import AutomationService from "#/api/automation-service/automation-service.api"; +const mockUseActiveBackend = vi.hoisted(() => + vi.fn(() => ({ + backend: { id: "test-backend", kind: "local" }, + orgId: null, + })), +); + vi.mock("#/api/automation-service/automation-service.api", () => ({ default: { checkHealth: vi.fn(), @@ -11,10 +18,7 @@ vi.mock("#/api/automation-service/automation-service.api", () => ({ })); vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => ({ - backend: { id: "test-backend", kind: "local" }, - orgId: null, - }), + useActiveBackend: mockUseActiveBackend, })); function createWrapper() { @@ -33,10 +37,36 @@ function createWrapper() { describe("useAutomationHealth", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseActiveBackend.mockReturnValue({ + backend: { id: "test-backend", kind: "local" }, + orgId: null, + }); + }); + + it("returns a synthetic error for remote backends without calling checkHealth", async () => { + mockUseActiveBackend.mockReturnValue({ + backend: { id: "remote-backend", kind: "remote" }, + orgId: null, + }); + + const { result } = renderHook(() => useAutomationHealth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + status: "error", + message: + "Remote agent servers do not include the local automation sidecar.", + }); + expect(AutomationService.checkHealth).not.toHaveBeenCalled(); }); it("should return healthy status when backend is available", async () => { - vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); + vi.mocked(AutomationService.checkHealth).mockResolvedValue({ + status: "ok", + }); const { result } = renderHook(() => useAutomationHealth(), { wrapper: createWrapper(), diff --git a/src/api/backend-registry/active-store.ts b/src/api/backend-registry/active-store.ts index 78d0c5999..d872215cc 100644 --- a/src/api/backend-registry/active-store.ts +++ b/src/api/backend-registry/active-store.ts @@ -5,7 +5,12 @@ import { writeStoredActiveBackend, writeStoredBackends, } from "./storage"; -import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types"; +import { + isAgentServerBackend, + type Backend, + type BackendSelection, + type ResolvedActiveBackend, +} from "./types"; type Listener = () => void; @@ -16,16 +21,16 @@ interface Snapshot { } /** - * Pick the local backend the GUI should talk to for local-protocol calls - * (settings, conversations, secrets, …). Prefers the user's first - * registered local backend. As a last resort — when the registry has no - * local entry at all — synthesize one from env/agent-server-config so - * synchronous call sites never have to handle a `null` backend; the - * synthesized entry is never persisted. + * Pick the agent-server backend the GUI should talk to for direct + * agent-server protocol calls (settings, conversations, secrets, …). + * Prefers the user's first registered local or remote backend. As a last + * resort — when the registry has no agent-server entry at all — synthesize + * one from env/agent-server-config so synchronous call sites never have to + * handle a `null` backend; the synthesized entry is never persisted. */ -function pickLocalBackend(backends: Backend[]): Backend { - const firstLocal = backends.find((b) => b.kind === "local"); - return firstLocal ?? makeDefaultLocalBackend(); +function pickAgentServerBackend(backends: Backend[]): Backend { + const firstAgentServer = backends.find(isAgentServerBackend); + return firstAgentServer ?? makeDefaultLocalBackend(); } function computeSnapshot( @@ -48,7 +53,7 @@ function computeSnapshot( // @spec BM-003 — Fallback on active backend removal if (!activeBackend) { - activeBackend = pickLocalBackend(backends); + activeBackend = pickAgentServerBackend(backends); activeOrgId = null; } @@ -86,8 +91,8 @@ export function getActiveBackend(): ResolvedActiveBackend { */ export function getEffectiveLocalBackend(): Backend { const active = snapshot.active.backend; - if (active.kind === "local") return active; - return pickLocalBackend(snapshot.backends); + if (isAgentServerBackend(active)) return active; + return pickAgentServerBackend(snapshot.backends); } export function getRegisteredBackends(): Backend[] { diff --git a/src/api/backend-registry/auth.ts b/src/api/backend-registry/auth.ts index 3b2d47b98..addec686b 100644 --- a/src/api/backend-registry/auth.ts +++ b/src/api/backend-registry/auth.ts @@ -1,15 +1,18 @@ import { getAgentServerSessionApiKey } from "../agent-server-config"; import { DEFAULT_LOCAL_BACKEND_ID } from "./default-backend"; -import type { Backend } from "./types"; +import { isAgentServerBackend, type Backend } from "./types"; /** * Build the auth headers to send to a backend. * - * Local agent-server uses `X-Session-API-Key`. Cloud expects a bearer - * token in the `Authorization` header. + * Local and remote agent servers use `X-Session-API-Key`. Cloud expects a + * bearer token in the `Authorization` header. */ export function buildAuthHeaders(backend: Backend): Record { - if (backend.kind === "local" && backend.id === DEFAULT_LOCAL_BACKEND_ID) { + if ( + isAgentServerBackend(backend) && + backend.id === DEFAULT_LOCAL_BACKEND_ID + ) { const configuredSessionApiKey = getAgentServerSessionApiKey(); if (configuredSessionApiKey) { return { "X-Session-API-Key": configuredSessionApiKey }; diff --git a/src/api/backend-registry/storage.ts b/src/api/backend-registry/storage.ts index dfe89298b..d474a6407 100644 --- a/src/api/backend-registry/storage.ts +++ b/src/api/backend-registry/storage.ts @@ -1,12 +1,17 @@ import { syncBakedSessionApiKey } from "../agent-server-config"; import { makeDefaultLocalBackend } from "./default-backend"; -import type { Backend, BackendKind, BackendSelection } from "./types"; +import { + isAgentServerBackend, + type Backend, + type BackendKind, + type BackendSelection, +} from "./types"; export const BACKENDS_STORAGE_KEY = "openhands-backends"; export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend"; function isValidKind(value: unknown): value is BackendKind { - return value === "local" || value === "cloud"; + return value === "local" || value === "remote" || value === "cloud"; } function isValidBackend(value: unknown): value is Backend { @@ -35,7 +40,7 @@ function syncDefaultLocalBackendAuth(backend: Backend): Backend { if ( backend.id !== defaultBackend.id || - backend.kind !== "local" || + !isAgentServerBackend(backend) || !defaultBackend.apiKey || normalizeHostForComparison(backend.host) !== normalizeHostForComparison(defaultBackend.host) diff --git a/src/api/backend-registry/types.ts b/src/api/backend-registry/types.ts index fb50708df..1b36144b2 100644 --- a/src/api/backend-registry/types.ts +++ b/src/api/backend-registry/types.ts @@ -1,4 +1,10 @@ -export type BackendKind = "local" | "cloud"; +export type BackendKind = "local" | "remote" | "cloud"; + +export function isAgentServerBackend( + backend: Pick, +): backend is Pick & { kind: "local" | "remote" } { + return backend.kind === "local" || backend.kind === "remote"; +} export interface Backend { id: string; diff --git a/src/components/features/automations/recommended-automations-launcher.tsx b/src/components/features/automations/recommended-automations-launcher.tsx index 9d457d585..e55ecd93e 100644 --- a/src/components/features/automations/recommended-automations-launcher.tsx +++ b/src/components/features/automations/recommended-automations-launcher.tsx @@ -115,7 +115,7 @@ export function RecommendedAutomationsLauncher({ const prompt = buildAutomationPrompt( automation.prompt, - activeBackend.backend.kind, + "local", activeBackend.backend.host, ); @@ -144,7 +144,7 @@ export function RecommendedAutomationsLauncher({ ); }, [ - activeBackend.backend.kind, + activeBackend.backend.host, createConversation, isCreatingConversation, navigate, @@ -210,14 +210,14 @@ export function RecommendedAutomationsLauncher({ const installEntry = installQueue[0] ?? null; - // Recommended automations are a local-backend-only feature; cloud - // automations are managed elsewhere. - if (activeBackend.backend.kind === "cloud") return null; + // Recommended automations require the automation sidecar started by the + // local canvas launcher; remote/cloud backends are managed elsewhere. + if (activeBackend.backend.kind !== "local") return null; return ( <> void; } +// Keep this list to canonical OpenHands Cloud hosts. Other all-hands.dev +// hosts, such as work-* tunnels, may be standalone remote agent servers. + +const CLOUD_BACKEND_HOSTS = new Set([ + "app.all-hands.dev", + "app.openhands.dev", + "cloud.all-hands.dev", + "cloud.openhands.dev", +]); + function inferKindFromHost(host: string): BackendKind { const trimmed = host.trim().toLowerCase(); - if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) { - return "cloud"; + try { + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + return CLOUD_BACKEND_HOSTS.has(new URL(withScheme).hostname) + ? "cloud" + : "remote"; + } catch { + return "remote"; } - return "local"; } /** @@ -149,7 +169,7 @@ function BackendStatusBadge({ }, retry: false, staleTime: 60_000, - enabled: backend.kind === "local" && !disabled, + enabled: isAgentServerBackend(backend) && !disabled, }); let statusLabel: string; @@ -164,7 +184,9 @@ function BackendStatusBadge({ const kindLabel = backend.kind === "cloud" ? t(I18nKey.BACKEND$KIND_CLOUD) - : t(I18nKey.BACKEND$KIND_LOCAL); + : backend.kind === "remote" + ? t(I18nKey.BACKEND$KIND_REMOTE) + : t(I18nKey.BACKEND$KIND_LOCAL); return (
@@ -293,13 +315,15 @@ export function BackendForm({ const [nameTouched, setNameTouched] = React.useState(false); const [hostTouched, setHostTouched] = React.useState(false); - // Kind is inferred from the host on every change. - const kind: BackendKind = inferKindFromHost(host); + // Manual additions infer remote/cloud from the host; editing the bundled + // local entry preserves its local/canvas semantics. + const kind: BackendKind = + backend?.kind === "local" ? "local" : inferKindFromHost(host); const testIdRoot = explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend"); - const needsApiKey = requireApiKey || kind !== "local"; + const needsApiKey = requireApiKey || kind === "cloud"; const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && @@ -458,7 +482,7 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) { const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && - (kind === "local" || apiKey.trim().length > 0); + (kind !== "cloud" || apiKey.trim().length > 0); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/features/backends/backend-selector.tsx b/src/components/features/backends/backend-selector.tsx index 01504a29e..e1aaefeca 100644 --- a/src/components/features/backends/backend-selector.tsx +++ b/src/components/features/backends/backend-selector.tsx @@ -12,7 +12,10 @@ import { type BackendHealth, } from "#/hooks/query/use-backends-health"; import { I18nKey } from "#/i18n/declaration"; -import type { Backend } from "#/api/backend-registry/types"; +import { + isAgentServerBackend, + type Backend, +} from "#/api/backend-registry/types"; // Import the trigger helpers from the lightweight store, not the overlay // component, so the eagerly-mounted sidebar/backend-selector graph does not // pull in the overlay's render code (the overlay is lazy-loaded from @@ -57,10 +60,10 @@ function buildOptions( ): DropdownOption[] { const options: DropdownOption[] = []; - const locals = registered.filter((b) => b.kind === "local"); + const agentServers = registered.filter(isAgentServerBackend); const clouds = registered.filter((b) => b.kind === "cloud"); - for (const b of locals) { + for (const b of agentServers) { options.push({ value: makeOptionValue(b.id, null), label: b.name, diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx index 29e5aafef..aa150ae4b 100644 --- a/src/components/features/backends/manage-backends-modal.tsx +++ b/src/components/features/backends/manage-backends-modal.tsx @@ -4,7 +4,10 @@ import { useQuery } from "@tanstack/react-query"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { ServerClient } from "@openhands/typescript-client/clients"; -import { type Backend } from "#/api/backend-registry/types"; +import { + isAgentServerBackend, + type Backend, +} from "#/api/backend-registry/types"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; import { BrandButton } from "#/components/features/settings/brand-button"; import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; @@ -43,7 +46,7 @@ function BackendVersion({ backend }: { backend: Backend }) { }, retry: false, staleTime: 60_000, - enabled: backend.kind === "local", + enabled: isAgentServerBackend(backend), }); if (!version) return null; @@ -95,7 +98,9 @@ function BackendRow({ backend, health, onEdit, onRemove }: BackendRowProps) { {backend.kind === "cloud" ? t(I18nKey.BACKEND$KIND_CLOUD) - : t(I18nKey.BACKEND$KIND_LOCAL)} + : backend.kind === "remote" + ? t(I18nKey.BACKEND$KIND_REMOTE) + : t(I18nKey.BACKEND$KIND_LOCAL)}
- {isLocal ? ( + {usesAgentServerBackend ? ( setIsDialogOpen(false)} diff --git a/src/components/features/mcp-page/marketplace-section.tsx b/src/components/features/mcp-page/marketplace-section.tsx index d7caed271..871415e9b 100644 --- a/src/components/features/mcp-page/marketplace-section.tsx +++ b/src/components/features/mcp-page/marketplace-section.tsx @@ -17,7 +17,7 @@ import { } from "#/utils/extension-module-card-classes"; interface MarketplaceSectionProps { - backendKind: "local" | "cloud"; + backendKind: "local" | "remote" | "cloud"; onSelect: (entry: MarketplaceEntry) => void; onAdd: (entry: MarketplaceEntry) => void; /** Empty string = no filter. */ diff --git a/src/components/features/onboarding/steps/setup-llm-step.tsx b/src/components/features/onboarding/steps/setup-llm-step.tsx index dabea7924..8f8416608 100644 --- a/src/components/features/onboarding/steps/setup-llm-step.tsx +++ b/src/components/features/onboarding/steps/setup-llm-step.tsx @@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration"; import { LlmSettingsScreen } from "#/routes/llm-settings"; import type { SdkSectionSaveControl } from "#/components/features/settings/sdk-settings/sdk-section-page"; import { useActiveBackend } from "#/contexts/active-backend-context"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; import { useSaveLlmProfile } from "#/hooks/mutation/use-save-llm-profile"; import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile"; import { deriveProfileNameFromModel } from "#/utils/derive-profile-name"; @@ -38,7 +39,7 @@ const ONBOARDING_LLM_OVERRIDES = { export function SetupLlmStep({ onBack, onNext }: SetupLlmStepProps) { const { t } = useTranslation("openhands"); const { backend } = useActiveBackend(); - const isLocalBackend = backend.kind === "local"; + const usesAgentServerBackend = isAgentServerBackend(backend); const saveProfile = useSaveLlmProfile(); const activateProfile = useActivateLlmProfile(); const [saveControl, setSaveControl] = @@ -49,7 +50,7 @@ export function SetupLlmStep({ onBack, onNext }: SetupLlmStepProps) { // truth; without this step the form save only updates agent_settings and // the new config never shows up in the profiles list ("ghost profile"). const persistAsProfile = React.useCallback(async () => { - if (!isLocalBackend || !saveControl) return; + if (!usesAgentServerBackend || !saveControl) return; const values = saveControl.values; const model = typeof values["llm.model"] === "string" ? values["llm.model"] : ""; @@ -77,7 +78,7 @@ export function SetupLlmStep({ onBack, onNext }: SetupLlmStepProps) { // user is not blocked from completing onboarding. console.error("Failed to persist onboarding LLM as profile:", error); } - }, [isLocalBackend, saveControl, saveProfile, activateProfile]); + }, [usesAgentServerBackend, saveControl, saveProfile, activateProfile]); const handleSaveSuccess = React.useCallback(async () => { setIsFinalizing(true); diff --git a/src/hooks/chat/use-model-interceptor.ts b/src/hooks/chat/use-model-interceptor.ts index 2b1b94d59..ad0de1c57 100644 --- a/src/hooks/chat/use-model-interceptor.ts +++ b/src/hooks/chat/use-model-interceptor.ts @@ -7,6 +7,7 @@ import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-ancho import { LLM_PROFILES_QUERY_KEYS } from "#/hooks/query/query-keys"; import { I18nKey } from "#/i18n/declaration"; import { useActiveBackend } from "#/contexts/active-backend-context"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; import { useModelStore } from "#/stores/model-store"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { MODEL_COMMAND } from "#/utils/constants"; @@ -28,7 +29,7 @@ export const useModelInterceptor = ( const queryClient = useQueryClient(); const { switchAndLog } = useSwitchLlmProfileAndLog(); const { backend, orgId } = useActiveBackend(); - const isLocal = backend.kind === "local"; + const usesAgentServerBackend = isAgentServerBackend(backend); const { t } = useTranslation(); return useCallback( @@ -36,7 +37,7 @@ export const useModelInterceptor = ( const trimmed = message.trim(); const isModel = trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX); - if (!isModel || !isLocal) { + if (!isModel || !usesAgentServerBackend) { onSubmit(message); return; } @@ -81,7 +82,7 @@ export const useModelInterceptor = ( }, [ conversationId, - isLocal, + usesAgentServerBackend, onSubmit, showProfiles, queryClient, diff --git a/src/hooks/query/use-automation-health.ts b/src/hooks/query/use-automation-health.ts index c81065ba9..f555296ec 100644 --- a/src/hooks/query/use-automation-health.ts +++ b/src/hooks/query/use-automation-health.ts @@ -7,8 +7,22 @@ export const AUTOMATION_HEALTH_QUERY_KEY = ["automation-health"] as const; export function useAutomationHealth() { const active = useActiveBackend(); return useQuery({ - queryKey: [...AUTOMATION_HEALTH_QUERY_KEY, active.backend.id, active.orgId], - queryFn: () => AutomationService.checkHealth(), + queryKey: [ + ...AUTOMATION_HEALTH_QUERY_KEY, + active.backend.id, + active.backend.kind, + active.orgId, + ], + queryFn: async () => { + if (active.backend.kind === "remote") { + return { + status: "error" as const, + message: + "Remote agent servers do not include the local automation sidecar.", + }; + } + return AutomationService.checkHealth(); + }, staleTime: 30 * 1000, // 30 seconds retry: false, // Don't retry on failure - we want to show the error state immediately }); diff --git a/src/hooks/query/use-has-git-commits.ts b/src/hooks/query/use-has-git-commits.ts index 0400369a8..8044e9ee6 100644 --- a/src/hooks/query/use-has-git-commits.ts +++ b/src/hooks/query/use-has-git-commits.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service"; import { useActiveBackend } from "#/contexts/active-backend-context"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; @@ -31,7 +32,7 @@ export function useHasGitCommits(options?: { enabled?: boolean }): { const { data: conversation } = useActiveConversation(); const runtimeIsReady = useRuntimeIsReady(); const { backend } = useActiveBackend(); - const isLocalBackend = backend.kind === "local"; + const usesAgentServerBackend = isAgentServerBackend(backend); const conversationId = conversation?.id; const conversationUrl = conversation?.conversation_url; @@ -40,7 +41,7 @@ export function useHasGitCommits(options?: { enabled?: boolean }): { const enabled = (options?.enabled ?? true) && - isLocalBackend && + usesAgentServerBackend && runtimeIsReady && !!conversationId && !!workingDir; diff --git a/src/hooks/query/use-local-git-info.ts b/src/hooks/query/use-local-git-info.ts index c7c5347ee..94e198f8f 100644 --- a/src/hooks/query/use-local-git-info.ts +++ b/src/hooks/query/use-local-git-info.ts @@ -3,6 +3,7 @@ import { useRef } from "react"; import type { CommandResult } from "#/api/runtime-service/agent-server-runtime-service"; import { useActiveBackend } from "#/contexts/active-backend-context"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; import { useBashCommandRunner } from "#/hooks/use-bash-command-runner"; @@ -97,7 +98,7 @@ export const useLocalGitInfo = () => { const { data: conversation } = useActiveConversation(); const runtimeIsReady = useRuntimeIsReady(); const { backend } = useActiveBackend(); - const isLocalBackend = backend.kind === "local"; + const usesAgentServerBackend = isAgentServerBackend(backend); const conversationId = conversation?.id; const conversationUrl = conversation?.conversation_url; @@ -108,7 +109,7 @@ export const useLocalGitInfo = () => { const hasConversationBranch = !!conversation?.selected_branch; const queryEnabled = - isLocalBackend && + usesAgentServerBackend && runtimeIsReady && !!conversationId && !!workingDir && diff --git a/src/hooks/query/use-workspace-session.ts b/src/hooks/query/use-workspace-session.ts index fd5be818a..c3051e979 100644 --- a/src/hooks/query/use-workspace-session.ts +++ b/src/hooks/query/use-workspace-session.ts @@ -2,6 +2,7 @@ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-w import { useQuery } from "@tanstack/react-query"; import { getActiveBackend } from "#/api/backend-registry/active-store"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; @@ -55,9 +56,11 @@ export function useWorkspaceSession(): { const conversationId = conversation?.id; const conversationUrl = conversation?.conversation_url; const sessionApiKey = conversation?.session_api_key; - const isLocal = getActiveBackend().backend.kind === "local"; + const usesAgentServerBackend = isAgentServerBackend( + getActiveBackend().backend, + ); - const enabled = runtimeIsReady && !!conversationId && isLocal; + const enabled = runtimeIsReady && !!conversationId && usesAgentServerBackend; const query = useQuery({ queryKey: [ diff --git a/src/hooks/use-settings-nav-items.ts b/src/hooks/use-settings-nav-items.ts index a61773c04..6725ca1c2 100644 --- a/src/hooks/use-settings-nav-items.ts +++ b/src/hooks/use-settings-nav-items.ts @@ -5,6 +5,7 @@ import { ACP_PROVIDERS } from "#/constants/acp-providers"; import { isSettingsPageHidden } from "#/utils/settings-utils"; import { I18nKey } from "#/i18n/declaration"; import { useActiveBackend } from "#/contexts/active-backend-context"; +import { isAgentServerBackend } from "#/api/backend-registry/types"; export type SettingsNavRenderedItem = | { @@ -44,14 +45,12 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] { item.to === "/settings" ? { ...item, - text: - backend.kind === "local" - ? I18nKey.SETTINGS$LLM_PROFILES - : item.text, - subtitle: - backend.kind === "local" - ? I18nKey.SETTINGS$PAGE_LLM_PROFILES_SUBLINE - : item.subtitle, + text: isAgentServerBackend(backend) + ? I18nKey.SETTINGS$LLM_PROFILES + : item.text, + subtitle: isAgentServerBackend(backend) + ? I18nKey.SETTINGS$PAGE_LLM_PROFILES_SUBLINE + : item.subtitle, } : item; diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 9d8de6c0e..1ef13c1d7 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -26281,6 +26281,23 @@ "uk": "Локальний", "ca": "Local" }, + "BACKEND$KIND_REMOTE": { + "en": "Remote", + "ja": "Remote", + "zh-CN": "Remote", + "zh-TW": "Remote", + "ko-KR": "Remote", + "no": "Remote", + "it": "Remote", + "pt": "Remote", + "es": "Remote", + "ar": "Remote", + "fr": "Remote", + "tr": "Remote", + "de": "Remote", + "uk": "Remote", + "ca": "Remote" + }, "BACKEND$KIND_CLOUD": { "en": "Cloud", "ja": "クラウド", diff --git a/src/ui/dropdown/dropdown.tsx b/src/ui/dropdown/dropdown.tsx index 7888f7bdf..bd95a202d 100644 --- a/src/ui/dropdown/dropdown.tsx +++ b/src/ui/dropdown/dropdown.tsx @@ -121,6 +121,15 @@ export function Dropdown({ }, }); + React.useEffect( + () => () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + }, + [], + ); + const isDisabled = loading || disabled; // `selectedItem` is downshift's internal state, frozen to whatever diff --git a/src/utils/mcp-marketplace-utils.ts b/src/utils/mcp-marketplace-utils.ts index 24f6448bd..562686274 100644 --- a/src/utils/mcp-marketplace-utils.ts +++ b/src/utils/mcp-marketplace-utils.ts @@ -149,11 +149,14 @@ function transportMatchesServer( export function isMarketplaceEntryAvailable( entry: MarketplaceEntry, - backendKind: "local" | "cloud", + backendKind: "local" | "remote" | "cloud", ): boolean { if (!entry.runtimeAvailability || entry.runtimeAvailability === "all") return true; - return entry.runtimeAvailability === backendKind; + // Remote agent servers expose the same runtime capabilities as the local + // canvas-managed server, so they share marketplace availability. + const runtimeBackendKind = backendKind === "remote" ? "local" : backendKind; + return entry.runtimeAvailability === runtimeBackendKind; } function normalize(query: string): string { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7fc024385..a1370a90d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -175,12 +175,12 @@ export const getFileExtension = (fileName: string): string => { * - github → installation-based ONLY when the active backend is cloud * - gitlab / azure_devops / forgejo → direct (search) flow * - * `appMode` accepts the active backend `kind` ("local" | "cloud") so call - * sites can hand it through directly. + * `appMode` accepts the active backend `kind`; remote agent servers use + * the same repository flow as local/canvas agent servers. */ export const shouldUseInstallationRepos = ( provider: Provider | null | undefined, - appMode?: "local" | "cloud", + appMode?: "local" | "remote" | "cloud", ) => { if (!provider) return false;