diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx
index 99bbb1a061..d318c829a9 100644
--- a/__tests__/components/backends/add-backend-modal.test.tsx
+++ b/__tests__/components/backends/add-backend-modal.test.tsx
@@ -118,7 +118,7 @@ describe("AddBackendModal – two-column layout", () => {
expect(submit).not.toBeDisabled();
});
- it("saves the backend and closes WITHOUT switching the active selection", async () => {
+ it("saves the backend, switches to it, and closes", async () => {
const onClose = vi.fn();
renderWithProviders();
@@ -146,8 +146,11 @@ describe("AddBackendModal – two-column layout", () => {
kind: "local",
});
- // Adding a backend must NOT change the active selection.
- expect(window.localStorage.getItem("openhands-active-backend")).toBeNull();
+ // Active selection must point at the newly added backend.
+ const active = JSON.parse(
+ window.localStorage.getItem("openhands-active-backend") ?? "null",
+ );
+ expect(active).toEqual({ backendId: added.id, orgId: null });
});
it("shows the close button", () => {
diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx
index 986b2f8c44..a455af97bf 100644
--- a/__tests__/components/backends/backend-selector.test.tsx
+++ b/__tests__/components/backends/backend-selector.test.tsx
@@ -39,6 +39,21 @@ vi.mock("@openhands/typescript-client/clients", () => ({
ServerClient: vi.fn(),
}));
+// Shared seed configs reused across tests.
+const SEED_LOCAL_1 = {
+ name: "Local 1",
+ host: "http://localhost:9000",
+ apiKey: "k",
+ kind: "local" as const,
+};
+
+const SEED_CLOUD_PRODUCTION = {
+ name: "Production",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-key",
+ kind: "cloud" as const,
+};
+
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -141,12 +156,7 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
ctx.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
@@ -181,12 +191,11 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- cloudId = ctx.addBackend({
- name: "Production",
- host: "https://app.all-hands.dev",
- apiKey: "bearer-key",
- kind: "cloud",
- }).id;
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
+ // Add a second backend so auto-switch lands here, leaving the
+ // cloud backend unselected (the dropdown only expands org rows
+ // for non-active cloud backends).
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -245,6 +254,9 @@ describe("BackendSelector", () => {
apiKey: "key-acme",
kind: "cloud",
});
+ // Land on a local backend so both cloud backends are unselected
+ // and their org rows render in the dropdown.
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -289,12 +301,10 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- ctx.addBackend({
- name: "Production",
- host: "https://app.all-hands.dev",
- apiKey: "bearer-key",
- kind: "cloud",
- });
+ ctx.addBackend(SEED_CLOUD_PRODUCTION);
+ // Land on a local backend so the cloud backend is unselected
+ // and its org rows render in the dropdown.
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -333,12 +343,7 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- cloudId = ctx.addBackend({
- name: "Production",
- host: "https://app.all-hands.dev",
- apiKey: "bearer-key",
- kind: "cloud",
- }).id;
+ cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id;
// Simulate the post-refresh malformed state: active backend is
// the cloud one but no orgId is set yet.
ctx.setActive(cloudId, null);
@@ -364,24 +369,20 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
,
);
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
const user = await openDropdown();
- await user.click(screen.getByText("Local 1"));
+ await user.click(screen.getByText("Local"));
const wrapper = screen.getByTestId("backend-selector");
const input = wrapper.querySelector("input") as HTMLInputElement;
- expect(input.value).toBe("Local 1");
+ expect(input.value).toBe("Local");
});
it("redirects to home when switching backends from a conversation route", async () => {
@@ -389,12 +390,7 @@ describe("BackendSelector", () => {
return (
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -419,8 +415,9 @@ describe("BackendSelector", () => {
,
);
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
const user = await openDropdown();
- await user.click(screen.getByText("Local 1"));
+ await user.click(screen.getByText("Local"));
expect(await screen.findByTestId("home")).toBeInTheDocument();
});
@@ -430,12 +427,7 @@ describe("BackendSelector", () => {
return (
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -461,8 +453,9 @@ describe("BackendSelector", () => {
,
);
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
const user = await openDropdown();
- await user.click(screen.getByText("Local 1"));
+ await user.click(screen.getByText("Local"));
// The settings route is still in the DOM after the switch — no
// redirect to /conversations or anywhere else.
@@ -495,17 +488,17 @@ describe("BackendSelector", () => {
);
render();
- // Act — select a different backend, then immediately unmount the
- // selector (the click itself would do this in production via the
- // outside-click handler).
+ // Act — auto-switch lands on "Acme Local"; click the seeded default
+ // to trigger a switch, then immediately unmount the selector (the
+ // click itself would do this in production via the outside-click handler).
const user = await openDropdown();
- await user.click(screen.getByText("Acme Local"));
+ await user.click(screen.getByText("Local"));
selectorRender.unmount();
// Assert — the overlay is still in the DOM with the chosen target
expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute(
"data-target",
- "Acme Local",
+ "Local",
);
});
@@ -560,12 +553,7 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -661,12 +649,7 @@ describe("BackendSelector", () => {
return (
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -691,8 +674,9 @@ describe("BackendSelector", () => {
,
);
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
const user = await openDropdown();
- await user.click(screen.getByText("Local 1"));
+ await user.click(screen.getByText("Local"));
expect(await screen.findByTestId("automations-list")).toBeInTheDocument();
});
@@ -702,12 +686,7 @@ describe("BackendSelector", () => {
return (
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
@@ -735,12 +714,13 @@ describe("BackendSelector", () => {
const settingsButton = screen.getByTestId("backend-selector-settings-link");
expect(settingsButton).toHaveAttribute("data-active", "true");
+ // Auto-switch lands on "Local 1"; click the seeded default to switch.
const user = await openDropdown();
- await user.click(screen.getByText("Local 1"));
+ await user.click(screen.getByText("Local"));
const wrapper = screen.getByTestId("backend-selector");
const input = wrapper.querySelector("input") as HTMLInputElement;
- expect(input.value).toBe("Local 1");
+ expect(input.value).toBe("Local");
expect(screen.queryByTestId("home")).not.toBeInTheDocument();
});
@@ -755,12 +735,7 @@ describe("BackendSelector", () => {
renderWithProviders(
{
- ctx.addBackend({
- name: "Local 1",
- host: "http://localhost:9000",
- apiKey: "k",
- kind: "local",
- });
+ ctx.addBackend(SEED_LOCAL_1);
}}
>
diff --git a/__tests__/contexts/active-backend-context.test.tsx b/__tests__/contexts/active-backend-context.test.tsx
index 4f3fc11c3b..2b93e5232d 100644
--- a/__tests__/contexts/active-backend-context.test.tsx
+++ b/__tests__/contexts/active-backend-context.test.tsx
@@ -76,6 +76,30 @@ describe("ActiveBackendProvider", () => {
});
});
+ // @spec BM-001 — Auto-switch to newly connected backend
+ it("addBackend automatically switches the active backend to the newly added one", () => {
+ const { result } = renderHook(() => useActiveBackendContext(), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.active.backend.id).toBe(DEFAULT_LOCAL_BACKEND_ID);
+
+ let added: { id: string } | null = null;
+ act(() => {
+ added = result.current.addBackend({
+ name: "OpenHands Cloud",
+ host: "https://app.all-hands.dev",
+ apiKey: "bearer-token",
+ kind: "cloud",
+ });
+ });
+
+ expect(result.current.active.backend.id).toBe(added!.id);
+ // Previous backends remain in the registry.
+ expect(result.current.backends).toHaveLength(2);
+ expect(result.current.backends.find((b) => b.id === DEFAULT_LOCAL_BACKEND_ID)).toBeDefined();
+ });
+
it("setActive switches the active backend without touching unrelated React Query cache entries", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["dummy"], { value: 1 });
diff --git a/specs/backend-management.md b/specs/backend-management.md
new file mode 100644
index 0000000000..98e41b079b
--- /dev/null
+++ b/specs/backend-management.md
@@ -0,0 +1,4 @@
+# Backend Management Specs
+
+## BM-001: Auto-switch on connect
+- [x] Adding a backend shall automatically switch the active selection to it.
diff --git a/src/contexts/active-backend-context.tsx b/src/contexts/active-backend-context.tsx
index 26077df427..bfee8baf69 100644
--- a/src/contexts/active-backend-context.tsx
+++ b/src/contexts/active-backend-context.tsx
@@ -73,11 +73,13 @@ export function ActiveBackendProvider({
[],
);
+ // @spec BM-001 — Auto-switch to newly connected backend
const addBackend = React.useCallback(
(backend: Omit): Backend => {
const next: Backend = { ...backend, id: generateId() };
const list = [...getRegisteredBackends(), next];
setRegisteredBackends(list);
+ setActiveSelection({ backendId: next.id });
return next;
},
[],