Improve i18n support and add missing translations (#6070)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2025-01-14 08:46:22 -05:00
committed by GitHub
parent 92b8d55c2d
commit f0ebf3eba8
59 changed files with 5189 additions and 2285 deletions

View File

@@ -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", () => {

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -19,7 +19,7 @@ describe("GitHubRepositorySelector", () => {
);
expect(
screen.getByPlaceholderText("Select a GitHub project"),
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
).toBeInTheDocument();
});

View File

@@ -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,
});
});

View File

@@ -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();
});

View File

@@ -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

View 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(', ')}`);
}
});
});

View File

@@ -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 = {

View File

@@ -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} />);

View File

@@ -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(

View 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);
});
});

View 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();
});
});

View 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");
});
});

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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&apos;s start building!
{t(I18nKey.LANDING$TITLE)}
</span>
</div>
<Suggestions

View File

@@ -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}

View File

@@ -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

View File

@@ -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) => (

View File

@@ -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>
);
}

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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" }}

View File

@@ -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}
/>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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" />}

View File

@@ -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}
>

View File

@@ -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} />

View File

@@ -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"
>

View File

@@ -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>
);
}

View File

@@ -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} />

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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&apos;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>

View File

@@ -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>

View File

@@ -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]"
/>

View File

@@ -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());

View File

@@ -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]",
}}

View File

@@ -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),
},
}}

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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

View File

@@ -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&nbsp;
{t(I18nKey.LANDING$OR)}&nbsp;
<a
className="underline"
href={`/conversations/${latestConversation}`}
>
jump back to your most recent conversation
{t(I18nKey.LANDING$RECENT_CONVERSATION)}
</a>
</p>
</div>

View File

@@ -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>
),

View File

@@ -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.`;

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"husky": "^9.1.7"
}
}