mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(frontend): Settings screen (#6550)
This commit is contained in:
@@ -18,7 +18,6 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should always render the right options", () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -28,30 +27,12 @@ describe("AccountSettingsContextMenu", () => {
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -67,7 +48,6 @@ describe("AccountSettingsContextMenu", () => {
|
||||
test("onLogout should be disabled if the user is not logged in", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn={false}
|
||||
@@ -83,14 +63,13 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<BrandButton testId="brand-button" type="button" variant="primary">
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("brand-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" onClick={onClickMock}>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Test Button"));
|
||||
});
|
||||
|
||||
it("should be disabled if isDisabled is true", () => {
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" isDisabled>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/optional/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
showOptionalTag
|
||||
label="Test Input"
|
||||
type="text"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/optional/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the input if isDisabled is true", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeEnabled();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
isDisabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set a placeholder on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
placeholder="Test Placeholder"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Test Placeholder",
|
||||
);
|
||||
});
|
||||
|
||||
it("should set a default value on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveValue("Test Value");
|
||||
});
|
||||
|
||||
it("should render start content", async () => {
|
||||
const startContent = <div>Start Content</div>;
|
||||
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
startContent={startContent}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Start Content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<SettingsSwitch testId="test-switch" onToggle={onToggleMock}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta={false}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/beta/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SettingsSwitch
|
||||
testId="test-switch"
|
||||
onToggle={onToggleMock}
|
||||
defaultIsToggled
|
||||
>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
|
||||
agents={["CodeActAgent", "agent2"]}
|
||||
securityAnalyzers={["analyzer1", "analyzer2"]}
|
||||
models={[DEFAULT_SETTINGS.LLM_MODEL]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
@@ -38,39 +24,17 @@ describe("SettingsForm", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
it("should not show runtime size selector by default", () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
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(<RouterStub />);
|
||||
renderWithProviders(<RouteStub />);
|
||||
|
||||
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(<RouterStub />);
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,24 +14,14 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
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(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
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(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
@@ -89,12 +58,7 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
test("onLogout should not be called when the user is not logged in", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
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(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
user.click(userAvatar);
|
||||
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
36
frontend/__tests__/hooks/mutation/use-save-settings.test.tsx
Normal file
36
frontend/__tests__/hooks/mutation/use-save-settings.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={() => {}}
|
||||
onLogout={() => {}}
|
||||
onClose={() => {}}
|
||||
isLoggedIn={true}
|
||||
/>
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
114
frontend/__tests__/routes/home.test.tsx
Normal file
114
frontend/__tests__/routes/home.test.tsx
Normal file
@@ -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(<RouterStub initialEntries={["/"]} />);
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when the settings button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
873
frontend/__tests__/routes/settings.test.tsx
Normal file
873
frontend/__tests__/routes/settings.test.tsx
Normal file
@@ -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(<RouterStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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<PostApiSettings> = {
|
||||
...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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
frontend/__tests__/utils/has-advanced-settings-set.test.ts
Normal file
56
frontend/__tests__/utils/has-advanced-settings-set.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
frontend/__tests__/utils/is-custom-model.test.ts
Normal file
20
frontend/__tests__/utils/is-custom-model.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ApiSettings>): Promise<boolean> {
|
||||
static async saveSettings(
|
||||
settings: Partial<PostApiSettings>,
|
||||
): Promise<boolean> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,10 @@ export function AnalyticsConsentFormModal({
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
testId="account-settings-button"
|
||||
onClick={onClickAccountSettings}
|
||||
>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
@@ -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<string>("");
|
||||
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 (
|
||||
<>
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
39
frontend/src/components/features/settings/brand-button.tsx
Normal file
39
frontend/src/components/features/settings/brand-button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
variant: "primary" | "secondary";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function BrandButton({
|
||||
testId,
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
isDisabled,
|
||||
className,
|
||||
onClick,
|
||||
}: React.PropsWithChildren<BrandButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
disabled={isDisabled}
|
||||
// The type is alreadt passed as a prop to the button component
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variant === "primary" && "bg-[#C9B974] text-[#0D0F11]",
|
||||
variant === "secondary" && "border border-[#C9B974] text-[#C9B974]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/features/settings/help-link.tsx
Normal file
22
frontend/src/components/features/settings/help-link.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface HelpLinkProps {
|
||||
testId: string;
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
|
||||
return (
|
||||
<p data-testid={testId} className="text-xs">
|
||||
{text}{" "}
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
|
||||
<SuccessIcon
|
||||
className={cn(isSet ? "text-[#A5E75E]" : "text-[#E76A5E]")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-[#B7BDC2]">(Optional)</span>;
|
||||
}
|
||||
@@ -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 (
|
||||
<label className="flex flex-col gap-2.5 w-[680px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<Autocomplete
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
defaultItems={items}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
className="w-full"
|
||||
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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/features/settings/settings-input.tsx
Normal file
50
frontend/src/components/features/settings/settings-input.tsx
Normal file
@@ -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 (
|
||||
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{startContent}
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<input
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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<SettingsSwitchProps>) {
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
setIsToggled(value);
|
||||
onToggle?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 w-fit">
|
||||
<input
|
||||
hidden
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={isToggled} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-[#C9B974] px-1 rounded-full">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StyledSwitchComponentProps {
|
||||
isToggled: boolean;
|
||||
}
|
||||
|
||||
export function StyledSwitchComponent({
|
||||
isToggled,
|
||||
}: StyledSwitchComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-6 rounded-xl flex items-center p-1.5 cursor-pointer",
|
||||
isToggled && "justify-end bg-[#C9B974]",
|
||||
!isToggled && "justify-start bg-[#1F2228] border border-[#B7BDC2]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#1F2228] w-3 h-3 rounded-xl",
|
||||
isToggled ? "bg-[#1F2228]" : "bg-[#B7BDC2]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
<nav className="flex flex-row md:flex-col items-center justify-between h-full">
|
||||
<div className="flex flex-col items-center gap-[26px]">
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
>
|
||||
<FaListUl size={22} />
|
||||
</TooltipButton>
|
||||
)}
|
||||
<DocsButton />
|
||||
</div>
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
ariaLabel="Conversations"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
>
|
||||
<FaListUl size={22} />
|
||||
</TooltipButton>
|
||||
)}
|
||||
<DocsButton />
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
|
||||
<div className="flex flex-col items-center gap-[26px] mb-4">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "text-white" : "text-[#9099AC]"
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SettingsButton />
|
||||
</NavLink>
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
@@ -122,10 +121,7 @@ export function Sidebar() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{(settingsError?.status === 404 || settingsModalIsOpen) && (
|
||||
{settingsModalIsOpen && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
|
||||
@@ -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 && (
|
||||
<AccountSettingsContextMenu
|
||||
isLoggedIn={!!user}
|
||||
onClickAccountSettings={handleClickAccountSettings}
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
|
||||
@@ -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 && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
<DefaultUserAvatar
|
||||
<ProfileIcon
|
||||
aria-label={t(I18nKey.USER$AVATAR_PLACEHOLDER)}
|
||||
width={20}
|
||||
height={20}
|
||||
width={28}
|
||||
height={28}
|
||||
className="text-[#9099AC]"
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
ariaLabel="All Hands Logo"
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={44} height={30} />
|
||||
<AllHandsLogo width={34} height={23} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<DocsIcon width={28} height={28} />
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<NewProjectIcon width={26} height={26} />
|
||||
<PlusIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TooltipButton
|
||||
testId="settings-button"
|
||||
@@ -16,7 +17,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FaCog size={24} />
|
||||
<SettingsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
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<PostSettings> = {};
|
||||
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 (
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.ACCOUNT_SETTINGS$TITLE)} />
|
||||
|
||||
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</a>
|
||||
)}
|
||||
<FormFieldset
|
||||
id="language"
|
||||
label={t(I18nKey.LANGUAGE$LABEL)}
|
||||
defaultSelectedKey={selectedLanguage}
|
||||
isClearable={false}
|
||||
items={AvailableLanguages.map(({ label, value: key }) => ({
|
||||
key,
|
||||
value: label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
|
||||
{!githubTokenIsSet && (
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isGitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{githubTokenIsSet && !isGitHubError && (
|
||||
<ModalButton
|
||||
testId="disconnect-github"
|
||||
variant="text-like"
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={onDisconnect}
|
||||
className="text-danger self-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex gap-2 items-center self-start">
|
||||
<input
|
||||
data-testid="analytics-consent"
|
||||
name="analytics"
|
||||
type="checkbox"
|
||||
defaultChecked={analyticsConsentValue}
|
||||
/>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<AccountSettingsForm onClose={onClose} />
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<label htmlFor="ghToken" className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
|
||||
{githubTokenIsSet && (
|
||||
<FaCheckCircle
|
||||
data-testid="github-token-set-checkmark"
|
||||
size={12}
|
||||
className="text-[#00D1B2]"
|
||||
/>
|
||||
)}
|
||||
{t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
</span>
|
||||
{!githubTokenIsSet && (
|
||||
<input
|
||||
data-testid="github-token-input"
|
||||
id="ghToken"
|
||||
name="ghToken"
|
||||
type="password"
|
||||
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -65,107 +65,109 @@ export function ModelSelector({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div data-testid="model-selector" className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-3">
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor="agent" className="font-[500] text-[#A3A3A3] text-xs">
|
||||
{t(I18nKey.LLM$PROVIDER)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-provider"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-provider"
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t(I18nKey.LLM$PROVIDER)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_PROVIDER_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
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]",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
{Object.keys(models)
|
||||
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`provider-item-${provider}`}
|
||||
key={provider}
|
||||
value={provider}
|
||||
>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
{Object.keys(models)
|
||||
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem key={provider} value={provider}>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
<div className="flex w-[680px] justify-between gap-[46px]">
|
||||
<fieldset className="flex flex-col gap-2.5 w-full">
|
||||
<label className="text-sm">{t(I18nKey.LLM$PROVIDER)}</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-provider-input"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-provider-input"
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t(I18nKey.LLM$PROVIDER)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_PROVIDER_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
{Object.keys(models)
|
||||
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`provider-item-${provider}`}
|
||||
key={provider}
|
||||
value={provider}
|
||||
>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
{Object.keys(models)
|
||||
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem key={provider} value={provider}>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor="agent" className="font-[500] text-[#A3A3A3] text-xs">
|
||||
{t(I18nKey.LLM$MODEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-model"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-model"
|
||||
aria-label={t(I18nKey.LLM$MODEL)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
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]",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem key={model} value={model}>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`model-item-${model}`}
|
||||
key={model}
|
||||
value={model}
|
||||
>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2.5 w-full">
|
||||
<label className="text-sm">{t(I18nKey.LLM$MODEL)}</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-model-input"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-model-input"
|
||||
aria-label={t(I18nKey.LLM$MODEL)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutocompleteSection title="Verified">
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem key={model} value={model}>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
<AutocompleteSection title="Others">
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`model-item-${model}`}
|
||||
key={model}
|
||||
value={model}
|
||||
>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</AutocompleteSection>
|
||||
</Autocomplete>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLFormElement>(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 (
|
||||
<div>
|
||||
@@ -144,115 +85,41 @@ export function SettingsForm({
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<AdvancedOptionSwitch
|
||||
isDisabled={!!disabled}
|
||||
showAdvancedOptions={showAdvancedOptions}
|
||||
setShowAdvancedOptions={setShowAdvancedOptions}
|
||||
<div className="flex flex-col gap-4">
|
||||
<ModelSelector
|
||||
models={organizeModelsAndProviders(models)}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<CustomModelInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
/>
|
||||
|
||||
<BaseUrlInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showAdvancedOptions && (
|
||||
<ModelSelector
|
||||
isDisabled={disabled}
|
||||
models={organizeModelsAndProviders(models)}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
<APIKeyInput
|
||||
isDisabled={!!disabled}
|
||||
isSet={settings.LLM_API_KEY === "**********"}
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={<KeyStatusIcon isSet={isLLMKeySet} />}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<AgentInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.AGENT}
|
||||
agents={agents}
|
||||
/>
|
||||
|
||||
{isSaasMode && (
|
||||
<RuntimeSizeSelector
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.REMOTE_RUNTIME_RESOURCE_FACTOR}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SecurityAnalyzerInput
|
||||
isDisabled={!!disabled}
|
||||
defaultValue={settings.SECURITY_ANALYZER}
|
||||
securityAnalyzers={securityAnalyzers}
|
||||
/>
|
||||
|
||||
<ConfirmationModeSwitch
|
||||
isDisabled={!!disabled}
|
||||
defaultSelected={settings.CONFIRMATION_MODE}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<ModalButton
|
||||
testId="save-settings-button"
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
disabled={disabled}
|
||||
text={t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
|
||||
variant="text-like"
|
||||
className="text-danger self-start"
|
||||
onClick={() => {
|
||||
setConfirmResetDefaultsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<BrandButton
|
||||
testId="save-settings-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{confirmResetDefaultsModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
testId="reset-defaults-modal"
|
||||
title={t(I18nKey.MODAL$CONFIRM_RESET_TITLE)}
|
||||
description={t(I18nKey.MODAL$CONFIRM_RESET_MESSAGE)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: t(I18nKey.BUTTON$RESET_TO_DEFAULTS),
|
||||
onClick: handleConfirmResetSettings,
|
||||
},
|
||||
cancel: {
|
||||
text: t(I18nKey.BUTTON$CANCEL),
|
||||
onClick: () => setConfirmResetDefaultsModalOpen(false),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
{confirmEndSessionModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
@@ -20,18 +21,24 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
data-testid="ai-config-modal"
|
||||
className="bg-root-primary min-w-[384px] max-w-[700px] p-6 rounded-xl flex flex-col gap-2"
|
||||
className="bg-root-primary min-w-[384px] p-6 rounded-xl flex flex-col gap-2"
|
||||
>
|
||||
{aiConfigOptions.error && (
|
||||
<p className="text-danger text-xs">{aiConfigOptions.error.message}</p>
|
||||
)}
|
||||
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
|
||||
<span className="text-xl leading-6 font-semibold -tracking-[0.01em]">
|
||||
{t(I18nKey.AI_SETTINGS$TITLE)}
|
||||
</span>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
{t(I18nKey.SETTINGS$DESCRIPTION)}
|
||||
{t(I18nKey.SETTINGS$DESCRIPTION)} For other options,{" "}
|
||||
<Link
|
||||
data-testid="advanced-settings-link"
|
||||
to="/settings"
|
||||
className="underline underline-offset-2 text-white"
|
||||
>
|
||||
see advanced settings
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-xs text-danger">{t(I18nKey.SETTINGS$WARNING)}</p>
|
||||
{aiConfigOptions.isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
@@ -41,8 +48,6 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
<SettingsForm
|
||||
settings={settings || DEFAULT_SETTINGS}
|
||||
models={aiConfigOptions.data?.models}
|
||||
agents={aiConfigOptions.data?.agents}
|
||||
securityAnalyzers={aiConfigOptions.data?.securityAnalyzers}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<void, Error, Partial<PostSettings>>["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(
|
||||
|
||||
@@ -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<PostSettings>) => {
|
||||
const resetLlmApiKey = settings.LLM_API_KEY === "";
|
||||
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
@@ -11,11 +14,14 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
16
frontend/src/hooks/use-app-logout.ts
Normal file
16
frontend/src/hooks/use-app-logout.ts
Normal file
@@ -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 };
|
||||
};
|
||||
4
frontend/src/icons/academy.svg
Normal file
4
frontend/src/icons/academy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.5719 9.17199V14.8456M15.7404 3.4984C14.9543 3.12524 14.095 2.93164 13.2248 2.93164C12.3546 2.93164 11.4952 3.12524 10.7091 3.4984L3.11671 7.05801C1.46456 7.83189 1.46456 10.5121 3.11671 11.286L10.708 14.8456C11.4943 15.2189 12.3538 15.4126 13.2242 15.4126C14.0946 15.4126 14.9541 15.2189 15.7404 14.8456L23.3328 11.286C24.985 10.5121 24.985 7.83189 23.3328 7.05801L15.7404 3.4984Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.28183 12.5762V18.3916C5.28183 21.7027 10.6082 23.356 13.2249 23.356C15.8415 23.356 21.1679 21.7027 21.1679 18.3916V12.5762" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 814 B |
11
frontend/src/icons/plus.svg
Normal file
11
frontend/src/icons/plus.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="27" height="26" viewBox="0 0 27 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_40000158_408)">
|
||||
<path d="M13.5381 1.83496C7.32618 1.83496 2.29074 6.8704 2.29074 13.0823C2.29074 19.2942 7.32618 24.3297 13.5381 24.3297C19.75 24.3297 24.7855 19.2942 24.7855 13.0823C24.7855 6.8704 19.75 1.83496 13.5381 1.83496Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.5007 12H14.5007V6C14.5007 5.73478 14.3953 5.48043 14.2078 5.29289C14.0203 5.10536 13.7659 5 13.5007 5C13.2355 5 12.9811 5.10536 12.7936 5.29289C12.6061 5.48043 12.5007 5.73478 12.5007 6V12H6.5007C6.23549 12 5.98113 12.1054 5.7936 12.2929C5.60606 12.4804 5.5007 12.7348 5.5007 13C5.5007 13.2652 5.60606 13.5196 5.7936 13.7071C5.98113 13.8946 6.23549 14 6.5007 14H12.5007V20C12.5007 20.2652 12.6061 20.5196 12.7936 20.7071C12.9811 20.8946 13.2355 21 13.5007 21C13.7659 21 14.0203 20.8946 14.2078 20.7071C14.3953 20.5196 14.5007 20.2652 14.5007 20V14H20.5007C20.7659 14 21.0203 13.8946 21.2078 13.7071C21.3953 13.5196 21.5007 13.2652 21.5007 13C21.5007 12.7348 21.3953 12.4804 21.2078 12.2929C21.0203 12.1054 20.7659 12 20.5007 12Z" fill="currentColor"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_40000158_408">
|
||||
<rect width="26" height="26" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
11
frontend/src/icons/profile.svg
Normal file
11
frontend/src/icons/profile.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="27" height="26" viewBox="0 0 27 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_40000158_426)">
|
||||
<path d="M13.5381 1.83496C7.32618 1.83496 2.29074 6.8704 2.29074 13.0823C2.29074 19.2942 7.32618 24.3297 13.5381 24.3297C19.75 24.3297 24.7855 19.2942 24.7855 13.0823C24.7855 6.8704 19.75 1.83496 13.5381 1.83496Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.84641 20.22C4.84641 20.22 7.35344 17.019 13.5395 17.019C19.7255 17.019 22.2337 20.22 22.2337 20.22M13.5395 13.0824C14.4344 13.0824 15.2926 12.7269 15.9254 12.0941C16.5582 11.4613 16.9137 10.6031 16.9137 9.70819C16.9137 8.8133 16.5582 7.95505 15.9254 7.32227C15.2926 6.68948 14.4344 6.33398 13.5395 6.33398C12.6446 6.33398 11.7863 6.68948 11.1536 7.32227C10.5208 7.95505 10.1653 8.8133 10.1653 9.70819C10.1653 10.6031 10.5208 11.4613 11.1536 12.0941C11.7863 12.7269 12.6446 13.0824 13.5395 13.0824Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_40000158_426">
|
||||
<rect width="26" height="26" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
frontend/src/icons/settings.svg
Normal file
4
frontend/src/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5 18.375C12.085 18.375 10.125 16.415 10.125 14C10.125 11.585 12.085 9.625 14.5 9.625C16.915 9.625 18.875 11.585 18.875 14C18.875 16.415 16.915 18.375 14.5 18.375ZM14.5 11.375C13.0533 11.375 11.875 12.5533 11.875 14C11.875 15.4467 13.0533 16.625 14.5 16.625C15.9467 16.625 17.125 15.4467 17.125 14C17.125 12.5533 15.9467 11.375 14.5 11.375Z" fill="currentColor"/>
|
||||
<path d="M18.245 25.8883C18 25.8883 17.755 25.8533 17.51 25.795C16.7867 25.5967 16.18 25.1417 15.795 24.5L15.655 24.2667C14.9667 23.0767 14.0217 23.0767 13.3333 24.2667L13.205 24.4883C12.82 25.1417 12.2133 25.6083 11.49 25.795C10.755 25.9933 9.99668 25.8883 9.35501 25.5033L7.34834 24.3483C6.63668 23.94 6.12334 23.275 5.90168 22.47C5.69168 21.665 5.79668 20.8367 6.20501 20.125C6.54334 19.53 6.63668 18.9933 6.43834 18.655C6.24001 18.3167 5.73834 18.1183 5.05001 18.1183C3.34668 18.1183 1.95834 16.73 1.95834 15.0267V12.9733C1.95834 11.27 3.34668 9.88168 5.05001 9.88168C5.73834 9.88168 6.24001 9.68334 6.43834 9.34501C6.63668 9.00668 6.55501 8.47001 6.20501 7.87501C5.79668 7.16334 5.69168 6.32334 5.90168 5.53001C6.11168 4.72501 6.62501 4.06001 7.34834 3.65168L9.36668 2.49668C10.685 1.71501 12.4233 2.17001 13.2167 3.51168L13.3567 3.74501C14.045 4.93501 14.99 4.93501 15.6783 3.74501L15.8067 3.52334C16.6 2.17001 18.3383 1.71501 19.6683 2.50834L21.675 3.66334C22.3867 4.07168 22.9 4.73668 23.1217 5.54168C23.3317 6.34668 23.2267 7.17501 22.8183 7.88668C22.48 8.48168 22.3867 9.01834 22.585 9.35668C22.7833 9.69501 23.285 9.89334 23.9733 9.89334C25.6767 9.89334 27.065 11.2817 27.065 12.985V15.0383C27.065 16.7417 25.6767 18.13 23.9733 18.13C23.285 18.13 22.7833 18.3283 22.585 18.6667C22.3867 19.005 22.4683 19.5417 22.8183 20.1367C23.2267 20.8483 23.3433 21.6883 23.1217 22.4817C22.9117 23.2867 22.3983 23.9517 21.675 24.36L19.6567 25.515C19.2133 25.76 18.735 25.8883 18.245 25.8883ZM14.5 21.5717C15.5383 21.5717 16.5067 22.225 17.1717 23.38L17.3 23.6017C17.44 23.8467 17.6733 24.0217 17.9533 24.0917C18.2333 24.1617 18.5133 24.1267 18.7467 23.9867L20.765 22.82C21.0683 22.645 21.3017 22.3533 21.395 22.0033C21.4883 21.6533 21.4417 21.2917 21.2667 20.9883C20.6017 19.845 20.52 18.6667 21.0333 17.7683C21.5467 16.87 22.6083 16.3567 23.9383 16.3567C24.685 16.3567 25.28 15.7617 25.28 15.015V12.9617C25.28 12.2267 24.685 11.62 23.9383 11.62C22.6083 11.62 21.5467 11.1067 21.0333 10.2083C20.52 9.31001 20.6017 8.13168 21.2667 6.98834C21.4417 6.68501 21.4883 6.32334 21.395 5.97334C21.3017 5.62334 21.08 5.34334 20.7767 5.15668L18.7583 4.00168C18.2567 3.69834 17.5917 3.87334 17.2883 4.38668L17.16 4.60834C16.495 5.76334 15.5267 6.41668 14.4883 6.41668C13.45 6.41668 12.4817 5.76334 11.8167 4.60834L11.6883 4.37501C11.3967 3.88501 10.7433 3.71001 10.2417 4.00168L8.22334 5.16834C7.92001 5.34334 7.68668 5.63501 7.59334 5.98501C7.50001 6.33501 7.54668 6.69668 7.72168 7.00001C8.38668 8.14334 8.46834 9.32168 7.95501 10.22C7.44168 11.1183 6.38001 11.6317 5.05001 11.6317C4.30334 11.6317 3.70834 12.2267 3.70834 12.9733V15.0267C3.70834 15.7617 4.30334 16.3683 5.05001 16.3683C6.38001 16.3683 7.44168 16.8817 7.95501 17.78C8.46834 18.6783 8.38668 19.8567 7.72168 21C7.54668 21.3033 7.50001 21.665 7.59334 22.015C7.68668 22.365 7.90834 22.645 8.21168 22.8317L10.23 23.9867C10.475 24.1383 10.7667 24.1733 11.035 24.1033C11.315 24.0333 11.5483 23.8467 11.7 23.6017L11.8283 23.38C12.4933 22.2367 13.4617 21.5717 14.5 21.5717Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
4
frontend/src/icons/success.svg
Normal file
4
frontend/src/icons/success.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.9509 15.588H10.8349L16.5125 9.91042L15.6285 9.0264L10.3991 14.2681L8.00867 11.8777L7.12465 12.7617L9.9509 15.588Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.8521 3.29895C14.8443 3.42346 16.712 4.41954 18.0816 5.78916C19.7002 7.53231 20.5718 9.64898 20.5718 12.1392C20.5718 14.1314 19.8247 15.999 18.5796 17.6177C17.3345 19.1118 15.5914 20.2324 13.5992 20.6059C11.607 20.9794 9.61486 20.7304 7.87171 19.7343C6.12856 18.7382 4.75895 17.2441 4.01189 15.3765C3.26482 13.5088 3.14031 11.3921 3.76286 9.52447C4.38542 7.53231 5.50601 5.91367 7.24916 4.79308C8.86779 3.67248 10.86 3.17444 12.8521 3.29895ZM13.4747 19.3608C15.0933 18.9873 16.5874 18.1157 17.708 16.7461C18.7041 15.3765 19.3267 13.7578 19.2022 12.0147C19.2022 10.0225 18.4551 8.03035 17.0855 6.66073C15.8404 5.41563 14.3463 4.66857 12.6031 4.54405C10.9845 4.41954 9.24132 4.79308 7.87171 5.78916C6.50209 6.78524 5.50601 8.15486 5.00797 9.89801C4.50993 11.5166 4.50993 13.2598 5.25699 14.8784C6.00405 16.4971 7.12465 17.7422 8.61877 18.6137C10.1129 19.4853 11.856 19.7343 13.4747 19.3608Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
5
frontend/src/icons/warning.svg
Normal file
5
frontend/src/icons/warning.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 13V7H13V13H11Z" fill="#E76A5E"/>
|
||||
<path d="M11 15V17H13V15H11Z" fill="#E76A5E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.75 12C20.75 16.8325 16.8325 20.75 12 20.75C7.16751 20.75 3.25 16.8325 3.25 12C3.25 7.16751 7.16751 3.25 12 3.25C16.8325 3.25 20.75 7.16751 20.75 12ZM12 19.25C16.0041 19.25 19.25 16.0041 19.25 12C19.25 7.99594 16.0041 4.75 12 4.75C7.99594 4.75 4.75 7.99594 4.75 12C4.75 16.0041 7.99594 19.25 12 19.25Z" fill="#E76A5E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -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 })),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
452
frontend/src/routes/settings.tsx
Normal file
452
frontend/src/routes/settings.tsx
Normal file
@@ -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 <div>Failed to fetch settings. Please try reloading.</div>;
|
||||
}
|
||||
|
||||
const onToggleAdvancedMode = (isToggled: boolean) => {
|
||||
setLlmConfigMode(isToggled ? "advanced" : "basic");
|
||||
if (!isToggled) {
|
||||
// reset advanced state
|
||||
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
className="bg-[#24272E] border border-[#454545] h-full rounded-xl"
|
||||
>
|
||||
<form action={formAction} className="flex flex-col h-full">
|
||||
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<h1 className="text-sm leading-6">Settings</h1>
|
||||
</header>
|
||||
|
||||
{isFetching && (
|
||||
<div className="flex grow p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && settings && (
|
||||
<div className="flex flex-col gap-12 grow overflow-y-auto px-11 py-9">
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
LLM Settings
|
||||
</h2>
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
Advanced
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
|
||||
{llmConfigMode === "basic" && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label="Custom Model"
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label="Base URL"
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={<KeyStatusIcon isSet={isLLMKeySet} />}
|
||||
placeholder={isLLMKeySet ? "**********" : ""}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
/>
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label="Agent"
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSaas && llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label="Runtime Settings"
|
||||
items={REMOTE_RUNTIME_OPTIONS}
|
||||
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
onToggle={setConfirmationModeIsEnabled}
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
Enable confirmation mode
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
|
||||
<div>
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label="Security Analyzer"
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
GitHub Settings
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Configure GitHub Repositories
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
{!isSaas && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={<KeyStatusIcon isSet={!!isGitHubTokenSet} />}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="github-token-help-anchor"
|
||||
text="Get your token"
|
||||
linkText="here"
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleLogout}
|
||||
isDisabled={!isGitHubTokenSet}
|
||||
>
|
||||
Disconnect from GitHub
|
||||
</BrandButton>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Additional Settings
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label="Language"
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
Enable analytics
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
Reset to defaults
|
||||
</BrandButton>
|
||||
<BrandButton type="submit" variant="primary">
|
||||
Save Changes
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
{resetSettingsModalIsOpen && (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="reset-modal"
|
||||
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
|
||||
>
|
||||
<p>Are you sure you want to reset all settings?</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setResetSettingsModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsScreen;
|
||||
7
frontend/src/types/react-query.d.ts
vendored
7
frontend/src/types/react-query.d.ts
vendored
@@ -1,8 +1,15 @@
|
||||
import "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
interface MyMeta extends Record<string, unknown> {
|
||||
disableToast?: boolean;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
defaultError: AxiosError;
|
||||
|
||||
queryMeta: MyMeta;
|
||||
mutationMeta: MyMeta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
frontend/src/utils/has-advanced-settings-set.ts
Normal file
10
frontend/src/utils/has-advanced-settings-set.ts
Normal file
@@ -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;
|
||||
22
frontend/src/utils/is-custom-model.ts
Normal file
22
frontend/src/utils/is-custom-model.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user