mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Improve i18n support and add missing translations (#6070)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -45,7 +45,7 @@ describe("Browser", () => {
|
||||
});
|
||||
|
||||
// i18n empty message key
|
||||
expect(screen.getByText("BROWSER$EMPTY_MESSAGE")).toBeInTheDocument();
|
||||
expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the url and a screenshot", () => {
|
||||
|
||||
@@ -84,12 +84,10 @@ describe("ChatInput", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render a placeholder", () => {
|
||||
render(
|
||||
<ChatInput placeholder="Enter your message" onSubmit={onSubmitMock} />,
|
||||
);
|
||||
it("should render a placeholder with translation key", () => {
|
||||
render(<ChatInput onSubmit={onSubmitMock} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("Enter your message");
|
||||
const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const emptyState = await screen.findByText("No conversations found");
|
||||
const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
|
||||
expect(emptyState).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("GitHubRepositorySelector", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText("Select a GitHub project"),
|
||||
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("Sidebar", () => {
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/github token/i);
|
||||
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_OPTIONAL/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton =
|
||||
@@ -151,7 +151,11 @@ describe("Sidebar", () => {
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/api key/i);
|
||||
// Click the advanced options switch to show the API key input
|
||||
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
|
||||
await user.click(advancedOptionsSwitch);
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
|
||||
await user.type(apiKeyInput, "SET");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
@@ -162,7 +166,7 @@ describe("Sidebar", () => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: undefined,
|
||||
llm_base_url: "",
|
||||
security_analyzer: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
describe("FeedbackForm", () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -28,20 +29,20 @@ describe("FeedbackForm", () => {
|
||||
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
|
||||
);
|
||||
|
||||
screen.getByLabelText("Email");
|
||||
screen.getByLabelText("Private");
|
||||
screen.getByLabelText("Public");
|
||||
screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
|
||||
screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
|
||||
screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
|
||||
|
||||
screen.getByRole("button", { name: "Submit" });
|
||||
screen.getByRole("button", { name: "Cancel" });
|
||||
screen.getByRole("button", { name: I18nKey.FEEDBACK$CONTRIBUTE_LABEL });
|
||||
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
|
||||
});
|
||||
|
||||
it("should switch between private and public permissions", async () => {
|
||||
renderWithProviders(
|
||||
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
|
||||
);
|
||||
const privateRadio = screen.getByLabelText("Private");
|
||||
const publicRadio = screen.getByLabelText("Public");
|
||||
const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
|
||||
const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);
|
||||
|
||||
expect(privateRadio).toBeChecked(); // private is the default value
|
||||
expect(publicRadio).not.toBeChecked();
|
||||
@@ -59,7 +60,7 @@ describe("FeedbackForm", () => {
|
||||
renderWithProviders(
|
||||
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }));
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onChange).not.toHaveBeenCalledWith("");
|
||||
|
||||
// Submit the message with image
|
||||
const submitButton = screen.getByRole("button", { name: "Send" });
|
||||
const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Verify onSubmit was called with the message and image
|
||||
|
||||
190
frontend/__tests__/components/landing-translations.test.tsx
Normal file
190
frontend/__tests__/components/landing-translations.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import translations from "../../src/i18n/translation.json";
|
||||
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
|
||||
|
||||
vi.mock("@nextui-org/react", () => ({
|
||||
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
|
||||
<div>
|
||||
{children}
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr'];
|
||||
|
||||
// Helper function to check if a translation exists for all supported languages
|
||||
function checkTranslationExists(key: string) {
|
||||
const missingTranslations: string[] = [];
|
||||
|
||||
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
|
||||
if (!translationEntry) {
|
||||
throw new Error(`Translation key "${key}" does not exist in translation.json`);
|
||||
}
|
||||
|
||||
for (const lang of supportedLanguages) {
|
||||
if (!translationEntry[lang]) {
|
||||
missingTranslations.push(lang);
|
||||
}
|
||||
}
|
||||
|
||||
return missingTranslations;
|
||||
}
|
||||
|
||||
// Helper function to find duplicate translation keys
|
||||
function findDuplicateKeys(obj: Record<string, any>) {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
// Only check top-level keys as these are our translation keys
|
||||
for (const key in obj) {
|
||||
if (seen.has(key)) {
|
||||
duplicates.add(key);
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicates);
|
||||
}
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
|
||||
return translationEntry?.ja || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Landing page translations", () => {
|
||||
test("should render Japanese translations correctly", () => {
|
||||
// Mock a simple component that uses the translations
|
||||
const TestComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<UserAvatar onClick={() => {}} />
|
||||
<div data-testid="main-content">
|
||||
<h1>{t("LANDING$TITLE")}</h1>
|
||||
<button>{t("VSCODE$OPEN")}</button>
|
||||
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
|
||||
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
|
||||
<button>{t("SUGGESTIONS$FIX_README")}</button>
|
||||
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
|
||||
</div>
|
||||
<div data-testid="tabs">
|
||||
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
|
||||
</div>
|
||||
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
|
||||
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
|
||||
<div data-testid="status">
|
||||
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
|
||||
<span>{t("STATUS$CONNECTED")}</span>
|
||||
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
|
||||
</div>
|
||||
<div data-testid="time">
|
||||
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
|
||||
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
|
||||
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Check main content translations
|
||||
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
|
||||
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
|
||||
expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
|
||||
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
|
||||
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
|
||||
|
||||
// Check user avatar tooltip
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
userAvatar.focus();
|
||||
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
|
||||
|
||||
// Check tab labels
|
||||
const tabs = screen.getByTestId("tabs");
|
||||
expect(tabs).toHaveTextContent("ターミナル");
|
||||
expect(tabs).toHaveTextContent("ブラウザ");
|
||||
expect(tabs).toHaveTextContent("Jupyter");
|
||||
expect(tabs).toHaveTextContent("コードエディタ");
|
||||
|
||||
// Check workspace label and new project button
|
||||
expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース");
|
||||
expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト");
|
||||
|
||||
// Check status messages
|
||||
const status = screen.getByTestId("status");
|
||||
expect(status).toHaveTextContent("クライアントの準備を待機中");
|
||||
expect(status).toHaveTextContent("接続済み");
|
||||
expect(status).toHaveTextContent("サーバーに接続済み");
|
||||
|
||||
// Check account settings menu
|
||||
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
|
||||
|
||||
// Check time-related translations
|
||||
const time = screen.getByTestId("time");
|
||||
expect(time).toHaveTextContent("5 分前");
|
||||
expect(time).toHaveTextContent("2 時間前");
|
||||
expect(time).toHaveTextContent("3 日前");
|
||||
});
|
||||
|
||||
test("all translation keys should have translations for all supported languages", () => {
|
||||
// Test all translation keys used in the component
|
||||
const translationKeys = [
|
||||
"LANDING$TITLE",
|
||||
"VSCODE$OPEN",
|
||||
"SUGGESTIONS$INCREASE_TEST_COVERAGE",
|
||||
"SUGGESTIONS$AUTO_MERGE_PRS",
|
||||
"SUGGESTIONS$FIX_README",
|
||||
"SUGGESTIONS$CLEAN_DEPENDENCIES",
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
"WORKSPACE$BROWSER_TAB_LABEL",
|
||||
"WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
"WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
"WORKSPACE$TITLE",
|
||||
"PROJECT$NEW_PROJECT",
|
||||
"TERMINAL$WAITING_FOR_CLIENT",
|
||||
"STATUS$CONNECTED",
|
||||
"STATUS$CONNECTED_TO_SERVER",
|
||||
"TIME$MINUTES_AGO",
|
||||
"TIME$HOURS_AGO",
|
||||
"TIME$DAYS_AGO"
|
||||
];
|
||||
|
||||
// Check all keys and collect missing translations
|
||||
const missingTranslationsMap = new Map<string, string[]>();
|
||||
translationKeys.forEach(key => {
|
||||
const missing = checkTranslationExists(key);
|
||||
if (missing.length > 0) {
|
||||
missingTranslationsMap.set(key, missing);
|
||||
}
|
||||
});
|
||||
|
||||
// If any translations are missing, throw an error with all missing translations
|
||||
if (missingTranslationsMap.size > 0) {
|
||||
const errorMessage = Array.from(missingTranslationsMap.entries())
|
||||
.map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`)
|
||||
.join('');
|
||||
throw new Error(`Missing translations:${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("translation file should not have duplicate keys", () => {
|
||||
const duplicates = findDuplicateKeys(translations);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: { [key: string]: string } = {
|
||||
LLM$PROVIDER: "LLM Provider",
|
||||
LLM$MODEL: "LLM Model",
|
||||
LLM$SELECT_PROVIDER_PLACEHOLDER: "Select a provider",
|
||||
LLM$SELECT_MODEL_PLACEHOLDER: "Select a model",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
const models = {
|
||||
|
||||
@@ -2,6 +2,20 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する",
|
||||
"LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する",
|
||||
"SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("SuggestionItem", () => {
|
||||
const suggestionItem = { label: "suggestion1", value: "a long text value" };
|
||||
@@ -18,6 +32,19 @@ describe("SuggestionItem", () => {
|
||||
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a translated suggestion when using I18nKey", async () => {
|
||||
const translatedSuggestion = {
|
||||
label: I18nKey.SUGGESTIONS$TODO_APP,
|
||||
value: "todo app value",
|
||||
};
|
||||
|
||||
const { container } = render(<SuggestionItem suggestion={translatedSuggestion} onClick={onClick} />);
|
||||
console.log('Rendered HTML:', container.innerHTML);
|
||||
|
||||
|
||||
expect(screen.getByText("ToDoリストアプリを開発する")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicking a suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("UserAvatar", () => {
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("user avatar placeholder"),
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("UserAvatar", () => {
|
||||
|
||||
expect(screen.getByAltText("user avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("user avatar placeholder"),
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -46,13 +46,13 @@ describe("UserAvatar", () => {
|
||||
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("user avatar placeholder"),
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("user avatar placeholder"),
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
|
||||
76
frontend/__tests__/i18n/duplicate-keys.test.ts
Normal file
76
frontend/__tests__/i18n/duplicate-keys.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
describe('translation.json', () => {
|
||||
it('should not have duplicate translation keys', () => {
|
||||
// Read the translation.json file
|
||||
const translationPath = path.join(__dirname, '../../src/i18n/translation.json');
|
||||
const translationContent = fs.readFileSync(translationPath, 'utf-8');
|
||||
|
||||
// First, let's check for exact string matches of key definitions
|
||||
const keyRegex = /"([^"]+)": {/g;
|
||||
const matches = translationContent.matchAll(keyRegex);
|
||||
const keyOccurrences = new Map<string, number>();
|
||||
const duplicateKeys: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
const key = match[1];
|
||||
const count = (keyOccurrences.get(key) || 0) + 1;
|
||||
keyOccurrences.set(key, count);
|
||||
if (count > 1) {
|
||||
duplicateKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates from duplicateKeys array
|
||||
const uniqueDuplicates = [...new Set(duplicateKeys)];
|
||||
|
||||
// If there are duplicates, create a helpful error message
|
||||
if (uniqueDuplicates.length > 0) {
|
||||
const errorMessage = `Found duplicate translation keys:\n${uniqueDuplicates
|
||||
.map((key) => ` - "${key}" appears ${keyOccurrences.get(key)} times`)
|
||||
.join('\n')}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Expect no duplicates (this will pass if we reach here)
|
||||
expect(uniqueDuplicates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should have consistent translations for each key', () => {
|
||||
// Read the translation.json file
|
||||
const translationPath = path.join(__dirname, '../../src/i18n/translation.json');
|
||||
const translationContent = fs.readFileSync(translationPath, 'utf-8');
|
||||
const translations = JSON.parse(translationContent);
|
||||
|
||||
// Create a map to store English translations for each key
|
||||
const englishTranslations = new Map<string, string>();
|
||||
const inconsistentKeys: string[] = [];
|
||||
|
||||
// Check each key's English translation
|
||||
Object.entries(translations).forEach(([key, value]: [string, any]) => {
|
||||
if (typeof value === 'object' && value.en !== undefined) {
|
||||
const currentEn = value.en.toLowerCase();
|
||||
const existingEn = englishTranslations.get(key)?.toLowerCase();
|
||||
|
||||
if (existingEn !== undefined && existingEn !== currentEn) {
|
||||
inconsistentKeys.push(key);
|
||||
} else {
|
||||
englishTranslations.set(key, value.en);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If there are inconsistencies, create a helpful error message
|
||||
if (inconsistentKeys.length > 0) {
|
||||
const errorMessage = `Found inconsistent translations for keys:\n${inconsistentKeys
|
||||
.map((key) => ` - "${key}" has multiple different English translations`)
|
||||
.join('\n')}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Expect no inconsistencies
|
||||
expect(inconsistentKeys).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
20
frontend/__tests__/i18n/translations.test.tsx
Normal file
20
frontend/__tests__/i18n/translations.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import i18n from '../../src/i18n';
|
||||
import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu';
|
||||
import { renderWithProviders } from '../../test-utils';
|
||||
|
||||
describe('Translations', () => {
|
||||
it('should render translated text', () => {
|
||||
i18n.changeLanguage('en');
|
||||
renderWithProviders(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={() => {}}
|
||||
onLogout={() => {}}
|
||||
onClose={() => {}}
|
||||
isLoggedIn={true}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
frontend/__tests__/utils/check-hardcoded-strings.test.tsx
Normal file
40
frontend/__tests__/utils/check-hardcoded-strings.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { ChatInput } from "#/components/features/chat/chat-input";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Check for hardcoded English strings", () => {
|
||||
test("InteractiveChatBox should not have hardcoded English strings", () => {
|
||||
const { container } = render(
|
||||
<InteractiveChatBox
|
||||
onSubmit={() => {}}
|
||||
onStop={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get all text content
|
||||
const text = container.textContent;
|
||||
|
||||
// List of English strings that should be translated
|
||||
const hardcodedStrings = [
|
||||
"What do you want to build?",
|
||||
];
|
||||
|
||||
// Check each string
|
||||
hardcodedStrings.forEach(str => {
|
||||
expect(text).not.toContain(str);
|
||||
});
|
||||
});
|
||||
|
||||
test("ChatInput should use translation key for placeholder", () => {
|
||||
render(<ChatInput onSubmit={() => {}} />);
|
||||
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
|
||||
});
|
||||
});
|
||||
29
frontend/__tests__/utils/i18n-test-utils.tsx
Normal file
29
frontend/__tests__/utils/i18n-test-utils.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
const mockI18n = {
|
||||
language: "ja",
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する",
|
||||
"LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する",
|
||||
"SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
|
||||
"LANDING$TITLE": "一緒に開発を始めましょう!",
|
||||
"OPEN_IN_VSCODE": "VS Codeで開く",
|
||||
"INCREASE_TEST_COVERAGE": "テストカバレッジを向上",
|
||||
"AUTO_MERGE_PRS": "PRを自動マージ",
|
||||
"FIX_README": "READMEを修正",
|
||||
"CLEAN_DEPENDENCIES": "依存関係を整理"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
exists: () => true,
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
use: () => mockI18n,
|
||||
};
|
||||
|
||||
export function I18nTestProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nextProvider i18n={mockI18n as any}>{children}</I18nextProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export function EmptyBrowserMessage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center h-full justify-center">
|
||||
<IoIosGlobe size={100} />
|
||||
{t(I18nKey.BROWSER$EMPTY_MESSAGE)}
|
||||
{t(I18nKey.BROWSER$NO_PAGE_LOADED)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SubmitButton } from "#/components/shared/buttons/submit-button";
|
||||
import { StopButton } from "#/components/shared/buttons/stop-button";
|
||||
@@ -8,7 +10,6 @@ interface ChatInputProps {
|
||||
name?: string;
|
||||
button?: "submit" | "stop";
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
showButton?: boolean;
|
||||
value?: string;
|
||||
maxRows?: number;
|
||||
@@ -26,7 +27,6 @@ export function ChatInput({
|
||||
name,
|
||||
button = "submit",
|
||||
disabled,
|
||||
placeholder,
|
||||
showButton = true,
|
||||
value,
|
||||
maxRows = 4,
|
||||
@@ -39,6 +39,7 @@ export function ChatInput({
|
||||
className,
|
||||
buttonClassName,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
|
||||
|
||||
@@ -117,7 +118,7 @@ export function ChatInput({
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
placeholder={t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)}
|
||||
onKeyDown={handleKeyPress}
|
||||
onChange={handleChange}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Suggestions } from "#/components/features/suggestions/suggestions";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
|
||||
@@ -7,12 +9,14 @@ interface ChatSuggestionsProps {
|
||||
}
|
||||
|
||||
export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
|
||||
<div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
Let's start building!
|
||||
{t(I18nKey.LANDING$TITLE)}
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
|
||||
@@ -68,7 +68,6 @@ export function InteractiveChatBox({
|
||||
<ChatInput
|
||||
disabled={isDisabled}
|
||||
button={mode}
|
||||
placeholder="What do you want to build?"
|
||||
onChange={onChange}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import PauseIcon from "#/assets/pause";
|
||||
import PlayIcon from "#/assets/play";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
@@ -9,6 +11,7 @@ import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
|
||||
import { ActionButton } from "#/components/shared/buttons/action-button";
|
||||
|
||||
export function AgentControlBar() {
|
||||
const { t } = useTranslation();
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
@@ -27,8 +30,8 @@ export function AgentControlBar() {
|
||||
}
|
||||
content={
|
||||
curAgentState === AgentState.PAUSED
|
||||
? "Resume the agent task"
|
||||
: "Pause the current task"
|
||||
? t(I18nKey.AGENT$RESUME_TASK)
|
||||
: t(I18nKey.AGENT$PAUSE_TASK)
|
||||
}
|
||||
action={
|
||||
curAgentState === AgentState.PAUSED
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { NavLink, useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
@@ -15,6 +17,7 @@ interface ConversationPanelProps {
|
||||
}
|
||||
|
||||
export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId: cid } = useParams();
|
||||
const endSession = useEndSession();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
@@ -78,7 +81,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
)}
|
||||
{conversations?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">No conversations found</p>
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface NewConversationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NewConversationButton({ onClick }: NewConversationButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid="new-conversation-button"
|
||||
@@ -10,7 +14,7 @@ export function NewConversationButton({ onClick }: NewConversationButtonProps) {
|
||||
onClick={onClick}
|
||||
className="font-bold bg-[#4465DB] px-2 py-1 rounded"
|
||||
>
|
||||
+ New Project
|
||||
+ {t(I18nKey.PROJECT$NEW)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import hotToast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Feedback } from "#/api/open-hands.types";
|
||||
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
@@ -13,8 +15,9 @@ interface FeedbackFormProps {
|
||||
}
|
||||
|
||||
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const copiedToClipboardToast = () => {
|
||||
hotToast("Password copied to clipboard", {
|
||||
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
|
||||
icon: "📋",
|
||||
position: "bottom-right",
|
||||
});
|
||||
@@ -41,10 +44,13 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Go to shared feedback
|
||||
{t(I18nKey.FEEDBACK$GO_TO_FEEDBACK)}
|
||||
</a>
|
||||
<span onClick={() => onPressToast(password)} className="cursor-pointer">
|
||||
Password: {password} <span className="text-gray-500">(copy)</span>
|
||||
{t(I18nKey.FEEDBACK$PASSWORD)}: {password}{" "}
|
||||
<span className="text-gray-500">
|
||||
({t(I18nKey.FEEDBACK$COPY_LABEL)})
|
||||
</span>
|
||||
</span>
|
||||
</div>,
|
||||
{ duration: 10000 },
|
||||
@@ -86,12 +92,14 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 w-full">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs text-neutral-400">Email</span>
|
||||
<span className="text-xs text-neutral-400">
|
||||
{t(I18nKey.FEEDBACK$EMAIL_LABEL)}
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Please enter your email"
|
||||
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
|
||||
className="bg-[#27272A] px-3 py-[10px] rounded"
|
||||
/>
|
||||
</label>
|
||||
@@ -104,11 +112,11 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
type="radio"
|
||||
defaultChecked
|
||||
/>
|
||||
Private
|
||||
{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}
|
||||
</label>
|
||||
<label className="flex gap-2 cursor-pointer">
|
||||
<input name="permissions" value="public" type="radio" />
|
||||
Public
|
||||
{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -116,12 +124,12 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
<ModalButton
|
||||
disabled={isPending}
|
||||
type="submit"
|
||||
text="Submit"
|
||||
text={t(I18nKey.FEEDBACK$CONTRIBUTE_LABEL)}
|
||||
className="bg-[#4465DB] grow"
|
||||
/>
|
||||
<ModalButton
|
||||
disabled={isPending}
|
||||
text="Cancel"
|
||||
text={t(I18nKey.FEEDBACK$CANCEL_LABEL)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373] grow"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteItem,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
} from "@nextui-org/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { setSelectedRepository } from "#/state/initial-query-slice";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
@@ -23,6 +25,7 @@ export function GitHubRepositorySelector({
|
||||
userRepositories,
|
||||
publicRepositories,
|
||||
}: GitHubRepositorySelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
|
||||
|
||||
@@ -49,14 +52,14 @@ export function GitHubRepositorySelector({
|
||||
dispatch(setSelectedRepository(null));
|
||||
};
|
||||
|
||||
const emptyContent = "No results found.";
|
||||
const emptyContent = t(I18nKey.GITHUB$NO_RESULTS);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
data-testid="github-repo-selector"
|
||||
name="repo"
|
||||
aria-label="GitHub Repository"
|
||||
placeholder="Select a GitHub project"
|
||||
placeholder={t(I18nKey.LANDING$SELECT_REPO)}
|
||||
isVirtualized={false}
|
||||
selectedKey={selectedKey}
|
||||
inputProps={{
|
||||
@@ -86,12 +89,12 @@ export function GitHubRepositorySelector({
|
||||
rel="noreferrer noopener"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Add more repositories...
|
||||
{t(I18nKey.GITHUB$ADD_MORE_REPOS)}
|
||||
</a>
|
||||
</AutocompleteItem> // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any)}
|
||||
{userRepositories.length > 0 && (
|
||||
<AutocompleteSection showDivider title="Your Repos">
|
||||
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$YOUR_REPOS)}>
|
||||
{userRepositories.map((repo) => (
|
||||
<AutocompleteItem
|
||||
data-testid="github-repo-item"
|
||||
@@ -106,7 +109,7 @@ export function GitHubRepositorySelector({
|
||||
</AutocompleteSection>
|
||||
)}
|
||||
{publicRepositories.length > 0 && (
|
||||
<AutocompleteSection showDivider title="Public Repos">
|
||||
<AutocompleteSection showDivider title={t(I18nKey.GITHUB$PUBLIC_REPOS)}>
|
||||
{publicRepositories.map((repo) => (
|
||||
<AutocompleteItem
|
||||
data-testid="github-repo-item"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import { GitHubRepositorySelector } from "./github-repo-selector";
|
||||
@@ -23,6 +25,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
gitHubAuthUrl,
|
||||
user,
|
||||
}: GitHubRepositoriesSuggestionBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
@@ -53,7 +56,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
return (
|
||||
<>
|
||||
<SuggestionBox
|
||||
title="Open a Repo"
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
@@ -64,7 +67,7 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
text="Connect to GitHub"
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import Clip from "#/icons/clip.svg?react";
|
||||
|
||||
export function AttachImageLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex self-start items-center text-[#A3A3A3] text-xs leading-[18px] -tracking-[0.08px] cursor-pointer">
|
||||
<Clip width={16} height={16} />
|
||||
Attach images
|
||||
{t(I18nKey.LANDING$ATTACH_IMAGES)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Markdown from "react-markdown";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { JupyterLine } from "#/utils/parse-cell-content";
|
||||
|
||||
interface JupyterCellOutputProps {
|
||||
@@ -8,9 +10,12 @@ interface JupyterCellOutputProps {
|
||||
}
|
||||
|
||||
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">STDOUT/STDERR</div>
|
||||
<div className="mb-1 text-gray-400">
|
||||
{t(I18nKey.JUPYTER$OUTPUT_LABEL)}
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -11,11 +13,12 @@ interface UserAvatarProps {
|
||||
}
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="user-avatar"
|
||||
tooltip="Account settings"
|
||||
ariaLabel="Account settings"
|
||||
tooltip={t(I18nKey.USER$ACCOUNT_SETTINGS)}
|
||||
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
|
||||
@@ -25,7 +28,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
<DefaultUserAvatar
|
||||
aria-label="user avatar placeholder"
|
||||
aria-label={t(I18nKey.USER$AVATAR_PLACEHOLDER)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SuggestionBox } from "./suggestion-box";
|
||||
|
||||
interface ImportProjectSuggestionBoxProps {
|
||||
@@ -7,13 +9,14 @@ interface ImportProjectSuggestionBoxProps {
|
||||
export function ImportProjectSuggestionBox({
|
||||
onChange,
|
||||
}: ImportProjectSuggestionBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SuggestionBox
|
||||
title="+ Import Project"
|
||||
title={t(I18nKey.LANDING$IMPORT_PROJECT)}
|
||||
content={
|
||||
<label htmlFor="import-project" className="w-full flex justify-center">
|
||||
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
|
||||
Upload a .zip
|
||||
{t(I18nKey.LANDING$UPLOAD_ZIP)}
|
||||
</span>
|
||||
<input
|
||||
hidden
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshButton } from "#/components/shared/buttons/refresh-button";
|
||||
import Lightbulb from "#/icons/lightbulb.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SuggestionBubbleProps {
|
||||
suggestion: string;
|
||||
suggestion: { key: string; value: string };
|
||||
onClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
@@ -12,6 +14,7 @@ export function SuggestionBubble({
|
||||
onClick,
|
||||
onRefresh,
|
||||
}: SuggestionBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleRefresh = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onRefresh();
|
||||
@@ -24,7 +27,7 @@ export function SuggestionBubble({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb width={18} height={18} />
|
||||
<span className="text-sm">{suggestion}</span>
|
||||
<span className="text-sm">{t(suggestion.key as I18nKey)}</span>
|
||||
</div>
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export type Suggestion = { label: string; value: string };
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export type Suggestion = { label: I18nKey | string; value: string };
|
||||
|
||||
interface SuggestionItemProps {
|
||||
suggestion: Suggestion;
|
||||
@@ -6,6 +9,7 @@ interface SuggestionItemProps {
|
||||
}
|
||||
|
||||
export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<li className="list-none border border-neutral-600 rounded-xl hover:bg-neutral-700 flex-1">
|
||||
<button
|
||||
@@ -14,7 +18,7 @@ export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
|
||||
onClick={() => onClick(suggestion.value)}
|
||||
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
|
||||
>
|
||||
{suggestion.label}
|
||||
{t(suggestion.label)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function TerminalStatusLabel() {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
return (
|
||||
@@ -17,7 +20,7 @@ export function TerminalStatusLabel() {
|
||||
: "bg-green-500",
|
||||
)}
|
||||
/>
|
||||
Terminal
|
||||
{t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface TOSCheckboxProps {
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export function TOSCheckbox({ onChange }: TOSCheckboxProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" onChange={onChange} />
|
||||
<span>
|
||||
I accept the{" "}
|
||||
{t(I18nKey.TOS$ACCEPT)}{" "}
|
||||
<a
|
||||
href="https://www.all-hands.dev/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
terms of service
|
||||
{t(I18nKey.TOS$TERMS)}
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function BetaBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-[11px] leading-5 text-root-primary bg-neutral-400 px-1 rounded-xl">
|
||||
Beta
|
||||
{t(I18nKey.BADGE$BETA)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActiveHost } from "#/hooks/query/use-active-host";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function ServedAppLabel() {
|
||||
const { t } = useTranslation();
|
||||
const { activeHost } = useActiveHost();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">App</div>
|
||||
<div className="flex items-center gap-2">{t(I18nKey.APP$TITLE)}</div>
|
||||
<span className="border rounded-md text- px-1 font-bold">BETA</span>
|
||||
</div>
|
||||
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
|
||||
|
||||
@@ -22,7 +22,11 @@ export function ActionTooltip({ type, onClick }: ActionTooltipProps) {
|
||||
<button
|
||||
data-testid={`action-${type}-button`}
|
||||
type="button"
|
||||
aria-label={type === "confirm" ? "Confirm action" : "Reject action"}
|
||||
aria-label={
|
||||
type === "confirm"
|
||||
? t(I18nKey.ACTION$CONFIRM)
|
||||
: t(I18nKey.ACTION$REJECT)
|
||||
}
|
||||
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocsIcon from "#/icons/docs.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
export function DocsButton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip="Documentation"
|
||||
ariaLabel="Documentation"
|
||||
tooltip={t(I18nKey.SIDEBAR$DOCS)}
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
>
|
||||
<DocsIcon width={28} height={28} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import NewProjectIcon from "#/icons/new-project.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -6,10 +8,12 @@ interface ExitProjectButtonProps {
|
||||
}
|
||||
|
||||
export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const startNewProject = t(I18nKey.PROJECT$START_NEW);
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip="Start new project"
|
||||
ariaLabel="Start new project"
|
||||
tooltip={startNewProject}
|
||||
ariaLabel={startNewProject}
|
||||
onClick={onClick}
|
||||
testId="new-project-button"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
|
||||
|
||||
@@ -10,6 +12,9 @@ export function OpenVSCodeButton({
|
||||
isDisabled,
|
||||
onClick,
|
||||
}: OpenVSCodeButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const buttonText = t(I18nKey.VSCODE$OPEN);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -21,10 +26,10 @@ export function OpenVSCodeButton({
|
||||
? "bg-neutral-600 cursor-not-allowed"
|
||||
: "bg-[#4465DB] hover:bg-[#3451C7]",
|
||||
)}
|
||||
aria-label="Open in VS Code"
|
||||
aria-label={buttonText}
|
||||
>
|
||||
<VSCodeIcon width={20} height={20} />
|
||||
Open in VS Code
|
||||
{buttonText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="settings-button"
|
||||
tooltip="Settings"
|
||||
ariaLabel="Settings"
|
||||
tooltip={t(I18nKey.SETTINGS$TITLE)}
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FaCog size={24} />
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface StopButtonProps {
|
||||
isDisabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StopButton({ isDisabled, onClick }: StopButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid="stop-button"
|
||||
aria-label="Stop"
|
||||
aria-label={t(I18nKey.BUTTON$STOP)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowSendIcon from "#/icons/arrow-send.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isDisabled?: boolean;
|
||||
@@ -6,9 +8,10 @@ interface SubmitButtonProps {
|
||||
}
|
||||
|
||||
export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
aria-label="Send"
|
||||
aria-label={t(I18nKey.BUTTON$SEND)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
type="submit"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface DownloadProgressState {
|
||||
filesTotal: number;
|
||||
filesDownloaded: number;
|
||||
@@ -16,6 +19,7 @@ export function DownloadProgress({
|
||||
progress,
|
||||
onCancel,
|
||||
}: DownloadProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
@@ -33,12 +37,12 @@ export function DownloadProgress({
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold mb-2 text-white">
|
||||
{progress.isDiscoveringFiles
|
||||
? "Preparing Download..."
|
||||
: "Downloading Files"}
|
||||
? t(I18nKey.DOWNLOAD$PREPARING)
|
||||
: t(I18nKey.DOWNLOAD$DOWNLOADING)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 truncate">
|
||||
{progress.isDiscoveringFiles
|
||||
? `Found ${progress.filesTotal} files...`
|
||||
? t(I18nKey.DOWNLOAD$FOUND_FILES, { count: progress.filesTotal })
|
||||
: progress.currentFile}
|
||||
</p>
|
||||
</div>
|
||||
@@ -64,8 +68,11 @@ export function DownloadProgress({
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>
|
||||
{progress.isDiscoveringFiles
|
||||
? `Scanning workspace...`
|
||||
: `${progress.filesDownloaded} of ${progress.filesTotal} files`}
|
||||
? t(I18nKey.DOWNLOAD$SCANNING)
|
||||
: t(I18nKey.DOWNLOAD$FILES_PROGRESS, {
|
||||
downloaded: progress.filesDownloaded,
|
||||
total: progress.filesTotal,
|
||||
})}
|
||||
</span>
|
||||
{!progress.isDiscoveringFiles && (
|
||||
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
|
||||
@@ -78,7 +85,7 @@ export function DownloadProgress({
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.DOWNLOAD$CANCEL)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function HeroHeading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="w-[304px] text-center flex flex-col gap-4 items-center py-4">
|
||||
<BuildIt width={88} height={104} />
|
||||
<h1 className="text-[38px] leading-[32px] -tracking-[0.02em]">
|
||||
Let's Start Building!
|
||||
{t(I18nKey.LANDING$TITLE)}
|
||||
</h1>
|
||||
<p className="mx-4 text-sm flex flex-col gap-2">
|
||||
OpenHands makes it easy to build and maintain software using a simple
|
||||
prompt.{" "}
|
||||
{t(I18nKey.LANDING$SUBTITLE)}{" "}
|
||||
<span className="">
|
||||
Not sure how to start?{" "}
|
||||
{t(I18nKey.LANDING$START_HELP)}{" "}
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://docs.all-hands.dev/modules/usage/getting-started"
|
||||
className="text-hyperlink underline underline-offset-[3px]"
|
||||
>
|
||||
Read this
|
||||
{t(I18nKey.LANDING$START_HELP_LINK)}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -22,14 +22,14 @@ export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
|
||||
{!isSet && (
|
||||
<FaExclamationCircle className="text-[#FF3860] inline-block" />
|
||||
)}
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
{t(I18nKey.API$KEY)}
|
||||
</label>
|
||||
</Tooltip>
|
||||
<Input
|
||||
isDisabled={isDisabled}
|
||||
id="api-key"
|
||||
name="api-key"
|
||||
aria-label="API Key"
|
||||
aria-label={t(I18nKey.API$KEY)}
|
||||
type="password"
|
||||
defaultValue=""
|
||||
classNames={{
|
||||
@@ -37,14 +37,14 @@ export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-[#A3A3A3]">
|
||||
{t(I18nKey.SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL)}{" "}
|
||||
{t(I18nKey.API$DONT_KNOW_KEY)}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{t(I18nKey.SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL)}
|
||||
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function AccountSettingsForm({
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title="Account Settings" />
|
||||
<BaseModalTitle title={t(I18nKey.ACCOUNT_SETTINGS$TITLE)} />
|
||||
|
||||
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
|
||||
<a
|
||||
@@ -73,12 +73,12 @@ export function AccountSettingsForm({
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
Configure Github Repositories
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</a>
|
||||
)}
|
||||
<FormFieldset
|
||||
id="language"
|
||||
label="Language"
|
||||
label={t(I18nKey.LANGUAGE$LABEL)}
|
||||
defaultSelectedKey={selectedLanguage}
|
||||
isClearable={false}
|
||||
items={AvailableLanguages.map(({ label, value: key }) => ({
|
||||
@@ -91,32 +91,32 @@ export function AccountSettingsForm({
|
||||
<>
|
||||
<CustomInput
|
||||
name="ghToken"
|
||||
label="GitHub Token"
|
||||
label={t(I18nKey.GITHUB$TOKEN_OPTIONAL)}
|
||||
type="password"
|
||||
defaultValue={gitHubToken ?? ""}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
</>
|
||||
)}
|
||||
{gitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{gitHubToken && !gitHubError && (
|
||||
<ModalButton
|
||||
variant="text-like"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={() => {
|
||||
logout();
|
||||
onClose();
|
||||
@@ -132,7 +132,7 @@ export function AccountSettingsForm({
|
||||
type="checkbox"
|
||||
defaultChecked={analyticsConsent === "true"}
|
||||
/>
|
||||
Enable analytics
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
@@ -140,11 +140,11 @@ export function AccountSettingsForm({
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$CLOSE)}
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AutocompleteSection,
|
||||
} from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { mapProvider } from "#/utils/map-provider";
|
||||
import { VERIFIED_MODELS, VERIFIED_PROVIDERS } from "#/utils/verified-models";
|
||||
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
|
||||
@@ -60,12 +62,14 @@ export function ModelSelector({
|
||||
setLitellmId(null);
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div data-testid="model-selector" className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-3">
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor="agent" className="font-[500] text-[#A3A3A3] text-xs">
|
||||
LLM Provider
|
||||
{t(I18nKey.LLM$PROVIDER)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-provider"
|
||||
@@ -73,8 +77,8 @@ export function ModelSelector({
|
||||
isVirtualized={false}
|
||||
name="llm-provider"
|
||||
isDisabled={isDisabled}
|
||||
aria-label="LLM Provider"
|
||||
placeholder="Select a provider"
|
||||
aria-label={t(I18nKey.LLM$PROVIDER)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_PROVIDER_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
if (e?.toString()) handleChangeProvider(e.toString());
|
||||
@@ -115,15 +119,15 @@ export function ModelSelector({
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor="agent" className="font-[500] text-[#A3A3A3] text-xs">
|
||||
LLM Model
|
||||
{t(I18nKey.LLM$MODEL)}
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-model"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-model"
|
||||
aria-label="LLM Model"
|
||||
placeholder="Select a model"
|
||||
aria-label={t(I18nKey.LLM$MODEL)}
|
||||
placeholder={t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER)}
|
||||
isClearable={false}
|
||||
onSelectionChange={(e) => {
|
||||
if (e?.toString()) handleChangeModel(e.toString());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface RuntimeSizeSelectorProps {
|
||||
isDisabled: boolean;
|
||||
@@ -18,7 +19,7 @@ export function RuntimeSizeSelector({
|
||||
htmlFor="runtime-size"
|
||||
className="font-[500] text-[#A3A3A3] text-xs"
|
||||
>
|
||||
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
{t(I18nKey.SETTINGS_FORM$RUNTIME_SIZE_LABEL)}
|
||||
</label>
|
||||
<Select
|
||||
data-testid="runtime-size"
|
||||
@@ -26,7 +27,7 @@ export function RuntimeSizeSelector({
|
||||
name="runtime-size"
|
||||
defaultSelectedKeys={[String(defaultValue || 1)]}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
|
||||
aria-label={t(I18nKey.SETTINGS_FORM$RUNTIME_SIZE_LABEL)}
|
||||
classNames={{
|
||||
trigger: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
}}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { getDefaultSettings, Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
@@ -209,18 +209,18 @@ export function SettingsForm({
|
||||
testId="save-settings-button"
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB] w-full"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.SETTINGS_FORM$CLOSE_LABEL)}
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
disabled={disabled}
|
||||
text={t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL)}
|
||||
text={t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
|
||||
variant="text-like"
|
||||
className="text-danger self-start"
|
||||
onClick={() => {
|
||||
@@ -234,17 +234,15 @@ export function SettingsForm({
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
testId="reset-defaults-modal"
|
||||
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
|
||||
)}
|
||||
title={t(I18nKey.MODAL$CONFIRM_RESET_TITLE)}
|
||||
description={t(I18nKey.MODAL$CONFIRM_RESET_MESSAGE)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: t(I18nKey.SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL),
|
||||
text: t(I18nKey.BUTTON$RESET_TO_DEFAULTS),
|
||||
onClick: handleConfirmResetSettings,
|
||||
},
|
||||
cancel: {
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
text: t(I18nKey.BUTTON$CANCEL),
|
||||
onClick: () => setConfirmResetDefaultsModalOpen(false),
|
||||
},
|
||||
}}
|
||||
@@ -254,17 +252,15 @@ export function SettingsForm({
|
||||
{confirmEndSessionModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title={t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE,
|
||||
)}
|
||||
title={t(I18nKey.MODAL$END_SESSION_TITLE)}
|
||||
description={t(I18nKey.MODAL$END_SESSION_MESSAGE)}
|
||||
buttons={{
|
||||
danger: {
|
||||
text: t(I18nKey.SETTINGS_FORM$END_SESSION_LABEL),
|
||||
text: t(I18nKey.BUTTON$END_SESSION),
|
||||
onClick: handleConfirmEndSession,
|
||||
},
|
||||
cancel: {
|
||||
text: t(I18nKey.SETTINGS_FORM$CANCEL_LABEL),
|
||||
text: t(I18nKey.BUTTON$CANCEL),
|
||||
onClick: () => setConfirmEndSessionModalOpen(false),
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
@@ -11,6 +13,7 @@ interface SettingsModalProps {
|
||||
|
||||
export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
const aiConfigOptions = useAIConfigOptions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
@@ -22,14 +25,12 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
<p className="text-danger text-xs">{aiConfigOptions.error.message}</p>
|
||||
)}
|
||||
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
|
||||
AI Provider Configuration
|
||||
{t(I18nKey.AI_SETTINGS$TITLE)}
|
||||
</span>
|
||||
<p className="text-xs text-[#A3A3A3]">
|
||||
To continue, connect an OpenAI, Anthropic, or other LLM account
|
||||
</p>
|
||||
<p className="text-xs text-danger">
|
||||
Changing settings during an active session will end the session
|
||||
{t(I18nKey.SETTINGS$DESCRIPTION)}
|
||||
</p>
|
||||
<p className="text-xs text-danger">{t(I18nKey.SETTINGS$WARNING)}</p>
|
||||
{aiConfigOptions.isLoading && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
|
||||
@@ -23,14 +23,13 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const { files } = useSelector((state: RootState) => state.initialQuery);
|
||||
|
||||
const [text, setText] = React.useState("");
|
||||
const [suggestion, setSuggestion] = React.useState(
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [suggestion, setSuggestion] = React.useState(() => {
|
||||
const key = getRandomKey(SUGGESTIONS["non-repo"]);
|
||||
return { key, value: SUGGESTIONS["non-repo"][key] };
|
||||
});
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
|
||||
@@ -38,26 +37,16 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
// remove current suggestion to avoid refreshing to the same suggestion
|
||||
const suggestionCopy = { ...suggestions };
|
||||
delete suggestionCopy[suggestion];
|
||||
delete suggestionCopy[suggestion.key];
|
||||
|
||||
const key = getRandomKey(suggestionCopy);
|
||||
setSuggestion(key);
|
||||
setSuggestion({ key, value: suggestions[key] });
|
||||
};
|
||||
|
||||
const onClickSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
const value = suggestions[suggestion];
|
||||
setText(value);
|
||||
setText(suggestion.value);
|
||||
};
|
||||
|
||||
const placeholder = React.useMemo(() => {
|
||||
if (selectedRepository) {
|
||||
return `What would you like to change in ${selectedRepository}?`;
|
||||
}
|
||||
|
||||
return "What do you want to build?";
|
||||
}, [selectedRepository]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
@@ -105,7 +94,6 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { initReactI18next } from "react-i18next";
|
||||
|
||||
export const AvailableLanguages = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "繁體中文", value: "zh-TW" },
|
||||
{ label: "한국어", value: "ko-KR" },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
@@ -13,6 +15,7 @@ import { HeroHeading } from "#/components/shared/hero-heading";
|
||||
import { TaskForm } from "#/components/shared/task-form";
|
||||
|
||||
function Home() {
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const dispatch = useDispatch();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
@@ -59,12 +62,12 @@ function Home() {
|
||||
{latestConversation && (
|
||||
<div className="flex gap-4 w-full text-center mt-8">
|
||||
<p className="text-center w-full">
|
||||
Or
|
||||
{t(I18nKey.LANDING$OR)}
|
||||
<a
|
||||
className="underline"
|
||||
href={`/conversations/${latestConversation}`}
|
||||
>
|
||||
jump back to your most recent conversation
|
||||
{t(I18nKey.LANDING$RECENT_CONVERSATION)}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer } from "react-icons/fa";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
ConversationProvider,
|
||||
useConversation,
|
||||
@@ -39,6 +41,7 @@ import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
function AppContent() {
|
||||
useConversationConfig();
|
||||
const { t } = useTranslation();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversation();
|
||||
@@ -126,7 +129,11 @@ function AppContent() {
|
||||
<Container
|
||||
className="h-full"
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TITLE),
|
||||
to: "",
|
||||
icon: <CodeIcon />,
|
||||
},
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
{
|
||||
label: <ServedAppLabel />,
|
||||
@@ -136,7 +143,7 @@ function AppContent() {
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
Browser
|
||||
{t(I18nKey.BROWSER$TITLE)}
|
||||
{updateCount > 0 && <CountBadge count={updateCount} />}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const KEY_1 = "Build an app to view pull requests";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const KEY_1 = I18nKey.LANDING$BUILD_APP_BUTTON;
|
||||
const VALUE_1 = `I want to create a React app to view all of the open pull
|
||||
requests that exist on all of my team's github repos. Here
|
||||
are some details:
|
||||
@@ -15,7 +17,7 @@ are some details:
|
||||
When things are working, initialize a github repo, create
|
||||
a .gitignore file, and commit the changes.`;
|
||||
|
||||
const KEY_2 = "Build a todo list application";
|
||||
const KEY_2 = I18nKey.SUGGESTIONS$TODO_APP;
|
||||
const VALUE_2 = `I want to create a VueJS app that allows me to:
|
||||
* See all the items on my todo list
|
||||
* add a new item to the list
|
||||
@@ -28,7 +30,7 @@ This should be a client-only app with no backend. The list should persist in loc
|
||||
|
||||
Please add tests for all of the above and make sure they pass`;
|
||||
|
||||
const KEY_3 = "Write a bash script that shows the top story on Hacker News";
|
||||
const KEY_3 = I18nKey.SUGGESTIONS$HACKER_NEWS;
|
||||
const VALUE_3 = `Please write a bash script which displays the top story on Hacker News. It should show the title, the link, and the number of points.
|
||||
|
||||
The script should only use tools that are widely available on unix systems, like curl and grep.`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const KEY_1 = "Increase my test coverage";
|
||||
const KEY_1 = "INCREASE_TEST_COVERAGE";
|
||||
const VALUE_1 = `I want to increase the test coverage of the repository in the current directory.
|
||||
|
||||
Please investigate the repo to figure out what language is being used, and where tests are located, if there are any.
|
||||
@@ -9,10 +9,10 @@ If there are existing tests, find a function or method which lacks adequate unit
|
||||
|
||||
Make sure the tests pass before you finish.`;
|
||||
|
||||
const KEY_2 = "Auto-merge Dependabot PRs";
|
||||
const KEY_2 = "AUTO_MERGE_PRS";
|
||||
const VALUE_2 = `Please add a GitHub action to this repository which automatically merges pull requests from Dependabot so long as the tests are passing.`;
|
||||
|
||||
const KEY_3 = "Fix up my README";
|
||||
const KEY_3 = "FIX_README";
|
||||
const VALUE_3 = `Please look at the README and make the following improvements, if they make sense:
|
||||
* correct any typos that you find
|
||||
* add missing language annotations on codeblocks
|
||||
@@ -22,7 +22,7 @@ const VALUE_3 = `Please look at the README and make the following improvements,
|
||||
|
||||
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier`;
|
||||
|
||||
const KEY_4 = "Clean up my dependencies";
|
||||
const KEY_4 = "CLEAN_DEPENDENCIES";
|
||||
const VALUE_4 = `Examine the dependencies of the current codebase. Make sure you can run the code and any tests.
|
||||
|
||||
Then run any commands necessary to update all dependencies to the latest versions, and make sure the code continues to run correctly and the tests pass. If changes need to be made to the codebase, go ahead and make those changes. You can look up documentation for new versions using the browser if you need to.
|
||||
@@ -31,10 +31,10 @@ If a particular dependency update is causing trouble (e.g. breaking changes that
|
||||
|
||||
Additionally, if you're able to prune any dependencies that are obviously unused, please do so. You may use third party tools to check for unused dependencies.`;
|
||||
|
||||
const KEY_5 = "Add best practices docs for contributors";
|
||||
const KEY_5 = "ADD_DOCS";
|
||||
const VALUE_5 = `Investigate the documentation in the root of the current repo. Please add a CODE_OF_CONDUCT.md and CONTRIBUTORS.md with good defaults if they are not present. Use information in the README to inform the CONTRIBUTORS doc. If there is no LICENSE currently in the repo, please add the Apache 2.0 license. Add links to all these documents into the README`;
|
||||
|
||||
const KEY_6 = "Add/improve a Dockerfile";
|
||||
const KEY_6 = "ADD_DOCKERFILE";
|
||||
const VALUE_6 = `Investigate the current repo to understand the installation instructions. Then create a Dockerfile that runs the application, using best practices like arguments and multi-stage builds wherever appropriate.
|
||||
|
||||
If there is an existing Dockerfile, and there are ways to improve it according to best practices, do so.`;
|
||||
|
||||
28
package-lock.json
generated
Normal file
28
package-lock.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "OpenHands",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user