import React from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ChevronDown, Globe, Info, Monitor } from "lucide-react"; import { ServerClient } from "@openhands/typescript-client/clients"; import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { MODAL_MAX_WIDTH_VIEWPORT, modalWidthClassName, } from "#/components/shared/modals/modal-body"; import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SettingsInput } from "#/components/features/settings/settings-input"; import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle"; import { useActiveBackendContext } from "#/contexts/active-backend-context"; import { useNavigation } from "#/context/navigation-context"; import { useBackendsHealth } from "#/hooks/query/use-backends-health"; import { useTracking } from "#/hooks/use-tracking"; import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; import { getLockedCloudHost } from "#/api/agent-server-config"; import { isOpenHandsCloudHost } from "#/api/device-flow-client"; import { getDisplayAgentServerVersion, validateLocalBackend, } from "#/api/agent-server-compatibility"; import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react"; import { I18nKey } from "#/i18n/declaration"; import type { Backend, BackendKind } from "#/api/backend-registry/types"; import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error"; import { cn } from "#/utils/utils"; import { modalTitleLgClassName, modalTitleLgMediumClassName, } from "#/utils/modal-classes"; import ExternalLinkIcon from "#/icons/external-link.svg?react"; import ServerIcon from "#/icons/server.svg?react"; import { getBackendStatusLabel } from "./backend-status-label"; import { BackendStatusDot } from "./backend-status-dot"; import { DeviceFlowAuth } from "./device-flow-auth"; export type BackendFormMode = "add" | "edit"; interface BackendConnectionTestMetadata { agentServerVersion: string | null; } interface BackendFormModalProps { mode: BackendFormMode; /** Required when `mode === "edit"`. */ backend?: Backend; onClose: () => void; /** Analytics surface for the `backend_added` event (add mode only). */ source?: BackendAddedSource; /** Hide the close button and disable backdrop/escape dismissal. Used for locked Cloud first-run. */ hideCloseButton?: boolean; } /** * Seed the default backend kind from the host. Uses proper hostname-suffix * matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so * a look-alike host such as `all-hands-testing.dev` isn't misread as cloud. * * This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance * on a truly custom domain is indistinguishable from a local agent-server by * host alone, so the manual add form lets the user override the kind * explicitly (see the Type selector in ManualConnectionColumn). */ function inferKindFromHost(host: string): BackendKind { return isOpenHandsCloudHost(host) ? "cloud" : "local"; } /** * Returns true for hostnames that represent a local / private-network address. * Used by normalizeHost to choose http:// instead of https://. */ function isLocalAddress(hostname: string): boolean { // Strip IPv6 bracket notation: [::1] → ::1 const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // IPv6 loopback, any-address, and named loopback if (h === "localhost" || h === "::1" || h === "::" || h === "0.0.0.0") return true; // 127.x.x.x loopback range + IPv4-mapped loopback (::ffff:127.x.x.x) if (/^127\./.test(h) || /^::ffff:127\./i.test(h)) return true; // RFC 1918 private ranges if (/^10\./.test(h)) return true; if (/^192\.168\./.test(h)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // IPv6 link-local (fe80::/10) and unique local (fc00::/7) if (/^fe[89ab][0-9a-f]:/i.test(h)) return true; if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true; // mDNS / Bonjour (.local) if (h.endsWith(".local")) return true; // Single-label hostnames (no dots, no colons) are local network names. // Colons are excluded so bare IPv6 addresses don't accidentally match. if (!h.includes(".") && !h.includes(":")) return true; return false; } function normalizeHost(host: string): string { const trimmed = host.trim().replace(/\/+$/, ""); if (!trimmed) return ""; // Already has an explicit scheme — respect it. if (/^https?:\/\//i.test(trimmed)) return trimmed; // Extract the pure hostname for scheme selection, handling three cases: // [::1]:8080 → bracket IPv6 notation → extract ::1 // ::1 → bare IPv6 (multiple colons, no bracket) → whole string // host:port → regular host:port → part before the colon const bracketMatch = trimmed.match(/^\[([^\]]+)\]/); const hostname = bracketMatch ? bracketMatch[1] : (trimmed.match(/:/g) ?? []).length > 1 ? trimmed : trimmed.split(":")[0]; const scheme = isLocalAddress(hostname) ? "http" : "https"; return `${scheme}://${trimmed}`; } /** * Returns true when `host` represents a reachable backend URL. * * Rules (applied in order): * 1. Must be non-empty after trimming. * 2. Must contain no whitespace — spaces can never appear in a host/port. * 3. After normalisation (bare hosts get `https://` prepended), must parse * as a valid http or https URL with a non-empty hostname. */ function isValidHostUrl(host: string): boolean { const trimmed = host.trim(); if (!trimmed) return false; // Spaces anywhere in the input are an immediate rejection. if (/\s/.test(trimmed)) return false; const normalized = normalizeHost(trimmed); if (!normalized) return false; try { const url = new URL(normalized); return ( (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0 ); } catch { return false; } } const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev"; const LOCAL_BACKEND_COMMAND = "agent-canvas --backend-only --port 8001"; const LOCAL_AGENT_SERVER_DOCS_URL = "https://github.com/OpenHands/OpenHands/blob/main/docs/DEVELOPMENT.md#alternative-development-workflows"; const REMOTE_AGENT_SERVER_DOCS_URL = "https://github.com/OpenHands/OpenHands/blob/main/docs/SELF_HOSTING.md"; const DEPLOYMENT_OPTIONS_URL = "https://docs.openhands.dev/overview/introduction"; export type BackendConnectionMethod = "manual" | "cloud_login"; export type BackendAddedSource = CloudConnectionSource; type AddBackendOption = "cloud" | "agent-server"; type AgentServerLocation = "local" | "remote"; function getConnectionTestFailedTitle( t: ReturnType["t"], host: string, ): string { return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, { host, interpolation: { escapeValue: false }, }); } function getConnectionErrorDetail(error: unknown): string | null { return getUserFacingConnectionErrorMessage(error); } function getConnectionTestFailedMessage(title: string, error: unknown): string { const detail = getConnectionErrorDetail(error); return detail ? `${title}\n${detail}` : title; } async function testBackendConnection( backend: Pick, ): Promise { // Cloud backends authenticate via OAuth; preflight GET is not applicable. if (backend.kind !== "local") return { agentServerVersion: null }; const agentServerVersion = await validateLocalBackend(backend, 5000); return { agentServerVersion }; } /** * Live status row for the edit form: shows a connection dot, a * "Local"/"Cloud" label, and the agent server's reported version when * available. Replaces the legacy local/cloud radio fieldset (kind is * now inferred from the host). */ function BackendStatusBadge({ backend, testIdRoot, }: { backend: Backend; testIdRoot: string; }) { const { t } = useTranslation("openhands"); const healthByBackendId = useBackendsHealth([backend]); const health = healthByBackendId[backend.id]; const isConnected = health?.isConnected ?? null; const disabled = health?.disabled === true; const consecutiveFailures = health?.consecutiveFailures ?? 0; const lastError = health?.lastError ?? null; const { data: version } = useQuery({ queryKey: ["backend-version", backend.host, backend.apiKey], queryFn: async () => { const info = await new ServerClient( getAgentServerClientOptions({ host: backend.host, sessionApiKey: backend.apiKey || null, timeout: 5000, }), ).getServerInfo(); return getDisplayAgentServerVersion(info); }, retry: false, staleTime: 60_000, enabled: backend.kind === "local" && !disabled, }); const statusLabel = getBackendStatusLabel(t, backend, health); const kindLabel = backend.kind === "cloud" ? t(I18nKey.BACKEND$KIND_CLOUD) : t(I18nKey.BACKEND$KIND_LOCAL); return (
{statusLabel} · {kindLabel} {version ? ( {t(I18nKey.BACKEND$VERSION_LABEL, { version })} ) : null}
{disabled ? (
{t(I18nKey.BACKEND$HEALTH_FAILED_TITLE)} {t(I18nKey.BACKEND$HEALTH_FAILED_DETAIL, { count: consecutiveFailures, })} {lastError ? ( {lastError} ) : null}
) : null}
); } export interface BackendFormSubmitPayload { name: string; host: string; apiKey: string; kind: BackendKind; } interface UseBackendFormOptions { initialName?: string; initialHost?: string; initialApiKey?: string; /** * Called to test the connection. The hook does NOT call * `testBackendConnection` directly so callers can inject a * wrapped version (e.g. with extra logging or different timeout). * Should throw on failure. */ onTestConnection: ( payload: BackendFormSubmitPayload, ) => Promise; /** Called after a successful connection test and persistence. */ onSuccess: (metadata: BackendConnectionTestMetadata) => void; /** Require a non-empty API key even when the host looks local. */ requireApiKey?: boolean; /** * When provided, completely replaces the default submit flow * (onTestConnection + onSuccess). The hook still manages form state * and canSubmit validation, but the caller owns error handling and * success side effects. Should throw on failure. */ onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise; /** Fix the persisted backend kind instead of inferring it from the host. */ fixedKind?: BackendKind; } /** * Shared hook for the backend-form state used by both `BackendForm` * (edit/add mode) and `ManualConnectionColumn` (add-mode-only column). * Encapsulates name / host / apiKey fields, `connectionError`, * `isSubmitting`, and the shared `handleSubmit` flow. */ function useBackendForm({ initialName = "", initialHost = "", initialApiKey = "", onTestConnection, onSuccess, requireApiKey = false, onSubmitOverride, fixedKind, }: UseBackendFormOptions) { const { t } = useTranslation("openhands"); const [name, setName] = React.useState(initialName); const [host, setHost] = React.useState(initialHost); const [apiKey, setApiKey] = React.useState(initialApiKey); const [connectionError, setConnectionError] = React.useState( null, ); const [isSubmitting, setIsSubmitting] = React.useState(false); const [kindOverride, setKindOverride] = React.useState( null, ); // Kind follows host inference until the user explicitly picks a type, then // respects that choice. A custom-domain OHE can't be distinguished from a // custom-domain local agent-server by host alone, so ManualConnectionColumn // exposes `setKind` (a Type selector) to let the user declare it. const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host); const needsApiKey = requireApiKey || kind !== "local"; const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && (!needsApiKey || apiKey.trim().length > 0); const handleSubmit = React.useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit || isSubmitting) return; const payload: BackendFormSubmitPayload = { name: name.trim(), host: normalizeHost(host), apiKey: apiKey.trim(), kind, }; setConnectionError(null); setIsSubmitting(true); try { // When onSubmitOverride is provided, it completely replaces the // default flow (onTestConnection + onSuccess). if (onSubmitOverride) { await onSubmitOverride(payload); } else { const metadata = await onTestConnection(payload); onSuccess(metadata); } } catch (error) { setConnectionError( getConnectionTestFailedMessage( getConnectionTestFailedTitle(t, payload.host), error, ), ); } finally { setIsSubmitting(false); } }, [ canSubmit, isSubmitting, name, host, apiKey, kind, onTestConnection, onSuccess, requireApiKey, onSubmitOverride, fixedKind, t, ], ); return { name, setName, host, setHost, apiKey, setApiKey, connectionError, setConnectionError, isSubmitting, kind, setKind: setKindOverride, canSubmit, handleSubmit, }; } export interface BackendFormProps { mode: BackendFormMode; /** Required when `mode === "edit"`. */ backend?: Backend; /** * Called after the form is submitted and the backend has been * persisted. Use this to dismiss a containing modal, advance an * onboarding step, etc. */ onSubmitted: () => void; /** * Optional render slot rendered in place of the default * Save / Cancel button row, so callers (e.g. the onboarding flow) * can re-skin the action area while still owning submission via the * standard `
` flow. Receives the form's submit-ready * state. */ renderActions?: (state: { canSubmit: boolean; isSubmitting: boolean; testIdRoot: string; }) => React.ReactNode; /** Used to disambiguate test ids across the same screen. */ testIdRoot?: string; /** When true, the host field is pre-filled and disabled. */ hostReadOnly?: boolean; /** * When true, a non-empty API key is required for submission regardless * of the inferred backend kind. The standard add form allows empty * keys for local backends; the auth-gate screen needs to enforce one. */ requireApiKey?: boolean; /** * When true, hides the name/host/API-key inputs (and related inline * errors) while keeping the action row visible — used by onboarding * after a successful connection probe. */ hideConfigurationFields?: boolean; /** * Replace the default synchronous add/update-and-close submit with a * custom async handler. The form builds the payload, validates * client-side, then hands it to this callback. If the callback throws, * the form remains open so the caller can surface errors. */ onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise; } /** * Reusable form body for adding / editing a backend. Renders the * common name / host / API-key inputs plus the kind selector * (radio buttons in `add` mode, status badge in `edit` mode). * * Rendered as a ``, so consumers should put any extra controls * either inside `renderActions` or as siblings inside a wrapping * element — but submission flows through the standard form submit so * Enter-to-submit still works. */ export function BackendForm({ mode, backend, onSubmitted, renderActions, testIdRoot: explicitTestIdRoot, hostReadOnly, requireApiKey, hideConfigurationFields = false, onSubmitOverride, }: BackendFormProps) { const { t } = useTranslation("openhands"); const { addBackend, updateBackend } = useActiveBackendContext(); // In edit mode preserve the existing backend's kind so that renaming or // rotating the API key on a cloud backend (e.g. an OHE/enterprise instance // on a custom domain) does not silently downgrade it to "local" and switch // the auth header from `Authorization: Bearer` to `X-Session-API-Key`. // Only infer from the host when adding a new backend. const fixedKind: BackendKind | null = mode === "edit" && backend ? backend.kind : null; const { name, setName, host, setHost, apiKey, setApiKey, connectionError, setConnectionError, isSubmitting, kind: inferredKind, handleSubmit: runSubmit, } = useBackendForm({ initialName: backend?.name ?? "", initialHost: backend?.host ?? "", initialApiKey: backend?.apiKey ?? "", onTestConnection: testBackendConnection, onSuccess: async () => { const payload: BackendFormSubmitPayload = { name: name.trim(), host: normalizeHost(host), apiKey: apiKey.trim(), kind: fixedKind ?? inferredKind, }; if (mode === "edit" && backend) { updateBackend(backend.id, payload); } else { addBackend(payload); } onSubmitted(); }, requireApiKey, onSubmitOverride, }); // Inline validation: only show errors after the user has left a field. const [nameTouched, setNameTouched] = React.useState(false); const [hostTouched, setHostTouched] = React.useState(false); const kind = fixedKind ?? inferredKind; const testIdRoot = explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend"); const needsApiKey = requireApiKey || kind !== "local"; const canSubmit = name.trim().length > 0 && isValidHostUrl(host) && (!needsApiKey || apiKey.trim().length > 0); // Error messages — only surfaced after the user has blurred the field. const nameError = nameTouched && !name.trim() ? t(I18nKey.BACKEND$NAME_REQUIRED) : undefined; const hostError = hostTouched ? !host.trim() ? t(I18nKey.BACKEND$HOST_REQUIRED) : !isValidHostUrl(host) ? t(I18nKey.BACKEND$HOST_INVALID) : undefined : undefined; const handleSubmit = async (event: React.FormEvent) => { if (!canSubmit) { // Mark all validated fields as touched so inline errors become visible // (e.g. user pressed Enter before filling required fields). setNameTouched(true); setHostTouched(true); return; } await runSubmit(event); }; return (
{ setName(value); setConnectionError(null); }} onBlur={() => setNameTouched(true)} // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy placeholder="Production" className="w-full" showRequiredTag error={nameError} /> { setHost(value); setConnectionError(null); } } onBlur={() => setHostTouched(true)} placeholder={DEFAULT_OPENHANDS_CLOUD_HOST} className="w-full" showRequiredTag error={hostError} isDisabled={hostReadOnly} /> { setApiKey(value); setConnectionError(null); }} placeholder="" className="w-full" /> {connectionError ? (
{connectionError}
) : null} {mode === "edit" && backend && ( )}
{renderActions ? ( renderActions({ canSubmit: canSubmit && !isSubmitting, isSubmitting, testIdRoot, }) ) : (
{t(I18nKey.BUTTON$CANCEL)} {t(I18nKey.BACKEND$SAVE)}
)} ); } // ── Add-mode two-column layout ────────────────────────────────────── /** * @spec BM-002 — Adding a backend auto-switches the active selection to it * (BM-001), so a backend-scoped detail page the user is viewing now belongs * to the previous backend. Redirect to that section's list so they never see * stale data, mirroring the switch-backend redirect in BackendSelector. */ function useRedirectAfterAddBackend() { const { currentPath, navigate } = useNavigation(); return React.useCallback(() => { if (/^\/automations\/[^/]+/.test(currentPath)) navigate("/automations"); else if (/^\/conversations\/[^/]+/.test(currentPath)) navigate("/conversations"); }, [currentPath, navigate]); } interface BackendConnectionOptionsProps { onConnected: ( payload: BackendFormSubmitPayload, connectionMethod: BackendConnectionMethod, metadata?: BackendConnectionTestMetadata, ) => void; testIdRoot?: string; initialManualBackend?: Partial< Pick >; requireManualApiKey?: boolean; manualSubmitLabel?: React.ReactNode; manualSubmittingLabel?: React.ReactNode; manualSubmitTestId?: string; analyticsSource?: CloudConnectionSource; } /** * Manual agent-server connection plus OpenHands Cloud OAuth login. * Used by both the Add Backend modal and the onboarding backend step so * supported backend choices stay consistent across first-run and settings UI. */ export function BackendConnectionOptions({ onConnected, testIdRoot = "add-backend", initialManualBackend, requireManualApiKey = false, manualSubmitLabel, manualSubmittingLabel, manualSubmitTestId, analyticsSource, }: BackendConnectionOptionsProps) { const { t } = useTranslation("openhands"); const lockedCloudHost = getLockedCloudHost(); if (lockedCloudHost) { return (
); } return (
); } interface ManualConnectionColumnProps { onConnected: ( payload: BackendFormSubmitPayload, connectionMethod: BackendConnectionMethod, metadata?: BackendConnectionTestMetadata, ) => void; testIdRoot: string; initialBackend?: Partial< Pick >; requireApiKey: boolean; submitLabel: React.ReactNode; submittingLabel: React.ReactNode; submitTestId?: string; fixedKind?: BackendKind; showKindSelector?: boolean; } /** * Manual connection via Host + API Key. Designed for self-hosted agent servers * and self-hosted OpenHands Cloud with API key auth. */ function ManualConnectionColumn({ onConnected, testIdRoot, initialBackend, requireApiKey, submitLabel, submittingLabel, submitTestId, fixedKind, showKindSelector = true, }: ManualConnectionColumnProps) { const { t } = useTranslation("openhands"); const { name, setName, host, setHost, apiKey, setApiKey, connectionError, setConnectionError, isSubmitting, kind, setKind, canSubmit, handleSubmit, } = useBackendForm({ initialName: initialBackend?.name ?? "", initialHost: initialBackend?.host ?? "", initialApiKey: initialBackend?.apiKey ?? "", onTestConnection: testBackendConnection, onSuccess: (metadata) => { onConnected( { name: name.trim(), host: normalizeHost(host), apiKey: apiKey.trim(), kind, }, "manual", metadata, ); }, requireApiKey, fixedKind, }); return (
{ setName(value); setConnectionError(null); }} // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy placeholder="e.g. My Server" className="w-full" /> { setHost(value); setConnectionError(null); }} // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable placeholder="http://localhost:8000" className="w-full" /> {showKindSelector ? (
{t(I18nKey.BACKEND$KIND_LABEL)} value={kind} options={[ { value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL) }, { value: "cloud", label: t(I18nKey.BACKEND$KIND_CLOUD) }, ]} onChange={(value) => setKind(value)} ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)} testId={`${testIdRoot}-kind`} />
) : null} { setApiKey(value); setConnectionError(null); }} // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable placeholder="sk-••••••••••" className="w-full" /> {connectionError ? (
{connectionError}
) : null} {isSubmitting ? submittingLabel : submitLabel} ); } interface CloudLoginColumnProps { onConnected: ( payload: BackendFormSubmitPayload, connectionMethod: BackendConnectionMethod, metadata?: BackendConnectionTestMetadata, ) => void; testIdRoot: string; lockedHost?: string; analyticsSource?: CloudConnectionSource; /** Omit repeated branding when a surrounding chooser already names Cloud. */ showBranding?: boolean; } /** * One-click OAuth login with OpenHands Cloud. Includes an "Advanced" * disclosure for users who self-host OpenHands Cloud and need to override the * host. */ function CloudLoginColumn({ onConnected, testIdRoot, lockedHost, analyticsSource, showBranding = true, }: CloudLoginColumnProps) { const { t } = useTranslation("openhands"); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [customHost, setCustomHost] = React.useState(""); const advancedPanelId = `${testIdRoot}-advanced-panel`; const effectiveHost = lockedHost ?? (customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST); const handleLoginSuccess = (apiKey: string) => { onConnected( { name: "OpenHands Cloud", host: normalizeHost(effectiveHost), apiKey, kind: "cloud", }, "cloud_login", ); }; return (
{showBranding ? (

{t(I18nKey.BACKEND$CLOUD_TITLE)}

) : null} {t(I18nKey.BACKEND$CLOUD_DESCRIPTION)}

} // Host overriding only matters before the flow starts, so it rides the // same idle-only slot as the description instead of sitting under the // authorization status. idleFooter={ lockedHost ? null : ( // Keep the Advanced disclosure at the original compact width even // though the surrounding cloud panel spans the full chooser.
{/* Height animates through `grid-template-rows` (0fr ↔ 1fr) so no max-height guess is needed; the inner clip is what makes `0fr` collapse. Content stays mounted so a typed host survives, and `inert` keeps the collapsed field out of the tab order. */}

{t(I18nKey.BACKEND$LOGIN_CLOUD_HINT)}

) } />
); } interface BackendOptionTabProps { value: AddBackendOption; selectedValue: AddBackendOption; title: string; description: string; icon: React.ReactNode; onSelect: (value: AddBackendOption) => void; panelId: string; testId: string; } /** * Presents a connection-method tab: icon, title, and a one-line subtitle. * * The shared tablist owns the outer border, so these buttons meet cleanly at * the center. Selection uses a bottom bar instead of recoloring the full * outline, preserving the group as one visual control. */ function BackendOptionTab({ value, selectedValue, title, description, icon, onSelect, panelId, testId, }: BackendOptionTabProps) { const isSelected = value === selectedValue; const tabId = `${testId}-tab`; return ( ); } interface AnimatedPanelHeightProps { children: React.ReactNode; } /** * Animates a panel between content-driven heights. * * A ResizeObserver keeps the wrapper synchronized as tabs or nested * disclosures change. The transition class remains mounted before the * observer publishes a new height; adding it in the same render as the height * would give the browser no previous painted value to interpolate from. */ function AnimatedPanelHeight({ children }: AnimatedPanelHeightProps) { const contentRef = React.useRef(null); const [height, setHeight] = React.useState(); React.useLayoutEffect(() => { const content = contentRef.current; if (!content) return undefined; const measure = () => { const nextHeight = content.getBoundingClientRect().height; // jsdom reports zero-sized layout boxes; leaving height automatic there // keeps component tests representative without changing browser behavior. setHeight(nextHeight > 0 ? nextHeight : undefined); }; measure(); if (typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(measure); observer.observe(content); return () => observer.disconnect(); }, []); return (
{children}
); } /** * Collects every setup instruction for the selected location into one * collapsible note. * * All of the guidance lives here — the intro, the example command (local) or * the host formats to use with a tunnel (remote), and the docs link — so users * never have to leave the modal to find out what belongs in the host field. * It starts collapsed so the connection form remains compact; users can * reveal the complete instructions without leaving the modal. * * Open/close uses a `grid-template-rows` transition (`0fr` ↔ `1fr`) so the * height animates without a fixed `max-height` guess. Content stays mounted * so the toggle does not remount the docs link on every expand. */ function AgentServerGuidance({ location }: { location: AgentServerLocation }) { const { t } = useTranslation("openhands"); const [isExpanded, setIsExpanded] = React.useState(false); const isRemote = location === "remote"; const title = isRemote ? t(I18nKey.BACKEND$REMOTE_SETUP_TITLE) : t(I18nKey.BACKEND$BEFORE_CONNECT_TITLE); const description = isRemote ? t(I18nKey.BACKEND$REMOTE_SETUP_DESCRIPTION) : t(I18nKey.BACKEND$LOCAL_SETUP_DESCRIPTION); const docsHref = isRemote ? REMOTE_AGENT_SERVER_DOCS_URL : LOCAL_AGENT_SERVER_DOCS_URL; const docsLabel = isRemote ? t(I18nKey.BACKEND$REMOTE_SETUP_DOCS) : t(I18nKey.BACKEND$LOCAL_SETUP_DOCS); const testIdRoot = isRemote ? "add-backend-remote" : "add-backend-local"; const toggleId = `${testIdRoot}-guidance-toggle`; const bodyId = `${testIdRoot}-guidance-body`; return ( ); } function AddBackendChooser({ onConnected, source, }: { onConnected: ( payload: BackendFormSubmitPayload, connectionMethod: BackendConnectionMethod, metadata?: BackendConnectionTestMetadata, ) => void; source: BackendAddedSource; }) { const { t } = useTranslation("openhands"); const [selectedOption, setSelectedOption] = React.useState("cloud"); const [agentServerLocation, setAgentServerLocation] = React.useState("local"); const panelId = "add-backend-selected-panel"; const selectedTabId = `add-backend-option-${selectedOption}-tab`; const isCloudSelected = selectedOption === "cloud"; return (
} onSelect={setSelectedOption} panelId={panelId} testId="add-backend-option-cloud" /> } onSelect={setSelectedOption} panelId={panelId} testId="add-backend-option-agent-server" />
{isCloudSelected ? ( /* Padding keeps the CTA and Advanced host field from hugging the border; when Advanced expands the panel grows with the content instead of squeezing it into the reserved min-height. */
) : (
{/* Rules on either side center the toggle and read as a divider between the chooser and the connection form. */}
value={agentServerLocation} options={[ { value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL), icon: , }, { value: "remote", label: t(I18nKey.BACKEND$KIND_REMOTE), icon: , }, ]} onChange={setAgentServerLocation} ariaLabel={t(I18nKey.BACKEND$AGENT_SERVER_LOCATION)} testId="add-backend-location" />
)}
); } function AddBackendConnectionOptions({ onClose, source, }: { onClose: () => void; source: BackendAddedSource; }) { const { addBackend } = useActiveBackendContext(); const redirectAfterAdd = useRedirectAfterAddBackend(); const { trackBackendAdded } = useTracking(); const lockedCloudHost = getLockedCloudHost(); const handleConnected = React.useCallback( ( payload: BackendFormSubmitPayload, connectionMethod: BackendConnectionMethod, metadata?: BackendConnectionTestMetadata, ) => { addBackend(payload); trackBackendAdded({ backendKind: payload.kind, connectionMethod, hasApiKey: Boolean(payload.apiKey), source, agentServerVersion: metadata?.agentServerVersion, }); redirectAfterAdd(); onClose(); }, [addBackend, redirectAfterAdd, onClose, trackBackendAdded, source], ); if (lockedCloudHost) { return ( ); } return ; } // ── Modal wrappers ────────────────────────────────────────────────── /** * Modal wrapper. In **add** mode it renders a two-column layout * (manual connection | OR | Cloud login). In **edit** mode it wraps * the standard `BackendForm`. */ export function BackendFormModal({ mode, backend, onClose, source = "add_backend_modal", hideCloseButton = false, }: BackendFormModalProps) { const { t } = useTranslation("openhands"); if (mode === "add") { return (
{hideCloseButton ? null : ( )} {hideCloseButton ? null : (

{t(I18nKey.BACKEND$CHOOSER_TITLE)}

{t(I18nKey.BACKEND$CHOOSER_DESCRIPTION)}{" "} {t(I18nKey.CTA$LEARN_MORE)}

)}
); } // Edit mode — single-column form (unchanged) const testIdRoot = "edit-backend"; return (

{t(I18nKey.BACKEND$EDIT_TITLE)}

); }