From 0c03e257b7b5e8bb51ed0cc85a09e8db1214b790 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:11:18 +0400 Subject: [PATCH] feat(frontend): Settings screen (#6550) --- .../account-settings-context-menu.test.tsx | 23 +- .../analytics-consent-form-modal.test.tsx | 23 +- .../features/sidebar/sidebar.test.tsx | 162 +--- .../settings/account-settings-modal.test.tsx | 174 ---- .../modals/settings/brand-button.test.tsx | 39 + .../modals/settings/model-selector.test.tsx | 1 - .../settings/settings-input.test.tsx | 88 ++ .../settings/settings-switch.test.tsx | 64 ++ .../modals/settings/settings-form.test.tsx | 68 +- .../components/user-actions.test.tsx | 59 +- .../hooks/mutation/use-save-settings.test.tsx | 36 + frontend/__tests__/i18n/translations.test.tsx | 25 +- frontend/__tests__/routes/home.test.tsx | 114 +++ frontend/__tests__/routes/settings.test.tsx | 873 ++++++++++++++++++ .../utils/has-advanced-settings-set.test.ts | 56 ++ .../__tests__/utils/is-custom-model.test.ts | 20 + frontend/src/api/open-hands.ts | 6 +- .../analytics-consent-form-modal.tsx | 3 +- .../account-settings-context-menu.tsx | 10 - .../github-repositories-suggestion-box.tsx | 55 +- .../features/settings/brand-button.tsx | 39 + .../features/settings/help-link.tsx | 22 + .../features/settings/key-status-icon.tsx | 16 + .../features/settings/optional-tag.tsx | 3 + .../settings/settings-dropdown-input.tsx | 56 ++ .../features/settings/settings-input.tsx | 50 + .../features/settings/settings-switch.tsx | 50 + .../settings/styled-switch-component.tsx | 26 + .../components/features/sidebar/sidebar.tsx | 86 +- .../features/sidebar/user-actions.tsx | 13 +- .../features/sidebar/user-avatar.tsx | 11 +- .../shared/buttons/all-hands-logo-button.tsx | 2 +- .../components/shared/buttons/docs-button.tsx | 4 +- .../shared/buttons/exit-project-button.tsx | 4 +- .../shared/buttons/settings-button.tsx | 7 +- .../account-settings-form.tsx | 160 ---- .../account-settings-modal.tsx | 14 - .../account-settings/github-token-input.tsx | 39 - .../shared/modals/settings/model-selector.tsx | 202 ++-- .../shared/modals/settings/settings-form.tsx | 195 +--- .../shared/modals/settings/settings-modal.tsx | 17 +- frontend/src/context/settings-context.tsx | 10 +- .../src/hooks/mutation/use-save-settings.ts | 13 +- frontend/src/hooks/query/use-settings.ts | 14 +- frontend/src/hooks/use-app-logout.ts | 16 + frontend/src/icons/academy.svg | 4 + frontend/src/icons/plus.svg | 11 + frontend/src/icons/profile.svg | 11 + frontend/src/icons/settings.svg | 4 + frontend/src/icons/success.svg | 4 + frontend/src/icons/warning.svg | 5 + frontend/src/mocks/handlers.ts | 4 + frontend/src/query-client-config.ts | 20 +- frontend/src/routes.ts | 1 + frontend/src/routes/settings.tsx | 452 +++++++++ frontend/src/types/react-query.d.ts | 7 + frontend/src/types/settings.ts | 4 +- .../src/utils/has-advanced-settings-set.ts | 10 + frontend/src/utils/is-custom-model.ts | 22 + frontend/src/utils/settings-utils.ts | 6 +- 60 files changed, 2429 insertions(+), 1104 deletions(-) delete mode 100644 frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx create mode 100644 frontend/__tests__/components/modals/settings/brand-button.test.tsx create mode 100644 frontend/__tests__/components/settings/settings-input.test.tsx create mode 100644 frontend/__tests__/components/settings/settings-switch.test.tsx create mode 100644 frontend/__tests__/hooks/mutation/use-save-settings.test.tsx create mode 100644 frontend/__tests__/routes/home.test.tsx create mode 100644 frontend/__tests__/routes/settings.test.tsx create mode 100644 frontend/__tests__/utils/has-advanced-settings-set.test.ts create mode 100644 frontend/__tests__/utils/is-custom-model.test.ts create mode 100644 frontend/src/components/features/settings/brand-button.tsx create mode 100644 frontend/src/components/features/settings/help-link.tsx create mode 100644 frontend/src/components/features/settings/key-status-icon.tsx create mode 100644 frontend/src/components/features/settings/optional-tag.tsx create mode 100644 frontend/src/components/features/settings/settings-dropdown-input.tsx create mode 100644 frontend/src/components/features/settings/settings-input.tsx create mode 100644 frontend/src/components/features/settings/settings-switch.tsx create mode 100644 frontend/src/components/features/settings/styled-switch-component.tsx delete mode 100644 frontend/src/components/shared/modals/account-settings/account-settings-form.tsx delete mode 100644 frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx delete mode 100644 frontend/src/components/shared/modals/account-settings/github-token-input.tsx create mode 100644 frontend/src/hooks/use-app-logout.ts create mode 100644 frontend/src/icons/academy.svg create mode 100644 frontend/src/icons/plus.svg create mode 100644 frontend/src/icons/profile.svg create mode 100644 frontend/src/icons/settings.svg create mode 100644 frontend/src/icons/success.svg create mode 100644 frontend/src/icons/warning.svg create mode 100644 frontend/src/routes/settings.tsx create mode 100644 frontend/src/utils/has-advanced-settings-set.ts create mode 100644 frontend/src/utils/is-custom-model.ts diff --git a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx index 89780e07ae..00ac105322 100644 --- a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx +++ b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx @@ -18,7 +18,6 @@ describe("AccountSettingsContextMenu", () => { it("should always render the right options", () => { render( { expect( screen.getByTestId("account-settings-context-menu"), ).toBeInTheDocument(); - expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument(); expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument(); }); - it("should call onClickAccountSettings when the account settings option is clicked", async () => { - render( - , - ); - - const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); - await user.click(accountSettingsOption); - - expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); - }); - it("should call onLogout when the logout option is clicked", async () => { render( { test("onLogout should be disabled if the user is not logged in", async () => { render( { it("should call onClose when clicking outside of the element", async () => { render( , ); - const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); + const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); await user.click(accountSettingsButton); await user.click(document.body); diff --git a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx index 01f7f5a137..4ebfbe3ba5 100644 --- a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +++ b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import OpenHands from "#/api/open-hands"; @@ -8,7 +8,7 @@ import { SettingsProvider } from "#/context/settings-context"; import { AuthProvider } from "#/context/auth-context"; describe("AnalyticsConsentFormModal", () => { - it("should call saveUserSettings with default settings on confirm reset settings", async () => { + it("should call saveUserSettings with consent", async () => { const user = userEvent.setup(); const onCloseMock = vi.fn(); const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); @@ -26,20 +26,9 @@ describe("AnalyticsConsentFormModal", () => { const confirmButton = screen.getByTestId("confirm-preferences"); await user.click(confirmButton); - expect(saveUserSettingsSpy).toHaveBeenCalledWith({ - user_consents_to_analytics: true, - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - github_token: undefined, - language: "en", - llm_api_key: undefined, - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - unset_github_token: undefined, - }); - expect(onCloseMock).toHaveBeenCalled(); + expect(saveUserSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ user_consents_to_analytics: true }), + ); + await waitFor(() => expect(onCloseMock).toHaveBeenCalled()); }); }); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 62e2c05e04..0039a1819b 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -1,9 +1,6 @@ -import { screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; -import { AxiosError } from "axios"; import { Sidebar } from "#/components/features/sidebar/sidebar"; import OpenHands from "#/api/open-hands"; @@ -21,161 +18,14 @@ const renderSidebar = () => renderWithProviders(); describe("Sidebar", () => { - describe("Settings", () => { - const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should fetch settings data on mount", () => { - renderSidebar(); - expect(getSettingsSpy).toHaveBeenCalledOnce(); - }); - - it("should send all settings data when saving AI configuration", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - const saveButton = within(settingsModal).getByTestId( - "save-settings-button", - ); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - }); - }); - - it("should not reset AI configuration when saving account settings", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const menu = screen.getByTestId("account-settings-context-menu"); - const accountSettingsButton = within(menu).getByTestId( - "account-settings-button", - ); - await user.click(accountSettingsButton); - - const accountSettingsModal = screen.getByTestId("account-settings-form"); - - const languageInput = - within(accountSettingsModal).getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const tokenInput = - within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i); - await user.type(tokenInput, "new-token"); - - const analyticsConsentInput = - within(accountSettingsModal).getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = - within(accountSettingsModal).getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - github_token: "new-token", - language: "no", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: true, - }); - }); - - it("should not send the api key if its SET", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - - // Click the advanced options switch to show the API key input - const advancedOptionsSwitch = within(settingsModal).getByTestId( - "advanced-option-switch", - ); - await user.click(advancedOptionsSwitch); - - const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i); - await user.type(apiKeyInput, "**********"); - - const saveButton = within(settingsModal).getByTestId( - "save-settings-button", - ); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - }); - }); + afterEach(() => { + vi.clearAllMocks(); }); - describe("Settings Modal", () => { - it("should open the settings modal if the user clicks the settings button", async () => { - const user = userEvent.setup(); - renderSidebar(); - - expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - }); - - it("should open the settings modal if GET /settings fails with a 404", async () => { - const error = new AxiosError( - "Request failed with status code 404", - "ERR_BAD_REQUEST", - undefined, - undefined, - { - status: 404, - statusText: "Not Found", - data: { message: "Settings not found" }, - headers: {}, - // @ts-expect-error - we only need the response object for this test - config: {}, - }, - ); - - vi.spyOn(OpenHands, "getSettings").mockRejectedValue(error); - - renderSidebar(); - - const settingsModal = await screen.findByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - }); + it("should fetch settings data on mount", () => { + renderSidebar(); + expect(getSettingsSpy).toHaveBeenCalled(); }); }); diff --git a/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx b/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx deleted file mode 100644 index 2291b0af5d..0000000000 --- a/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import OpenHands from "#/api/open-hands"; -import * as ConsentHandlers from "#/utils/handle-capture-consent"; - -describe("AccountSettingsModal", () => { - const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it.skip("should set the appropriate user analytics consent default", async () => { - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - user_consents_to_analytics: true, - }); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await waitFor(() => expect(analyticsConsentInput).toBeChecked()); - }); - - it("should save the users consent to analytics when saving account settings", async () => { - const user = userEvent.setup(); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: true, - }); - }); - - it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => { - const user = userEvent.setup(); - const handleCaptureConsentSpy = vi.spyOn( - ConsentHandlers, - "handleCaptureConsent", - ); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true); - - await user.click(analyticsConsentInput); - await user.click(saveButton); - - expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false); - }); - - it("should send all settings data when saving account settings", async () => { - const user = userEvent.setup(); - renderWithProviders( {}} />); - - const languageInput = screen.getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const tokenInput = screen.getByTestId("github-token-input"); - await user.type(tokenInput, "new-token"); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "no", - github_token: "new-token", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: false, - }); - }); - - it("should render a checkmark and not the input if the github token is set", async () => { - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - await waitFor(() => { - const checkmark = screen.queryByTestId("github-token-set-checkmark"); - const input = screen.queryByTestId("github-token-input"); - - expect(checkmark).toBeInTheDocument(); - expect(input).not.toBeInTheDocument(); - }); - }); - - it("should send an unset github token property when pressing disconnect", async () => { - const user = userEvent.setup(); - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - const disconnectButton = await screen.findByTestId("disconnect-github"); - await user.click(disconnectButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - github_token: undefined, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - unset_github_token: true, - }); - }); - - it("should not unset the github token when changing the language", async () => { - const user = userEvent.setup(); - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - const languageInput = screen.getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "no", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: false, - }); - }); -}); diff --git a/frontend/__tests__/components/modals/settings/brand-button.test.tsx b/frontend/__tests__/components/modals/settings/brand-button.test.tsx new file mode 100644 index 0000000000..784cecc625 --- /dev/null +++ b/frontend/__tests__/components/modals/settings/brand-button.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { BrandButton } from "#/components/features/settings/brand-button"; + +describe("BrandButton", () => { + const onClickMock = vi.fn(); + + it("should set a test id", () => { + render( + + Test Button + , + ); + + expect(screen.getByTestId("brand-button")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const user = userEvent.setup(); + render( + + Test Button + , + ); + + await user.click(screen.getByText("Test Button")); + }); + + it("should be disabled if isDisabled is true", () => { + render( + + Test Button + , + ); + + expect(screen.getByText("Test Button")).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/modals/settings/model-selector.test.tsx b/frontend/__tests__/components/modals/settings/model-selector.test.tsx index 757f5dcd45..cacc6fad10 100644 --- a/frontend/__tests__/components/modals/settings/model-selector.test.tsx +++ b/frontend/__tests__/components/modals/settings/model-selector.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; -import { I18nKey } from "#/i18n/declaration"; // Mock react-i18next vi.mock("react-i18next", () => ({ diff --git a/frontend/__tests__/components/settings/settings-input.test.tsx b/frontend/__tests__/components/settings/settings-input.test.tsx new file mode 100644 index 0000000000..6009a2409e --- /dev/null +++ b/frontend/__tests__/components/settings/settings-input.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SettingsInput } from "#/components/features/settings/settings-input"; + +describe("SettingsInput", () => { + it("should render an optional tag if showOptionalTag is true", async () => { + const { rerender } = render( + , + ); + + expect(screen.queryByText(/optional/i)).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText(/optional/i)).toBeInTheDocument(); + }); + + it("should disable the input if isDisabled is true", async () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId("test-input")).toBeEnabled(); + + rerender( + , + ); + + expect(screen.getByTestId("test-input")).toBeDisabled(); + }); + + it("should set a placeholder on the input", async () => { + render( + , + ); + + expect(screen.getByTestId("test-input")).toHaveAttribute( + "placeholder", + "Test Placeholder", + ); + }); + + it("should set a default value on the input", async () => { + render( + , + ); + + expect(screen.getByTestId("test-input")).toHaveValue("Test Value"); + }); + + it("should render start content", async () => { + const startContent =
Start Content
; + + render( + , + ); + + expect(screen.getByText("Start Content")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/settings/settings-switch.test.tsx b/frontend/__tests__/components/settings/settings-switch.test.tsx new file mode 100644 index 0000000000..054bbc9328 --- /dev/null +++ b/frontend/__tests__/components/settings/settings-switch.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; + +describe("SettingsSwitch", () => { + it("should call the onChange handler when the input is clicked", async () => { + const user = userEvent.setup(); + const onToggleMock = vi.fn(); + render( + + Test Switch + , + ); + + const switchInput = screen.getByTestId("test-switch"); + + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(true); + + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(false); + }); + + it("should render a beta tag if isBeta is true", () => { + const { rerender } = render( + + Test Switch + , + ); + + expect(screen.queryByText(/beta/i)).not.toBeInTheDocument(); + + rerender( + + Test Switch + , + ); + + expect(screen.getByText(/beta/i)).toBeInTheDocument(); + }); + + it("should be able to set a default toggle state", async () => { + const user = userEvent.setup(); + const onToggleMock = vi.fn(); + render( + + Test Switch + , + ); + + expect(screen.getByTestId("test-switch")).toBeChecked(); + + const switchInput = screen.getByTestId("test-switch"); + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(false); + + expect(screen.getByTestId("test-switch")).not.toBeChecked(); + }); +}); diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index 06d1628e1f..d1d623f137 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -1,36 +1,22 @@ -import { screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, afterEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; -import userEvent from "@testing-library/user-event"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; +import { screen } from "@testing-library/react"; import OpenHands from "#/api/open-hands"; +import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; +import { DEFAULT_SETTINGS } from "#/services/settings"; describe("SettingsForm", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + const onCloseMock = vi.fn(); const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); - const onCloseMock = vi.fn(); - - afterEach(() => { - vi.clearAllMocks(); - }); - - getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "123", - }); - - const RouterStub = createRoutesStub([ + const RouteStub = createRoutesStub([ { Component: () => ( ), @@ -38,39 +24,17 @@ describe("SettingsForm", () => { }, ]); - it("should not show runtime size selector by default", () => { - renderWithProviders(); - expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument(); - }); - - it("should show runtime size selector when advanced options are enabled", async () => { + it("should save the user settings and close the modal when the form is submitted", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); - const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); - await user.click(toggleAdvancedMode); - - await screen.findByTestId("runtime-size"); - }); - - it("should not submit the form if required fields are empty", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument(); - - const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); - await user.click(toggleAdvancedMode); - - const customModelInput = screen.getByTestId("custom-model-input"); - expect(customModelInput).toBeInTheDocument(); - - await user.clear(customModelInput); - - const saveButton = screen.getByTestId("save-settings-button"); + const saveButton = screen.getByRole("button", { name: /save/i }); await user.click(saveButton); - expect(saveSettingsSpy).not.toHaveBeenCalled(); - expect(onCloseMock).not.toHaveBeenCalled(); + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_model: DEFAULT_SETTINGS.LLM_MODEL, + }), + ); }); }); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index 143af7d711..3ce7e308d5 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -14,24 +14,14 @@ describe("UserActions", () => { }); it("should render", () => { - render( - , - ); + render(); expect(screen.getByTestId("user-actions")).toBeInTheDocument(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); }); it("should toggle the user menu when the user avatar is clicked", async () => { - render( - , - ); + render(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); @@ -47,30 +37,9 @@ describe("UserActions", () => { ).not.toBeInTheDocument(); }); - it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => { - render( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); - await user.click(accountSettingsOption); - - expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); - }); - it("should call onLogout and close the menu when the logout option is clicked", async () => { render( , @@ -89,12 +58,7 @@ describe("UserActions", () => { }); test("onLogout should not be called when the user is not logged in", async () => { - render( - , - ); + render(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); @@ -104,21 +68,4 @@ describe("UserActions", () => { expect(onLogoutMock).not.toHaveBeenCalled(); }); - - // FIXME: Spinner now provided through useQuery - it.skip("should display the loading spinner", () => { - render( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - user.click(userAvatar); - - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument(); - }); }); diff --git a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx new file mode 100644 index 0000000000..2cf5dce1d4 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; + +describe("useSaveSettings", () => { + it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => { + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const { result } = renderHook(() => useSaveSettings(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + result.current.mutate({ LLM_API_KEY: "" }); + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: "", + }), + ); + }); + + result.current.mutate({ LLM_API_KEY: null }); + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: undefined, + }), + ); + }); + }); +}); diff --git a/frontend/__tests__/i18n/translations.test.tsx b/frontend/__tests__/i18n/translations.test.tsx index 3833b4d306..01a0bebffe 100644 --- a/frontend/__tests__/i18n/translations.test.tsx +++ b/frontend/__tests__/i18n/translations.test.tsx @@ -1,20 +1,21 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import i18n from '../../src/i18n'; -import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu'; -import { renderWithProviders } from '../../test-utils'; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import i18n from "../../src/i18n"; +import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu"; +import { renderWithProviders } from "../../test-utils"; -describe('Translations', () => { - it('should render translated text', () => { - i18n.changeLanguage('en'); +describe("Translations", () => { + it("should render translated text", () => { + i18n.changeLanguage("en"); renderWithProviders( {}} onLogout={() => {}} onClose={() => {}} - isLoggedIn={true} - /> + isLoggedIn + />, ); - expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument(); + expect( + screen.getByTestId("account-settings-context-menu"), + ).toBeInTheDocument(); }); }); diff --git a/frontend/__tests__/routes/home.test.tsx b/frontend/__tests__/routes/home.test.tsx new file mode 100644 index 0000000000..ec7a24761f --- /dev/null +++ b/frontend/__tests__/routes/home.test.tsx @@ -0,0 +1,114 @@ +import { createRoutesStub } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { AxiosError } from "axios"; +import MainApp from "#/routes/_oh/route"; +import SettingsScreen from "#/routes/settings"; +import Home from "#/routes/_oh._index/route"; +import OpenHands from "#/api/open-hands"; + +const createAxiosNotFoundErrorObject = () => + new AxiosError( + "Request failed with status code 404", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 404, + statusText: "Not Found", + data: { message: "Settings not found" }, + headers: {}, + // @ts-expect-error - we only need the response object for this test + config: {}, + }, + ); + +describe("Home Screen", () => { + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + + const RouterStub = createRoutesStub([ + { + // layout route + Component: MainApp, + path: "/", + children: [ + { + // home route + Component: Home, + path: "/", + }, + ], + }, + { + Component: SettingsScreen, + path: "/settings", + }, + ]); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the home screen", () => { + renderWithProviders(); + }); + + it("should navigate to the settings screen when the settings button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const settingsButton = await screen.findByTestId("settings-button"); + await user.click(settingsButton); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + + it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const connectToGitHubButton = + await screen.findByTestId("connect-to-github"); + await user.click(connectToGitHubButton); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + + describe("Settings 404", () => { + it("should open the settings modal if GET /settings fails with a 404", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + renderWithProviders(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + }); + + it("should navigate to the settings screen when clicking the advanced settings button", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + const user = userEvent.setup(); + renderWithProviders(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + + const advancedSettingsButton = await screen.findByTestId( + "advanced-settings-link", + ); + await user.click(advancedSettingsButton); + + const settingsModalAfter = screen.queryByTestId("ai-config-modal"); + expect(settingsModalAfter).not.toBeInTheDocument(); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx new file mode 100644 index 0000000000..2052d6eb9c --- /dev/null +++ b/frontend/__tests__/routes/settings.test.tsx @@ -0,0 +1,873 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { afterEach, describe, expect, it, test, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent, { UserEvent } from "@testing-library/user-event"; +import OpenHands from "#/api/open-hands"; +import { AuthProvider } from "#/context/auth-context"; +import SettingsScreen from "#/routes/settings"; +import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { PostApiSettings } from "#/types/settings"; +import * as ConsentHandlers from "#/utils/handle-capture-consent"; + +const toggleAdvancedSettings = async (user: UserEvent) => { + const advancedSwitch = await screen.findByTestId("advanced-settings-switch"); + await user.click(advancedSwitch); +}; + +describe("Settings Screen", () => { + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + + const { handleLogoutMock } = vi.hoisted(() => ({ + handleLogoutMock: vi.fn(), + })); + vi.mock("#/hooks/use-app-logout", () => ({ + useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }), + })); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const RouterStub = createRoutesStub([ + { + Component: SettingsScreen, + path: "/settings", + }, + ]); + + const renderSettingsScreen = () => { + const queryClient = new QueryClient(); + return render(, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + }; + + it("should render", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByText("LLM Settings"); + screen.getByText("GitHub Settings"); + screen.getByText("Additional Settings"); + screen.getByText("Reset to defaults"); + screen.getByText("Save Changes"); + }); + }); + + describe("Account Settings", () => { + it("should render the account settings", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByTestId("github-token-input"); + screen.getByTestId("github-token-help-anchor"); + screen.getByTestId("language-input"); + screen.getByTestId("enable-analytics-switch"); + }); + }); + + it("should render an indicator if the GitHub token is not set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("github-token-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("unset-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("GitHub token input parent not found"); + } + }); + }); + + it("should render an indicator if the GitHub token is set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + + const input = await screen.findByTestId("github-token-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = await within(inputParent).findByTestId("set-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("GitHub token input parent not found"); + } + }); + + it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + }); + + renderSettingsScreen(); + + const button = await screen.findByText("Disconnect from GitHub"); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + const button = await screen.findByText("Disconnect from GitHub"); + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + + // input should still be rendered + const input = await screen.findByTestId("github-token-input"); + expect(input).toBeInTheDocument(); + }); + + it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => { + const user = userEvent.setup(); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + + const button = await screen.findByText("Disconnect from GitHub"); + await user.click(button); + + expect(handleLogoutMock).toHaveBeenCalled(); + }); + + it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + const button = screen.queryByText("Configure GitHub Repositories"); + expect(button).not.toBeInTheDocument(); + }); + + it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + APP_SLUG: "test-app", + }); + + renderSettingsScreen(); + await screen.findByText("Configure GitHub Repositories"); + }); + + it("should not render the GitHub token input if SaaS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.queryByTestId("github-token-input"); + const helpAnchor = screen.queryByTestId("github-token-help-anchor"); + + expect(input).not.toBeInTheDocument(); + expect(helpAnchor).not.toBeInTheDocument(); + }); + }); + + it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + llm_model: "anthropic/claude-3-5-sonnet-20241022", + }); + saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token")); + + renderSettingsScreen(); + + let llmProviderInput = await screen.findByTestId("llm-provider-input"); + let llmModelInput = await screen.findByTestId("llm-model-input"); + + expect(llmProviderInput).toHaveValue("Anthropic"); + expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022"); + + const input = await screen.findByTestId("github-token-input"); + await user.type(input, "invalid-token"); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + llmProviderInput = await screen.findByTestId("llm-provider-input"); + llmModelInput = await screen.findByTestId("llm-model-input"); + + expect(llmProviderInput).toHaveValue("Anthropic"); + expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022"); + }); + + test("enabling advanced, enabling confirmation mode, and then disabling + enabling advanced should not render the security analyzer input", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + let securityAnalyzerInput = screen.queryByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).toBeInTheDocument(); + + await toggleAdvancedSettings(user); + + securityAnalyzerInput = screen.queryByTestId("security-analyzer-input"); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + + await toggleAdvancedSettings(user); + + securityAnalyzerInput = screen.queryByTestId("security-analyzer-input"); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + }); + }); + + describe("LLM Settings", () => { + it("should render the basic LLM settings by default", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByTestId("advanced-settings-switch"); + screen.getByTestId("llm-provider-input"); + screen.getByTestId("llm-model-input"); + screen.getByTestId("llm-api-key-input"); + screen.getByTestId("llm-api-key-help-anchor"); + }); + }); + + it("should render the advanced LLM settings if the advanced switch is toggled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + // Should not render the advanced settings by default + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + + const advancedSwitch = await screen.findByTestId( + "advanced-settings-switch", + ); + await user.click(advancedSwitch); + + // Should render the advanced settings + expect( + screen.queryByTestId("llm-provider-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("llm-model-input")).not.toBeInTheDocument(); + + screen.getByTestId("llm-custom-model-input"); + screen.getByTestId("base-url-input"); + screen.getByTestId("agent-input"); + + // "Invariant" security analyzer + screen.getByTestId("enable-confirmation-mode-switch"); + + // Not rendered until the switch is toggled + // screen.getByTestId("security-analyzer-input"); + }); + + it("should render an indicator if the LLM API key is not set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: null, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("unset-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("LLM API Key input parent not found"); + } + }); + }); + + it("should render an indicator if the LLM API key is set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: "**********", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("set-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("LLM API Key input parent not found"); + } + }); + }); + + it("should set asterik placeholder if the LLM API key is set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: "**********", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + expect(input).toHaveProperty("placeholder", "**********"); + }); + }); + + describe("Basic Model Selector", () => { + it("should set the provider and model", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_model: "anthropic/claude-3-5-sonnet-20241022", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + expect(providerInput).toHaveValue("Anthropic"); + expect(modelInput).toHaveValue("claude-3-5-sonnet-20241022"); + }); + }); + + it.todo("should change the model values if the provider is changed"); + + it.todo("should clear the model values if the provider is cleared"); + }); + + describe("Advanced LLM Settings", () => { + it("should not render the runtime settings input if OSS mode", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + const input = screen.queryByTestId("runtime-settings-input"); + expect(input).not.toBeInTheDocument(); + }); + + it("should render the runtime settings input if SaaS mode", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + screen.getByTestId("runtime-settings-input"); + }); + + it("should set the default runtime setting set", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + remote_runtime_resource_factor: 1, + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(userEvent.setup()); + + const input = await screen.findByTestId("runtime-settings-input"); + expect(input).toHaveValue("1x (2 core, 8G)"); + }); + + it("should save the runtime settings when the 'Save Changes' button is clicked", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const input = await screen.findByTestId("runtime-settings-input"); + await user.click(input); + + const option = await screen.findByText("2x (4 core, 16G)"); + await user.click(option); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + remote_runtime_resource_factor: 2, + }), + ); + }); + + test("saving with no changes but having advanced enabled should hide the advanced items", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + await waitFor(() => { + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("base-url-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + }); + }); + + test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const resetButton = screen.getByText("Reset to defaults"); + await user.click(resetButton); + + // show modal + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + // confirm reset + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + await waitFor(() => { + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("base-url-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + }); + }); + + it("should save if only confirmation mode is enabled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + confirmation_mode: true, + }), + ); + }); + }); + + it("should toggle advanced if user had set a custom model", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_model: "some/custom-model", + }); + renderSettingsScreen(); + + await waitFor(() => { + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + expect(advancedSwitch).toBeChecked(); + + const llmCustomInput = screen.getByTestId("llm-custom-model-input"); + expect(llmCustomInput).toBeInTheDocument(); + expect(llmCustomInput).toHaveValue("some/custom-model"); + }); + }); + + it("should have advanced settings enabled if the user previously had them enabled", async () => { + const hasAdvancedSettingsSetSpy = vi.spyOn( + AdvancedSettingsUtlls, + "hasAdvancedSettingsSet", + ); + hasAdvancedSettingsSetSpy.mockReturnValue(true); + + renderSettingsScreen(); + + await waitFor(() => { + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + expect(advancedSwitch).toBeChecked(); + + const llmCustomInput = screen.getByTestId("llm-custom-model-input"); + expect(llmCustomInput).toBeInTheDocument(); + }); + }); + + it("should have confirmation mode enabled if the user previously had it enabled", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + confirmation_mode: true, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const confirmationModeSwitch = screen.getByTestId( + "enable-confirmation-mode-switch", + ); + expect(confirmationModeSwitch).toBeChecked(); + }); + }); + + // FIXME: security analyzer is not found for some reason... + it.skip("should have the values set if the user previously had them set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "no", + github_token_is_set: true, + user_consents_to_analytics: true, + llm_base_url: "https://test.com", + llm_model: "anthropic/claude-3-5-sonnet-20241022", + agent: "CoActAgent", + security_analyzer: "mock-invariant", + }); + + renderSettingsScreen(); + + await waitFor(() => { + expect(screen.getByTestId("language-input")).toHaveValue("Norsk"); + expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument(); + expect(screen.getByTestId("enable-analytics-switch")).toBeChecked(); + expect(screen.getByTestId("advanced-settings-switch")).toBeChecked(); + expect(screen.getByTestId("base-url-input")).toHaveValue( + "https://test.com", + ); + expect(screen.getByTestId("llm-custom-model-input")).toHaveValue( + "anthropic/claude-3-5-sonnet-20241022", + ); + expect(screen.getByTestId("agent-input")).toHaveValue("CoActAgent"); + expect( + screen.getByTestId("enable-confirmation-mode-switch"), + ).toBeChecked(); + expect(screen.getByTestId("security-analyzer-input")).toHaveValue( + "mock-invariant", + ); + }); + }); + + it("should save the settings when the 'Save Changes' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + const languageInput = await screen.findByTestId("language-input"); + await user.click(languageInput); + + const norskOption = await screen.findByText("Norsk"); + await user.click(norskOption); + + expect(languageInput).toHaveValue("Norsk"); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: undefined, + github_token: undefined, + language: "no", + }), + ); + }); + + it("should properly save basic LLM model settings", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + // disable advanced mode + const advancedSwitch = await screen.findByTestId( + "advanced-settings-switch", + ); + await user.click(advancedSwitch); + + const providerInput = await screen.findByTestId("llm-provider-input"); + await user.click(providerInput); + + const openaiOption = await screen.findByText("OpenAI"); + await user.click(openaiOption); + + const modelInput = await screen.findByTestId("llm-model-input"); + await user.click(modelInput); + + const gpt4Option = await screen.findByText("gpt-4o"); + await user.click(gpt4Option); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + github_token: undefined, + llm_api_key: undefined, + llm_model: "openai/gpt-4o", + }), + ); + }); + + it("should reset the settings when the 'Reset to defaults' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + renderSettingsScreen(); + + const languageInput = await screen.findByTestId("language-input"); + await user.click(languageInput); + + const norskOption = await screen.findByText("Norsk"); + await user.click(norskOption); + + expect(languageInput).toHaveValue("Norsk"); + + const resetButton = screen.getByText("Reset to defaults"); + await user.click(resetButton); + + expect(saveSettingsSpy).not.toHaveBeenCalled(); + + // show modal + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + // confirm reset + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + const mockCopy: Partial = { + ...MOCK_DEFAULT_USER_SETTINGS, + }; + delete mockCopy.github_token_is_set; + delete mockCopy.unset_github_token; + delete mockCopy.user_consents_to_analytics; + + expect(saveSettingsSpy).toHaveBeenCalledWith({ + ...mockCopy, + github_token: undefined, // not set + llm_api_key: "", // reset as well + }); + expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument(); + }); + + it("should cancel the reset when the 'Cancel' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + renderSettingsScreen(); + + const resetButton = await screen.findByText("Reset to defaults"); + await user.click(resetButton); + + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + const cancelButton = within(modal).getByText("Cancel"); + await user.click(cancelButton); + + expect(saveSettingsSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument(); + }); + + it("should call handleCaptureConsent with true if the save is successful", async () => { + const user = userEvent.setup(); + const handleCaptureConsentSpy = vi.spyOn( + ConsentHandlers, + "handleCaptureConsent", + ); + renderSettingsScreen(); + + const analyticsConsentInput = await screen.findByTestId( + "enable-analytics-switch", + ); + + expect(analyticsConsentInput).not.toBeChecked(); + await user.click(analyticsConsentInput); + expect(analyticsConsentInput).toBeChecked(); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true); + }); + + it("should call handleCaptureConsent with false if the save is successful", async () => { + const user = userEvent.setup(); + const handleCaptureConsentSpy = vi.spyOn( + ConsentHandlers, + "handleCaptureConsent", + ); + renderSettingsScreen(); + + const saveButton = await screen.findByText("Save Changes"); + await user.click(saveButton); + + expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false); + }); + + it("should not reset analytics consent when resetting to defaults", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + user_consents_to_analytics: true, + }); + + renderSettingsScreen(); + + const analyticsConsentInput = await screen.findByTestId( + "enable-analytics-switch", + ); + expect(analyticsConsentInput).toBeChecked(); + + const resetButton = await screen.findByText("Reset to defaults"); + await user.click(resetButton); + + const modal = await screen.findByTestId("reset-modal"); + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ user_consents_to_analytics: undefined }), + ); + }); + + it("should render the security analyzer input if the confirmation mode is enabled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + let securityAnalyzerInput = screen.queryByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + securityAnalyzerInput = await screen.findByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).toBeInTheDocument(); + }); + + // FIXME: localStorage isn't being set + it.skip("should save with ENABLE_DEFAULT_CONDENSER with true if user set the feature flag in local storage", async () => { + localStorage.setItem("ENABLE_DEFAULT_CONDENSER", "true"); + + const user = userEvent.setup(); + renderSettingsScreen(); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + enable_default_condenser: true, + }), + ); + }); + }); +}); diff --git a/frontend/__tests__/utils/has-advanced-settings-set.test.ts b/frontend/__tests__/utils/has-advanced-settings-set.test.ts new file mode 100644 index 0000000000..73568ccd98 --- /dev/null +++ b/frontend/__tests__/utils/has-advanced-settings-set.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, test } from "vitest"; +import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set"; +import { DEFAULT_SETTINGS } from "#/services/settings"; + +describe("hasAdvancedSettingsSet", () => { + it("should return false by default", () => { + expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false); + }); + + describe("should be true if", () => { + test("LLM_BASE_URL is set", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + LLM_BASE_URL: "test", + }), + ).toBe(true); + }); + + test("AGENT is not default value", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + AGENT: "test", + }), + ).toBe(true); + }); + + test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + REMOTE_RUNTIME_RESOURCE_FACTOR: 999, + }), + ).toBe(true); + }); + + test("CONFIRMATION_MODE is true", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + CONFIRMATION_MODE: true, + }), + ).toBe(true); + }); + + test("SECURITY_ANALYZER is set", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + SECURITY_ANALYZER: "test", + }), + ).toBe(true); + }); + }); +}); diff --git a/frontend/__tests__/utils/is-custom-model.test.ts b/frontend/__tests__/utils/is-custom-model.test.ts new file mode 100644 index 0000000000..1da5667920 --- /dev/null +++ b/frontend/__tests__/utils/is-custom-model.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isCustomModel } from "#/utils/is-custom-model"; + +describe("isCustomModel", () => { + const models = ["anthropic/claude-3.5", "openai/gpt-3.5-turbo", "gpt-4o"]; + + it("should return false by default", () => { + expect(isCustomModel(models, "")).toBe(false); + }); + + it("should be true if it is a custom model", () => { + expect(isCustomModel(models, "some/model")).toBe(true); + }); + + it("should be false if it is not a custom model", () => { + expect(isCustomModel(models, "anthropic/claude-3.5")).toBe(false); + expect(isCustomModel(models, "openai/gpt-3.5-turbo")).toBe(false); + expect(isCustomModel(models, "openai/gpt-4o")).toBe(false); + }); +}); diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 0f8771a161..e77d7a5527 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -13,7 +13,7 @@ import { GetTrajectoryResponse, } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; -import { ApiSettings } from "#/types/settings"; +import { ApiSettings, PostApiSettings } from "#/types/settings"; class OpenHands { /** @@ -267,7 +267,9 @@ class OpenHands { * Save the settings to the server. Only valid settings are saved. * @param settings - the settings to save */ - static async saveSettings(settings: Partial): Promise { + static async saveSettings( + settings: Partial, + ): Promise { const data = await openHands.post("/api/settings", settings); return data.status === 200; } diff --git a/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx b/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx index 21c0c04a04..ea22445db0 100644 --- a/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx +++ b/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx @@ -27,11 +27,10 @@ export function AnalyticsConsentFormModal({ { onSuccess: () => { handleCaptureConsent(analytics); + onClose(); }, }, ); - - onClose(); }; return ( diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index 92a3359e83..8be19387f5 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -1,19 +1,16 @@ import { useTranslation } from "react-i18next"; import { ContextMenu } from "./context-menu"; import { ContextMenuListItem } from "./context-menu-list-item"; -import { ContextMenuSeparator } from "./context-menu-separator"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; import { I18nKey } from "#/i18n/declaration"; interface AccountSettingsContextMenuProps { - onClickAccountSettings: () => void; onLogout: () => void; onClose: () => void; isLoggedIn: boolean; } export function AccountSettingsContextMenu({ - onClickAccountSettings, onLogout, onClose, isLoggedIn, @@ -27,13 +24,6 @@ export function AccountSettingsContextMenu({ ref={ref} className="absolute left-full -top-1 z-10" > - - {t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)} - - {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} diff --git a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx index 45eb527806..2450dd7b59 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; import { I18nKey } from "#/i18n/declaration"; import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"; import GitHubLogo from "#/assets/branding/github-logo.svg?react"; @@ -10,7 +11,6 @@ import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; import { useUserRepositories } from "#/hooks/query/use-user-repositories"; import { sanitizeQuery } from "#/utils/sanitize-query"; import { useDebounce } from "#/hooks/use-debounce"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; interface GitHubRepositoriesSuggestionBoxProps { handleSubmit: () => void; @@ -24,8 +24,7 @@ export function GitHubRepositoriesSuggestionBox({ user, }: GitHubRepositoriesSuggestionBoxProps) { const { t } = useTranslation(); - const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = - React.useState(false); + const navigate = useNavigate(); const [searchQuery, setSearchQuery] = React.useState(""); const debouncedSearchQuery = useDebounce(searchQuery, 300); @@ -45,39 +44,33 @@ export function GitHubRepositoriesSuggestionBox({ if (gitHubAuthUrl) { window.location.href = gitHubAuthUrl; } else { - setConnectToGitHubModalOpen(true); + navigate("/settings"); } }; const isLoggedIn = !!user; return ( - <> - - ) : ( - } - className="bg-[#791B80] w-full" - onClick={handleConnectToGitHub} - /> - ) - } - /> - {connectToGitHubModalOpen && ( - setConnectToGitHubModalOpen(false)} - /> - )} - + + ) : ( + } + className="bg-[#791B80] w-full" + onClick={handleConnectToGitHub} + /> + ) + } + /> ); } diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx new file mode 100644 index 0000000000..b4d2dc24aa --- /dev/null +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -0,0 +1,39 @@ +import { cn } from "#/utils/utils"; + +interface BrandButtonProps { + testId?: string; + variant: "primary" | "secondary"; + type: React.ButtonHTMLAttributes["type"]; + isDisabled?: boolean; + className?: string; + onClick?: () => void; +} + +export function BrandButton({ + testId, + children, + variant, + type, + isDisabled, + className, + onClick, +}: React.PropsWithChildren) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/help-link.tsx b/frontend/src/components/features/settings/help-link.tsx new file mode 100644 index 0000000000..984f279de2 --- /dev/null +++ b/frontend/src/components/features/settings/help-link.tsx @@ -0,0 +1,22 @@ +interface HelpLinkProps { + testId: string; + text: string; + linkText: string; + href: string; +} + +export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) { + return ( +

+ {text}{" "} + + {linkText} + +

+ ); +} diff --git a/frontend/src/components/features/settings/key-status-icon.tsx b/frontend/src/components/features/settings/key-status-icon.tsx new file mode 100644 index 0000000000..ad4bd3cf83 --- /dev/null +++ b/frontend/src/components/features/settings/key-status-icon.tsx @@ -0,0 +1,16 @@ +import SuccessIcon from "#/icons/success.svg?react"; +import { cn } from "#/utils/utils"; + +interface KeyStatusIconProps { + isSet: boolean; +} + +export function KeyStatusIcon({ isSet }: KeyStatusIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/features/settings/optional-tag.tsx b/frontend/src/components/features/settings/optional-tag.tsx new file mode 100644 index 0000000000..3df207fc1b --- /dev/null +++ b/frontend/src/components/features/settings/optional-tag.tsx @@ -0,0 +1,3 @@ +export function OptionalTag() { + return (Optional); +} diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx new file mode 100644 index 0000000000..69385bf08f --- /dev/null +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -0,0 +1,56 @@ +import { Autocomplete, AutocompleteItem } from "@heroui/react"; +import { OptionalTag } from "./optional-tag"; + +interface SettingsDropdownInputProps { + testId: string; + label: string; + name: string; + items: { key: React.Key; label: string }[]; + showOptionalTag?: boolean; + isDisabled?: boolean; + defaultSelectedKey?: string; + isClearable?: boolean; +} + +export function SettingsDropdownInput({ + testId, + label, + name, + items, + showOptionalTag, + isDisabled, + defaultSelectedKey, + isClearable, +}: SettingsDropdownInputProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx new file mode 100644 index 0000000000..5362af09aa --- /dev/null +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -0,0 +1,50 @@ +import { cn } from "#/utils/utils"; +import { OptionalTag } from "./optional-tag"; + +interface SettingsInputProps { + testId?: string; + name?: string; + label: string; + type: React.HTMLInputTypeAttribute; + defaultValue?: string; + placeholder?: string; + showOptionalTag?: boolean; + isDisabled?: boolean; + startContent?: React.ReactNode; + className?: string; +} + +export function SettingsInput({ + testId, + name, + label, + type, + defaultValue, + placeholder, + showOptionalTag, + isDisabled, + startContent, + className, +}: SettingsInputProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/settings-switch.tsx b/frontend/src/components/features/settings/settings-switch.tsx new file mode 100644 index 0000000000..d1bfaff949 --- /dev/null +++ b/frontend/src/components/features/settings/settings-switch.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { StyledSwitchComponent } from "./styled-switch-component"; + +interface SettingsSwitchProps { + testId?: string; + name?: string; + onToggle?: (value: boolean) => void; + defaultIsToggled?: boolean; + isBeta?: boolean; +} + +export function SettingsSwitch({ + children, + testId, + name, + onToggle, + defaultIsToggled, + isBeta, +}: React.PropsWithChildren) { + const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false); + + const handleToggle = (value: boolean) => { + setIsToggled(value); + onToggle?.(value); + }; + + return ( + + ); +} diff --git a/frontend/src/components/features/settings/styled-switch-component.tsx b/frontend/src/components/features/settings/styled-switch-component.tsx new file mode 100644 index 0000000000..36d9ffda6b --- /dev/null +++ b/frontend/src/components/features/settings/styled-switch-component.tsx @@ -0,0 +1,26 @@ +import { cn } from "#/utils/utils"; + +interface StyledSwitchComponentProps { + isToggled: boolean; +} + +export function StyledSwitchComponent({ + isToggled, +}: StyledSwitchComponentProps) { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 777878a182..645543ac6f 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -3,6 +3,7 @@ import { FaListUl } from "react-icons/fa"; import { useDispatch } from "react-redux"; import posthog from "posthog-js"; import toast from "react-hot-toast"; +import { NavLink } from "react-router"; import { useGitHubUser } from "#/hooks/query/use-github-user"; import { UserActions } from "./user-actions"; import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button"; @@ -10,7 +11,6 @@ import { DocsButton } from "#/components/shared/buttons/docs-button"; import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button"; import { SettingsButton } from "#/components/shared/buttons/settings-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; import { SettingsModal } from "#/components/shared/modals/settings/settings-modal"; import { useCurrentSettings } from "#/context/settings-context"; import { useSettings } from "#/hooks/query/use-settings"; @@ -30,28 +30,18 @@ export function Sidebar() { const user = useGitHubUser(); const { data: config } = useConfig(); const { - data: settings, error: settingsError, isError: settingsIsError, isFetching: isFetchingSettings, } = useSettings(); const { mutateAsync: logout } = useLogout(); - const { saveUserSettings } = useCurrentSettings(); + const { settings, saveUserSettings } = useCurrentSettings(); - const [accountSettingsModalOpen, setAccountSettingsModalOpen] = - React.useState(false); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(false); - React.useEffect(() => { - // If the github token is invalid, open the account settings modal again - if (user.isError) { - setAccountSettingsModalOpen(true); - } - }, [user.isError]); - React.useEffect(() => { // We don't show toast errors for settings in the global error handler // because we have a special case for 404 errors @@ -63,6 +53,8 @@ export function Sidebar() { toast.error( "Something went wrong while fetching settings. Please reload the page.", ); + } else if (settingsError?.status === 404) { + setSettingsModalIsOpen(true); } }, [settingsError?.status, settingsError, isFetchingSettings]); @@ -71,10 +63,6 @@ export function Sidebar() { endSession(); }; - const handleAccountSettingsModalClose = () => { - setAccountSettingsModalOpen(false); - }; - const handleLogout = async () => { if (config?.APP_MODE === "saas") await logout(); else await saveUserSettings({ unset_github_token: true }); @@ -84,33 +72,44 @@ export function Sidebar() { return ( <> - {accountSettingsModalOpen && ( - - )} - {(settingsError?.status === 404 || settingsModalIsOpen) && ( + {settingsModalIsOpen && ( setSettingsModalIsOpen(false)} diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index 359c8fbbb3..0ac8dc95f5 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -3,16 +3,11 @@ import { UserAvatar } from "./user-avatar"; import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu"; interface UserActionsProps { - onClickAccountSettings: () => void; onLogout: () => void; user?: { avatar_url: string }; } -export function UserActions({ - onClickAccountSettings, - onLogout, - user, -}: UserActionsProps) { +export function UserActions({ onLogout, user }: UserActionsProps) { const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = React.useState(false); @@ -24,11 +19,6 @@ export function UserActions({ setAccountContextMenuIsVisible(false); }; - const handleClickAccountSettings = () => { - onClickAccountSettings(); - closeAccountMenu(); - }; - const handleLogout = () => { onLogout(); closeAccountMenu(); @@ -41,7 +31,6 @@ export function UserActions({ {accountContextMenuIsVisible && ( diff --git a/frontend/src/components/features/sidebar/user-avatar.tsx b/frontend/src/components/features/sidebar/user-avatar.tsx index 3857f8f52d..3e5d0fda57 100644 --- a/frontend/src/components/features/sidebar/user-avatar.tsx +++ b/frontend/src/components/features/sidebar/user-avatar.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; -import DefaultUserAvatar from "#/icons/default-user.svg?react"; +import ProfileIcon from "#/icons/profile.svg?react"; import { cn } from "#/utils/utils"; import { Avatar } from "./avatar"; import { TooltipButton } from "#/components/shared/buttons/tooltip-button"; @@ -21,16 +21,17 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)} onClick={onClick} className={cn( - "w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200", + "w-8 h-8 rounded-full flex items-center justify-center", isLoading && "bg-transparent", )} > {!isLoading && avatarUrl && } {!isLoading && !avatarUrl && ( - )} {isLoading && } diff --git a/frontend/src/components/shared/buttons/all-hands-logo-button.tsx b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx index f8af9ac476..e068f152af 100644 --- a/frontend/src/components/shared/buttons/all-hands-logo-button.tsx +++ b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx @@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) { ariaLabel="All Hands Logo" onClick={onClick} > - + ); } diff --git a/frontend/src/components/shared/buttons/docs-button.tsx b/frontend/src/components/shared/buttons/docs-button.tsx index d2cafe2422..4c2b248e0b 100644 --- a/frontend/src/components/shared/buttons/docs-button.tsx +++ b/frontend/src/components/shared/buttons/docs-button.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import DocsIcon from "#/icons/docs.svg?react"; +import DocsIcon from "#/icons/academy.svg?react"; import { I18nKey } from "#/i18n/declaration"; import { TooltipButton } from "./tooltip-button"; @@ -11,7 +11,7 @@ export function DocsButton() { ariaLabel={t(I18nKey.SIDEBAR$DOCS)} href="https://docs.all-hands.dev" > - + ); } diff --git a/frontend/src/components/shared/buttons/exit-project-button.tsx b/frontend/src/components/shared/buttons/exit-project-button.tsx index 5a6db074f6..eaef96680a 100644 --- a/frontend/src/components/shared/buttons/exit-project-button.tsx +++ b/frontend/src/components/shared/buttons/exit-project-button.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import NewProjectIcon from "#/icons/new-project.svg?react"; +import PlusIcon from "#/icons/plus.svg?react"; import { TooltipButton } from "./tooltip-button"; interface ExitProjectButtonProps { @@ -17,7 +17,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) { onClick={onClick} testId="new-project-button" > - + ); } diff --git a/frontend/src/components/shared/buttons/settings-button.tsx b/frontend/src/components/shared/buttons/settings-button.tsx index 2b792e5ed4..80bd5eee8a 100644 --- a/frontend/src/components/shared/buttons/settings-button.tsx +++ b/frontend/src/components/shared/buttons/settings-button.tsx @@ -1,14 +1,15 @@ -import { FaCog } from "react-icons/fa"; import { useTranslation } from "react-i18next"; +import SettingsIcon from "#/icons/settings.svg?react"; import { TooltipButton } from "./tooltip-button"; import { I18nKey } from "#/i18n/declaration"; interface SettingsButtonProps { - onClick: () => void; + onClick?: () => void; } export function SettingsButton({ onClick }: SettingsButtonProps) { const { t } = useTranslation(); + return ( - + ); } diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx deleted file mode 100644 index cbce86d6ee..0000000000 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import posthog from "posthog-js"; -import { - BaseModalDescription, - BaseModalTitle, -} from "../confirmation-modals/base-modal"; -import { ModalBody } from "../modal-body"; -import { AvailableLanguages } from "#/i18n"; -import { I18nKey } from "#/i18n/declaration"; -import { handleCaptureConsent } from "#/utils/handle-capture-consent"; -import { ModalButton } from "../../buttons/modal-button"; -import { FormFieldset } from "../../form-fieldset"; -import { useConfig } from "#/hooks/query/use-config"; -import { useCurrentSettings } from "#/context/settings-context"; -import { GitHubTokenInput } from "./github-token-input"; -import { PostSettings } from "#/types/settings"; -import { useGitHubUser } from "#/hooks/query/use-github-user"; - -interface AccountSettingsFormProps { - onClose: () => void; -} - -export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) { - const { isError: isGitHubError } = useGitHubUser(); - const { data: config } = useConfig(); - const { saveUserSettings, settings } = useCurrentSettings(); - const { t } = useTranslation(); - - const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET; - const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS; - const selectedLanguage = settings?.LANGUAGE || "en"; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - - const ghToken = formData.get("ghToken")?.toString(); - const language = formData.get("language")?.toString(); - const analytics = formData.get("analytics")?.toString() === "on"; - - const newSettings: Partial = {}; - newSettings.user_consents_to_analytics = analytics; - - if (ghToken) newSettings.github_token = ghToken; - - // The form returns the language label, so we need to find the corresponding - // language key to save it in the settings - if (language) { - const languageKey = AvailableLanguages.find( - ({ label }) => label === language, - )?.value; - - if (languageKey) newSettings.LANGUAGE = languageKey; - } - - await saveUserSettings(newSettings, { - onSuccess: () => { - handleCaptureConsent(analytics); - }, - }); - - onClose(); - }; - - const onDisconnect = async () => { - await saveUserSettings({ unset_github_token: true }); - posthog.reset(); - onClose(); - }; - - return ( - -
-
- - - {config?.APP_MODE === "saas" && config?.APP_SLUG && ( - - {t(I18nKey.GITHUB$CONFIGURE_REPOS)} - - )} - ({ - key, - value: label, - }))} - /> - - {config?.APP_MODE !== "saas" && ( - <> - - {!githubTokenIsSet && ( - - {t(I18nKey.GITHUB$GET_TOKEN)}{" "} - - {t(I18nKey.COMMON$HERE)} - - - )} - - )} - {isGitHubError && ( -

- {t(I18nKey.GITHUB$TOKEN_INVALID)} -

- )} - {githubTokenIsSet && !isGitHubError && ( - - )} -
- - - -
- - -
-
-
- ); -} diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx deleted file mode 100644 index 49a0d54f3e..0000000000 --- a/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ModalBackdrop } from "../modal-backdrop"; -import { AccountSettingsForm } from "./account-settings-form"; - -interface AccountSettingsModalProps { - onClose: () => void; -} - -export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) { - return ( - - - - ); -} diff --git a/frontend/src/components/shared/modals/account-settings/github-token-input.tsx b/frontend/src/components/shared/modals/account-settings/github-token-input.tsx deleted file mode 100644 index f5f4de4b22..0000000000 --- a/frontend/src/components/shared/modals/account-settings/github-token-input.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { FaCheckCircle } from "react-icons/fa"; -import { I18nKey } from "#/i18n/declaration"; - -interface GitHubTokenInputProps { - githubTokenIsSet: boolean; -} - -export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) { - const { t } = useTranslation(); - - return ( - - ); -} diff --git a/frontend/src/components/shared/modals/settings/model-selector.tsx b/frontend/src/components/shared/modals/settings/model-selector.tsx index c169d67870..8111138441 100644 --- a/frontend/src/components/shared/modals/settings/model-selector.tsx +++ b/frontend/src/components/shared/modals/settings/model-selector.tsx @@ -65,107 +65,109 @@ export function ModelSelector({ const { t } = useTranslation(); return ( -
-
-
- - { - if (e?.toString()) handleChangeProvider(e.toString()); - }} - onInputChange={(value) => !value && clear()} - defaultSelectedKey={selectedProvider ?? undefined} - selectedKey={selectedProvider} - inputProps={{ - classNames: { - inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]", - }, - }} - > - - {Object.keys(models) - .filter((provider) => VERIFIED_PROVIDERS.includes(provider)) - .map((provider) => ( - - {mapProvider(provider)} - - ))} - - - {Object.keys(models) - .filter((provider) => !VERIFIED_PROVIDERS.includes(provider)) - .map((provider) => ( - - {mapProvider(provider)} - - ))} - - -
+
+
+ + { + if (e?.toString()) handleChangeProvider(e.toString()); + }} + onInputChange={(value) => !value && clear()} + defaultSelectedKey={selectedProvider ?? undefined} + selectedKey={selectedProvider} + classNames={{ + popoverContent: "bg-[#454545] rounded-xl border border-[#717888]", + }} + inputProps={{ + classNames: { + inputWrapper: + "bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic", + }, + }} + > + + {Object.keys(models) + .filter((provider) => VERIFIED_PROVIDERS.includes(provider)) + .map((provider) => ( + + {mapProvider(provider)} + + ))} + + + {Object.keys(models) + .filter((provider) => !VERIFIED_PROVIDERS.includes(provider)) + .map((provider) => ( + + {mapProvider(provider)} + + ))} + + +
-
- - { - if (e?.toString()) handleChangeModel(e.toString()); - }} - isDisabled={isDisabled || !selectedProvider} - selectedKey={selectedModel} - defaultSelectedKey={selectedModel ?? undefined} - inputProps={{ - classNames: { - inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]", - }, - }} - > - - {models[selectedProvider || ""]?.models - .filter((model) => VERIFIED_MODELS.includes(model)) - .map((model) => ( - - {model} - - ))} - - - {models[selectedProvider || ""]?.models - .filter((model) => !VERIFIED_MODELS.includes(model)) - .map((model) => ( - - {model} - - ))} - - -
-
+
+ + { + if (e?.toString()) handleChangeModel(e.toString()); + }} + isDisabled={isDisabled || !selectedProvider} + selectedKey={selectedModel} + defaultSelectedKey={selectedModel ?? undefined} + classNames={{ + popoverContent: "bg-[#454545] rounded-xl border border-[#717888]", + }} + inputProps={{ + classNames: { + inputWrapper: + "bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic", + }, + }} + > + + {models[selectedProvider || ""]?.models + .filter((model) => VERIFIED_MODELS.includes(model)) + .map((model) => ( + + {model} + + ))} + + + {models[selectedProvider || ""]?.models + .filter((model) => !VERIFIED_MODELS.includes(model)) + .map((model) => ( + + {model} + + ))} + + +
); } diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index 6ed859f47d..0c7ef45079 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -4,86 +4,34 @@ import React from "react"; import posthog from "posthog-js"; import { I18nKey } from "#/i18n/declaration"; import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; -import { getDefaultSettings } from "#/services/settings"; -import { extractModelAndProvider } from "#/utils/extract-model-and-provider"; import { DangerModal } from "../confirmation-modals/danger-modal"; import { extractSettings } from "#/utils/settings-utils"; import { useEndSession } from "#/hooks/use-end-session"; -import { ModalButton } from "../../buttons/modal-button"; -import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch"; -import { AgentInput } from "../../inputs/agent-input"; -import { APIKeyInput } from "../../inputs/api-key-input"; -import { BaseUrlInput } from "../../inputs/base-url-input"; -import { ConfirmationModeSwitch } from "../../inputs/confirmation-mode-switch"; -import { CustomModelInput } from "../../inputs/custom-model-input"; -import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input"; import { ModalBackdrop } from "../modal-backdrop"; import { ModelSelector } from "./model-selector"; - -import { RuntimeSizeSelector } from "./runtime-size-selector"; -import { useConfig } from "#/hooks/query/use-config"; import { useCurrentSettings } from "#/context/settings-context"; import { MEMORY_CONDENSER } from "#/utils/feature-flags"; import { Settings } from "#/types/settings"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { HelpLink } from "#/components/features/settings/help-link"; interface SettingsFormProps { - disabled?: boolean; settings: Settings; models: string[]; - agents: string[]; - securityAnalyzers: string[]; onClose: () => void; } -export function SettingsForm({ - disabled, - settings, - models, - agents, - securityAnalyzers, - onClose, -}: SettingsFormProps) { +export function SettingsForm({ settings, models, onClose }: SettingsFormProps) { const { saveUserSettings } = useCurrentSettings(); const endSession = useEndSession(); - const { data: config } = useConfig(); const location = useLocation(); const { t } = useTranslation(); const formRef = React.useRef(null); - const advancedAlreadyInUse = React.useMemo(() => { - if (models.length > 0) { - const organizedModels = organizeModelsAndProviders(models); - const { provider, model } = extractModelAndProvider( - settings.LLM_MODEL || "", - ); - const isKnownModel = - provider in organizedModels && - organizedModels[provider].models.includes(model); - - const isUsingSecurityAnalyzer = !!settings.SECURITY_ANALYZER; - const isUsingConfirmationMode = !!settings.CONFIRMATION_MODE; - const isUsingBaseUrl = !!settings.LLM_BASE_URL; - const isUsingCustomModel = !!settings.LLM_MODEL && !isKnownModel; - const isUsingDefaultCondenser = !!settings.ENABLE_DEFAULT_CONDENSER; - - return ( - isUsingSecurityAnalyzer || - isUsingConfirmationMode || - isUsingBaseUrl || - isUsingCustomModel || - isUsingDefaultCondenser - ); - } - - return false; - }, [settings, models]); - - const [showAdvancedOptions, setShowAdvancedOptions] = - React.useState(advancedAlreadyInUse); - const [confirmResetDefaultsModalOpen, setConfirmResetDefaultsModalOpen] = - React.useState(false); const [confirmEndSessionModalOpen, setConfirmEndSessionModalOpen] = React.useState(false); @@ -111,13 +59,6 @@ export function SettingsForm({ }); }; - const handleConfirmResetSettings = async () => { - await saveUserSettings(getDefaultSettings()); - onClose(); - resetOngoingSession(); - posthog.capture("settings_reset"); - }; - const handleConfirmEndSession = () => { const formData = new FormData(formRef.current ?? undefined); handleFormSubmission(formData); @@ -134,7 +75,7 @@ export function SettingsForm({ } }; - const isSaasMode = config?.APP_MODE === "saas"; + const isLLMKeySet = settings.LLM_API_KEY !== "**********"; return (
@@ -144,115 +85,41 @@ export function SettingsForm({ className="flex flex-col gap-6" onSubmit={handleSubmit} > -
- + - {showAdvancedOptions && ( - <> - - - - - )} - - {!showAdvancedOptions && ( - - )} - - } /> - {showAdvancedOptions && ( - <> - - - {isSaasMode && ( - - )} - - - - - - )} +
-
- - -
- { - setConfirmResetDefaultsModalOpen(true); - }} - /> + + {t(I18nKey.BUTTON$SAVE)} +
- {confirmResetDefaultsModalOpen && ( - - setConfirmResetDefaultsModalOpen(false), - }, - }} - /> - - )} {confirmEndSessionModalOpen && (
{aiConfigOptions.error && (

{aiConfigOptions.error.message}

)} - + {t(I18nKey.AI_SETTINGS$TITLE)}

- {t(I18nKey.SETTINGS$DESCRIPTION)} + {t(I18nKey.SETTINGS$DESCRIPTION)} For other options,{" "} + + see advanced settings +

-

{t(I18nKey.SETTINGS$WARNING)}

{aiConfigOptions.isLoading && (
@@ -41,8 +48,6 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) { )} diff --git a/frontend/src/context/settings-context.tsx b/frontend/src/context/settings-context.tsx index c3abbe3184..ac05ea92bd 100644 --- a/frontend/src/context/settings-context.tsx +++ b/frontend/src/context/settings-context.tsx @@ -1,8 +1,10 @@ import React from "react"; import { MutateOptions } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { useSettings } from "#/hooks/query/use-settings"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; import { PostSettings, Settings } from "#/types/settings"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; type SaveUserSettingsConfig = { onSuccess: MutateOptions>["onSuccess"]; @@ -41,7 +43,13 @@ export function SettingsProvider({ children }: SettingsProviderProps) { delete updatedSettings.LLM_API_KEY; } - await saveSettings(updatedSettings, { onSuccess: config?.onSuccess }); + await saveSettings(updatedSettings, { + onSuccess: config?.onSuccess, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + toast.error(errorMessage); + }, + }); }; const value = React.useMemo( diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 77a34fbe10..0a12f6f1eb 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -2,8 +2,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { DEFAULT_SETTINGS } from "#/services/settings"; import OpenHands from "#/api/open-hands"; import { PostSettings, PostApiSettings } from "#/types/settings"; +import { MEMORY_CONDENSER } from "#/utils/feature-flags"; const saveSettingsMutationFn = async (settings: Partial) => { + const resetLlmApiKey = settings.LLM_API_KEY === ""; + const apiSettings: Partial = { llm_model: settings.LLM_MODEL, llm_base_url: settings.LLM_BASE_URL, @@ -11,11 +14,14 @@ const saveSettingsMutationFn = async (settings: Partial) => { language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE, confirmation_mode: settings.CONFIRMATION_MODE, security_analyzer: settings.SECURITY_ANALYZER, - llm_api_key: settings.LLM_API_KEY?.trim() || undefined, + llm_api_key: resetLlmApiKey + ? "" + : settings.LLM_API_KEY?.trim() || undefined, remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR, github_token: settings.github_token, unset_github_token: settings.unset_github_token, - enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER, + enable_default_condenser: + MEMORY_CONDENSER || settings.ENABLE_DEFAULT_CONDENSER, user_consents_to_analytics: settings.user_consents_to_analytics, }; @@ -30,5 +36,8 @@ export const useSaveSettings = () => { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["settings"] }); }, + meta: { + disableToast: true, + }, }); }; diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index dcbca22007..d5286b8dd7 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -4,6 +4,7 @@ import posthog from "posthog-js"; import OpenHands from "#/api/open-hands"; import { useAuth } from "#/context/auth-context"; import { useConfig } from "#/hooks/query/use-config"; +import { DEFAULT_SETTINGS } from "#/services/settings"; const getSettingsQueryFn = async () => { const apiSettings = await OpenHands.getSettings(); @@ -28,7 +29,7 @@ export const useSettings = () => { const { data: config } = useConfig(); const query = useQuery({ - queryKey: ["settings"], + queryKey: ["settings", githubTokenIsSet], queryFn: getSettingsQueryFn, enabled: config?.APP_MODE !== "saas" || githubTokenIsSet, // Only retry if the error is not a 404 because we @@ -50,5 +51,16 @@ export const useSettings = () => { setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET); }, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]); + // We want to return the defaults if the settings aren't found so the user can still see the + // options to make their initial save. We don't set the defaults in `initialData` above because + // that would prepopulate the data to the cache and mess with expectations. Read more: + // https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query + if (query.error?.status === 404) { + return { + ...query, + data: DEFAULT_SETTINGS, + }; + } + return query; }; diff --git a/frontend/src/hooks/use-app-logout.ts b/frontend/src/hooks/use-app-logout.ts new file mode 100644 index 0000000000..403e443eb0 --- /dev/null +++ b/frontend/src/hooks/use-app-logout.ts @@ -0,0 +1,16 @@ +import { useCurrentSettings } from "#/context/settings-context"; +import { useLogout } from "./mutation/use-logout"; +import { useConfig } from "./query/use-config"; + +export const useAppLogout = () => { + const { data: config } = useConfig(); + const { mutateAsync: logout } = useLogout(); + const { saveUserSettings } = useCurrentSettings(); + + const handleLogout = async () => { + if (config?.APP_MODE === "saas") await logout(); + else await saveUserSettings({ unset_github_token: true }); + }; + + return { handleLogout }; +}; diff --git a/frontend/src/icons/academy.svg b/frontend/src/icons/academy.svg new file mode 100644 index 0000000000..86320b3c6d --- /dev/null +++ b/frontend/src/icons/academy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/plus.svg b/frontend/src/icons/plus.svg new file mode 100644 index 0000000000..a3c0dffd58 --- /dev/null +++ b/frontend/src/icons/plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/icons/profile.svg b/frontend/src/icons/profile.svg new file mode 100644 index 0000000000..a3ed9941d6 --- /dev/null +++ b/frontend/src/icons/profile.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/icons/settings.svg b/frontend/src/icons/settings.svg new file mode 100644 index 0000000000..ef366cac9e --- /dev/null +++ b/frontend/src/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/success.svg b/frontend/src/icons/success.svg new file mode 100644 index 0000000000..ff27cfcfcf --- /dev/null +++ b/frontend/src/icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/warning.svg b/frontend/src/icons/warning.svg new file mode 100644 index 0000000000..dc404bc1c7 --- /dev/null +++ b/frontend/src/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 1a4aa19810..7ccaeaf689 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -179,8 +179,10 @@ export const handlers = [ return HttpResponse.json(config); }), http.get("/api/settings", async () => { + await delay(); const settings: ApiSettings = { ...MOCK_USER_PREFERENCES.settings, + language: "no", }; // @ts-expect-error - mock types if (settings.github_token) settings.github_token_is_set = true; @@ -290,4 +292,6 @@ export const handlers = [ return HttpResponse.json(null, { status: 404 }); }), + + http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), ]; diff --git a/frontend/src/query-client-config.ts b/frontend/src/query-client-config.ts index fa9342ce45..9b95523c01 100644 --- a/frontend/src/query-client-config.ts +++ b/frontend/src/query-client-config.ts @@ -1,4 +1,8 @@ -import { QueryClientConfig, QueryCache } from "@tanstack/react-query"; +import { + QueryClientConfig, + QueryCache, + MutationCache, +} from "@tanstack/react-query"; import toast from "react-hot-toast"; import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"; @@ -20,16 +24,18 @@ export const queryClientConfig: QueryClientConfig = { } }, }), + mutationCache: new MutationCache({ + onError: (error, _, __, mutation) => { + if (!mutation?.meta?.disableToast) { + const message = retrieveAxiosErrorMessage(error); + toast.error(message); + } + }, + }), defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes }, - mutations: { - onError: (error) => { - const message = retrieveAxiosErrorMessage(error); - toast.error(message); - }, - }, }, }; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index f712998877..b840b25402 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -8,6 +8,7 @@ import { export default [ layout("routes/_oh/route.tsx", [ index("routes/_oh._index/route.tsx"), + route("settings", "routes/settings.tsx"), route("conversations/:conversationId", "routes/_oh.app/route.tsx", [ index("routes/_oh.app._index/route.tsx"), route("browser", "routes/_oh.app.browser.tsx"), diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx new file mode 100644 index 0000000000..15078c9e13 --- /dev/null +++ b/frontend/src/routes/settings.tsx @@ -0,0 +1,452 @@ +import React from "react"; +import toast from "react-hot-toast"; +import { Link } from "react-router"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; +import { HelpLink } from "#/components/features/settings/help-link"; +import { AvailableLanguages } from "#/i18n"; +import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useConfig } from "#/hooks/query/use-config"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options"; +import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; +import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; +import { useAppLogout } from "#/hooks/use-app-logout"; +import { handleCaptureConsent } from "#/utils/handle-capture-consent"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import SettingsIcon from "#/icons/settings.svg?react"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { isCustomModel } from "#/utils/is-custom-model"; + +const REMOTE_RUNTIME_OPTIONS = [ + { key: 1, label: "1x (2 core, 8G)" }, + { key: 2, label: "2x (4 core, 16G)" }, +]; + +const displayErrorToast = (error: string) => { + toast.error(error, { + position: "top-right", + style: { + background: "#454545", + border: "1px solid #717888", + color: "#fff", + borderRadius: "4px", + }, + }); +}; + +const displaySuccessToast = (message: string) => { + toast.success(message, { + position: "top-right", + style: { + background: "#454545", + border: "1px solid #717888", + color: "#fff", + borderRadius: "4px", + }, + }); +}; + +function SettingsScreen() { + const { + data: settings, + isFetching: isFetchingSettings, + isFetched, + isSuccess: isSuccessfulSettings, + } = useSettings(); + const { data: config } = useConfig(); + const { + data: resources, + isFetching: isFetchingResources, + isSuccess: isSuccessfulResources, + } = useAIConfigOptions(); + const { mutate: saveSettings } = useSaveSettings(); + const { handleLogout } = useAppLogout(); + + const isFetching = isFetchingSettings || isFetchingResources; + const isSuccess = isSuccessfulSettings && isSuccessfulResources; + + const determineWhetherToToggleAdvancedSettings = () => { + if (isSuccess) { + return ( + isCustomModel(resources.models, settings.LLM_MODEL) || + hasAdvancedSettingsSet(settings) + ); + } + + return false; + }; + + const isSaas = config?.APP_MODE === "saas"; + const hasAppSlug = !!config?.APP_SLUG; + const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET; + const isLLMKeySet = settings?.LLM_API_KEY === "**********"; + const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS; + const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings(); + + const modelsAndProviders = organizeModelsAndProviders( + resources?.models || [], + ); + + const [llmConfigMode, setLlmConfigMode] = React.useState< + "basic" | "advanced" + >(isAdvancedSettingsSet ? "advanced" : "basic"); + const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] = + React.useState(!!settings?.SECURITY_ANALYZER); + const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] = + React.useState(false); + + const formAction = async (formData: FormData) => { + const languageLabel = formData.get("language-input")?.toString(); + const languageValue = AvailableLanguages.find( + ({ label }) => label === languageLabel, + )?.value; + + const llmProvider = formData.get("llm-provider-input")?.toString(); + const llmModel = formData.get("llm-model-input")?.toString(); + const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase(); + const customLlmModel = formData.get("llm-custom-model-input")?.toString(); + + const rawRemoteRuntimeResourceFactor = formData + .get("runtime-settings-input") + ?.toString(); + const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find( + ({ label }) => label === rawRemoteRuntimeResourceFactor, + )?.key; + + const userConsentsToAnalytics = + formData.get("enable-analytics-switch")?.toString() === "on"; + + saveSettings( + { + github_token: + formData.get("github-token-input")?.toString() || undefined, + LANGUAGE: languageValue, + user_consents_to_analytics: userConsentsToAnalytics, + LLM_MODEL: customLlmModel || fullLlmModel, + LLM_BASE_URL: formData.get("base-url-input")?.toString() || "", + LLM_API_KEY: formData.get("llm-api-key-input")?.toString() || undefined, + AGENT: formData.get("agent-input")?.toString(), + SECURITY_ANALYZER: + formData.get("security-analyzer-input")?.toString() || "", + REMOTE_RUNTIME_RESOURCE_FACTOR: + remoteRuntimeResourceFactor || + DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, + CONFIRMATION_MODE: confirmationModeIsEnabled, + }, + { + onSuccess: () => { + handleCaptureConsent(userConsentsToAnalytics); + displaySuccessToast("Settings saved"); + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage); + }, + }, + ); + }; + + const handleReset = () => { + saveSettings( + { + ...DEFAULT_SETTINGS, + LLM_API_KEY: "", // reset LLM API key + }, + { + onSuccess: () => { + displaySuccessToast("Settings reset"); + setResetSettingsModalIsOpen(false); + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, + }, + ); + }; + + React.useEffect(() => { + // If settings is still loading by the time the state is set, it will always + // default to basic settings. This is a workaround to ensure the correct + // settings are displayed. + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, [isAdvancedSettingsSet]); + + if (isFetched && !settings) { + return
Failed to fetch settings. Please try reloading.
; + } + + const onToggleAdvancedMode = (isToggled: boolean) => { + setLlmConfigMode(isToggled ? "advanced" : "basic"); + if (!isToggled) { + // reset advanced state + setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER); + } + }; + + return ( +
+
+
+ +

Settings

+
+ + {isFetching && ( +
+ +
+ )} + {!isFetching && settings && ( +
+
+
+

+ LLM Settings +

+ + Advanced + +
+ + {llmConfigMode === "basic" && ( + + )} + + {llmConfigMode === "advanced" && ( + + )} + {llmConfigMode === "advanced" && ( + + )} + + } + placeholder={isLLMKeySet ? "**********" : ""} + /> + + + + {llmConfigMode === "advanced" && ( + ({ + key: agent, + label: agent, + })) || [] + } + defaultSelectedKey={settings.AGENT} + isClearable={false} + /> + )} + + {isSaas && llmConfigMode === "advanced" && ( + + )} + + {llmConfigMode === "advanced" && ( + + Enable confirmation mode + + )} + {llmConfigMode === "advanced" && confirmationModeIsEnabled && ( +
+ ({ + key: analyzer, + label: analyzer, + })) || [] + } + defaultSelectedKey={settings.SECURITY_ANALYZER} + isClearable + showOptionalTag + /> +
+ )} +
+ +
+

+ GitHub Settings +

+ {isSaas && hasAppSlug && ( + + + Configure GitHub Repositories + + + )} + {!isSaas && ( + <> + } + /> + + + + )} + + + Disconnect from GitHub + +
+ +
+

+ Additional Settings +

+ + ({ + key: language.value, + label: language.label, + }))} + defaultSelectedKey={settings.LANGUAGE} + isClearable={false} + /> + + + Enable analytics + +
+
+ )} + +
+ setResetSettingsModalIsOpen(true)} + > + Reset to defaults + + + Save Changes + +
+
+ + {resetSettingsModalIsOpen && ( + +
+

Are you sure you want to reset all settings?

+
+ { + handleReset(); + }} + > + Reset + + + { + setResetSettingsModalIsOpen(false); + }} + > + Cancel + +
+
+
+ )} +
+ ); +} + +export default SettingsScreen; diff --git a/frontend/src/types/react-query.d.ts b/frontend/src/types/react-query.d.ts index 830a95a340..870623edb2 100644 --- a/frontend/src/types/react-query.d.ts +++ b/frontend/src/types/react-query.d.ts @@ -1,8 +1,15 @@ import "@tanstack/react-query"; import type { AxiosError } from "axios"; +interface MyMeta extends Record { + disableToast?: boolean; +} + declare module "@tanstack/react-query" { interface Register { defaultError: AxiosError; + + queryMeta: MyMeta; + mutationMeta: MyMeta; } } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 51da54b9ac..4723690e52 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -6,7 +6,7 @@ export type Settings = { LLM_API_KEY: string | null; CONFIRMATION_MODE: boolean; SECURITY_ANALYZER: string; - REMOTE_RUNTIME_RESOURCE_FACTOR: number; + REMOTE_RUNTIME_RESOURCE_FACTOR: number | null; GITHUB_TOKEN_IS_SET: boolean; ENABLE_DEFAULT_CONDENSER: boolean; USER_CONSENTS_TO_ANALYTICS: boolean | null; @@ -20,7 +20,7 @@ export type ApiSettings = { llm_api_key: string | null; confirmation_mode: boolean; security_analyzer: string; - remote_runtime_resource_factor: number; + remote_runtime_resource_factor: number | null; github_token_is_set: boolean; enable_default_condenser: boolean; user_consents_to_analytics: boolean | null; diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts new file mode 100644 index 0000000000..374047c36a --- /dev/null +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -0,0 +1,10 @@ +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { Settings } from "#/types/settings"; + +export const hasAdvancedSettingsSet = (settings: Settings): boolean => + !!settings.LLM_BASE_URL || + settings.AGENT !== DEFAULT_SETTINGS.AGENT || + settings.REMOTE_RUNTIME_RESOURCE_FACTOR !== + DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR || + settings.CONFIRMATION_MODE || + !!settings.SECURITY_ANALYZER; diff --git a/frontend/src/utils/is-custom-model.ts b/frontend/src/utils/is-custom-model.ts new file mode 100644 index 0000000000..b4d5418f38 --- /dev/null +++ b/frontend/src/utils/is-custom-model.ts @@ -0,0 +1,22 @@ +import { extractModelAndProvider } from "./extract-model-and-provider"; +import { organizeModelsAndProviders } from "./organize-models-and-providers"; + +/** + * Check if a model is a custom model. A custom model is a model that is not part of the default models. + * @param models Full list of models + * @param model Model to check + * @returns Whether the model is a custom model + */ +export const isCustomModel = (models: string[], model: string): boolean => { + if (!model) return false; + + const organizedModels = organizeModelsAndProviders(models); + const { provider: extractedProvider, model: extractedModel } = + extractModelAndProvider(model); + + const isKnownModel = + extractedProvider in organizedModels && + organizedModels[extractedProvider].models.includes(extractedModel); + + return !isKnownModel; +}; diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index f32835d2d8..b8533b3a49 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -1,11 +1,11 @@ import { Settings } from "#/types/settings"; const extractBasicFormData = (formData: FormData) => { - const provider = formData.get("llm-provider")?.toString(); - const model = formData.get("llm-model")?.toString(); + const provider = formData.get("llm-provider-input")?.toString(); + const model = formData.get("llm-model-input")?.toString(); const LLM_MODEL = `${provider}/${model}`.toLowerCase(); - const LLM_API_KEY = formData.get("api-key")?.toString(); + const LLM_API_KEY = formData.get("llm-api-key-input")?.toString(); const AGENT = formData.get("agent")?.toString(); const LANGUAGE = formData.get("language")?.toString();