chore(frontend): Breakdown settings screen (#7929)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
sp.wack
2025-04-28 22:32:28 +04:00
committed by GitHub
parent f2725eeb3d
commit ae9573a503
37 changed files with 2988 additions and 1472 deletions

View File

@@ -0,0 +1,291 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
describe("Content", () => {
it("should render the screen", () => {
renderAppSettingsScreen();
screen.getByTestId("app-settings-screen");
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
});
renderAppSettingsScreen();
await waitFor(() => {
const language = screen.getByTestId("language-input");
const analytics = screen.getByTestId("enable-analytics-switch");
const sound = screen.getByTestId("enable-sound-notifications-switch");
expect(language).toHaveValue("Norsk");
expect(analytics).toBeChecked();
expect(sound).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
AvailableLanguages.forEach((lang) => {
const option = screen.getByText(lang.label);
expect(option).toBeInTheDocument();
});
});
});
describe("Form submission", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
const analytics = await screen.findByTestId("enable-analytics-switch");
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
expect(language).toHaveValue("English");
expect(analytics).not.toBeChecked();
expect(sound).not.toBeChecked();
// change language
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(language).toHaveValue("Norsk");
// toggle options
await userEvent.click(analytics);
expect(analytics).toBeChecked();
await userEvent.click(sound);
expect(sound).toBeChecked();
// submit the form
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
}),
);
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
// Language check
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(submit).not.toBeDisabled();
await userEvent.click(language);
const english = screen.getByText("English");
await userEvent.click(english);
expect(submit).toBeDisabled();
// Analytics check
const analytics = await screen.findByTestId("enable-analytics-switch");
await userEvent.click(analytics);
expect(submit).not.toBeDisabled();
await userEvent.click(analytics);
expect(submit).toBeDisabled();
// Sound check
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
await userEvent.click(sound);
expect(submit).toBeDisabled();
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
);
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
);
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,461 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const VALID_SAAS_CONFIG: GetConfigResponse = {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const queryClient = new QueryClient();
const GitSettingsRouterStub = createRoutesStub([
{
Component: GitSettingsScreen,
path: "/settings/github",
},
]);
const renderGitSettingsScreen = () => {
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
</AuthProvider>
</QueryClientProvider>,
);
return {
...rest,
rerender: rerenderGitSettingsScreen,
};
};
beforeEach(() => {
// Since we don't recreate the query client on every test, we need to
// reset the query client before each test to avoid state leaks
// between tests.
queryClient.invalidateQueries();
});
describe("Content", () => {
it("should render", async () => {
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
await screen.findByTestId("github-token-input");
await screen.findByTestId("github-token-help-anchor");
await screen.findByTestId("gitlab-token-input");
await screen.findByTestId("gitlab-token-help-anchor");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
expect(
screen.queryByTestId("github-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("github-token-help-anchor"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-help-anchor"),
).not.toBeInTheDocument();
});
});
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
const { rerender } = renderGitSettingsScreen();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).not.toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
let button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
// wait until queries are resolved
expect(queryClient.isFetching()).toBe(0);
button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
});
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
button = screen.getByTestId("configure-github-repositories-button");
expect(button).toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
).not.toBeInTheDocument();
});
});
});
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const githubInput = await screen.findByTestId("github-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "test-token",
gitlab: "",
},
}),
);
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "",
gitlab: "test-token",
},
}),
);
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(githubInput);
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(gitlabInput);
expect(submit).toBeDisabled();
});
it("should enable a disconnect tokens button if there is at least one token set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
await waitFor(() => expect(disconnectButton).toBeDisabled());
});
it("should call logout when pressing the disconnect tokens button", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const logoutSpy = vi.spyOn(OpenHands, "logout");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
const disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
await userEvent.click(disconnectButton);
expect(logoutSpy).toHaveBeenCalled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(gitlabInput).toHaveValue("test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderGitSettingsScreen();
// Toggle setting to change
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
// Toggle setting to change
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,674 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import OpenHands from "#/api/open-hands";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
beforeEach(() => {
vi.resetAllMocks();
resetTestHandlersMockSettings();
});
describe("Content", () => {
describe("Basic form", () => {
it("should render the basic form by default", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicFom = screen.getByTestId("llm-settings-form-basic");
within(basicFom).getByTestId("llm-provider-input");
within(basicFom).getByTestId("llm-model-input");
within(basicFom).getByTestId("llm-api-key-input");
within(basicFom).getByTestId("llm-api-key-help-anchor");
});
it("should render the default values if non exist", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
});
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
expect(model).toHaveValue("gpt-4o");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
it("should render the advanced form if the switch is toggled", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const basicForm = screen.getByTestId("llm-settings-form-basic");
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(basicForm).toBeInTheDocument();
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).toBeInTheDocument();
expect(basicForm).not.toBeInTheDocument();
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
within(advancedForm).getByTestId("llm-custom-model-input");
within(advancedForm).getByTestId("base-url-input");
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
});
it("should render the default advanced settings", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).not.toBeChecked();
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
const hasAdvancedSettingsSetSpy = vi.spyOn(
AdvancedSettingsUtlls,
"hasAdvancedSettingsSet",
);
hasAdvancedSettingsSetSpy.mockReturnValue(true);
renderLlmSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
screen.getByTestId("llm-settings-form-advanced");
});
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await waitFor(() => {
expect(model).toHaveValue("openai/gpt-4o");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions",
);
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
it.todo("should render an indicator if the llm api key is set");
});
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
expect(provider).toHaveValue("OpenAI");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_api_key: "test-api-key",
}),
);
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.clear(model);
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
// enter base url
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
// select agent
await userEvent.click(agent);
const agentOption = screen.getByText("CoActAgent");
await userEvent.click(agentOption);
expect(agent).toHaveValue("CoActAgent");
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
}),
);
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-basic");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o-mini");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.click(model);
const modelOption2 = screen.getByText("gpt-4o");
await userEvent.click(modelOption2);
expect(model).toHaveValue("gpt-4o");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
expect(model).toHaveValue("openai/gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.clear(model);
expect(model).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
expect(submitButton).toBeDisabled();
// enter base url
await userEvent.type(baseUrl, "/extra");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions/extra",
);
expect(submitButton).not.toBeDisabled();
await userEvent.clear(baseUrl);
expect(baseUrl).toHaveValue("");
expect(submitButton).not.toBeDisabled();
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
// set agent
await userEvent.clear(agent);
await userEvent.type(agent, "test-agent");
expect(agent).toHaveValue("test-agent");
expect(submitButton).not.toBeDisabled();
// reset agent
await userEvent.clear(agent);
expect(agent).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(agent, "CodeActAgent");
expect(agent).toHaveValue("CodeActAgent");
expect(submitButton).toBeDisabled();
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
expect(submitButton).toBeDisabled();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(condensor);
expect(condensor).toBeChecked();
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should reset button state when switching between forms", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
// dirty the basic form
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
// dirty the advanced form
const model = screen.getByTestId("llm-custom-model-input");
await userEvent.type(model, "openai/gpt-4o");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "test-api-key",
}),
);
expect(submitButton).toHaveTextContent("Saving...");
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).toHaveTextContent("Save");
expect(submitButton).toBeDisabled();
});
});
});
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
});
describe("SaaS mode", () => {
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).not.toBeInTheDocument();
});
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
});
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
expect(runtimeSettingsInput).toBeDisabled();
});
});

