@@ -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;