diff --git a/.pr/cloud-settings-button-closeup.png b/.pr/cloud-settings-button-closeup.png new file mode 100644 index 0000000000..716cbc9943 Binary files /dev/null and b/.pr/cloud-settings-button-closeup.png differ diff --git a/.pr/cloud-settings-button-hidden-local-backend.png b/.pr/cloud-settings-button-hidden-local-backend.png new file mode 100644 index 0000000000..d6d1bff7fe Binary files /dev/null and b/.pr/cloud-settings-button-hidden-local-backend.png differ diff --git a/.pr/cloud-settings-button-visible.png b/.pr/cloud-settings-button-visible.png new file mode 100644 index 0000000000..15a21c9e25 Binary files /dev/null and b/.pr/cloud-settings-button-visible.png differ diff --git a/__tests__/components/features/settings/cloud-settings-link.test.tsx b/__tests__/components/features/settings/cloud-settings-link.test.tsx new file mode 100644 index 0000000000..d6bc200577 --- /dev/null +++ b/__tests__/components/features/settings/cloud-settings-link.test.tsx @@ -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( + + + + + , + ); +} + +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(); + }); +}); diff --git a/scripts/check-translation-completeness.cjs b/scripts/check-translation-completeness.cjs index 63f39b4c03..f6a2e17999 100755 --- a/scripts/check-translation-completeness.cjs +++ b/scripts/check-translation-completeness.cjs @@ -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', diff --git a/src/components/features/settings/cloud-settings-link.tsx b/src/components/features/settings/cloud-settings-link.tsx new file mode 100644 index 0000000000..445b44dd2c --- /dev/null +++ b/src/components/features/settings/cloud-settings-link.tsx @@ -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 ( + + + + + + {t(I18nKey.SETTINGS$CLOUD_SETTINGS_LINK)} + + + + ); +} diff --git a/src/components/features/settings/settings-desktop-sidebar.tsx b/src/components/features/settings/settings-desktop-sidebar.tsx index 39fc47c82d..6464481d37 100644 --- a/src/components/features/settings/settings-desktop-sidebar.tsx +++ b/src/components/features/settings/settings-desktop-sidebar.tsx @@ -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({ } /> ))} +
diff --git a/src/components/features/settings/settings-mobile-drawer.tsx b/src/components/features/settings/settings-mobile-drawer.tsx index 13d323e65f..8c1e941ad4 100644 --- a/src/components/features/settings/settings-mobile-drawer.tsx +++ b/src/components/features/settings/settings-mobile-drawer.tsx @@ -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({ /> ); })} +
diff --git a/src/components/features/settings/settings-mobile-hub.tsx b/src/components/features/settings/settings-mobile-hub.tsx index 9aca31b9f4..3ccd8ff19b 100644 --- a/src/components/features/settings/settings-mobile-hub.tsx +++ b/src/components/features/settings/settings-mobile-hub.tsx @@ -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} /> ))} +
diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 57712d72cb..9488656888 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -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": "削除",