View File

@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
@@ -7,6 +7,30 @@ import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"SETTINGS$NAV_GIT": "Git",
"SETTINGS$NAV_APPLICATION": "Application",
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
@@ -19,18 +43,22 @@ describe("Settings Billing", () => {
Component: () => <PaymentForm />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the navbar if OSS mode", async () => {
it("should not render the credits tab if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
@@ -43,15 +71,12 @@ describe("Settings Billing", () => {
renderSettingsScreen();
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).queryByText("Credits");
expect(credits).not.toBeInTheDocument();
});
it("should render the navbar if SaaS mode", async () => {
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
@@ -64,11 +89,8 @@ describe("Settings Billing", () => {
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Credits");
});
it("should render the billing settings if clicking the credits item", async () => {
@@ -90,6 +112,6 @@ describe("Settings Billing", () => {
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
expect(billingSection).toBeInTheDocument();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@ describe("hasAdvancedSettingsSet", () => {
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
});
it("should return false if an empty object", () => {
expect(hasAdvancedSettingsSet({})).toBe(false);
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
expect(
@@ -26,15 +30,6 @@ describe("hasAdvancedSettingsSet", () => {
).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({

View File

@@ -107,6 +107,10 @@ function isRawTranslationKey(str) {
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
"Merge Request", // Git provider specific terminology
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
];
function isExcludedTechnicalString(str) {

View File

@@ -0,0 +1,15 @@
import { InputSkeleton } from "../input-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function AppSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<InputSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface LanguageInputProps {
name: string;
onChange: (value: string) => void;
defaultKey: string;
}
export function LanguageInput({
defaultKey,
onChange,
name,
}: LanguageInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId={name}
name={name}
onInputChange={onChange}
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((l) => ({
key: l.value,
label: l.label,
}))}
defaultSelectedKey={defaultKey}
isClearable={false}
wrapperClassName="w-[680px]"
/>
);
}

View File

@@ -2,6 +2,7 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
name?: string;
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
@@ -12,6 +13,7 @@ interface BrandButtonProps {
export function BrandButton({
testId,
name,
children,
variant,
type,
@@ -22,6 +24,7 @@ export function BrandButton({
}: React.PropsWithChildren<BrandButtonProps>) {
return (
<button
name={name}
data-testid={testId}
disabled={isDisabled}
// The type is alreadt passed as a prop to the button component

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfigureGitHubRepositoriesAnchorProps {
slug: string;
}
export function ConfigureGitHubRepositoriesAnchor({
slug,
}: ConfigureGitHubRepositoriesAnchorProps) {
const { t } = useTranslation();
return (
<a
data-testid="configure-github-repositories-button"
href={`https://github.com/apps/${slug}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="px-11 py-9"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
);
}

View File

@@ -0,0 +1,18 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
export function GitSettingInputsSkeleton() {
return (
<div className="px-11 py-9 flex flex-col gap-12">
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitHubTokenHelpAnchor() {
return (
<p data-testid="github-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
components={[
<a
key="github-token-help-anchor-link"
aria-label="GitHub token help link"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="github-token-help-anchor-link-2"
aria-label="GitHub token see more link"
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
isGitHubTokenSet: boolean;
name: string;
}
export function GitHubTokenInput({
onChange,
isGitHubTokenSet,
name,
}: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
startContent={
isGitHubTokenSet && (
<KeyStatusIcon
testId="gh-set-token-indicator"
isSet={isGitHubTokenSet}
/>
)
}
/>
<GitHubTokenHelpAnchor />
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitLabTokenHelpAnchor() {
return (
<p data-testid="gitlab-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
components={[
<a
key="gitlab-token-help-anchor-link"
aria-label="Gitlab token help link"
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="gitlab-token-help-anchor-link-2"
aria-label="GitLab token see more link"
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}

View File

@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
isGitLabTokenSet: boolean;
name: string;
}
export function GitLabTokenInput({
onChange,
isGitLabTokenSet,
name,
}: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
startContent={
isGitLabTokenSet && (
<KeyStatusIcon
testId="gl-set-token-indicator"
isSet={isGitLabTokenSet}
/>
)
}
/>
<GitLabTokenHelpAnchor />
</div>
);
}

View File

@@ -0,0 +1,8 @@
export function InputSkeleton() {
return (
<div className="flex flex-col gap-2.5">
<div className="w-[70px] h-[20px] skeleton" />
<div className="w-[680px] h-[40px] skeleton" />
</div>
);
}

View File

@@ -2,12 +2,13 @@ import SuccessIcon from "#/icons/success.svg?react";
import { cn } from "#/utils/utils";
interface KeyStatusIconProps {
testId?: string;
isSet: boolean;
}
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
return (
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
</span>
);

View File

@@ -0,0 +1,21 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function LlmSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<SwitchSkeleton />
<InputSkeleton />
<InputSkeleton />
<InputSkeleton />
<SubtextSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
<InputSkeleton />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ResetSettingsModalProps {
onReset: () => void;
}
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
<BrandButton
testId="confirm-button"
type="submit"
name="reset-settings"
variant="primary"
className="grow"
>
Reset
</BrandButton>
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
className="grow"
onClick={onReset}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -6,6 +6,7 @@ interface SettingsSwitchProps {
name?: string;
onToggle?: (value: boolean) => void;
defaultIsToggled?: boolean;
isToggled?: boolean;
isBeta?: boolean;
}
@@ -15,6 +16,7 @@ export function SettingsSwitch({
name,
onToggle,
defaultIsToggled,
isToggled: controlledIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
@@ -25,17 +27,18 @@ export function SettingsSwitch({
};
return (
<label className="flex items-center gap-2 w-fit">
<label className="flex items-center gap-2 w-fit cursor-pointer">
<input
hidden
data-testid={testId}
name={name}
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={isToggled} />
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>

View File

@@ -0,0 +1,3 @@
export function SubtextSkeleton() {
return <div className="w-[250px] h-[20px] skeleton" />;
}

View File

@@ -0,0 +1,8 @@
export function SwitchSkeleton() {
return (
<div className="flex items-center gap-2">
<div className="w-[48px] h-[24px] skeleton-round" />
<div className="w-[100px] h-[20px] skeleton" />
</div>
);
}

View File

@@ -14,12 +14,14 @@ interface ModelSelectorProps {
isDisabled?: boolean;
models: Record<string, { separator: string; models: string[] }>;
currentModel?: string;
onChange?: (model: string | null) => void;
}
export function ModelSelector({
isDisabled,
models,
currentModel,
onChange,
}: ModelSelectorProps) {
const [, setLitellmId] = React.useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
@@ -55,6 +57,7 @@ export function ModelSelector({
}
setLitellmId(fullModel);
setSelectedModel(model);
onChange?.(fullModel);
};
const clear = () => {

View File

@@ -4,8 +4,9 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
const getSettingsQueryFn = async () => {
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
return {

View File

@@ -26,6 +26,17 @@ export enum I18nKey {
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
SETTINGS$SAVING = "SETTINGS$SAVING",
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",
GIT$GITHUB_API = "GIT$GITHUB_API",
BUTTON$COPY = "BUTTON$COPY",
BUTTON$COPIED = "BUTTON$COPIED",
APP$TITLE = "APP$TITLE",
@@ -95,6 +106,9 @@ export enum I18nKey {
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
COMMON$HERE = "COMMON$HERE",
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
@@ -437,6 +451,9 @@ export enum I18nKey {
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",

View File

@@ -389,6 +389,171 @@
"tr": "Tercihleri Onayla",
"de": "Einstellungen bestätigen"
},
"SETTINGS$SAVING": {
"en": "Saving...",
"ja": "保存中...",
"zh-CN": "保存中...",
"zh-TW": "儲存中...",
"ko-KR": "저장 중...",
"no": "Lagrer...",
"it": "Salvataggio in corso...",
"pt": "Salvando...",
"es": "Guardando...",
"ar": "جار الحفظ...",
"fr": "Enregistrement en cours...",
"tr": "Kayıt yapılıyor...",
"de": "Speichern..."
},
"SETTINGS$SAVE_CHANGES": {
"en": "Save Changes",
"ja": "変更を保存",
"zh-CN": "保存更改",
"zh-TW": "儲存變更",
"ko-KR": "변경 사항 저장",
"no": "Lagre endringer",
"it": "Salva modifiche",
"pt": "Salvar alterações",
"es": "Guardar cambios",
"ar": "حفظ التغييرات",
"fr": "Enregistrer les modifications",
"tr": "Değişiklikleri Kaydet",
"de": "Änderungen speichern"
},
"SETTINGS$NAV_GIT": {
"en": "Git",
"ja": "Git",
"zh-CN": "Git",
"zh-TW": "Git",
"ko-KR": "Git",
"no": "Git",
"it": "Git",
"pt": "Git",
"es": "Git",
"ar": "Git",
"fr": "Git",
"tr": "Git",
"de": "Git"
},
"SETTINGS$NAV_APPLICATION": {
"en": "Application",
"ja": "アプリケーション",
"zh-CN": "应用程序",
"zh-TW": "應用程式",
"ko-KR": "애플리케이션",
"no": "Applikasjon",
"it": "Applicazione",
"pt": "Aplicação",
"es": "Aplicación",
"ar": "التطبيق",
"fr": "Application",
"tr": "Uygulama",
"de": "Anwendung"
},
"SETTINGS$NAV_CREDITS": {
"en": "Credits",
"ja": "クレジット",
"zh-CN": "积分",
"zh-TW": "點數",
"ko-KR": "크레딧",
"no": "Kreditter",
"it": "Crediti",
"pt": "Créditos",
"es": "Créditos",
"ar": "الرصيد",
"fr": "Crédits",
"tr": "Krediler",
"de": "Guthaben"
},
"SETTINGS$NAV_API_KEYS": {
"en": "API Keys",
"ja": "APIキー",
"zh-CN": "API密钥",
"zh-TW": "API金鑰",
"ko-KR": "API 키",
"no": "API-nøkler",
"it": "Chiavi API",
"pt": "Chaves de API",
"es": "Claves API",
"ar": "مفاتيح API",
"fr": "Clés API",
"tr": "API Anahtarları",
"de": "API-Schlüssel"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
"zh-CN": "LLM",
"zh-TW": "LLM",
"ko-KR": "LLM",
"no": "LLM",
"it": "LLM",
"pt": "LLM",
"es": "LLM",
"ar": "LLM",
"fr": "LLM",
"tr": "LLM",
"de": "LLM"
},
"GIT$MERGE_REQUEST": {
"en": "Merge Request",
"ja": "マージリクエスト",
"zh-CN": "合并请求",
"zh-TW": "合併請求",
"ko-KR": "머지 요청",
"no": "Fletteforespørsel",
"it": "Richiesta di fusione",
"pt": "Solicitação de mesclagem",
"es": "Solicitud de fusión",
"ar": "طلب الدمج",
"fr": "Demande de fusion",
"tr": "Birleştirme İsteği",
"de": "Merge-Anfrage"
},
"GIT$GITLAB_API": {
"en": "GitLab API",
"ja": "GitLab API",
"zh-CN": "GitLab API",
"zh-TW": "GitLab API",
"ko-KR": "GitLab API",
"no": "GitLab API",
"it": "API GitLab",
"pt": "API do GitLab",
"es": "API de GitLab",
"ar": "واجهة برمجة تطبيقات GitLab",
"fr": "API GitLab",
"tr": "GitLab API",
"de": "GitLab API"
},
"GIT$PULL_REQUEST": {
"en": "Pull Request",
"ja": "プルリクエスト",
"zh-CN": "拉取请求",
"zh-TW": "拉取請求",
"ko-KR": "풀 리퀘스트",
"no": "Trekkforespørsel",
"it": "Richiesta di pull",
"pt": "Solicitação de pull",
"es": "Solicitud de extracción",
"ar": "طلب السحب",
"fr": "Demande de tirage",
"tr": "Çekme İsteği",
"de": "Pull Request"
},
"GIT$GITHUB_API": {
"en": "GitHub API",
"ja": "GitHub API",
"zh-CN": "GitHub API",
"zh-TW": "GitHub API",
"ko-KR": "GitHub API",
"no": "GitHub API",
"it": "API GitHub",
"pt": "API do GitHub",
"es": "API de GitHub",
"ar": "واجهة برمجة تطبيقات GitHub",
"fr": "API GitHub",
"tr": "GitHub API",
"de": "GitHub API"
},
"BUTTON$COPY": {
"en": "Copy to clipboard",
"ja": "クリップボードにコピー",
@@ -1434,6 +1599,51 @@
"tr": "Jetonunuzu alın",
"de": "Token abrufen"
},
"GITHUB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitHub token</0> or <1>click here for instructions</1>",
"ja": "<0>GitHubトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitHub令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitHub權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitHub 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitHub-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitHub</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitHub</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitHub</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitHub</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitHub</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitHub jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitHub-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITHUB$TOKEN_LINK_TEXT": {
"en": "GitHub token",
"ja": "GitHubトークン",
"zh-CN": "GitHub令牌",
"zh-TW": "GitHub權杖",
"ko-KR": "GitHub 토큰",
"no": "GitHub-token",
"it": "token GitHub",
"pt": "token GitHub",
"es": "token de GitHub",
"ar": "رمز GitHub",
"fr": "jeton GitHub",
"tr": "GitHub jetonu",
"de": "GitHub-Token"
},
"GITHUB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"COMMON$HERE": {
"en": "here",
"ja": "こちら",
@@ -6274,6 +6484,51 @@
"tr": "Üzerinde bir jeton oluştur",
"de": "Token generieren auf"
},
"GITLAB$TOKEN_HELP_TEXT": {
"en": "Get your <0>GitLab token</0> or <1>click here for instructions</1>",
"ja": "<0>GitLabトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
"zh-CN": "获取您的<0>GitLab令牌</0>或<1>点击此处获取说明</1>",
"zh-TW": "取得您的<0>GitLab權杖</0>或<1>點擊此處獲取說明</1>",
"ko-KR": "<0>GitLab 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
"no": "Få din <0>GitLab-token</0> eller <1>klikk her for instruksjoner</1>",
"it": "Ottieni il tuo <0>token GitLab</0> o <1>clicca qui per istruzioni</1>",
"pt": "Obtenha seu <0>token GitLab</0> ou <1>clique aqui para instruções</1>",
"es": "Obtenga su <0>token de GitLab</0> o <1>haga clic aquí para obtener instrucciones</1>",
"ar": "احصل على <0>رمز GitLab</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
"fr": "Obtenez votre <0>jeton GitLab</0> ou <1>cliquez ici pour les instructions</1>",
"tr": "<0>GitLab jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
"de": "Holen Sie sich Ihren <0>GitLab-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
},
"GITLAB$TOKEN_LINK_TEXT": {
"en": "GitLab token",
"ja": "GitLabトークン",
"zh-CN": "GitLab令牌",
"zh-TW": "GitLab權杖",
"ko-KR": "GitLab 토큰",
"no": "GitLab-token",
"it": "token GitLab",
"pt": "token GitLab",
"es": "token de GitLab",
"ar": "رمز GitLab",
"fr": "jeton GitLab",
"tr": "GitLab jetonu",
"de": "GitLab-Token"
},
"GITLAB$INSTRUCTIONS_LINK_TEXT": {
"en": "click here for instructions",
"ja": "手順についてはここをクリック",
"zh-CN": "点击此处获取说明",
"zh-TW": "點擊此處獲取說明",
"ko-KR": "지침을 보려면 여기를 클릭",
"no": "klikk her for instruksjoner",
"it": "clicca qui per istruzioni",
"pt": "clique aqui para instruções",
"es": "haga clic aquí para obtener instrucciones",
"ar": "انقر هنا للحصول على تعليمات",
"fr": "cliquez ici pour les instructions",
"tr": "talimatlar için buraya tıklayın",
"de": "klicken Sie hier für Anweisungen"
},
"GITLAB$OR_SEE": {
"en": "or see the",
"ja": "または参照",

View File

@@ -35,6 +35,15 @@ const MOCK_USER_PREFERENCES: {
settings: null,
};
/**
* Set the user settings to the default settings
*
* Useful for resetting the settings in tests
*/
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
};
const conversations: Conversation[] = [
{
conversation_id: "1",
@@ -80,6 +89,7 @@ const openHandsHandlers = [
HttpResponse.json([
"gpt-3.5-turbo",
"gpt-4o",
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-3-5-sonnet-20241022",
]),
@@ -173,6 +183,7 @@ export const handlers = [
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
if (body) {

View File

@@ -9,7 +9,9 @@ export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
index("routes/llm-settings.tsx"),
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),

View File

@@ -1,531 +0,0 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { HelpLink } from "#/components/features/settings/help-link";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { useAppLogout } from "#/hooks/use-app-logout";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { isCustomModel } from "#/utils/is-custom-model";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ProviderOptions } from "#/types/settings";
import { useAuth } from "#/context/auth-context";
// Define REMOTE_RUNTIME_OPTIONS for testing
const REMOTE_RUNTIME_OPTIONS = [
{ key: "1", label: "Standard" },
{ key: "2", label: "Enhanced" },
{ key: "4", label: "Premium" },
];
function AccountSettings() {
const { t } = useTranslation();
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 { providerTokensSet, providersAreSet } = useAuth();
const isFetching = isFetchingSettings || isFetchingResources;
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const isSaas = config?.APP_MODE === "saas";
const shouldHandleSpecialSaasCase =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
const determineWhetherToToggleAdvancedSettings = () => {
if (shouldHandleSpecialSaasCase) return true;
if (isSuccess) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const hasAppSlug = !!config?.APP_SLUG;
const isGitHubTokenSet =
providerTokensSet.includes(ProviderOptions.github) || false;
const isGitLabTokenSet =
providerTokensSet.includes(ProviderOptions.gitlab) || false;
const isLLMKeySet = settings?.LLM_API_KEY_SET;
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 formRef = React.useRef<HTMLFormElement>(null);
const onSubmit = 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";
const enableMemoryCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
const llmApiKey =
inputApiKey === "" && isLLMKeySet
? undefined // don't update if it's already set and input is empty
: inputApiKey; // otherwise use the input value
const githubToken = formData.get("github-token-input")?.toString();
const gitlabToken = formData.get("gitlab-token-input")?.toString();
// we don't want the user to be able to modify these settings in SaaS
const finalLlmModel = shouldHandleSpecialSaasCase
? undefined
: customLlmModel || fullLlmModel;
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
? undefined
: llmBaseUrl;
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
const newSettings = {
provider_tokens:
githubToken || gitlabToken
? {
github: githubToken || "",
gitlab: gitlabToken || "",
}
: undefined,
LANGUAGE: languageValue,
user_consents_to_analytics: userConsentsToAnalytics,
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
LLM_MODEL: finalLlmModel,
LLM_BASE_URL: finalLlmBaseUrl,
llm_api_key: finalLlmApiKey,
AGENT: formData.get("agent-input")?.toString(),
SECURITY_ANALYZER:
formData.get("security-analyzer-input")?.toString() || "",
REMOTE_RUNTIME_RESOURCE_FACTOR:
remoteRuntimeResourceFactor !== null
? Number(remoteRuntimeResourceFactor)
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
CONFIRMATION_MODE: confirmationModeIsEnabled,
};
saveSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(userConsentsToAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
});
};
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);
}
};
if (isFetching || !settings) {
return (
<div className="flex grow p-4">
<LoadingSpinner size="large" />
</div>
);
}
return (
<>
<form
data-testid="account-settings-form"
ref={formRef}
action={onSubmit}
className="flex flex-col grow overflow-auto"
>
<div className="flex flex-col gap-12 px-11 py-9">
{!shouldHandleSpecialSaasCase && (
<section
data-testid="llm-settings-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">
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
</h2>
{!shouldHandleSpecialSaasCase && (
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={isAdvancedSettingsSet}
onToggle={onToggleAdvancedMode}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
)}
</div>
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL}
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={settings.LLM_MODEL}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
/>
)}
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
/>
)}
{!shouldHandleSpecialSaasCase && (
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
/>
)}
{!shouldHandleSpecialSaasCase && (
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
)}
{llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
wrapperClassName="w-[680px]"
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
)}
{isSaas && llmConfigMode === "advanced" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={REMOTE_RUNTIME_OPTIONS}
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
isDisabled
isClearable={false}
/>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-confirmation-mode-switch"
onToggle={setConfirmationModeIsEnabled}
defaultIsToggled={!!settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && (
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
)}
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
<div>
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$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">
{t(I18nKey.SETTINGS$GIT_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">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</Link>
)}
{!isSaas && (
<>
<SettingsInput
testId="github-token-input"
name="github-token-input"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitHubTokenSet && (
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitHub
</a>{" "}
</b>
{t(I18nKey.COMMON$HERE)}{" "}
<b>
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
</a>
</b>
.
</p>
<SettingsInput
testId="gitlab-token-input"
name="gitlab-token-input"
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
startContent={
isGitLabTokenSet && (
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
/>
<p data-testid="gitlab-token-help-anchor" className="text-xs">
{" "}
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
<b>
{" "}
<a
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
GitLab
</a>{" "}
</b>
{t(I18nKey.GITLAB$OR_SEE)}{" "}
<b>
<a
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$DOCUMENTATION)}
</a>
</b>
.
</p>
<BrandButton
type="button"
variant="secondary"
onClick={handleLogout}
isDisabled={!providersAreSet}
>
Disconnect Tokens
</BrandButton>
</>
)}
</section>
<section className="flex flex-col gap-6">
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
</h2>
<SettingsDropdownInput
testId="language-input"
name="language-input"
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((language) => ({
key: language.value,
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
wrapperClassName="w-[680px]"
isClearable={false}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!isAnalyticsEnabled}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</section>
</div>
</form>
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
type="button"
variant="primary"
onClick={() => {
formRef.current?.requestSubmit();
}}
>
{t(I18nKey.BUTTON$SAVE)}
</BrandButton>
</footer>
</>
);
}
export default AccountSettings;

View File

@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
function ApiKeysScreen() {
return (
<div className="flex flex-col grow overflow-auto p-11">
<div className="flex flex-col grow overflow-auto p-9">
<ApiKeysManager />
</div>
);

View File

@@ -0,0 +1,150 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
function AppSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading } = useSettings();
const [languageInputHasChanged, setLanguageInputHasChanged] =
React.useState(false);
const [analyticsSwitchHasChanged, setAnalyticsSwitchHasChanged] =
React.useState(false);
const [
soundNotificationsSwitchHasChanged,
setSoundNotificationsSwitchHasChanged,
] = React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
const enableAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
saveSettings(
{
LANGUAGE: language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
},
{
onSuccess: () => {
handleCaptureConsent(enableAnalytics);
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setLanguageInputHasChanged(false);
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
},
},
);
};
const checkIfLanguageInputHasChanged = (value: string) => {
const selectedLanguage = AvailableLanguages.find(
({ label: langValue }) => langValue === value,
)?.label;
const currentLanguage = AvailableLanguages.find(
({ value: langValue }) => langValue === settings?.LANGUAGE,
)?.label;
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
};
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
setSoundNotificationsSwitchHasChanged(
checked !== currentSoundNotifications,
);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
return (
<form
data-testid="app-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{shouldBeLoading && <AppSettingsInputsSkeleton />}
{!shouldBeLoading && (
<div className="p-9 flex flex-col gap-6">
<LanguageInput
name="language-input"
defaultKey={settings.LANGUAGE}
onChange={checkIfLanguageInputHasChanged}
/>
<SettingsSwitch
testId="enable-analytics-switch"
name="enable-analytics-switch"
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
onToggle={checkIfAnalyticsSwitchHasChanged}
>
{t(I18nKey.ANALYTICS$ENABLE)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-sound-notifications-switch"
name="enable-sound-notifications-switch"
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
onToggle={checkIfSoundNotificationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
</div>
)}
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
variant="primary"
type="submit"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
);
}
export default AppSettingsScreen;

View File

@@ -0,0 +1,134 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
function GitSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { mutate: disconnectGitTokens } = useLogout();
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
React.useState(false);
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
React.useState(false);
const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = !!settings?.PROVIDER_TOKENS_SET.github;
const isGitLabTokenSet = !!settings?.PROVIDER_TOKENS_SET.gitlab;
const formAction = async (formData: FormData) => {
const disconnectButtonClicked =
formData.get("disconnect-tokens-button") !== null;
if (disconnectButtonClicked) {
disconnectGitTokens();
return;
}
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
saveSettings(
{
provider_tokens: {
github: githubToken,
gitlab: gitlabToken,
},
},
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled: () => {
setGithubTokenInputHasValue(false);
setGitlabTokenInputHasValue(false);
},
},
);
};
const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue;
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
return (
<form
data-testid="git-settings-screen"
action={formAction}
className="flex flex-col h-full justify-between"
>
{isLoading && <GitSettingInputsSkeleton />}
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
)}
{!isSaas && !isLoading && (
<div className="p-9 flex flex-col gap-12">
<GitHubTokenInput
name="github-token-input"
isGitHubTokenSet={isGitHubTokenSet}
onChange={(value) => {
setGithubTokenInputHasValue(!!value);
}}
/>
<GitLabTokenInput
name="gitlab-token-input"
isGitLabTokenSet={isGitLabTokenSet}
onChange={(value) => {
setGitlabTokenInputHasValue(!!value);
}}
/>
</div>
)}
{!shouldRenderExternalConfigureButtons && (
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="disconnect-tokens-button"
name="disconnect-tokens-button"
type="submit"
variant="secondary"
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
>
Disconnect Tokens
</BrandButton>
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={isPending || formIsClean}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
)}
</form>
);
}
export default GitSettingsScreen;

View File

@@ -0,0 +1,430 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { AxiosError } from "axios";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useSettings } from "#/hooks/query/use-settings";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/components/features/settings/help-link";
import { BrandButton } from "#/components/features/settings/brand-button";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { useConfig } from "#/hooks/query/use-config";
import { isCustomModel } from "#/utils/is-custom-model";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
function LlmSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: resources } = useAIConfigOptions();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
const [view, setView] = React.useState<"basic" | "advanced">("basic");
const [securityAnalyzerInputIsVisible, setSecurityAnalyzerInputIsVisible] =
React.useState(false);
const [dirtyInputs, setDirtyInputs] = React.useState({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
if (resources && settings) {
return (
isCustomModel(resources.models, settings.LLM_MODEL) ||
hasAdvancedSettingsSet({
...settings,
})
);
}
return false;
};
const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings();
if (settings) setSecurityAnalyzerInputIsVisible(settings.CONFIRMATION_MODE);
if (userSettingsIsAdvanced) setView("advanced");
else setView("basic");
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleErrorMutation = (error: AxiosError) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
};
const basicFormAction = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
const model = formData.get("llm-model-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const fullLlmModel =
provider && model && `${provider}/${model}`.toLowerCase();
saveSettings(
{
LLM_MODEL: fullLlmModel,
llm_api_key: apiKey || null,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const advancedFormAction = (formData: FormData) => {
const model = formData.get("llm-custom-model-input")?.toString();
const baseUrl = formData.get("base-url-input")?.toString();
const apiKey = formData.get("llm-api-key-input")?.toString();
const agent = formData.get("agent-input")?.toString();
const confirmationMode =
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
const enableDefaultCondenser =
formData.get("enable-memory-condenser-switch")?.toString() === "on";
const securityAnalyzer = formData
.get("security-analyzer-input")
?.toString();
saveSettings(
{
LLM_MODEL: model,
LLM_BASE_URL: baseUrl,
llm_api_key: apiKey,
AGENT: agent,
CONFIRMATION_MODE: confirmationMode,
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
SECURITY_ANALYZER: confirmationMode ? securityAnalyzer : undefined,
},
{
onSuccess: handleSuccessfulMutation,
onError: handleErrorMutation,
},
);
};
const formAction = (formData: FormData) => {
if (view === "basic") basicFormAction(formData);
else advancedFormAction(formData);
};
const handleToggleAdvancedSettings = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(!!settings?.CONFIRMATION_MODE);
setView(isToggled ? "advanced" : "basic");
setDirtyInputs({
model: false,
apiKey: false,
baseUrl: false,
agent: false,
confirmationMode: false,
enableDefaultCondenser: false,
securityAnalyzer: false,
});
};
const handleModelIsDirty = (model: string | null) => {
// openai providers are special case; see ModelSelector
// component for details
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleApiKeyIsDirty = (apiKey: string) => {
const apiKeyIsDirty = apiKey !== "";
setDirtyInputs((prev) => ({
...prev,
apiKey: apiKeyIsDirty,
}));
};
const handleCustomModelIsDirty = (model: string) => {
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
}));
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
setDirtyInputs((prev) => ({
...prev,
baseUrl: baseUrlIsDirty,
}));
};
const handleAgentIsDirty = (agent: string) => {
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
setDirtyInputs((prev) => ({
...prev,
agent: agentIsDirty,
}));
};
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
setSecurityAnalyzerInputIsVisible(isToggled);
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
setDirtyInputs((prev) => ({
...prev,
confirmationMode: confirmationModeIsDirty,
}));
};
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
const enableDefaultCondenserIsDirty =
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
setDirtyInputs((prev) => ({
...prev,
enableDefaultCondenser: enableDefaultCondenserIsDirty,
}));
};
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
const securityAnalyzerIsDirty =
securityAnalyzer !== settings?.SECURITY_ANALYZER;
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: securityAnalyzerIsDirty,
}));
};
const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty);
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
return (
<div data-testid="llm-settings-screen" className="h-full">
<form
action={formAction}
className="flex flex-col h-full justify-between"
>
<div className="p-9 flex flex-col gap-6">
<SettingsSwitch
testId="advanced-settings-switch"
defaultIsToggled={view === "advanced"}
onToggle={handleToggleAdvancedSettings}
isToggled={view === "advanced"}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
{view === "basic" && (
<div
data-testid="llm-settings-form-basic"
className="flex flex-col gap-6"
>
{!isLoading && !isFetching && (
<ModelSelector
models={modelsAndProviders}
currentModel={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
onChange={handleModelIsDirty}
/>
)}
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
</div>
)}
{view === "advanced" && (
<div
data-testid="llm-settings-form-advanced"
className="flex flex-col gap-6"
>
<SettingsInput
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
}
placeholder="anthropic/claude-3-5-sonnet-20241022"
type="text"
className="w-[680px]"
onChange={handleCustomModelIsDirty}
/>
<SettingsInput
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
defaultValue={settings.LLM_BASE_URL}
placeholder="https://api.openai.com"
type="text"
className="w-[680px]"
onChange={handleBaseUrlIsDirty}
/>
<SettingsInput
testId="llm-api-key-input"
name="llm-api-key-input"
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
)
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
<SettingsDropdownInput
testId="agent-input"
name="agent-input"
label={t(I18nKey.SETTINGS$AGENT)}
items={
resources?.agents.map((agent) => ({
key: agent,
label: agent,
})) || []
}
defaultSelectedKey={settings.AGENT}
isClearable={false}
onInputChange={handleAgentIsDirty}
wrapperClassName="w-[680px]"
/>
{config?.APP_MODE === "saas" && (
<SettingsDropdownInput
testId="runtime-settings-input"
name="runtime-settings-input"
label={
<>
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
<a href="mailto:contact@all-hands.dev">
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
</a>
)
</>
}
items={[]}
isDisabled
/>
)}
<SettingsSwitch
testId="enable-memory-condenser-switch"
name="enable-memory-condenser-switch"
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
onToggle={handleEnableDefaultCondenserIsDirty}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
</SettingsSwitch>
<SettingsSwitch
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.CONFIRMATION_MODE}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
{securityAnalyzerInputIsVisible && (
<SettingsDropdownInput
testId="security-analyzer-input"
name="security-analyzer-input"
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
items={
resources?.securityAnalyzers.map((analyzer) => ({
key: analyzer,
label: analyzer,
})) || []
}
defaultSelectedKey={settings.SECURITY_ANALYZER}
isClearable
showOptionalTag
onInputChange={handleSecurityAnalyzerIsDirty}
wrapperClassName="w-[680px]"
/>
)}
</div>
)}
</div>
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
<BrandButton
testId="submit-button"
type="submit"
variant="primary"
isDisabled={!formIsDirty || isPending}
>
{!isPending && t("SETTINGS$SAVE_CHANGES")}
{isPending && t("SETTINGS$SAVING")}
</BrandButton>
</div>
</form>
</div>
);
}
export default LlmSettingsScreen;

View File

@@ -1,5 +1,6 @@
import { NavLink, Outlet } from "react-router";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import React from "react";
import SettingsIcon from "#/icons/settings.svg?react";
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
@@ -7,9 +8,44 @@ import { I18nKey } from "#/i18n/declaration";
function SettingsScreen() {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const saasNavItems = [
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
];
const ossNavItems = [
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
];
React.useEffect(() => {
if (isSaas) {
if (pathname === "/settings") {
navigate("/settings/git");
}
} else {
const noEnteringPaths = [
"/settings/billing",
"/settings/credits",
"/settings/api-keys",
];
if (noEnteringPaths.includes(pathname)) {
navigate("/settings");
}
}
}, [isSaas, pathname]);
const navItems = isSaas ? saasNavItems : ossNavItems;
return (
<main
data-testid="settings-screen"
@@ -20,32 +56,26 @@ function SettingsScreen() {
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
</header>
{isSaas && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-tertiary"
>
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
{ to: "/settings/api-keys", text: "API Keys" },
].map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
)}
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-9 border-b border-tertiary"
>
{navItems.map(({ to, text }) => (
<NavLink
end
key={to}
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
</NavLink>
))}
</nav>
<div className="flex flex-col grow overflow-auto">
<Outlet />

View File

@@ -6,10 +6,14 @@
@apply bg-tertiary border border-neutral-600 rounded;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.skeleton {
@apply bg-gray-400 rounded-md animate-pulse;
}
.skeleton-round {
@apply bg-gray-400 rounded-full animate-pulse;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}

View File

@@ -2,9 +2,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial<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;
Object.keys(settings).length > 0 &&
(!!settings.LLM_BASE_URL ||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
settings.CONFIRMATION_MODE ||
!!settings.SECURITY_ANALYZER);