mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:17:58 +08:00
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:
committed by
GitHub
co-authored by
openhands
neubig
allhands-bot
parent
3931f4c30c
commit
3f52df2e39
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 />
|
||||
|
||||
@@ -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": "削除",
|
||||
|
||||
Reference in New Issue
Block a user