diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx
index 898570161e..04ae0640fa 100644
--- a/frontend/__tests__/components/features/home/repo-connector.test.tsx
+++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx
@@ -74,7 +74,8 @@ describe("RepoConnector", () => {
renderRepoConnector();
- const dropdown = screen.getByTestId("repo-dropdown");
+ // Wait for the loading state to be replaced with the dropdown
+ const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await waitFor(() => {
@@ -98,7 +99,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
- const dropdown = screen.getByTestId("repo-dropdown");
+ // Wait for the loading state to be replaced with the dropdown
+ const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
@@ -132,6 +134,14 @@ describe("RepoConnector", () => {
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
+ const retrieveUserGitRepositoriesSpy = vi.spyOn(
+ GitService,
+ "retrieveUserGitRepositories",
+ );
+ retrieveUserGitRepositoriesSpy.mockResolvedValue({
+ data: MOCK_RESPOSITORIES,
+ nextPage: null,
+ });
renderRepoConnector();
@@ -144,7 +154,9 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
- const dropdown = within(repoConnector).getByTestId("repo-dropdown");
+ const dropdown = await waitFor(() =>
+ within(repoConnector).getByTestId("repo-dropdown")
+ );
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
@@ -178,7 +190,8 @@ describe("RepoConnector", () => {
const launchButton = screen.getByTestId("repo-launch-button");
- const dropdown = screen.getByTestId("repo-dropdown");
+ // Wait for the loading state to be replaced with the dropdown
+ const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
diff --git a/frontend/src/components/features/home/repo-selection-form.test.tsx b/frontend/src/components/features/home/repo-selection-form.test.tsx
new file mode 100644
index 0000000000..1f6634dd73
--- /dev/null
+++ b/frontend/src/components/features/home/repo-selection-form.test.tsx
@@ -0,0 +1,138 @@
+import { render, screen } from "@testing-library/react";
+import { describe, test, expect, vi, beforeEach } from "vitest";
+import { RepositorySelectionForm } from "./repo-selection-form";
+
+// Create mock functions
+const mockUseUserRepositories = vi.fn();
+const mockUseCreateConversation = vi.fn();
+const mockUseIsCreatingConversation = vi.fn();
+const mockUseTranslation = vi.fn();
+const mockUseAuth = vi.fn();
+
+// Setup default mock returns
+mockUseUserRepositories.mockReturnValue({
+ data: { pages: [{ data: [] }] },
+ isLoading: false,
+ isError: false,
+});
+
+mockUseCreateConversation.mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+});
+
+mockUseIsCreatingConversation.mockReturnValue(false);
+
+mockUseTranslation.mockReturnValue({ t: (key: string) => key });
+
+mockUseAuth.mockReturnValue({
+ isAuthenticated: true,
+ isLoading: false,
+ providersAreSet: true,
+ user: {
+ id: 1,
+ login: "testuser",
+ avatar_url: "https://example.com/avatar.png",
+ name: "Test User",
+ email: "test@example.com",
+ company: "Test Company",
+ },
+ login: vi.fn(),
+ logout: vi.fn(),
+});
+
+// Mock the modules
+vi.mock("#/hooks/query/use-user-repositories", () => ({
+ useUserRepositories: () => mockUseUserRepositories(),
+}));
+
+vi.mock("#/hooks/mutation/use-create-conversation", () => ({
+ useCreateConversation: () => mockUseCreateConversation(),
+}));
+
+vi.mock("#/hooks/use-is-creating-conversation", () => ({
+ useIsCreatingConversation: () => mockUseIsCreatingConversation(),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => mockUseTranslation(),
+}));
+
+vi.mock("#/context/auth-context", () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+describe("RepositorySelectionForm", () => {
+ const mockOnRepoSelection = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("shows loading indicator when repositories are being fetched", () => {
+ // Setup loading state
+ mockUseUserRepositories.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ });
+
+ render();
+
+ // Check if loading indicator is displayed
+ expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
+ expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
+ });
+
+ test("shows dropdown when repositories are loaded", () => {
+ // Setup loaded repositories
+ mockUseUserRepositories.mockReturnValue({
+ data: {
+ pages: [
+ {
+ data: [
+ {
+ id: 1,
+ full_name: "user/repo1",
+ git_provider: "github",
+ is_public: true,
+ },
+ {
+ id: 2,
+ full_name: "user/repo2",
+ git_provider: "github",
+ is_public: true,
+ },
+ ],
+ },
+ ],
+ },
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ // Check if dropdown is displayed
+ expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
+ });
+
+ test("shows error message when repository fetch fails", () => {
+ // Setup error state
+ mockUseUserRepositories.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: new Error("Failed to fetch repositories"),
+ });
+
+ render();
+
+ // Check if error message is displayed
+ expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
+ expect(
+ screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx
index 997878979a..514f9fe55e 100644
--- a/frontend/src/components/features/home/repo-selection-form.tsx
+++ b/frontend/src/components/features/home/repo-selection-form.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
+import { Spinner } from "@heroui/react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
@@ -11,12 +12,68 @@ interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
+// Loading state component
+function RepositoryLoadingState() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("HOME$LOADING_REPOSITORIES")}
+
+ );
+}
+
+// Error state component
+function RepositoryErrorState() {
+ const { t } = useTranslation();
+ return (
+
+ {t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
+
+ );
+}
+
+// Repository dropdown component
+interface RepositoryDropdownProps {
+ items: { key: React.Key; label: string }[];
+ onSelectionChange: (key: React.Key | null) => void;
+ onInputChange: (value: string) => void;
+}
+
+function RepositoryDropdown({
+ items,
+ onSelectionChange,
+ onInputChange,
+}: RepositoryDropdownProps) {
+ return (
+
+ );
+}
+
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState(null);
- const { data: repositories } = useUserRepositories();
+ const {
+ data: repositories,
+ isLoading: isLoadingRepositories,
+ isError: isRepositoriesError,
+ } = useUserRepositories();
const {
mutate: createConversation,
isPending,
@@ -52,23 +109,39 @@ export function RepositorySelectionForm({
}
};
- return (
- <>
- {
+ if (isLoadingRepositories) {
+ return ;
+ }
+
+ if (isRepositoriesError) {
+ return ;
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+ <>
+ {renderRepositorySelector()}
createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index 1128f99eff..f2c8b00511 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -6,6 +6,8 @@ export enum I18nKey {
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$LOADING = "HOME$LOADING",
+ HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
+ HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 876572504b..e33d5e8e79 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -89,6 +89,36 @@
"tr": "Yükleniyor...",
"de": "Wird geladen..."
},
+ "HOME$LOADING_REPOSITORIES": {
+ "en": "Loading repositories...",
+ "ja": "リポジトリを読み込み中...",
+ "zh-CN": "加载仓库中...",
+ "zh-TW": "載入儲存庫中...",
+ "ko-KR": "저장소 로딩 중...",
+ "no": "Laster repositories...",
+ "it": "Caricamento repository in corso...",
+ "pt": "Carregando repositórios...",
+ "es": "Cargando repositorios...",
+ "ar": "جار تحميل المستودعات...",
+ "fr": "Chargement des dépôts...",
+ "tr": "Depolar yükleniyor...",
+ "de": "Repositories werden geladen..."
+ },
+ "HOME$FAILED_TO_LOAD_REPOSITORIES": {
+ "en": "Failed to load repositories",
+ "ja": "リポジトリの読み込みに失敗しました",
+ "zh-CN": "加载仓库失败",
+ "zh-TW": "載入儲存庫失敗",
+ "ko-KR": "저장소 로딩 실패",
+ "no": "Kunne ikke laste repositories",
+ "it": "Impossibile caricare i repository",
+ "pt": "Falha ao carregar repositórios",
+ "es": "Error al cargar repositorios",
+ "ar": "فشل في تحميل المستودعات",
+ "fr": "Échec du chargement des dépôts",
+ "tr": "Depolar yüklenemedi",
+ "de": "Fehler beim Laden der Repositories"
+ },
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",