From 932edbf812525427d7b8873e7507b280e6528492 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 30 Jul 2026 23:35:47 -0700 Subject: [PATCH] feat(backends): compact Cloud vs Agent-server add-backend chooser (#16211) Co-authored-by: Devin Co-authored-by: Cursor Co-authored-by: Graham Neubig Co-authored-by: Graham Neubig Co-authored-by: openhands --- .../backends/add-backend-modal.test.tsx | 259 ++++++- .../backends/manage-backends-modal.test.tsx | 1 + .../features/backends/backend-form-modal.tsx | 691 +++++++++++++++--- .../features/backends/device-flow-auth.tsx | 43 +- .../backends/manage-backends-modal.tsx | 2 +- .../features/files-tab/segmented-toggle.tsx | 9 +- .../features/settings/settings-input.tsx | 15 + src/i18n/translation.json | 304 +++++++- .../backends/mock-llm-cross-connect.spec.ts | 7 + 9 files changed, 1152 insertions(+), 179 deletions(-) diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx index 5f8f8b89fa..f8d1ebe6a6 100644 --- a/__tests__/components/backends/add-backend-modal.test.tsx +++ b/__tests__/components/backends/add-backend-modal.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MockInstance } from "vitest"; @@ -15,6 +15,23 @@ import * as telemetry from "#/services/telemetry"; const getServerInfoMock = vi.hoisted(() => vi.fn()); +const deviceFlowMocks = vi.hoisted(() => ({ + startDeviceFlow: vi.fn(), + pollForToken: vi.fn(), +})); + +// Partial mock: only the network calls are stubbed so the rest of the module +// (host classification) keeps its production behavior. +vi.mock("#/api/device-flow-client", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + startDeviceFlow: deviceFlowMocks.startDeviceFlow, + pollForToken: deviceFlowMocks.pollForToken, + }; +}); + vi.mock("@openhands/typescript-client/clients", () => ({ ServerClient: vi.fn(function ServerClientMock() { return { @@ -51,11 +68,28 @@ function renderWithProviders( ); } +async function selectAgentServer(user = userEvent.setup()) { + await user.click(screen.getByTestId("add-backend-option-agent-server")); + return user; +} + beforeEach(() => { captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined); window.localStorage.clear(); getServerInfoMock.mockReset(); getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); + deviceFlowMocks.startDeviceFlow.mockReset(); + deviceFlowMocks.startDeviceFlow.mockResolvedValue({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://app.all-hands.dev/device", + verification_uri_complete: + "https://app.all-hands.dev/device?user_code=ABCD-EFGH", + expires_in: 600, + interval: 5, + }); + deviceFlowMocks.pollForToken.mockReset(); + deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {})); __resetActiveStoreForTests(); }); @@ -65,42 +99,162 @@ afterEach(() => { __resetActiveStoreForTests(); }); -describe("AddBackendModal – two-column layout", () => { - it("renders a two-column layout with manual and cloud sections", () => { +describe("AddBackendModal – connection chooser", () => { + it("renders OpenHands Cloud first with its brand mark", () => { renderWithProviders(); - expect(screen.getByTestId("add-backend-name")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-host")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-host-helper")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-api-key")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-submit")).toBeInTheDocument(); - - expect(screen.getByTestId("add-backend-cloud-title")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-login-button")).toBeInTheDocument(); + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveAttribute("data-testid", "add-backend-option-cloud"); + expect(tabs[0]).toHaveAttribute("aria-selected", "true"); + expect(tabs[1]).toHaveAttribute( + "data-testid", + "add-backend-option-agent-server", + ); + expect(tabs[1]).toHaveAttribute("aria-selected", "false"); expect( - screen.getByTestId("add-backend-advanced-toggle"), + within(tabs[0]).getByTestId("add-backend-option-cloud-logo"), ).toBeInTheDocument(); + expect(tabs[0]).toHaveTextContent("BACKEND$CLOUD_OPTION_DESCRIPTION"); + expect(tabs[1]).toHaveTextContent( + "BACKEND$AGENT_SERVER_OPTION_DESCRIPTION", + ); + + expect(screen.getByTestId("add-backend-cloud-panel")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-login-button")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-description")).toHaveTextContent( + "BACKEND$CHOOSER_DESCRIPTION", + ); + expect( + screen.getByTestId("add-backend-deployment-options-link"), + ).toHaveAttribute( + "href", + "https://docs.openhands.dev/overview/introduction", + ); + // Short inline link so the description reads as one flowing sentence. + expect( + screen.getByTestId("add-backend-deployment-options-link"), + ).toHaveTextContent("CTA$LEARN_MORE"); }); - it("starts with an empty host field (no prefilled value)", () => { + it("hides the Advanced host disclosure while authorization is pending", async () => { + const user = userEvent.setup(); + vi.spyOn(window, "open").mockReturnValue({ + closed: false, + close: vi.fn(), + location: { href: "" }, + } as unknown as Window); renderWithProviders(); + expect(screen.getByTestId("add-backend-advanced-toggle")).toBeVisible(); + + await user.click(screen.getByTestId("add-backend-login-button")); + + expect( + await screen.findByTestId("add-backend-auth-awaiting"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("add-backend-advanced-toggle"), + ).not.toBeInTheDocument(); + }); + + it("shows Local and Remote inside the Agent-server tab", async () => { + renderWithProviders(); + + await selectAgentServer(); + + expect( + screen.getByTestId("add-backend-agent-server-panel"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("add-backend-location-option-local"), + ).toHaveAttribute("aria-checked", "true"); + expect( + screen.getByTestId("add-backend-location-option-remote"), + ).toHaveAttribute("aria-checked", "false"); + expect( + screen + .getByTestId("add-backend-location-option-local") + .querySelector("svg"), + ).not.toBeNull(); + expect( + screen + .getByTestId("add-backend-location-option-remote") + .querySelector("svg"), + ).not.toBeNull(); + expect( + screen.getByTestId("add-backend-local-guidance"), + ).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-local-docs-link")).toHaveAttribute( + "href", + expect.stringContaining("docs/DEVELOPMENT.md"), + ); + expect(screen.getByTestId("add-backend-name")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-host")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-api-key")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-submit")).toBeInTheDocument(); + }); + + it("keeps the full setup guidance in a collapsible note", async () => { + renderWithProviders(); + const user = await selectAgentServer(); + + expect( + screen.getByTestId("add-backend-local-guidance-toggle"), + ).toHaveAttribute("aria-expanded", "false"); + expect( + screen.getByTestId("add-backend-local-guidance-body"), + ).toHaveAttribute("aria-hidden", "true"); + + await user.click(screen.getByTestId("add-backend-local-guidance-toggle")); + expect( + screen.getByTestId("add-backend-local-guidance-toggle"), + ).toHaveAttribute("aria-expanded", "true"); + const localGuidance = screen.getByTestId("add-backend-local-guidance"); + expect(localGuidance).toHaveTextContent("BACKEND$LOCAL_SETUP_DESCRIPTION"); + expect(localGuidance).toHaveTextContent( + "agent-canvas --backend-only --port 8001", + ); + + await user.click(screen.getByTestId("add-backend-location-option-remote")); + expect( + screen.getByTestId("add-backend-remote-guidance-toggle"), + ).toHaveAttribute("aria-expanded", "false"); + expect( + screen.getByTestId("add-backend-remote-guidance-body"), + ).toHaveAttribute("aria-hidden", "true"); + + await user.click(screen.getByTestId("add-backend-remote-guidance-toggle")); + const remoteGuidance = screen.getByTestId("add-backend-remote-guidance"); + expect(remoteGuidance).toHaveTextContent( + "BACKEND$REMOTE_SETUP_DESCRIPTION", + ); + expect(remoteGuidance).toHaveTextContent( + "BACKEND$REMOTE_CONNECTION_DESCRIPTION", + ); + }); + + it("starts the Agent-server form with an empty host field", async () => { + renderWithProviders(); + + await selectAgentServer(); + expect(screen.getByTestId("add-backend-host")).toHaveValue(""); }); it("disables Connect until name and host are filled (local backend)", async () => { renderWithProviders(); + const user = await selectAgentServer(); const submit = screen.getByTestId( "add-backend-submit", ) as HTMLButtonElement; expect(submit).toBeDisabled(); - const user = userEvent.setup(); await user.type(screen.getByTestId("add-backend-name"), "My Server"); expect(submit).toBeDisabled(); - // A localhost host infers "local" kind → no API key required + // Local agent-server connections do not require an API key. await user.type( screen.getByTestId("add-backend-host"), "http://localhost:8000", @@ -112,7 +266,7 @@ describe("AddBackendModal – two-column layout", () => { const onClose = vi.fn(); renderWithProviders(); - const user = userEvent.setup(); + const user = await selectAgentServer(); await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); await user.type( screen.getByTestId("add-backend-host"), @@ -137,31 +291,54 @@ describe("AddBackendModal – two-column layout", () => { }); }); - it("requires API key when host infers cloud kind", async () => { - renderWithProviders(); + it("requires an API key for a Remote agent-server", async () => { + const onClose = vi.fn(); + renderWithProviders(); + const user = await selectAgentServer(); + await user.click(screen.getByTestId("add-backend-location-option-remote")); const submit = screen.getByTestId( "add-backend-submit", ) as HTMLButtonElement; - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "Cloud"); + expect( + screen.getByTestId("add-backend-remote-guidance"), + ).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-remote-docs-link")).toHaveAttribute( + "href", + expect.stringContaining("docs/SELF_HOSTING.md"), + ); + + await user.type(screen.getByTestId("add-backend-name"), "Remote GPU"); await user.type( screen.getByTestId("add-backend-host"), - "https://app.openhands.dev", + "https://agent.example.com", ); - // Cloud host without API key → submit should be disabled expect(submit).toBeDisabled(); await user.type(screen.getByTestId("add-backend-api-key"), "token"); expect(submit).not.toBeDisabled(); + + await user.click(submit); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + + const stored = JSON.parse( + window.localStorage.getItem("openhands-backends") ?? "[]", + ); + expect( + stored.find((backend: { name: string }) => backend.name === "Remote GPU"), + ).toMatchObject({ + host: "https://agent.example.com", + apiKey: "token", + kind: "local", + }); }); it("saves the backend, switches to it, and closes", async () => { const onClose = vi.fn(); renderWithProviders(); - const user = userEvent.setup(); + const user = await selectAgentServer(); await user.type(screen.getByTestId("add-backend-name"), "Local 1"); await user.type( screen.getByTestId("add-backend-host"), @@ -197,7 +374,7 @@ describe("AddBackendModal – two-column layout", () => { const onClose = vi.fn(); renderWithProviders(); - const user = userEvent.setup(); + const user = await selectAgentServer(); await user.type(screen.getByTestId("add-backend-name"), "GPU Tunnel"); await user.type( screen.getByTestId("add-backend-host"), @@ -220,7 +397,7 @@ describe("AddBackendModal – two-column layout", () => { const onClose = vi.fn(); renderWithProviders(); - const user = userEvent.setup(); + const user = await selectAgentServer(); await user.type(screen.getByTestId("add-backend-name"), "Old Tunnel"); await user.type( screen.getByTestId("add-backend-host"), @@ -245,21 +422,43 @@ describe("AddBackendModal – two-column layout", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("keeps advanced host settings in the layout when collapsed", async () => { + it("hides advanced host settings until expanded while preserving what was typed", async () => { const user = userEvent.setup(); renderWithProviders(); - expect(screen.getByTestId("add-backend-cloud-host")).toBeInTheDocument(); + // Collapsed: mounted so state survives, but collapsed to zero height and + // kept out of the tab order. + expect(screen.getByTestId("add-backend-advanced-panel")).toHaveAttribute( + "aria-hidden", + "true", + ); await user.click(screen.getByTestId("add-backend-advanced-toggle")); + expect(screen.getByTestId("add-backend-advanced-toggle")).toHaveAttribute( + "aria-expanded", + "true", + ); + expect( + screen.getByTestId("add-backend-advanced-panel"), + ).not.toHaveAttribute("aria-hidden", "true"); - expect(screen.getByTestId("add-backend-cloud-host")).toBeInTheDocument(); + await user.type( + screen.getByTestId("add-backend-cloud-host"), + "https://cloud.example.com", + ); + await user.click(screen.getByTestId("add-backend-advanced-toggle")); + await user.click(screen.getByTestId("add-backend-advanced-toggle")); + + expect(screen.getByTestId("add-backend-cloud-host")).toHaveValue( + "https://cloud.example.com", + ); }); it("renders the cloud login button without a key icon prefix", () => { renderWithProviders(); const loginButton = screen.getByTestId("add-backend-login-button"); + expect(loginButton).not.toHaveClass("w-full"); expect(loginButton.textContent?.trim()).not.toMatch(/^🔑/); expect(loginButton.textContent).not.toContain("🔑"); }); @@ -282,7 +481,7 @@ describe("AddBackendModal – redirect after adding a backend", () => { } async function addLocalBackend() { - const user = userEvent.setup(); + const user = await selectAgentServer(); await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); await user.type( screen.getByTestId("add-backend-host"), @@ -332,7 +531,7 @@ describe("AddBackendModal – analytics", () => { it("captures backend_added once with manual connection metadata", async () => { // Arrange renderWithProviders(); - const user = userEvent.setup(); + const user = await selectAgentServer(); // Act — connect a local backend through the manual form await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); diff --git a/__tests__/components/backends/manage-backends-modal.test.tsx b/__tests__/components/backends/manage-backends-modal.test.tsx index 18be777f02..e22ae2005c 100644 --- a/__tests__/components/backends/manage-backends-modal.test.tsx +++ b/__tests__/components/backends/manage-backends-modal.test.tsx @@ -502,6 +502,7 @@ describe("ManageBackendsModal", () => { await user.click(await screen.findByTestId("manage-backends-add")); await screen.findByTestId("add-backend-modal"); + await user.click(screen.getByTestId("add-backend-option-agent-server")); await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); await user.type( diff --git a/src/components/features/backends/backend-form-modal.tsx b/src/components/features/backends/backend-form-modal.tsx index 4591b30818..652db065e8 100644 --- a/src/components/features/backends/backend-form-modal.tsx +++ b/src/components/features/backends/backend-form-modal.tsx @@ -1,6 +1,7 @@ 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"; @@ -33,6 +34,8 @@ 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"; @@ -142,10 +145,18 @@ function isValidHostUrl(host: string): boolean { } 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"], @@ -308,6 +319,8 @@ interface UseBackendFormOptions { * 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; } /** @@ -324,6 +337,7 @@ function useBackendForm({ onSuccess, requireApiKey = false, onSubmitOverride, + fixedKind, }: UseBackendFormOptions) { const { t } = useTranslation("openhands"); @@ -342,7 +356,7 @@ function useBackendForm({ // 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 = kindOverride ?? inferKindFromHost(host); + const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host); const needsApiKey = requireApiKey || kind !== "local"; const canSubmit = name.trim().length > 0 && @@ -395,6 +409,7 @@ function useBackendForm({ onSuccess, requireApiKey, onSubmitOverride, + fixedKind, t, ], ); @@ -793,6 +808,8 @@ interface ManualConnectionColumnProps { submitLabel: React.ReactNode; submittingLabel: React.ReactNode; submitTestId?: string; + fixedKind?: BackendKind; + showKindSelector?: boolean; } /** @@ -807,6 +824,8 @@ function ManualConnectionColumn({ submitLabel, submittingLabel, submitTestId, + fixedKind, + showKindSelector = true, }: ManualConnectionColumnProps) { const { t } = useTranslation("openhands"); @@ -842,6 +861,7 @@ function ManualConnectionColumn({ ); }, requireApiKey, + fixedKind, }); return ( @@ -850,62 +870,53 @@ function ManualConnectionColumn({ onSubmit={handleSubmit} className="flex flex-col gap-4 flex-1 min-w-0" > -
- { - 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" - /> -

- {t(I18nKey.BACKEND$NAME_HELPER)} -

-
+ { + 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" - /> -

- {t(I18nKey.BACKEND$HOST_HELPER)} -

-
+ { + setHost(value); + setConnectionError(null); + }} + // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable + placeholder="http://localhost:8000" + className="w-full" + /> -
- {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`} - /> -
+ {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} -
- +
+ {showBranding ? ( +
+ -

- {t(I18nKey.BACKEND$CLOUD_TITLE)} -

-
- -

- {t(I18nKey.BACKEND$CLOUD_DESCRIPTION)} -

+

+ {t(I18nKey.BACKEND$CLOUD_TITLE)} +

+
+ ) : null} - - {lockedHost ? null : ( -
- + {/* 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" + /> + +
+ +
+ + +
+
+ )} +
+
+
); } @@ -1066,6 +1498,7 @@ function AddBackendConnectionOptions({ const { addBackend } = useActiveBackendContext(); const redirectAfterAdd = useRedirectAfterAddBackend(); const { trackBackendAdded } = useTracking(); + const lockedCloudHost = getLockedCloudHost(); const handleConnected = React.useCallback( ( @@ -1087,12 +1520,18 @@ function AddBackendConnectionOptions({ [addBackend, redirectAfterAdd, onClose, trackBackendAdded, source], ); - return ( - - ); + if (lockedCloudHost) { + return ( + + ); + } + + return ; } // ── Modal wrappers ────────────────────────────────────────────────── @@ -1124,24 +1563,42 @@ export function BackendFormModal({ hideCloseButton ? "onboarding-modal" : "add-backend-modal" } className={cn( - "relative rounded-xl border border-[var(--oh-border)] bg-base-secondary", - modalWidthClassName("xl"), + "relative max-h-[92vh] w-[720px] overflow-y-auto rounded-xl border border-[var(--oh-border)] bg-base-secondary p-6", MODAL_MAX_WIDTH_VIEWPORT, )} > {hideCloseButton ? null : ( )} - {/* Header - hide in locked Cloud first-run mode for cleaner UX */} {hideCloseButton ? null : ( -
+

- {t(I18nKey.BACKEND$ADD_TITLE)} + {t(I18nKey.BACKEND$CHOOSER_TITLE)}

+

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

)} -
+
diff --git a/src/components/features/backends/device-flow-auth.tsx b/src/components/features/backends/device-flow-auth.tsx index 6398a942fd..eb79588969 100644 --- a/src/components/features/backends/device-flow-auth.tsx +++ b/src/components/features/backends/device-flow-auth.tsx @@ -28,6 +28,14 @@ interface DeviceFlowAuthProps { idleButtonLabel?: string; /** Optional visible content for the idle button. Defaults to the idle label. */ idleButtonContent?: React.ReactNode; + /** Optional content shown only before authentication starts. */ + idleDescription?: React.ReactNode; + /** + * Optional content rendered under the idle button and, like + * `idleDescription`, only before authentication starts — so secondary + * controls step aside once the flow takes over the surface. + */ + idleFooter?: React.ReactNode; /** Optional classes for the root wrapper. */ className?: string; /** Optional classes for the idle button. */ @@ -43,7 +51,7 @@ interface DeviceFlowAuthProps { /** * Device Flow authentication UI component. * - * Shows a "Login with OpenHands Cloud" button that initiates OAuth 2.0 Device Flow + * Shows a "Connect to OpenHands" button that initiates OAuth 2.0 Device Flow * authentication. Displays status during the auth process and auto-opens * the browser for user authorization. */ @@ -67,6 +75,8 @@ export function DeviceFlowAuth({ isDisabled = false, idleButtonLabel, idleButtonContent, + idleDescription, + idleFooter, className, buttonClassName, buttonVariant = "primary", @@ -180,6 +190,8 @@ export function DeviceFlowAuth({ data-testid={`${testIdRoot}-device-flow`} className={cn("flex flex-col gap-3", className)} > + {deviceFlow.status === "idle" ? idleDescription : null} + {deviceFlow.status === "idle" && buttonVariant === "unstyled" && ( ); diff --git a/src/components/features/settings/settings-input.tsx b/src/components/features/settings/settings-input.tsx index 1d35bcb9bc..7c60252a91 100644 --- a/src/components/features/settings/settings-input.tsx +++ b/src/components/features/settings/settings-input.tsx @@ -36,6 +36,12 @@ interface SettingsInputProps { error?: string; /** Renders a red asterisk next to the label to mark the field as required. */ showRequiredTag?: boolean; + /** + * Short guidance rendered next to the label, above the input. It sits inside + * the `
{ timeout: 5_000, }); + // The modal defaults to the Cloud tab; switch to Agent-server to access + // the manual connection form. + await page.getByTestId("add-backend-option-agent-server").click(); + await expect( + page.getByTestId("add-backend-agent-server-panel"), + ).toBeVisible({ timeout: 5_000 }); + // Fill in Backend B details const nameInput = page.getByTestId("add-backend-name"); await nameInput.click();