diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index fe0cf5f91b..8d42c9b06d 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -125,7 +125,7 @@ describe("ChatInterface - Chat Suggestions", () => { }); (useConfig as unknown as ReturnType).mockReturnValue({ - data: { APP_MODE: "local" }, + data: { app_mode: "local" }, }); (useGetTrajectory as unknown as ReturnType).mockReturnValue({ mutate: vi.fn(), @@ -258,7 +258,7 @@ describe("ChatInterface - Empty state", () => { errorMessage: null, }); (useConfig as unknown as ReturnType).mockReturnValue({ - data: { APP_MODE: "local" }, + data: { app_mode: "local" }, }); (useGetTrajectory as unknown as ReturnType).mockReturnValue({ mutate: vi.fn(), diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 4ba839b8af..fcabd84d94 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -113,15 +113,15 @@ describe("ExpandableMessage", () => { it("should render the out of credits message when the user is out of credits", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields + // @ts-expect-error - We only care about the app_mode and feature_flags fields getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - FEATURE_FLAGS: { - ENABLE_BILLING: true, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); const RouterStub = createRoutesStub([ diff --git a/frontend/__tests__/components/event-message.test.tsx b/frontend/__tests__/components/event-message.test.tsx index 0cab1a032d..fe9a18c1d2 100644 --- a/frontend/__tests__/components/event-message.test.tsx +++ b/frontend/__tests__/components/event-message.test.tsx @@ -5,7 +5,7 @@ import { EventMessage } from "#/components/features/chat/event-message"; vi.mock("#/hooks/query/use-config", () => ({ useConfig: () => ({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, }), })); diff --git a/frontend/__tests__/components/features/alerts/alert-banner.test.tsx b/frontend/__tests__/components/features/alerts/alert-banner.test.tsx new file mode 100644 index 0000000000..3ddceb19b0 --- /dev/null +++ b/frontend/__tests__/components/features/alerts/alert-banner.test.tsx @@ -0,0 +1,287 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { MemoryRouter } from "react-router"; +import { AlertBanner } from "#/components/features/alerts/alert-banner"; + +// Mock react-i18next +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: { time?: string }) => { + const translations: Record = { + MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`, + ALERT$FAULTY_MODELS_MESSAGE: + "The following models are currently reporting errors:", + "ERROR$TRANSLATED_KEY": "This is a translated error message", + }; + return translations[key] || key; + }, + }), + }; +}); + +describe("AlertBanner", () => { + afterEach(() => { + localStorage.clear(); + }); + + describe("Maintenance alerts", () => { + it("renders maintenance banner with formatted time", () => { + const startTime = "2024-01-15T10:00:00-05:00"; + const updatedAt = "2024-01-14T10:00:00Z"; + + const { container } = render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).toBeInTheDocument(); + + const svgIcon = container.querySelector("svg"); + expect(svgIcon).toBeInTheDocument(); + + const button = within(banner!).queryByTestId("dismiss-button"); + expect(button).toBeInTheDocument(); + }); + + it("click on dismiss button removes banner", () => { + const startTime = "2024-01-15T10:00:00-05:00"; + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + const button = within(banner!).queryByTestId("dismiss-button"); + + act(() => { + fireEvent.click(button!); + }); + + expect(banner).not.toBeInTheDocument(); + }); + + it("banner reappears when updatedAt changes", () => { + const startTime = "2024-01-15T10:00:00-05:00"; + const updatedAt = "2024-01-14T10:00:00Z"; + const newUpdatedAt = "2024-01-15T10:00:00Z"; + + const { rerender } = render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + const button = within(banner!).queryByTestId("dismiss-button"); + + act(() => { + fireEvent.click(button!); + }); + + expect(banner).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByTestId("alert-banner")).toBeInTheDocument(); + }); + }); + + describe("Faulty models alerts", () => { + it("renders banner with faulty models list", () => { + const faultyModels = ["gpt-4", "claude-3"]; + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).toBeInTheDocument(); + + expect( + screen.getByText(/The following models are currently reporting errors:/), + ).toBeInTheDocument(); + // Models are displayed in the order they are provided + expect(screen.getByText(/gpt-4/)).toBeInTheDocument(); + expect(screen.getByText(/claude-3/)).toBeInTheDocument(); + }); + + it("does not render banner when faulty models array is empty", () => { + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).not.toBeInTheDocument(); + }); + + it("banner reappears when updatedAt changes", () => { + const faultyModels = ["gpt-4"]; + const updatedAt = "2024-01-14T10:00:00Z"; + const newUpdatedAt = "2024-01-15T10:00:00Z"; + + const { rerender } = render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + const button = within(banner!).queryByTestId("dismiss-button"); + + act(() => { + fireEvent.click(button!); + }); + + expect(banner).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByTestId("alert-banner")).toBeInTheDocument(); + }); + }); + + describe("Error message alerts", () => { + it("renders banner with translated error message", () => { + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).toBeInTheDocument(); + expect( + screen.getByText("This is a translated error message"), + ).toBeInTheDocument(); + }); + + it("renders banner with raw error message when no translation exists", () => { + const rawErrorMessage = "This is a raw error without translation"; + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).toBeInTheDocument(); + expect(screen.getByText(rawErrorMessage)).toBeInTheDocument(); + }); + + it("does not render banner when error message is empty", () => { + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).not.toBeInTheDocument(); + }); + + it("does not render banner when error message is null", () => { + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).not.toBeInTheDocument(); + }); + }); + + describe("Multiple alerts", () => { + it("renders all alerts when multiple conditions are present", () => { + const startTime = "2024-01-15T10:00:00-05:00"; + const faultyModels = ["gpt-4"]; + const errorMessage = "ERROR$TRANSLATED_KEY"; + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + expect(banner).toBeInTheDocument(); + + expect( + screen.getByText(/Scheduled maintenance will begin at/), + ).toBeInTheDocument(); + expect( + screen.getByText(/The following models are currently reporting errors:/), + ).toBeInTheDocument(); + expect( + screen.getByText("This is a translated error message"), + ).toBeInTheDocument(); + }); + + it("dismissing hides all alerts", () => { + const startTime = "2024-01-15T10:00:00-05:00"; + const faultyModels = ["gpt-4"]; + const updatedAt = "2024-01-14T10:00:00Z"; + + render( + + + , + ); + + const banner = screen.queryByTestId("alert-banner"); + const button = within(banner!).queryByTestId("dismiss-button"); + + act(() => { + fireEvent.click(button!); + }); + + expect(banner).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx index 887ecef7e1..45716775cc 100644 --- a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx +++ b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx @@ -33,7 +33,7 @@ const { })), useConfigMock: vi.fn(() => ({ data: { - APP_MODE: "oss", + app_mode: "oss", }, })), })); @@ -659,7 +659,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); @@ -685,7 +685,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); @@ -718,7 +718,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); @@ -751,7 +751,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); @@ -781,7 +781,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); @@ -810,7 +810,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => { useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", + app_mode: "saas", }, }); }); diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 4418d57db3..17a43c75ed 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -177,10 +177,10 @@ describe("RepoConnector", () => { it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => { const getConfiSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return the APP_MODE and APP_SLUG + // @ts-expect-error - only return the app_mode and github_app_slug getConfiSpy.mockResolvedValue({ - APP_MODE: "saas", - APP_SLUG: "openhands", + app_mode: "saas", + github_app_slug: "openhands", }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -224,10 +224,10 @@ describe("RepoConnector", () => { it("should not render the 'add github repos' link if github provider is not set", async () => { const getConfiSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return the APP_MODE and APP_SLUG + // @ts-expect-error - only return the app_mode and github_app_slug for this test getConfiSpy.mockResolvedValue({ - APP_MODE: "saas", - APP_SLUG: "openhands", + app_mode: "saas", + github_app_slug: "openhands", }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -269,9 +269,9 @@ describe("RepoConnector", () => { it("should not render the 'add github repos' link in dropdown if oss mode", async () => { const getConfiSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return the APP_MODE + // @ts-expect-error - only return the app_mode getConfiSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); diff --git a/frontend/__tests__/components/features/home/task-suggestions.test.tsx b/frontend/__tests__/components/features/home/task-suggestions.test.tsx index d1fcb84db9..4bfe083e97 100644 --- a/frontend/__tests__/components/features/home/task-suggestions.test.tsx +++ b/frontend/__tests__/components/features/home/task-suggestions.test.tsx @@ -30,7 +30,7 @@ vi.mock("#/hooks/query/use-is-authed", () => ({ vi.mock("#/hooks/query/use-config", () => ({ useConfig: () => ({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }), })); diff --git a/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx b/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx deleted file mode 100644 index aae067b6b0..0000000000 --- a/frontend/__tests__/components/features/maintenance/maintenance-banner.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { act } from "react"; -import { MemoryRouter } from "react-router"; -import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; - -// Mock react-i18next -vi.mock("react-i18next", async () => { - const actual = - await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { time?: string }) => { - const translations: Record = { - MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`, - }; - return translations[key] || key; - }, - }), - }; -}); - -describe("MaintenanceBanner", () => { - afterEach(() => { - localStorage.clear(); - }); - - it("renders maintenance banner with formatted time", () => { - const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp - - const { container } = render( - - - , - ); - - // Check if the banner is rendered - const banner = screen.queryByTestId("maintenance-banner"); - expect(banner).toBeInTheDocument(); - - // Check if the warning icon (SVG) is present - const svgIcon = container.querySelector("svg"); - expect(svgIcon).toBeInTheDocument(); - - // Check if the button to close is present - const button = within(banner!).queryByTestId("dismiss-button"); - expect(button).toBeInTheDocument(); - }); - - it("handles invalid date gracefully", () => { - // Suppress expected console.warn for invalid date parsing - const consoleWarnSpy = vi - .spyOn(console, "warn") - .mockImplementation(() => {}); - - const invalidTime = "invalid-date"; - - render( - - - , - ); - - // Check if the banner is rendered - const banner = screen.queryByTestId("maintenance-banner"); - expect(banner).not.toBeInTheDocument(); - - // Restore console.warn - consoleWarnSpy.mockRestore(); - }); - - it("click on dismiss button removes banner", () => { - const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp - - render( - - - , - ); - - // Check if the banner is rendered - const banner = screen.queryByTestId("maintenance-banner"); - - const button = within(banner!).queryByTestId("dismiss-button"); - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - }); - it("banner reappears after dismissing on next maintenance event(future time)", () => { - const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp - const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp - - const { rerender } = render( - - - , - ); - - // Check if the banner is rendered - const banner = screen.queryByTestId("maintenance-banner"); - const button = within(banner!).queryByTestId("dismiss-button"); - - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - rerender( - - - , - ); - - expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument(); - }); -}); diff --git a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx index b256fa14d9..6d722fc2ae 100644 --- a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx +++ b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx @@ -305,7 +305,7 @@ describe("MicroagentManagement", () => { mockUseConfig.mockReturnValue({ data: { - APP_MODE: "oss", + app_mode: "oss", }, }); diff --git a/frontend/__tests__/components/features/payment/payment-form.test.tsx b/frontend/__tests__/components/features/payment/payment-form.test.tsx index 2e8d00c6f2..7b54876d2c 100644 --- a/frontend/__tests__/components/features/payment/payment-form.test.tsx +++ b/frontend/__tests__/components/features/payment/payment-form.test.tsx @@ -27,17 +27,17 @@ describe("PaymentForm", () => { const renderPaymentForm = () => renderWithProviders(); beforeEach(() => { - // useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled + // useBalance hook will return the balance only if the app_mode is "saas" and the billing feature is enabled + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: true, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "456", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); }); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 3314f325e5..daacfe02e2 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -9,27 +9,34 @@ import { Sidebar } from "#/components/features/sidebar/sidebar"; import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; // Helper to create mock config with sensible defaults const createMockConfig = ( - overrides: Omit, "FEATURE_FLAGS"> & { - FEATURE_FLAGS?: Partial; + overrides: Omit, "feature_flags"> & { + feature_flags?: Partial; } = {}, -): GetConfigResponse => { - const { FEATURE_FLAGS: featureFlagOverrides, ...restOverrides } = overrides; +): WebClientConfig => { + const { feature_flags: featureFlagOverrides, ...restOverrides } = overrides; return { - APP_MODE: "oss", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "oss", + posthog_client_key: "test-posthog-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, ...featureFlagOverrides, }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, ...restOverrides, }; }; @@ -76,9 +83,9 @@ describe("Sidebar", () => { }); describe("Settings modal auto-open behavior", () => { - it("should NOT open settings modal when HIDE_LLM_SETTINGS is true even with 404 error", async () => { + it("should NOT open settings modal when hide_llm_settings is true even with 404 error", async () => { getConfigSpy.mockResolvedValue( - createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: true } }), + createMockConfig({ feature_flags: { hide_llm_settings: true } }), ); getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject()); @@ -89,21 +96,21 @@ describe("Sidebar", () => { expect(getSettingsSpy).toHaveBeenCalled(); }); - // Settings modal should NOT appear when HIDE_LLM_SETTINGS is true + // Settings modal should NOT appear when hide_llm_settings is true await waitFor(() => { expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument(); }); }); - it("should open settings modal when HIDE_LLM_SETTINGS is false and 404 error in OSS mode", async () => { + it("should open settings modal when hide_llm_settings is false and 404 error in OSS mode", async () => { getConfigSpy.mockResolvedValue( - createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }), + createMockConfig({ feature_flags: { hide_llm_settings: false } }), ); getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject()); renderSidebar(); - // Settings modal should appear when HIDE_LLM_SETTINGS is false + // Settings modal should appear when hide_llm_settings is false await waitFor(() => { expect(screen.getByTestId("ai-config-modal")).toBeInTheDocument(); }); @@ -112,8 +119,8 @@ describe("Sidebar", () => { it("should NOT open settings modal in SaaS mode even with 404 error", async () => { getConfigSpy.mockResolvedValue( createMockConfig({ - APP_MODE: "saas", - FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false }, + app_mode: "saas", + feature_flags: { hide_llm_settings: false }, }), ); getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject()); @@ -133,7 +140,7 @@ describe("Sidebar", () => { it("should NOT open settings modal when settings exist (no 404 error)", async () => { getConfigSpy.mockResolvedValue( - createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }), + createMockConfig({ feature_flags: { hide_llm_settings: false } }), ); getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); @@ -152,7 +159,7 @@ describe("Sidebar", () => { it("should NOT open settings modal when on /settings path", async () => { getConfigSpy.mockResolvedValue( - createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }), + createMockConfig({ feature_flags: { hide_llm_settings: false } }), ); getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject()); diff --git a/frontend/__tests__/components/providers/posthog-wrapper.test.tsx b/frontend/__tests__/components/providers/posthog-wrapper.test.tsx index bfcf00554f..4d61f34935 100644 --- a/frontend/__tests__/components/providers/posthog-wrapper.test.tsx +++ b/frontend/__tests__/components/providers/posthog-wrapper.test.tsx @@ -22,7 +22,7 @@ describe("PostHogWrapper", () => { // Mock the config fetch // @ts-expect-error - partial mock vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - POSTHOG_CLIENT_KEY: "test-posthog-key", + posthog_client_key: "test-posthog-key", }); }); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index 144e2c46e6..e32931f053 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -13,7 +13,7 @@ const useIsAuthedMock = vi const useConfigMock = vi .fn() - .mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false }); + .mockReturnValue({ data: { app_mode: "saas" }, isLoading: false }); const useUserProvidersMock = vi .fn() @@ -46,7 +46,7 @@ describe("UserActions", () => { // Reset all mocks to default values before each test useIsAuthedMock.mockReturnValue({ data: true, isLoading: false }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -89,7 +89,7 @@ describe("UserActions", () => { useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); // Keep other mocks with default values useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -126,7 +126,7 @@ describe("UserActions", () => { useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); // Keep other mocks with default values useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -154,7 +154,7 @@ describe("UserActions", () => { useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); // Keep other mocks with default values useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -179,7 +179,7 @@ describe("UserActions", () => { useIsAuthedMock.mockReturnValue({ data: true, isLoading: false }); // Ensure config and providers are set correctly useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -211,7 +211,7 @@ describe("UserActions", () => { // Start with authentication and providers useIsAuthedMock.mockReturnValue({ data: true, isLoading: false }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -236,7 +236,7 @@ describe("UserActions", () => { useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); // Keep other mocks with default values useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ @@ -265,7 +265,7 @@ describe("UserActions", () => { // Ensure authentication and providers are set correctly useIsAuthedMock.mockReturnValue({ data: true, isLoading: false }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas" }, + data: { app_mode: "saas" }, isLoading: false, }); useUserProvidersMock.mockReturnValue({ diff --git a/frontend/__tests__/helpers/mock-config.ts b/frontend/__tests__/helpers/mock-config.ts new file mode 100644 index 0000000000..fa0b03b96d --- /dev/null +++ b/frontend/__tests__/helpers/mock-config.ts @@ -0,0 +1,29 @@ +import { WebClientConfig } from "#/api/option-service/option.types"; + +/** + * Creates a mock WebClientConfig with all required fields. + * Use this helper to create test config objects with sensible defaults. + */ +export const createMockWebClientConfig = ( + overrides: Partial = {}, +): WebClientConfig => ({ + app_mode: "oss", + posthog_client_key: "test-posthog-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + ...overrides.feature_flags, + }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: new Date().toISOString(), + github_app_slug: null, + ...overrides, +}); diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx index 64bb675341..a5bf00d14f 100644 --- a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -12,8 +12,8 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: appMode, - FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings }, + app_mode: appMode, + feature_flags: { hide_llm_settings: hideLlmSettings }, } as Awaited>); }; @@ -22,7 +22,7 @@ describe("useSettingsNavItems", () => { queryClient.clear(); }); - it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => { + it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => { mockConfig("saas"); const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); @@ -31,7 +31,7 @@ describe("useSettingsNavItems", () => { }); }); - it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => { + it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => { mockConfig("oss"); const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); @@ -40,7 +40,7 @@ describe("useSettingsNavItems", () => { }); }); - it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => { + it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => { mockConfig("saas", true); const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx index f88c30d220..6d441012d9 100644 --- a/frontend/__tests__/routes/_oh.test.tsx +++ b/frontend/__tests__/routes/_oh.test.tsx @@ -17,11 +17,11 @@ describe("frontend/routes/_oh", () => { const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted( () => { const defaultFeatureFlags = { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }; return { @@ -33,7 +33,7 @@ describe("frontend/routes/_oh", () => { isError: false, }), useConfigMock: vi.fn().mockReturnValue({ - data: { APP_MODE: "oss", FEATURE_FLAGS: defaultFeatureFlags }, + data: { app_mode: "oss", feature_flags: defaultFeatureFlags }, isLoading: false, }), }; @@ -84,7 +84,7 @@ describe("frontend/routes/_oh", () => { isError: false, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); @@ -108,7 +108,7 @@ describe("frontend/routes/_oh", () => { isError: false, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); @@ -129,16 +129,16 @@ describe("frontend/routes/_oh", () => { "handleCaptureConsent", ); + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", - GITHUB_CLIENT_ID: "test-id", - POSTHOG_CLIENT_KEY: "test-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "oss", + posthog_client_key: "test-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); @@ -167,20 +167,20 @@ describe("frontend/routes/_oh", () => { it("should not render the user consent form if saas mode", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-id", - POSTHOG_CLIENT_KEY: "test-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); @@ -255,20 +255,20 @@ describe("frontend/routes/_oh", () => { "displaySuccessToast", ); + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-id", - POSTHOG_CLIENT_KEY: "test-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 200e08bcb1..4466436534 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -10,35 +10,49 @@ import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import AuthService from "#/api/auth-service/auth-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; import { SecretsService } from "#/api/secrets-service"; import { integrationService } from "#/api/integration-service/integration-service.api"; -const VALID_OSS_CONFIG: GetConfigResponse = { - APP_MODE: "oss", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, +const VALID_OSS_CONFIG: WebClientConfig = { + app_mode: "oss", + posthog_client_key: "456", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, }; -const VALID_SAAS_CONFIG: GetConfigResponse = { - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, +const VALID_SAAS_CONFIG: WebClientConfig = { + app_mode: "saas", + posthog_client_key: "456", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, }; const queryClient = new QueryClient(); @@ -247,7 +261,7 @@ describe("Content", () => { }); }); - it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => { + it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG); @@ -272,7 +286,7 @@ describe("Content", () => { getConfigSpy.mockResolvedValue({ ...VALID_SAAS_CONFIG, - APP_SLUG: "test-slug", + github_app_slug: "test-slug", }); queryClient.invalidateQueries(); rerender(); @@ -615,7 +629,6 @@ describe("GitLab Webhook Manager Integration", () => { getConfigSpy.mockResolvedValue({ ...VALID_SAAS_CONFIG, - APP_SLUG: "test-slug", }); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -633,40 +646,4 @@ describe("GitLab Webhook Manager Integration", () => { ).not.toBeInTheDocument(); }); }); - - it("should render GitLab webhook manager when token is set", async () => { - // Arrange - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); - const getResourcesSpy = vi.spyOn( - integrationService, - "getGitLabResources", - ); - - getConfigSpy.mockResolvedValue({ - ...VALID_SAAS_CONFIG, - APP_SLUG: "test-slug", - }); - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - provider_tokens_set: { - gitlab: null, - }, - }); - getResourcesSpy.mockResolvedValue({ - resources: [], - }); - - // Act - renderGitSettingsScreen(); - await screen.findByTestId("git-settings-screen"); - - // Assert - await waitFor(() => { - expect( - screen.getByText("GITLAB$WEBHOOK_MANAGER_TITLE"), - ).toBeInTheDocument(); - expect(getResourcesSpy).toHaveBeenCalled(); - }); - }); }); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index 806f1109f4..b3f037989f 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -16,11 +16,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted( () => { const defaultFeatureFlags = { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }; return { @@ -33,8 +33,8 @@ const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted( }), useConfigMock: vi.fn().mockReturnValue({ data: { - APP_MODE: "oss", - FEATURE_FLAGS: defaultFeatureFlags, + app_mode: "oss", + feature_flags: defaultFeatureFlags, }, isLoading: false, }), @@ -141,19 +141,24 @@ describe("HomeScreen", () => { isError: false, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); // Mock config to avoid SaaS redirect logic vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "oss", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS, - } as Awaited>); + app_mode: "oss", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); @@ -444,18 +449,23 @@ describe("Settings 404", () => { isError: false, }); useConfigMock.mockReturnValue({ - data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS, - } as Awaited>); + app_mode: "oss", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); @@ -510,14 +520,14 @@ describe("Settings 404", () => { it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => { useConfigMock.mockReturnValue({ - data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS }, + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, isLoading: false, }); - // @ts-expect-error - we only need APP_MODE for this test + // @ts-expect-error - we only need app_mode for this test getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS, + app_mode: "saas", + feature_flags: DEFAULT_FEATURE_FLAGS, }); const error = createAxiosNotFoundErrorObject(); getSettingsSpy.mockRejectedValue(error); @@ -543,20 +553,25 @@ describe("Setup Payment modal", () => { }); useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", - FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true }, + app_mode: "saas", + feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true }, }, isLoading: false, }); getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true }, - } as Awaited>); + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true }, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 68e44d73e9..77ac4122c9 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -337,9 +337,9 @@ describe("Content", () => { describe("API key visibility in Basic Settings", () => { it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); renderLlmSettingsScreen(); @@ -364,9 +364,9 @@ describe("Content", () => { it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); renderLlmSettingsScreen(); @@ -395,9 +395,9 @@ describe("Content", () => { it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); renderLlmSettingsScreen(); @@ -422,9 +422,9 @@ describe("Content", () => { it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); renderLlmSettingsScreen(); @@ -453,9 +453,9 @@ describe("Content", () => { it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); renderLlmSettingsScreen(); @@ -498,9 +498,9 @@ describe("Content", () => { it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return APP_MODE for these tests + // @ts-expect-error - only return app_mode for these tests getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); renderLlmSettingsScreen(); @@ -1010,16 +1010,16 @@ describe("View persistence after saving advanced settings", () => { it("should remain on Advanced view after saving when search API key is set", async () => { // Arrange: Start with default settings (non-SaaS mode to show search API key field) const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", - GITHUB_CLIENT_ID: "fake-github-client-id", - POSTHOG_CLIENT_KEY: "fake-posthog-client-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "oss", + posthog_client_key: "fake-posthog-client-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); diff --git a/frontend/__tests__/routes/login.test.tsx b/frontend/__tests__/routes/login.test.tsx index 85a262fdb4..3abb9557ee 100644 --- a/frontend/__tests__/routes/login.test.tsx +++ b/frontend/__tests__/routes/login.test.tsx @@ -87,18 +87,18 @@ describe("LoginPage", () => { vi.clearAllMocks(); vi.stubGlobal("location", { href: "" }); + // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github", "gitlab", "bitbucket"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github", "gitlab", "bitbucket"], + auth_url: "https://auth.example.com", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); @@ -151,18 +151,18 @@ describe("LoginPage", () => { }); it("should only display configured providers", async () => { + // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); @@ -187,18 +187,18 @@ describe("LoginPage", () => { }); it("should display message when no providers are configured", async () => { + // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: [], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: [], + auth_url: "https://auth.example.com", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); @@ -320,16 +320,16 @@ describe("LoginPage", () => { }); it("should redirect OSS mode users to home", async () => { + // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "oss", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "oss", + posthog_client_key: "test-posthog-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); diff --git a/frontend/__tests__/routes/root-layout-refetch.test.tsx b/frontend/__tests__/routes/root-layout-refetch.test.tsx index 78e176bb96..3ab626d66e 100644 --- a/frontend/__tests__/routes/root-layout-refetch.test.tsx +++ b/frontend/__tests__/routes/root-layout-refetch.test.tsx @@ -21,11 +21,11 @@ vi.mock("#/hooks/query/use-config", () => ({ })); const DEFAULT_FEATURE_FLAGS = { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }; const RouterStub = createRoutesStub([ @@ -61,9 +61,9 @@ describe("MainApp - Auth refetch behavior", () => { }); useConfigMock.mockReturnValue({ data: { - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS, + app_mode: "saas", + github_client_id: "test-client-id", + feature_flags: DEFAULT_FEATURE_FLAGS, }, isLoading: false, }); diff --git a/frontend/__tests__/routes/root-layout.test.tsx b/frontend/__tests__/routes/root-layout.test.tsx index 224e0a5c42..0fd9f64deb 100644 --- a/frontend/__tests__/routes/root-layout.test.tsx +++ b/frontend/__tests__/routes/root-layout.test.tsx @@ -160,18 +160,18 @@ describe("MainApp", () => { beforeEach(() => { vi.clearAllMocks(); + // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - PROVIDERS_CONFIGURED: ["github"], - AUTH_URL: "https://auth.example.com", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }); diff --git a/frontend/__tests__/routes/secrets-settings.test.tsx b/frontend/__tests__/routes/secrets-settings.test.tsx index 9b5c315f92..2062117f8b 100644 --- a/frontend/__tests__/routes/secrets-settings.test.tsx +++ b/frontend/__tests__/routes/secrets-settings.test.tsx @@ -58,7 +58,7 @@ beforeEach(() => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return the config we need getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); }); @@ -78,7 +78,7 @@ describe("Content", () => { const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets"); // @ts-expect-error - only return the config we need getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -97,7 +97,7 @@ describe("Content", () => { const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets"); // @ts-expect-error - only return the config we need getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); renderSecretsSettings(); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index d07567c905..cec3a0a67c 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -64,15 +64,15 @@ describe("Settings Billing", () => { // Set default config to OSS mode mockUseConfig.mockReturnValue({ data: { - APP_MODE: "oss", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "oss", + github_client_id: "123", + posthog_client_key: "456", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }, isLoading: false, @@ -119,15 +119,15 @@ describe("Settings Billing", () => { it("should render the billing tab if SaaS mode and billing is enabled", async () => { mockUseConfig.mockReturnValue({ data: { - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: true, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + github_client_id: "123", + posthog_client_key: "456", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }, isLoading: false, @@ -143,15 +143,15 @@ describe("Settings Billing", () => { const user = userEvent.setup(); mockUseConfig.mockReturnValue({ data: { - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "456", - FEATURE_FLAGS: { - ENABLE_BILLING: true, - HIDE_LLM_SETTINGS: false, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + app_mode: "saas", + github_client_id: "123", + posthog_client_key: "456", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, }, isLoading: false, diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index 09031d4f86..ec2cf0974c 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -96,7 +96,7 @@ describe("Settings Screen", () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return app mode getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); // Clear any existing query data @@ -122,11 +122,11 @@ describe("Settings Screen", () => { }); it("should render the saas navbar", async () => { - const saasConfig = { APP_MODE: "saas" }; + const saasConfig = { app_mode: "saas" }; // Clear any existing query data and set the config mockQueryClient.clear(); - mockQueryClient.setQueryData(["config"], saasConfig); + mockQueryClient.setQueryData(["web-client-config"], saasConfig); const sectionsToInclude = [ "llm", // LLM settings are now always shown in SaaS mode @@ -160,7 +160,7 @@ describe("Settings Screen", () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return app mode getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); // Clear any existing query data diff --git a/frontend/src/api/auth-service/auth-service.api.ts b/frontend/src/api/auth-service/auth-service.api.ts index 8ea758b944..123ef6a8fe 100644 --- a/frontend/src/api/auth-service/auth-service.api.ts +++ b/frontend/src/api/auth-service/auth-service.api.ts @@ -1,6 +1,6 @@ import { openHands } from "../open-hands-axios"; import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types"; -import { GetConfigResponse } from "../option-service/option.types"; +import { WebClientConfig } from "../option-service/option.types"; /** * Authentication service for handling all authentication-related API calls @@ -12,7 +12,7 @@ class AuthService { * @returns Response with authentication status and user info if successful */ static async authenticate( - appMode: GetConfigResponse["APP_MODE"], + appMode: WebClientConfig["app_mode"], ): Promise { if (appMode === "oss") return true; @@ -42,7 +42,7 @@ class AuthService { * Logout user from the application * @param appMode The application mode (saas or oss) */ - static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise { + static async logout(appMode: WebClientConfig["app_mode"]): Promise { const endpoint = appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens"; await openHands.post(endpoint); diff --git a/frontend/src/api/option-service/option-service.api.ts b/frontend/src/api/option-service/option-service.api.ts index 0bf78182b8..149956a13d 100644 --- a/frontend/src/api/option-service/option-service.api.ts +++ b/frontend/src/api/option-service/option-service.api.ts @@ -1,5 +1,5 @@ import { openHands } from "../open-hands-axios"; -import { GetConfigResponse } from "./option.types"; +import { WebClientConfig } from "./option.types"; /** * Service for handling API options endpoints @@ -35,12 +35,12 @@ class OptionService { } /** - * Get the configuration from the server - * @returns Configuration response + * Get the web client configuration from the server + * @returns Web client configuration response */ - static async getConfig(): Promise { - const { data } = await openHands.get( - "/api/options/config", + static async getConfig(): Promise { + const { data } = await openHands.get( + "/api/v1/web-client/config", ); return data; } diff --git a/frontend/src/api/option-service/option.types.ts b/frontend/src/api/option-service/option.types.ts index 303fdd54a6..e0d01bb212 100644 --- a/frontend/src/api/option-service/option.types.ts +++ b/frontend/src/api/option-service/option.types.ts @@ -1,21 +1,23 @@ import { Provider } from "#/types/settings"; -export interface GetConfigResponse { - APP_MODE: "saas" | "oss"; - APP_SLUG?: string; - GITHUB_CLIENT_ID: string; - POSTHOG_CLIENT_KEY: string; - PROVIDERS_CONFIGURED?: Provider[]; - AUTH_URL?: string; - RECAPTCHA_SITE_KEY?: string; - FEATURE_FLAGS: { - ENABLE_BILLING: boolean; - HIDE_LLM_SETTINGS: boolean; - ENABLE_JIRA: boolean; - ENABLE_JIRA_DC: boolean; - ENABLE_LINEAR: boolean; - }; - MAINTENANCE?: { - startTime: string; - }; +export interface WebClientFeatureFlags { + enable_billing: boolean; + hide_llm_settings: boolean; + enable_jira: boolean; + enable_jira_dc: boolean; + enable_linear: boolean; +} + +export interface WebClientConfig { + app_mode: "saas" | "oss"; + posthog_client_key: string | null; + feature_flags: WebClientFeatureFlags; + providers_configured: Provider[]; + maintenance_start_time: string | null; + auth_url: string | null; + recaptcha_site_key: string | null; + faulty_models: string[]; + error_message: string | null; + updated_at: string; + github_app_slug: string | null; } diff --git a/frontend/src/components/features/alerts/alert-banner.tsx b/frontend/src/components/features/alerts/alert-banner.tsx new file mode 100644 index 0000000000..982aa5ee32 --- /dev/null +++ b/frontend/src/components/features/alerts/alert-banner.tsx @@ -0,0 +1,143 @@ +import { useLocation } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { FaTriangleExclamation } from "react-icons/fa6"; +import CloseIcon from "#/icons/close.svg?react"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface AlertBannerProps { + maintenanceStartTime?: string | null; + faultyModels?: string[]; + errorMessage?: string | null; + updatedAt: string; +} + +export function AlertBanner({ + maintenanceStartTime, + faultyModels, + errorMessage, + updatedAt, +}: AlertBannerProps) { + const { t } = useTranslation(); + const [dismissedAt, setDismissedAt] = useLocalStorage( + "alert_banner_dismissed_at", + null, + ); + + const { pathname } = useLocation(); + + // Format ISO timestamp to user's local timezone + const formatMaintenanceTime = (isoTimeString: string): string => { + const date = new Date(isoTimeString); + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + }; + + const localTime = maintenanceStartTime + ? formatMaintenanceTime(maintenanceStartTime) + : null; + + const hasMaintenanceAlert = !!maintenanceStartTime; + const hasFaultyModels = faultyModels && faultyModels.length > 0; + const hasErrorMessage = errorMessage && errorMessage.trim().length > 0; + + const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage; + + const isBannerVisible = useMemo(() => { + if (!hasAnyAlert) { + return false; + } + return dismissedAt !== updatedAt; + }, [dismissedAt, updatedAt, hasAnyAlert]); + + // Try to translate error message, fallback to raw message + const translatedErrorMessage = useMemo(() => { + if (!errorMessage) return null; + + // Check if the error message is a translation key (e.g., "ERROR$SOME_KEY") + const translated = t(errorMessage as I18nKey); + // If translation returns the same key, it means no translation exists + if (translated === errorMessage) { + return errorMessage; + } + return translated; + }, [errorMessage, t]); + + if (!isBannerVisible) { + return null; + } + + const renderMessages = () => { + const messages: React.ReactNode[] = []; + + if (hasMaintenanceAlert && localTime) { + messages.push( + + {t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })} + , + ); + } + + if (hasFaultyModels) { + messages.push( + + {t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")} + , + ); + } + + if (hasErrorMessage && translatedErrorMessage) { + messages.push( + + {translatedErrorMessage} + , + ); + } + + return messages; + }; + + return ( +
+
+
+ +
+
{renderMessages()}
+
+ + +
+ ); +} diff --git a/frontend/src/components/features/auth/login-content.tsx b/frontend/src/components/features/auth/login-content.tsx index 4ff8220ca2..1938929c91 100644 --- a/frontend/src/components/features/auth/login-content.tsx +++ b/frontend/src/components/features/auth/login-content.tsx @@ -5,7 +5,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react"; import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react"; import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react"; import { useAuthUrl } from "#/hooks/use-auth-url"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; import { Provider } from "#/types/settings"; import { useTracking } from "#/hooks/use-tracking"; import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice"; @@ -15,8 +15,8 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers"; export interface LoginContentProps { githubAuthUrl: string | null; - appMode?: GetConfigResponse["APP_MODE"] | null; - authUrl?: GetConfigResponse["AUTH_URL"]; + appMode?: WebClientConfig["app_mode"] | null; + authUrl?: WebClientConfig["auth_url"]; providersConfigured?: Provider[]; emailVerified?: boolean; hasDuplicatedEmail?: boolean; @@ -38,7 +38,7 @@ export function LoginContent({ // reCAPTCHA - only need token generation, verification happens at backend callback const { isReady: recaptchaReady, executeRecaptcha } = useRecaptcha({ - siteKey: config?.RECAPTCHA_SITE_KEY, + siteKey: config?.recaptcha_site_key ?? undefined, }); const gitlabAuthUrl = useAuthUrl({ @@ -59,7 +59,7 @@ export function LoginContent({ ) => { trackLoginButtonClick({ provider }); - if (!config?.RECAPTCHA_SITE_KEY || !recaptchaReady) { + if (!config?.recaptcha_site_key || !recaptchaReady) { // No reCAPTCHA or token generation failed - redirect normally window.location.href = redirectUrl; return; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index b239f845f8..91fe069627 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -369,7 +369,7 @@ export function ChatInterface() { onNegativeFeedback={() => onClickShareFeedbackActionButton("negative") } - isSaasMode={config?.APP_MODE === "saas"} + isSaasMode={config?.app_mode === "saas"} /> )} @@ -391,7 +391,7 @@ export function ChatInterface() { - {config?.APP_MODE !== "saas" && !isV1Conversation && ( + {config?.app_mode !== "saas" && !isV1Conversation && ( setFeedbackModalIsOpen(false)} diff --git a/frontend/src/components/features/chat/event-message-components/error-event-message.tsx b/frontend/src/components/features/chat/event-message-components/error-event-message.tsx index 9259f39db2..0cf9688249 100644 --- a/frontend/src/components/features/chat/event-message-components/error-event-message.tsx +++ b/frontend/src/components/features/chat/event-message-components/error-event-message.tsx @@ -18,7 +18,7 @@ interface ErrorEventMessageProps { }>; isLastMessage: boolean; isInLast10Actions: boolean; - config?: { APP_MODE?: string } | null; + config?: { app_mode?: string } | null; isCheckingFeedback: boolean; feedbackData: { exists: boolean; diff --git a/frontend/src/components/features/chat/event-message-components/finish-event-message.tsx b/frontend/src/components/features/chat/event-message-components/finish-event-message.tsx index 52bd9de7f2..9268bbaa71 100644 --- a/frontend/src/components/features/chat/event-message-components/finish-event-message.tsx +++ b/frontend/src/components/features/chat/event-message-components/finish-event-message.tsx @@ -19,7 +19,7 @@ interface FinishEventMessageProps { }>; isLastMessage: boolean; isInLast10Actions: boolean; - config?: { APP_MODE?: string } | null; + config?: { app_mode?: string } | null; isCheckingFeedback: boolean; feedbackData: { exists: boolean; diff --git a/frontend/src/components/features/chat/event-message-components/likert-scale-wrapper.tsx b/frontend/src/components/features/chat/event-message-components/likert-scale-wrapper.tsx index 4ea7a5b442..3e450cbcee 100644 --- a/frontend/src/components/features/chat/event-message-components/likert-scale-wrapper.tsx +++ b/frontend/src/components/features/chat/event-message-components/likert-scale-wrapper.tsx @@ -8,7 +8,7 @@ interface LikertScaleWrapperProps { event: OpenHandsAction | OpenHandsObservation; isLastMessage: boolean; isInLast10Actions: boolean; - config?: { APP_MODE?: string } | null; + config?: { app_mode?: string } | null; isCheckingFeedback: boolean; feedbackData: { exists: boolean; @@ -25,7 +25,7 @@ export function LikertScaleWrapper({ isCheckingFeedback, feedbackData, }: LikertScaleWrapperProps) { - if (config?.APP_MODE !== "saas" || isCheckingFeedback) { + if (config?.app_mode !== "saas" || isCheckingFeedback) { return null; } diff --git a/frontend/src/components/features/chat/event-message-components/user-assistant-event-message.tsx b/frontend/src/components/features/chat/event-message-components/user-assistant-event-message.tsx index 62b7fab57a..6b7e527af1 100644 --- a/frontend/src/components/features/chat/event-message-components/user-assistant-event-message.tsx +++ b/frontend/src/components/features/chat/event-message-components/user-assistant-event-message.tsx @@ -23,7 +23,7 @@ interface UserAssistantEventMessageProps { }>; isLastMessage: boolean; isInLast10Actions: boolean; - config?: { APP_MODE?: string } | null; + config?: { app_mode?: string } | null; isCheckingFeedback: boolean; feedbackData: { exists: boolean; diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index f1f7fe6869..2ce5165e08 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -94,8 +94,8 @@ export function ExpandableMessage({ const statusIconClasses = "h-4 w-4 ml-2 inline"; if ( - config?.FEATURE_FLAGS?.ENABLE_BILLING && - config?.APP_MODE === "saas" && + config?.feature_flags?.enable_billing && + config?.app_mode === "saas" && id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS ) { return ( diff --git a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx index f917577b49..fb745cd8c3 100644 --- a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx +++ b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx @@ -73,7 +73,7 @@ export function ConversationNameContextMenu({ // Check if we should show the public sharing option // Only show for V1 conversations in SAAS mode const shouldShowPublicSharing = - isV1Conversation && config?.APP_MODE === "saas" && onTogglePublic; + isV1Conversation && config?.app_mode === "saas" && onTogglePublic; const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation); const hasExport = Boolean(onExportConversation); diff --git a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx index 3cbe534e04..570c71ab4e 100644 --- a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx +++ b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx @@ -247,34 +247,6 @@ export function GitRepoDropdown({ const isLoadingState = isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading; - // Create sticky footer item for GitHub provider - const stickyFooterItem = useMemo(() => { - if ( - !config?.APP_SLUG || - provider !== ProviderOptions.github || - config.APP_MODE !== "saas" - ) - return null; - - const githubHref = `https://github.com/apps/${config.APP_SLUG}/installations/new`; - - return ( - { - // Prevent downshift from closing the menu when clicking the sticky footer - e.preventDefault(); - e.stopPropagation(); - }} - > - {t(I18nKey.HOME$ADD_GITHUB_REPOS)} - - ); - }, [provider, config, t]); - const renderItem = ( item: GitRepository, index: number, @@ -317,6 +289,34 @@ export function GitRepoDropdown({ ); }, [recentRepositories, localSelectedItem, getItemProps, t]); + // Create sticky footer item for GitHub provider + const stickyFooterItem = useMemo(() => { + if ( + !config?.github_app_slug || + provider !== ProviderOptions.github || + config.app_mode !== "saas" + ) + return null; + + const githubHref = `https://github.com/apps/${config.github_app_slug}/installations/new`; + + return ( + { + // Prevent downshift from closing the menu when clicking the sticky footer + e.preventDefault(); + e.stopPropagation(); + }} + > + {t(I18nKey.HOME$ADD_GITHUB_REPOS)} + + ); + }, [provider, config, t]); + return (
diff --git a/frontend/src/components/features/maintenance/maintenance-banner.tsx b/frontend/src/components/features/maintenance/maintenance-banner.tsx deleted file mode 100644 index 1412c2fa10..0000000000 --- a/frontend/src/components/features/maintenance/maintenance-banner.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useLocation } from "react-router"; -import { useTranslation } from "react-i18next"; -import { useMemo } from "react"; -import { useLocalStorage } from "@uidotdev/usehooks"; -import { FaTriangleExclamation } from "react-icons/fa6"; -import CloseIcon from "#/icons/close.svg?react"; -import { cn } from "#/utils/utils"; - -interface MaintenanceBannerProps { - startTime: string; -} - -export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) { - const { t } = useTranslation(); - const [dismissedAt, setDismissedAt] = useLocalStorage( - "maintenance_banner_dismissed_at", - null, - ); - - const { pathname } = useLocation(); - - // Convert EST timestamp to user's local timezone - const formatMaintenanceTime = (estTimeString: string): string => { - try { - // Parse the EST timestamp - // If the string doesn't include timezone info, assume it's EST - let dateToFormat: Date; - - if ( - estTimeString.includes("T") && - (estTimeString.includes("-05:00") || - estTimeString.includes("-04:00") || - estTimeString.includes("EST") || - estTimeString.includes("EDT")) - ) { - // Already has timezone info - dateToFormat = new Date(estTimeString); - } else { - // Assume EST and convert to UTC for proper parsing - // EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity - const estDate = new Date(estTimeString); - if (Number.isNaN(estDate.getTime())) { - throw new Error("Invalid date"); - } - dateToFormat = estDate; - } - - // Format to user's local timezone - return dateToFormat.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); - } catch (error) { - // Fallback to original string if parsing fails - // eslint-disable-next-line no-console - console.warn("Failed to parse maintenance time:", error); - return estTimeString; - } - }; - - const localTime = formatMaintenanceTime(startTime); - - const isBannerVisible = useMemo(() => { - const isValid = !Number.isNaN(new Date(startTime).getTime()); - if (!isValid) { - return false; - } - return dismissedAt !== localTime; - }, [dismissedAt, startTime]); - - if (!isBannerVisible) { - return null; - } - - return ( -
-
-
- -
-
-

- {t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })} -

-
-
- - -
- ); -} diff --git a/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx b/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx index c2afd7751c..d487e0c21d 100644 --- a/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx +++ b/frontend/src/components/features/settings/git-settings/configure-azure-devops-anchor.tsx @@ -9,9 +9,9 @@ export function ConfigureAzureDevOpsAnchor() { const { data: config } = useConfig(); const authUrl = useAuthUrl({ - appMode: config?.APP_MODE ?? null, + appMode: config?.app_mode ?? null, identityProvider: "azure_devops", - authUrl: config?.AUTH_URL, + authUrl: config?.auth_url, }); const handleOAuthFlow = () => { diff --git a/frontend/src/components/features/settings/project-management/project-management-integration.tsx b/frontend/src/components/features/settings/project-management/project-management-integration.tsx index 865badea82..0ae49ce0de 100644 --- a/frontend/src/components/features/settings/project-management/project-management-integration.tsx +++ b/frontend/src/components/features/settings/project-management/project-management-integration.tsx @@ -14,21 +14,21 @@ export function ProjectManagementIntegration() { {t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
- {config?.FEATURE_FLAGS?.ENABLE_JIRA && ( + {config?.feature_flags?.enable_jira && ( )} - {config?.FEATURE_FLAGS?.ENABLE_JIRA_DC && ( + {config?.feature_flags?.enable_jira_dc && ( )} - {config?.FEATURE_FLAGS?.ENABLE_LINEAR && ( + {config?.feature_flags?.enable_linear && ( { try { const config = await OptionService.getConfig(); - setPosthogClientKey(config.POSTHOG_CLIENT_KEY); + setPosthogClientKey(config.posthog_client_key); } catch { displayErrorToast("Error fetching PostHog client key"); } finally { diff --git a/frontend/src/hooks/mutation/use-logout.ts b/frontend/src/hooks/mutation/use-logout.ts index 431fcc01af..c0bc31f5a9 100644 --- a/frontend/src/hooks/mutation/use-logout.ts +++ b/frontend/src/hooks/mutation/use-logout.ts @@ -10,7 +10,7 @@ export const useLogout = () => { const { data: config } = useConfig(); return useMutation({ - mutationFn: () => AuthService.logout(config?.APP_MODE ?? "oss"), + mutationFn: () => AuthService.logout(config?.app_mode ?? "oss"), onSuccess: async () => { queryClient.removeQueries({ queryKey: ["tasks"] }); queryClient.removeQueries({ queryKey: ["settings"] }); @@ -18,7 +18,7 @@ export const useLogout = () => { queryClient.removeQueries({ queryKey: ["secrets"] }); // Clear login method and last page from local storage - if (config?.APP_MODE === "saas") { + if (config?.app_mode === "saas") { clearLoginData(); } diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts index 832467b821..954e22ad26 100644 --- a/frontend/src/hooks/query/use-api-keys.ts +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -9,7 +9,7 @@ export function useApiKeys() { return useQuery({ queryKey: [API_KEYS_QUERY_KEY], - enabled: config?.APP_MODE === "saas", + enabled: config?.app_mode === "saas", queryFn: async () => { const keys = await ApiKeysClient.getApiKeys(); return Array.isArray(keys) ? keys : []; diff --git a/frontend/src/hooks/query/use-app-installations.ts b/frontend/src/hooks/query/use-app-installations.ts index 85eee75909..20ea6855ef 100644 --- a/frontend/src/hooks/query/use-app-installations.ts +++ b/frontend/src/hooks/query/use-app-installations.ts @@ -17,7 +17,7 @@ export const useAppInstallations = (selectedProvider: Provider | null) => { enabled: userIsAuthenticated && !!selectedProvider && - shouldUseInstallationRepos(selectedProvider, config?.APP_MODE), + shouldUseInstallationRepos(selectedProvider, config?.app_mode), staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes }); diff --git a/frontend/src/hooks/query/use-balance.ts b/frontend/src/hooks/query/use-balance.ts index 1bc7075e9f..fd90b9cba6 100644 --- a/frontend/src/hooks/query/use-balance.ts +++ b/frontend/src/hooks/query/use-balance.ts @@ -12,7 +12,7 @@ export const useBalance = () => { queryFn: BillingService.getBalance, enabled: !isOnTosPage && - config?.APP_MODE === "saas" && - config?.FEATURE_FLAGS?.ENABLE_BILLING, + config?.app_mode === "saas" && + config?.feature_flags?.enable_billing, }); }; diff --git a/frontend/src/hooks/query/use-batch-feedback.ts b/frontend/src/hooks/query/use-batch-feedback.ts index 5e6c5678fe..c6914b0d9e 100644 --- a/frontend/src/hooks/query/use-batch-feedback.ts +++ b/frontend/src/hooks/query/use-batch-feedback.ts @@ -38,7 +38,7 @@ export const useBatchFeedback = () => { enabled: runtimeIsReady && !!conversationId && - config?.APP_MODE === "saas" && + config?.app_mode === "saas" && !isV1Conversation, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts index 1f5a14250b..8a97e20c62 100644 --- a/frontend/src/hooks/query/use-config.ts +++ b/frontend/src/hooks/query/use-config.ts @@ -6,7 +6,7 @@ export const useConfig = () => { const isOnTosPage = useIsOnTosPage(); return useQuery({ - queryKey: ["config"], + queryKey: ["web-client-config"], queryFn: OptionService.getConfig, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes, diff --git a/frontend/src/hooks/query/use-feedback-exists.ts b/frontend/src/hooks/query/use-feedback-exists.ts index c1d0f274d4..83be3b39ce 100644 --- a/frontend/src/hooks/query/use-feedback-exists.ts +++ b/frontend/src/hooks/query/use-feedback-exists.ts @@ -29,7 +29,7 @@ export const useFeedbackExists = (eventId?: number) => { enabled: !!eventId && !!conversationId && - config?.APP_MODE === "saas" && + config?.app_mode === "saas" && !isV1Conversation, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes diff --git a/frontend/src/hooks/query/use-get-secrets.ts b/frontend/src/hooks/query/use-get-secrets.ts index 05bae09ef4..e89df3d149 100644 --- a/frontend/src/hooks/query/use-get-secrets.ts +++ b/frontend/src/hooks/query/use-get-secrets.ts @@ -7,7 +7,7 @@ export const useGetSecrets = () => { const { data: config } = useConfig(); const { data: isAuthed } = useIsAuthed(); - const isOss = config?.APP_MODE === "oss"; + const isOss = config?.app_mode === "oss"; return useQuery({ queryKey: ["secrets"], diff --git a/frontend/src/hooks/query/use-git-repositories.ts b/frontend/src/hooks/query/use-git-repositories.ts index cdfb48bea9..bbf09162f4 100644 --- a/frontend/src/hooks/query/use-git-repositories.ts +++ b/frontend/src/hooks/query/use-git-repositories.ts @@ -31,7 +31,7 @@ export function useGitRepositories(options: UseGitRepositoriesOptions) { const { data: installations } = useAppInstallations(provider); const useInstallationRepos = provider - ? shouldUseInstallationRepos(provider, config?.APP_MODE) + ? shouldUseInstallationRepos(provider, config?.app_mode) : false; const repos = useInfiniteQuery< diff --git a/frontend/src/hooks/query/use-git-user.ts b/frontend/src/hooks/query/use-git-user.ts index b919ca460d..bc18e086b8 100644 --- a/frontend/src/hooks/query/use-git-user.ts +++ b/frontend/src/hooks/query/use-git-user.ts @@ -28,7 +28,7 @@ export const useGitUser = () => { name: user.data.name, email: user.data.email, user: user.data.login, - mode: config?.APP_MODE || "oss", + mode: config?.app_mode || "oss", }); } }, [user.data]); diff --git a/frontend/src/hooks/query/use-installation-repositories.ts b/frontend/src/hooks/query/use-installation-repositories.ts index 32a9f92fc4..8531855325 100644 --- a/frontend/src/hooks/query/use-installation-repositories.ts +++ b/frontend/src/hooks/query/use-installation-repositories.ts @@ -57,7 +57,7 @@ export const useInstallationRepositories = ( enabled: (providers || []).length > 0 && !!selectedProvider && - shouldUseInstallationRepos(selectedProvider, config?.APP_MODE) && + shouldUseInstallationRepos(selectedProvider, config?.app_mode) && Array.isArray(installations) && installations.length > 0, staleTime: 1000 * 60 * 5, // 5 minutes diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts index f8aa3aa6ca..5b969629f7 100644 --- a/frontend/src/hooks/query/use-is-authed.ts +++ b/frontend/src/hooks/query/use-is-authed.ts @@ -8,7 +8,7 @@ export const useIsAuthed = () => { const { data: config } = useConfig(); const isOnTosPage = useIsOnTosPage(); - const appMode = config?.APP_MODE; + const appMode = config?.app_mode; return useQuery({ queryKey: ["user", "authenticated", appMode], diff --git a/frontend/src/hooks/query/use-llm-api-key.ts b/frontend/src/hooks/query/use-llm-api-key.ts index 58dee11411..10b91b0e43 100644 --- a/frontend/src/hooks/query/use-llm-api-key.ts +++ b/frontend/src/hooks/query/use-llm-api-key.ts @@ -13,7 +13,7 @@ export function useLlmApiKey() { return useQuery({ queryKey: [LLM_API_KEY_QUERY_KEY], - enabled: config?.APP_MODE === "saas", + enabled: config?.app_mode === "saas", queryFn: async () => { const { data } = await openHands.get("/api/keys/llm/byor"); diff --git a/frontend/src/hooks/query/use-subscription-access.ts b/frontend/src/hooks/query/use-subscription-access.ts index b770aed466..2a2922c0ba 100644 --- a/frontend/src/hooks/query/use-subscription-access.ts +++ b/frontend/src/hooks/query/use-subscription-access.ts @@ -12,7 +12,7 @@ export const useSubscriptionAccess = () => { queryFn: BillingService.getSubscriptionAccess, enabled: !isOnTosPage && - config?.APP_MODE === "saas" && - config?.FEATURE_FLAGS?.ENABLE_BILLING, + config?.app_mode === "saas" && + config?.feature_flags?.enable_billing, }); }; diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts index ac647c0cf7..0b7313e285 100644 --- a/frontend/src/hooks/query/use-user-repositories.ts +++ b/frontend/src/hooks/query/use-user-repositories.ts @@ -18,7 +18,7 @@ export const useUserRepositories = (selectedProvider: Provider | null) => { enabled: (providers || []).length > 0 && !!selectedProvider && - !shouldUseInstallationRepos(selectedProvider, config?.APP_MODE), + !shouldUseInstallationRepos(selectedProvider, config?.app_mode), staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes }); diff --git a/frontend/src/hooks/use-app-title.test.tsx b/frontend/src/hooks/use-app-title.test.tsx index 440b857779..012079af98 100644 --- a/frontend/src/hooks/use-app-title.test.tsx +++ b/frontend/src/hooks/use-app-title.test.tsx @@ -38,7 +38,7 @@ describe("useAppTitle", () => { it("should return 'OpenHands' if is OSS and NOT in /conversations", async () => { // @ts-expect-error - only returning partial config for test getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", + app_mode: "oss", }); const { result } = renderAppTitleHook(); @@ -49,7 +49,7 @@ describe("useAppTitle", () => { it("should return 'OpenHands Cloud' if is SaaS and NOT in /conversations", async () => { // @ts-expect-error - only returning partial config for test getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", + app_mode: "saas", }); const { result } = renderAppTitleHook(); @@ -59,7 +59,7 @@ describe("useAppTitle", () => { it("should return '{some title} | OpenHands' if is OSS and in /conversations", async () => { // @ts-expect-error - only returning partial config for test - getConfigSpy.mockResolvedValue({ APP_MODE: "oss" }); + getConfigSpy.mockResolvedValue({ app_mode: "oss" }); mockUseParams.mockReturnValue({ conversationId: "123" }); mockUseUserConversation.mockReturnValue({ // @ts-expect-error - only returning partial config for test @@ -75,7 +75,7 @@ describe("useAppTitle", () => { it("should return '{some title} | OpenHands Cloud' if is SaaS and in /conversations", async () => { // @ts-expect-error - only returning partial config for test - getConfigSpy.mockResolvedValue({ APP_MODE: "saas" }); + getConfigSpy.mockResolvedValue({ app_mode: "saas" }); mockUseParams.mockReturnValue({ conversationId: "456" }); mockUseUserConversation.mockReturnValue({ // @ts-expect-error - only returning partial config for test @@ -93,7 +93,7 @@ describe("useAppTitle", () => { it("should return app name while conversation is loading", async () => { // @ts-expect-error - only returning partial config for test - getConfigSpy.mockResolvedValue({ APP_MODE: "oss" }); + getConfigSpy.mockResolvedValue({ app_mode: "oss" }); mockUseParams.mockReturnValue({ conversationId: "123" }); // @ts-expect-error - only returning partial config for test mockUseUserConversation.mockReturnValue({ data: undefined }); diff --git a/frontend/src/hooks/use-app-title.ts b/frontend/src/hooks/use-app-title.ts index 15ef49b486..6fe5a6740d 100644 --- a/frontend/src/hooks/use-app-title.ts +++ b/frontend/src/hooks/use-app-title.ts @@ -6,7 +6,7 @@ const APP_TITLE_OSS = "OpenHands"; const APP_TITLE_SAAS = "OpenHands Cloud"; /** - * Hook that returns the appropriate document title based on APP_MODE and current route. + * Hook that returns the appropriate document title based on app_mode and current route. * - For conversation pages: "Conversation Title | OpenHands" or "Conversation Title | OpenHands Cloud" * - For other pages: "OpenHands" or "OpenHands Cloud" */ @@ -15,7 +15,7 @@ export const useAppTitle = () => { const { conversationId } = useParams<{ conversationId: string }>(); const { data: conversation } = useUserConversation(conversationId ?? null); - const appTitle = config?.APP_MODE === "oss" ? APP_TITLE_OSS : APP_TITLE_SAAS; + const appTitle = config?.app_mode === "oss" ? APP_TITLE_OSS : APP_TITLE_SAAS; const conversationTitle = conversation?.title; if (conversationId && conversationTitle) { diff --git a/frontend/src/hooks/use-auth-callback.ts b/frontend/src/hooks/use-auth-callback.ts index 3c884cdff4..8e3b647ed9 100644 --- a/frontend/src/hooks/use-auth-callback.ts +++ b/frontend/src/hooks/use-auth-callback.ts @@ -15,7 +15,7 @@ export const useAuthCallback = () => { useEffect(() => { // Only run in SAAS mode - if (config?.APP_MODE !== "saas") { + if (config?.app_mode !== "saas") { return; } @@ -62,7 +62,7 @@ export const useAuthCallback = () => { isAuthLoading, location.search, location.pathname, - config?.APP_MODE, + config?.app_mode, navigate, ]); }; diff --git a/frontend/src/hooks/use-auth-url.ts b/frontend/src/hooks/use-auth-url.ts index c6a7d56a40..daf8db8229 100644 --- a/frontend/src/hooks/use-auth-url.ts +++ b/frontend/src/hooks/use-auth-url.ts @@ -1,10 +1,10 @@ import { generateAuthUrl } from "#/utils/generate-auth-url"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; interface UseAuthUrlConfig { - appMode: GetConfigResponse["APP_MODE"] | null; + appMode: WebClientConfig["app_mode"] | null; identityProvider: string; - authUrl?: GetConfigResponse["AUTH_URL"]; + authUrl?: WebClientConfig["auth_url"]; } export const useAuthUrl = (config: UseAuthUrlConfig) => { diff --git a/frontend/src/hooks/use-auto-login.ts b/frontend/src/hooks/use-auto-login.ts index 73a602e65f..1d9f766b0e 100644 --- a/frontend/src/hooks/use-auto-login.ts +++ b/frontend/src/hooks/use-auto-login.ts @@ -17,32 +17,32 @@ export const useAutoLogin = () => { // Get the auth URLs for all providers const githubAuthUrl = useAuthUrl({ - appMode: config?.APP_MODE || null, + appMode: config?.app_mode || null, identityProvider: "github", - authUrl: config?.AUTH_URL, + authUrl: config?.auth_url, }); const gitlabAuthUrl = useAuthUrl({ - appMode: config?.APP_MODE || null, + appMode: config?.app_mode || null, identityProvider: "gitlab", - authUrl: config?.AUTH_URL, + authUrl: config?.auth_url, }); const bitbucketAuthUrl = useAuthUrl({ - appMode: config?.APP_MODE || null, + appMode: config?.app_mode || null, identityProvider: "bitbucket", - authUrl: config?.AUTH_URL, + authUrl: config?.auth_url, }); const enterpriseSsoUrl = useAuthUrl({ - appMode: config?.APP_MODE || null, + appMode: config?.app_mode || null, identityProvider: "enterprise_sso", - authUrl: config?.AUTH_URL, + authUrl: config?.auth_url, }); useEffect(() => { // Only auto-login in SAAS mode - if (config?.APP_MODE !== "saas") { + if (config?.app_mode !== "saas") { return; } @@ -83,7 +83,7 @@ export const useAutoLogin = () => { window.location.href = url.toString(); } }, [ - config?.APP_MODE, + config?.app_mode, isAuthed, isConfigLoading, isAuthLoading, diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts index 699b394fb8..922070df47 100644 --- a/frontend/src/hooks/use-github-auth-url.ts +++ b/frontend/src/hooks/use-github-auth-url.ts @@ -1,10 +1,9 @@ import { useAuthUrl } from "./use-auth-url"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; interface UseGitHubAuthUrlConfig { - appMode: GetConfigResponse["APP_MODE"] | null; - gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null; - authUrl?: GetConfigResponse["AUTH_URL"]; + appMode: WebClientConfig["app_mode"] | null; + authUrl?: WebClientConfig["auth_url"]; } export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => diff --git a/frontend/src/hooks/use-reo-tracking.ts b/frontend/src/hooks/use-reo-tracking.ts index 8cb81e5a31..199c1e16c2 100644 --- a/frontend/src/hooks/use-reo-tracking.ts +++ b/frontend/src/hooks/use-reo-tracking.ts @@ -97,7 +97,7 @@ export const useReoTracking = () => { React.useEffect(() => { const initReo = async () => { if ( - config?.APP_MODE === "saas" && + config?.app_mode === "saas" && isProductionDomain() && !reoService.isInitialized() ) { @@ -106,12 +106,12 @@ export const useReoTracking = () => { }; initReo(); - }, [config?.APP_MODE]); + }, [config?.app_mode]); // Identify user when user data is available and we're in SaaS mode on correct domain React.useEffect(() => { if ( - config?.APP_MODE !== "saas" || + config?.app_mode !== "saas" || !isProductionDomain() || !user || hasIdentified || @@ -131,5 +131,5 @@ export const useReoTracking = () => { // Identify user in Reo reoService.identify(identity); setHasIdentified(true); - }, [config?.APP_MODE, user, hasIdentified]); + }, [config?.app_mode, user, hasIdentified]); }; diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index aa67e8cb9a..a0a0d02503 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -4,8 +4,8 @@ import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; export function useSettingsNavItems() { const { data: config } = useConfig(); - const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS; - const isSaasMode = config?.APP_MODE === "saas"; + const shouldHideLlmSettings = !!config?.feature_flags?.hide_llm_settings; + const isSaasMode = config?.app_mode === "saas"; const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; diff --git a/frontend/src/hooks/use-should-show-user-features.ts b/frontend/src/hooks/use-should-show-user-features.ts index 3f6af98da4..7c04205f68 100644 --- a/frontend/src/hooks/use-should-show-user-features.ts +++ b/frontend/src/hooks/use-should-show-user-features.ts @@ -15,14 +15,14 @@ export const useShouldShowUserFeatures = (): boolean => { const { providers } = useUserProviders(); return React.useMemo(() => { - if (!config?.APP_MODE || !isAuthed) return false; + if (!config?.app_mode || !isAuthed) return false; // In OSS mode, only show user features if Git providers are configured - if (config.APP_MODE === "oss") { + if (config.app_mode === "oss") { return providers.length > 0; } // In non-OSS modes (saas), always show user features when authenticated return true; - }, [config?.APP_MODE, isAuthed, providers.length]); + }, [config?.app_mode, isAuthed, providers.length]); }; diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index d04cdbb81a..8595d8b420 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -14,7 +14,7 @@ export const useTracking = () => { // Common properties included in all tracking events const commonProperties = { - app_surface: config?.APP_MODE || "unknown", + app_surface: config?.app_mode || "unknown", plan_tier: null, current_url: window.location.href, user_email: settings?.email || settings?.git_user_email || null, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 302eca37c4..c1ea8a4110 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1,6 +1,7 @@ // this file generate by script, don't modify it manually!!! export enum I18nKey { MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE", + ALERT$FAULTY_MODELS_MESSAGE = "ALERT$FAULTY_MODELS_MESSAGE", AZURE_DEVOPS$CONNECT_ACCOUNT = "AZURE_DEVOPS$CONNECT_ACCOUNT", GIT$AZURE_DEVOPS_TOKEN = "GIT$AZURE_DEVOPS_TOKEN", GIT$AZURE_DEVOPS_HOST = "GIT$AZURE_DEVOPS_HOST", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index f25bd4ffe2..ee2fdc59a5 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -15,6 +15,22 @@ "de": "Die geplante Wartung beginnt um {{time}}", "uk": "Планове технічне обслуговування розпочнеться о {{time}}" }, + "ALERT$FAULTY_MODELS_MESSAGE": { + "en": "The following models are currently reporting errors:", + "ja": "次のモデルで現在エラーが報告されています:", + "zh-CN": "以下模型当前报告错误:", + "zh-TW": "以下模型目前報告錯誤:", + "ko-KR": "다음 모델에서 현재 오류가 보고되고 있습니다:", + "no": "Følgende modeller rapporterer for øyeblikket feil:", + "it": "I seguenti modelli stanno attualmente segnalando errori:", + "pt": "Os seguintes modelos estão atualmente relatando erros:", + "es": "Los siguientes modelos están reportando errores actualmente:", + "ar": "النماذج التالية تُبلغ حاليًا عن أخطاء:", + "fr": "Les modèles suivants signalent actuellement des erreurs :", + "tr": "Aşağıdaki modeller şu anda hata bildiriyor:", + "de": "Die folgenden Modelle melden derzeit Fehler:", + "uk": "Наступні моделі наразі повідомляють про помилки:" + }, "AZURE_DEVOPS$CONNECT_ACCOUNT": { "en": "Connect Azure DevOps Account", "ja": "Azure DevOps アカウントを接続", diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts index 00de1e9c5d..1b9b34e841 100644 --- a/frontend/src/mocks/settings-handlers.ts +++ b/frontend/src/mocks/settings-handlers.ts @@ -1,5 +1,5 @@ import { http, delay, HttpResponse } from "msw"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { Provider, Settings } from "#/types/settings"; @@ -66,24 +66,29 @@ export const SETTINGS_HANDLERS = [ HttpResponse.json(["llm", "none"]), ), - http.get("/api/options/config", () => { + http.get("/api/v1/web-client/config", () => { const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true"; - const config: GetConfigResponse = { - APP_MODE: mockSaas ? "saas" : "oss", - GITHUB_CLIENT_ID: "fake-github-client-id", - POSTHOG_CLIENT_KEY: "fake-posthog-client-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: mockSaas, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, + const config: WebClientConfig = { + app_mode: mockSaas ? "saas" : "oss", + posthog_client_key: "fake-posthog-client-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: mockSaas, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, }, + providers_configured: [], + maintenance_start_time: null, // Uncomment the following to test the maintenance banner - // MAINTENANCE: { - // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp - // }, + // maintenance_start_time: "2024-01-15T10:00:00-05:00", // EST timestamp + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: new Date().toISOString(), + github_app_slug: mockSaas ? "openhands" : null, }; return HttpResponse.json(config); diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index a8524cc989..eafd225bca 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -215,7 +215,7 @@ function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)} - {config?.APP_MODE === "saas" && ( + {config?.app_mode === "saas" && ( )} - {config?.APP_MODE === "saas" && ( + {config?.app_mode === "saas" && ( {t(I18nKey.SETTINGS$GITHUB)} - +
diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index 1afd29806a..d28bfa661b 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -113,7 +113,7 @@ function LlmSettingsScreen() { // Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode) const currentModel = currentSelectedModel || settings?.llm_model; - const isSaasMode = config?.APP_MODE === "saas"; + const isSaasMode = config?.app_mode === "saas"; const isOpenHandsProvider = () => { if (view === "basic") { @@ -608,7 +608,7 @@ function LlmSettingsScreen() { )} - {config?.APP_MODE !== "saas" && ( + {config?.app_mode !== "saas" && ( <> { - if (!config.isLoading && config.data?.APP_MODE === "oss") { + if (!config.isLoading && config.data?.app_mode === "oss") { navigate("/", { replace: true }); } - }, [config.isLoading, config.data?.APP_MODE, navigate]); + }, [config.isLoading, config.data?.app_mode, navigate]); // Redirect authenticated users away from login page React.useEffect(() => { @@ -52,7 +51,7 @@ export default function LoginPage() { } // Don't render login content if user is authenticated or in OSS mode - if (isAuthed || config.data?.APP_MODE === "oss") { + if (isAuthed || config.data?.app_mode === "oss") { return null; } @@ -64,9 +63,9 @@ export default function LoginPage() { > { - let config = queryClient.getQueryData(["config"]); + let config = queryClient.getQueryData(["web-client-config"]); if (!config) { config = await OptionService.getConfig(); - queryClient.setQueryData(["config"], config); + queryClient.setQueryData(["web-client-config"], config); } return null; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index bbd4feeed1..5c6ef5e76f 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -26,7 +26,7 @@ import { useReoTracking } from "#/hooks/use-reo-tracking"; import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; -import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; +import { AlertBanner } from "#/components/features/alerts/alert-banner"; import { cn, isMobileDevice } from "#/utils/utils"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useAppTitle } from "#/hooks/use-app-title"; @@ -126,10 +126,10 @@ export default function MainApp() { }, [isOnTosPage]); React.useEffect(() => { - if (settings?.is_new_user && config.data?.APP_MODE === "saas") { + if (settings?.is_new_user && config.data?.app_mode === "saas") { displaySuccessToast(t(I18nKey.BILLING$YOURE_IN)); } - }, [settings?.is_new_user, config.data?.APP_MODE]); + }, [settings?.is_new_user, config.data?.app_mode]); // Function to check if login method exists in local storage const checkLoginMethodExists = React.useCallback(() => { @@ -179,7 +179,7 @@ export default function MainApp() { (!isAuthed && !isAuthError && !isOnTosPage && - config.data?.APP_MODE === "saas" && + config.data?.app_mode === "saas" && !loginMethodExists); React.useEffect(() => { @@ -210,7 +210,7 @@ export default function MainApp() { !isAuthError && !isFetchingAuth && !isOnTosPage && - config.data?.APP_MODE === "saas" && + config.data?.app_mode === "saas" && loginMethodExists; return ( @@ -226,9 +226,18 @@ export default function MainApp() {
- {config.data?.MAINTENANCE && ( - - )} + {config.data && + (config.data.maintenance_start_time || + (config.data.faulty_models && + config.data.faulty_models.length > 0) || + config.data.error_message) && ( + + )}
{renderReAuthModal && } - {config.data?.APP_MODE === "oss" && consentFormIsOpen && ( + {config.data?.app_mode === "oss" && consentFormIsOpen && ( { setConsentFormIsOpen(false); @@ -248,8 +257,8 @@ export default function MainApp() { /> )} - {config.data?.FEATURE_FLAGS.ENABLE_BILLING && - config.data?.APP_MODE === "saas" && + {config.data?.feature_flags.enable_billing && + config.data?.app_mode === "saas" && settings?.is_new_user && }
); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 4f35595d13..8ccad39907 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; -import { GetConfigResponse } from "#/api/option-service/option.types"; +import { WebClientConfig } from "#/api/option-service/option.types"; import { SettingsLayout } from "#/components/features/settings/settings-layout"; import { Typography } from "#/ui/typography"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; @@ -20,20 +20,20 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; - let config = queryClient.getQueryData(["config"]); + let config = queryClient.getQueryData(["web-client-config"]); if (!config) { config = await OptionService.getConfig(); - queryClient.setQueryData(["config"], config); + queryClient.setQueryData(["web-client-config"], config); } - const isSaas = config?.APP_MODE === "saas"; + const isSaas = config?.app_mode === "saas"; if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) { // if in OSS mode, do not allow access to saas-only paths return redirect("/settings"); } // If LLM settings are hidden and user tries to access the LLM settings page - if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") { + if (config?.feature_flags?.hide_llm_settings && pathname === "/settings") { // Redirect to the first available settings page return isSaas ? redirect("/settings/user") : redirect("/settings/mcp"); } diff --git a/frontend/src/ui/typography.tsx b/frontend/src/ui/typography.tsx index fc574d6f51..6e8dfbcaec 100644 --- a/frontend/src/ui/typography.tsx +++ b/frontend/src/ui/typography.tsx @@ -8,6 +8,7 @@ const typographyVariants = cva("", { h2: "text-xl font-semibold leading-6 -tracking-[0.02em] text-white", h3: "text-sm font-semibold text-gray-300", span: "text-sm font-normal text-white leading-5.5", + p: "text-sm font-normal text-white leading-5.5", codeBlock: "font-mono text-sm leading-relaxed text-gray-300 whitespace-pre-wrap", }, @@ -102,9 +103,22 @@ export function CodeBlock({ ); } +export function Paragraph({ + className, + testId, + children, +}: Omit) { + return ( + + {children} + + ); +} + // Attach components to Typography for the expected API Typography.H1 = H1; Typography.H2 = H2; Typography.H3 = H3; Typography.Text = Text; Typography.CodeBlock = CodeBlock; +Typography.Paragraph = Paragraph; diff --git a/frontend/src/utils/generate-auth-url.ts b/frontend/src/utils/generate-auth-url.ts index 0fdf71d5b1..a5ec936144 100644 --- a/frontend/src/utils/generate-auth-url.ts +++ b/frontend/src/utils/generate-auth-url.ts @@ -7,7 +7,7 @@ export const generateAuthUrl = ( identityProvider: string, requestUrl: URL, - authUrl?: string, + authUrl?: string | null, ) => { // Use HTTPS protocol unless the host is localhost const protocol = diff --git a/openhands/app_server/web_client/default_web_client_config_injector.py b/openhands/app_server/web_client/default_web_client_config_injector.py index d5d40f19cb..ce345fb61a 100644 --- a/openhands/app_server/web_client/default_web_client_config_injector.py +++ b/openhands/app_server/web_client/default_web_client_config_injector.py @@ -21,6 +21,14 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector): recaptcha_site_key: str | None = None faulty_models: list[str] = Field(default_factory=list) error_message: str | None = None + updated_at: datetime = Field( + default=datetime.fromisoformat('2026-01-01T00:00:00Z'), + description=( + 'The timestamp when error messages and faulty models were last updated. ' + 'The frontend uses this value to determine whether error messages are ' + 'new and should be displayed. (Default to start of 2026)' + ), + ) github_app_slug: str | None = None async def get_web_client_config(self) -> WebClientConfig: @@ -37,6 +45,7 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector): recaptcha_site_key=self.recaptcha_site_key, faulty_models=self.faulty_models, error_message=self.error_message, + updated_at=self.updated_at, github_app_slug=self.github_app_slug, ) return result diff --git a/openhands/app_server/web_client/web_client_models.py b/openhands/app_server/web_client/web_client_models.py index 38f9af7bc2..f6d176f4f9 100644 --- a/openhands/app_server/web_client/web_client_models.py +++ b/openhands/app_server/web_client/web_client_models.py @@ -25,4 +25,5 @@ class WebClientConfig(DiscriminatedUnionMixin): recaptcha_site_key: str | None faulty_models: list[str] error_message: str | None + updated_at: datetime github_app_slug: str | None diff --git a/openhands/app_server/web_client/web_client_router.py b/openhands/app_server/web_client/web_client_router.py index 3c7189597b..60950d6a2d 100644 --- a/openhands/app_server/web_client/web_client_router.py +++ b/openhands/app_server/web_client/web_client_router.py @@ -8,9 +8,12 @@ router = APIRouter(prefix='/web-client', tags=['Config']) @router.get('/config') async def get_web_client_config() -> WebClientConfig: - """Get the configuration of the web client. This endpoint is typically one of the first - invoked, and does not require authentication. It provides general settings for the - web client independent of users.""" + """Get the configuration of the web client. + + This endpoint is typically one of the first invoked, and does not require + authentication. It provides general settings for the web client independent + of users. + """ config = get_global_config() result = await config.web_client.get_web_client_config() return result