feat(settings): add Cloud link to settings sidebar for cloud backends (#1453)

* feat(settings): add Cloud link to settings sidebar for cloud backends

Add a "Cloud" external link at the bottom of the Settings sidebar that
appears only when the active backend is a Cloud backend. It links to
`{cloudHost}/settings` (opens in a new tab) with an external-link icon,
giving users a quick path to their hosted account/settings page. Local
backends and the no-backend state render nothing.

- New `CloudSettingsLink` component reads the active backend and renders
  only for cloud backends, normalizing the host (trailing slash) before
  appending `/settings`.
- Added to the desktop sidebar, mobile hub, and mobile drawer (next to
  the existing backend-synced badge).
- New i18n key `SETTINGS$CLOUD_SETTINGS_LINK` (allowlisted as a brand
  name since "Cloud" is identical across locales).
- Added unit tests covering cloud/local/no-backend cases and URL building.

Screenshot of the new Cloud button in the Settings window: .pr/settings-cloud-button.png

Co-authored-by: openhands <openhands@all-hands.dev>

* docs(pr): replace screenshot with correct Settings sidebar view

The previous screenshot captured the manage-backends overlay that
appears for a logged-out cloud backend, not the Settings sidebar.
Re-captured against a connected cloud backend so the Cloud link is
visible at the bottom of the Settings sidebar alongside the nav items.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat(settings): add cloud glyph to Cloud settings sidebar link

Render the Cloud settings link with a leading cloud icon (lucide `Cloud`)
next to the "Cloud" label, keeping the trailing external-link icon so
users can tell it opens the hosted page in a new tab. Matches the other
iconified rows in the Settings sidebar.

Update the PR screenshot to show the new two-icon layout.

* feat(settings): place Cloud link below Secrets with nav-row styling

Move the Cloud settings link out of the sidebar footer into the nav
list, directly below the Secrets entry and above the synced-settings
badge, so it sits where users expect a settings sub-page to be.

Restyle the link to match the other sidebar nav rows: reuse the shared
sidebar-layout classes (sidebarNavRowClassName + SIDEBAR_ROW_INTERACTIVE
idle, SIDEBAR_ICON_SLOT_CLASS, sidebarNavLabelClassName) so it has the
same height, padding, border-radius, transparent idle background, and
hover background as Secrets/LLM/etc. Drop the bespoke bordered card.
Still renders a leading cloud glyph, the "Cloud" label, and a trailing
external-link icon.

Apply the same placement in the mobile hub and mobile drawer.

* chore: Remove PR-only artifacts

* docs(settings): simplify CloudSettingsLink JSDoc; add PR screenshots

- Apply reviewer suggestion to trim verbose JSDoc (keep only the
  non-obvious note about why local backends are excluded).
- Add the three evidence screenshots referenced in the PR description
  to .pr/ so the Video/Screenshots links resolve on the branch.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: neubig <398875+neubig@users.noreply.github.com>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
This commit is contained in:
Graham Neubig
2026-06-25 21:16:21 +00:00
committed by GitHub
co-authored by openhands neubig allhands-bot
parent 3931f4c30c
commit 3f52df2e39
10 changed files with 170 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { CloudSettingsLink } from "#/components/features/settings/cloud-settings-link";
const cloudBackend: Backend = {
id: "cloud-1",
name: "OpenHands Cloud",
host: "https://app.all-hands.dev",
apiKey: "token",
kind: "cloud",
};
const localBackend: Backend = {
id: "local-1",
name: "Local",
host: "http://localhost:3001",
apiKey: "local-key",
kind: "local",
};
function renderWithProviders() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<CloudSettingsLink />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("CloudSettingsLink", () => {
it("renders a link to {cloudHost}/settings when a cloud backend is active", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
renderWithProviders();
const link = screen.getByTestId("settings-cloud-link");
expect(link).toHaveAttribute("href", "https://app.all-hands.dev/settings");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
// The test i18n mock returns the key verbatim; the human label
// ("Cloud") is exercised via the translation.json entry instead.
expect(link).toHaveTextContent("SETTINGS$CLOUD_SETTINGS_LINK");
});
it("strips a trailing slash when building the cloud settings URL", () => {
setRegisteredBackends([
{ ...cloudBackend, host: "https://app.all-hands.dev/" },
]);
setActiveSelection({ backendId: cloudBackend.id });
renderWithProviders();
expect(screen.getByTestId("settings-cloud-link")).toHaveAttribute(
"href",
"https://app.all-hands.dev/settings",
);
});
it("renders nothing when a local backend is active", () => {
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
renderWithProviders();
expect(screen.queryByTestId("settings-cloud-link")).toBeNull();
});
it("renders nothing when no backend is configured", () => {
renderWithProviders();
expect(screen.queryByTestId("settings-cloud-link")).toBeNull();
});
});
@@ -34,6 +34,7 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
'SETTINGS$AGENT_SERVER_URL_PLACEHOLDER',
'SETTINGS$AGENT_TYPE_OPENHANDS',
'SETTINGS$AZURE_DEVOPS',
'SETTINGS$CLOUD_SETTINGS_LINK',
'SETTINGS$GITHUB',
'SETTINGS$GITLAB',
'SETTINGS$MCP_DEFAULT_CONFIG',
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { Cloud, ExternalLink } from "lucide-react";
import { I18nKey } from "#/i18n/declaration";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { isNoBackend } from "#/api/backend-registry/active-store";
import { cn } from "#/utils/utils";
import {
SIDEBAR_ICON_SLOT_CLASS,
SIDEBAR_ROW_INTERACTIVE_CLASS,
sidebarNavLabelClassName,
sidebarNavRowClassName,
} from "#/components/features/sidebar/sidebar-layout";
/**
* Renders only for cloud backends — local backends have no equivalent
* hosted settings page.
*/
export function CloudSettingsLink() {
const { t } = useTranslation("openhands");
const { active } = useActiveBackendContext();
const { backend } = active;
if (isNoBackend(backend) || backend.kind !== "cloud") return null;
const cloudSettingsUrl = `${backend.host.replace(/\/+$/, "")}/settings`;
return (
<a
data-testid="settings-cloud-link"
href={cloudSettingsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
sidebarNavRowClassName({ collapsed: false }),
SIDEBAR_ROW_INTERACTIVE_CLASS.idle,
)}
>
<span className={SIDEBAR_ICON_SLOT_CLASS}>
<Cloud className="size-4 shrink-0" aria-hidden />
</span>
<span className={cn(sidebarNavLabelClassName(false), "flex-1")}>
{t(I18nKey.SETTINGS$CLOUD_SETTINGS_LINK)}
</span>
<ExternalLink
className="size-4 shrink-0 text-[var(--oh-muted)]"
aria-hidden
/>
</a>
);
}
@@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items";
import { SidebarNavLink } from "#/components/features/sidebar/sidebar-nav-link";
import { BackendSyncedSettingsBadge } from "#/components/features/settings/backend-synced-settings-badge";
import { CloudSettingsLink } from "#/components/features/settings/cloud-settings-link";
interface SettingsDesktopSidebarProps {
navigationItems: SettingsNavRenderedItem[];
@@ -61,6 +62,7 @@ export function SettingsDesktopSidebar({
}
/>
))}
<CloudSettingsLink />
</div>
<div className="px-2 pt-3">
<BackendSyncedSettingsBadge />
@@ -10,6 +10,7 @@ import { SettingsNavDivider } from "./settings-nav-divider";
import { SettingsNavLink } from "./settings-nav-link";
import { navInteractiveTransitionClassName } from "#/components/features/sidebar/sidebar-layout";
import { BackendSyncedSettingsBadge } from "#/components/features/settings/backend-synced-settings-badge";
import { CloudSettingsLink } from "#/components/features/settings/cloud-settings-link";
interface SettingsMobileDrawerProps {
isMobileMenuOpen: boolean;
@@ -87,6 +88,7 @@ export function SettingsMobileDrawer({
/>
);
})}
<CloudSettingsLink />
</div>
<div className="px-2 pt-3">
@@ -4,6 +4,7 @@ import { I18nKey } from "#/i18n/declaration";
import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items";
import { SidebarNavLink } from "#/components/features/sidebar/sidebar-nav-link";
import { BackendSyncedSettingsBadge } from "#/components/features/settings/backend-synced-settings-badge";
import { CloudSettingsLink } from "#/components/features/settings/cloud-settings-link";
interface SettingsMobileHubProps {
navigationItems: SettingsNavRenderedItem[];
@@ -34,6 +35,7 @@ export function SettingsMobileHub({ navigationItems }: SettingsMobileHubProps) {
icon={renderedItem.item.icon}
/>
))}
<CloudSettingsLink />
</nav>
<div className="pt-1">
<BackendSyncedSettingsBadge />
+17
View File
@@ -27539,6 +27539,23 @@
"uk": "Ці налаштування синхронізовано з бекендом {{name}} ({{host}})",
"ca": "Aquesta configuració està sincronitzada amb el backend {{name}} ({{host}})"
},
"SETTINGS$CLOUD_SETTINGS_LINK": {
"en": "Cloud",
"ja": "Cloud",
"zh-CN": "Cloud",
"zh-TW": "Cloud",
"ko-KR": "Cloud",
"no": "Cloud",
"it": "Cloud",
"pt": "Cloud",
"es": "Cloud",
"ar": "Cloud",
"fr": "Cloud",
"tr": "Cloud",
"de": "Cloud",
"uk": "Cloud",
"ca": "Cloud"
},
"BACKEND$REMOVE": {
"en": "Remove",
"ja": "削除",