From e9f2b72ea5e9078351c8a2a1fb0747407c7dd117 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:15:41 +0400 Subject: [PATCH] chore: Better home screen (#7784) Co-authored-by: openhands --- .../features/home/home-header.test.tsx | 70 ++++ .../features/home/repo-connector.test.tsx | 216 ++++++++++ .../features/home/task-card.test.tsx | 186 +++++++++ .../features/home/task-suggestions.test.tsx | 113 ++++++ .../__tests__/routes/home-screen.test.tsx | 370 ++++++++++++++++++ frontend/__tests__/routes/home.test.tsx | 177 --------- .../utils/group-suggested-tasks.test.ts | 91 +++++ .../scripts/check-unlocalized-strings.cjs | 1 + .../suggestions-service.api.ts | 9 + .../assets/branding/all-hands-logo-spark.svg | 6 +- .../home/connect-to-provider-message.tsx | 21 + .../components/features/home/home-header.tsx | 57 +++ .../features/home/repo-connector.tsx | 34 ++ .../features/home/repo-provider-links.tsx | 17 + .../features/home/repo-selection-form.tsx | 79 ++++ .../home/tasks/get-prompt-for-query.ts | 49 +++ .../features/home/tasks/task-card.tsx | 79 ++++ .../features/home/tasks/task-group.tsx | 22 ++ .../features/home/tasks/task-issue-number.tsx | 17 + .../features/home/tasks/task-item-title.tsx | 7 + .../home/tasks/task-suggestions-skeleton.tsx | 50 +++ .../features/home/tasks/task-suggestions.tsx | 43 ++ .../features/home/tasks/task.types.ts | 17 + .../features/settings/brand-button.tsx | 2 +- .../settings/settings-dropdown-input.tsx | 26 +- frontend/src/context/auth-context.tsx | 6 +- .../hooks/mutation/use-create-conversation.ts | 9 +- frontend/src/hooks/mutation/use-logout.ts | 5 + .../src/hooks/query/use-suggested-tasks.ts | 15 + .../src/hooks/use-is-creating-conversation.ts | 14 + frontend/src/i18n/declaration.ts | 11 + frontend/src/i18n/translation.json | 165 ++++++++ frontend/src/icons/hands.svg | 18 + frontend/src/mocks/handlers.ts | 32 +- .../src/mocks/task-suggestions-handlers.ts | 76 ++++ frontend/src/routes/account-settings.tsx | 2 + frontend/src/routes/home.tsx | 77 ++-- frontend/src/tailwind.css | 8 + frontend/src/utils/group-suggested-tasks.ts | 28 ++ frontend/test-utils.tsx | 2 +- 40 files changed, 1973 insertions(+), 254 deletions(-) create mode 100644 frontend/__tests__/components/features/home/home-header.test.tsx create mode 100644 frontend/__tests__/components/features/home/repo-connector.test.tsx create mode 100644 frontend/__tests__/components/features/home/task-card.test.tsx create mode 100644 frontend/__tests__/components/features/home/task-suggestions.test.tsx create mode 100644 frontend/__tests__/routes/home-screen.test.tsx delete mode 100644 frontend/__tests__/routes/home.test.tsx create mode 100644 frontend/__tests__/utils/group-suggested-tasks.test.ts create mode 100644 frontend/src/api/suggestions-service/suggestions-service.api.ts create mode 100644 frontend/src/components/features/home/connect-to-provider-message.tsx create mode 100644 frontend/src/components/features/home/home-header.tsx create mode 100644 frontend/src/components/features/home/repo-connector.tsx create mode 100644 frontend/src/components/features/home/repo-provider-links.tsx create mode 100644 frontend/src/components/features/home/repo-selection-form.tsx create mode 100644 frontend/src/components/features/home/tasks/get-prompt-for-query.ts create mode 100644 frontend/src/components/features/home/tasks/task-card.tsx create mode 100644 frontend/src/components/features/home/tasks/task-group.tsx create mode 100644 frontend/src/components/features/home/tasks/task-issue-number.tsx create mode 100644 frontend/src/components/features/home/tasks/task-item-title.tsx create mode 100644 frontend/src/components/features/home/tasks/task-suggestions-skeleton.tsx create mode 100644 frontend/src/components/features/home/tasks/task-suggestions.tsx create mode 100644 frontend/src/components/features/home/tasks/task.types.ts create mode 100644 frontend/src/hooks/query/use-suggested-tasks.ts create mode 100644 frontend/src/hooks/use-is-creating-conversation.ts create mode 100644 frontend/src/icons/hands.svg create mode 100644 frontend/src/mocks/task-suggestions-handlers.ts create mode 100644 frontend/src/utils/group-suggested-tasks.ts diff --git a/frontend/__tests__/components/features/home/home-header.test.tsx b/frontend/__tests__/components/features/home/home-header.test.tsx new file mode 100644 index 0000000000..e801530d84 --- /dev/null +++ b/frontend/__tests__/components/features/home/home-header.test.tsx @@ -0,0 +1,70 @@ +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { createRoutesStub } from "react-router"; +import { setupStore } from "test-utils"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { AuthProvider } from "#/context/auth-context"; +import { HomeHeader } from "#/components/features/home/home-header"; +import OpenHands from "#/api/open-hands"; + +const renderHomeHeader = () => { + const RouterStub = createRoutesStub([ + { + Component: HomeHeader, + path: "/", + }, + { + Component: () =>
, + path: "/conversations/:conversationId", + }, + ]); + + return render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); +}; + +describe("HomeHeader", () => { + it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderHomeHeader(); + + const launchButton = screen.getByRole("button", { + name: /launch from scratch/i, + }); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith( + undefined, + undefined, + [], + undefined, + ); + + // expect to be redirected to /conversations/:conversationId + await screen.findByTestId("conversation-screen"); + }); + + it("should change the launch button text to 'Loading...' when creating a conversation", async () => { + renderHomeHeader(); + + const launchButton = screen.getByRole("button", { + name: /launch from scratch/i, + }); + await userEvent.click(launchButton); + + expect(launchButton).toHaveTextContent(/Loading/i); + expect(launchButton).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx new file mode 100644 index 0000000000..898570161e --- /dev/null +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -0,0 +1,216 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { setupStore } from "test-utils"; +import { Provider } from "react-redux"; +import { createRoutesStub } from "react-router"; +import OpenHands from "#/api/open-hands"; +import { AuthProvider } from "#/context/auth-context"; +import { GitRepository } from "#/types/git"; +import * as GitService from "#/api/git"; +import { RepoConnector } from "#/components/features/home/repo-connector"; + +const renderRepoConnector = (initialProvidersAreSet = true) => { + const mockRepoSelection = vi.fn(); + const RouterStub = createRoutesStub([ + { + Component: () => , + path: "/", + }, + { + Component: () =>
, + path: "/conversations/:conversationId", + }, + { + Component: () =>
, + path: "/settings", + }, + ]); + + return render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); +}; + +const MOCK_RESPOSITORIES: GitRepository[] = [ + { + id: 1, + full_name: "rbren/polaris", + git_provider: "github", + is_public: true, + }, + { + id: 2, + full_name: "All-Hands-AI/OpenHands", + git_provider: "github", + is_public: true, + }, +]; + +describe("RepoConnector", () => { + it("should render the repository connector section", () => { + renderRepoConnector(); + screen.getByTestId("repo-connector"); + }); + + it("should render the available repositories in the dropdown", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + + renderRepoConnector(); + + const dropdown = screen.getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + + await waitFor(() => { + screen.getByText("rbren/polaris"); + screen.getByText("All-Hands-AI/OpenHands"); + }); + }); + + it("should only enable the launch button if a repo is selected", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + + renderRepoConnector(); + + const launchButton = screen.getByTestId("repo-launch-button"); + expect(launchButton).toBeDisabled(); + + const dropdown = screen.getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + await userEvent.click(screen.getByText("rbren/polaris")); + + expect(launchButton).toBeEnabled(); + }); + + it("should render the 'add git(hub|lab) repos' links if saas mode", async () => { + const getConfiSpy = vi.spyOn(OpenHands, "getConfig"); + // @ts-expect-error - only return the APP_MODE + getConfiSpy.mockResolvedValue({ + APP_MODE: "saas", + }); + + renderRepoConnector(); + + await screen.findByText("Add GitHub repos"); + }); + + it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => { + const getConfiSpy = vi.spyOn(OpenHands, "getConfig"); + // @ts-expect-error - only return the APP_MODE + getConfiSpy.mockResolvedValue({ + APP_MODE: "oss", + }); + + renderRepoConnector(); + + expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument(); + expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument(); + }); + + it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderRepoConnector(); + + const repoConnector = screen.getByTestId("repo-connector"); + const launchButton = + within(repoConnector).getByTestId("repo-launch-button"); + await userEvent.click(launchButton); + + // repo not selected yet + expect(createConversationSpy).not.toHaveBeenCalled(); + + // select a repository from the dropdown + const dropdown = within(repoConnector).getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + + const repoOption = screen.getByText("rbren/polaris"); + await userEvent.click(repoOption); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith( + { + full_name: "rbren/polaris", + git_provider: "github", + id: 1, + is_public: true, + }, + undefined, + [], + undefined, + ); + }); + + it("should change the launch button text to 'Loading...' when creating a conversation", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + + renderRepoConnector(); + + const launchButton = screen.getByTestId("repo-launch-button"); + + const dropdown = screen.getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + await userEvent.click(screen.getByText("rbren/polaris")); + + await userEvent.click(launchButton); + expect(launchButton).toBeDisabled(); + expect(launchButton).toHaveTextContent(/Loading/i); + }); + + it("should not display a button to settings if the user is signed in with their git provider", async () => { + renderRepoConnector(true); + expect( + screen.queryByTestId("navigate-to-settings-button"), + ).not.toBeInTheDocument(); + }); + + it("should display a button to settings if the user needs to sign in with their git provider", async () => { + renderRepoConnector(false); + + const goToSettingsButton = await screen.findByTestId( + "navigate-to-settings-button", + ); + const dropdown = screen.queryByTestId("repo-dropdown"); + const launchButton = screen.queryByTestId("repo-launch-button"); + const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i); + + expect(dropdown).not.toBeInTheDocument(); + expect(launchButton).not.toBeInTheDocument(); + expect(providerLinks.length).toBe(0); + + expect(goToSettingsButton).toBeInTheDocument(); + + await userEvent.click(goToSettingsButton); + await screen.findByTestId("settings-screen"); + }); +}); diff --git a/frontend/__tests__/components/features/home/task-card.test.tsx b/frontend/__tests__/components/features/home/task-card.test.tsx new file mode 100644 index 0000000000..d71c7ab46b --- /dev/null +++ b/frontend/__tests__/components/features/home/task-card.test.tsx @@ -0,0 +1,186 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { Provider } from "react-redux"; +import { createRoutesStub } from "react-router"; +import { setupStore } from "test-utils"; +import { SuggestedTask } from "#/components/features/home/tasks/task.types"; +import OpenHands from "#/api/open-hands"; +import { AuthProvider } from "#/context/auth-context"; +import { TaskCard } from "#/components/features/home/tasks/task-card"; +import * as GitService from "#/api/git"; +import { GitRepository } from "#/types/git"; +import { + getFailingChecksPrompt, + getMergeConflictPrompt, + getOpenIssuePrompt, + getUnresolvedCommentsPrompt, +} from "#/components/features/home/tasks/get-prompt-for-query"; + +const MOCK_TASK_1: SuggestedTask = { + issue_number: 123, + repo: "repo1", + title: "Task 1", + task_type: "MERGE_CONFLICTS", +}; + +const MOCK_TASK_2: SuggestedTask = { + issue_number: 456, + repo: "repo2", + title: "Task 2", + task_type: "FAILING_CHECKS", +}; + +const MOCK_TASK_3: SuggestedTask = { + issue_number: 789, + repo: "repo3", + title: "Task 3", + task_type: "UNRESOLVED_COMMENTS", +}; + +const MOCK_TASK_4: SuggestedTask = { + issue_number: 101112, + repo: "repo4", + title: "Task 4", + task_type: "OPEN_ISSUE", +}; + +const MOCK_RESPOSITORIES: GitRepository[] = [ + { id: 1, full_name: "repo1", git_provider: "github", is_public: true }, + { id: 2, full_name: "repo2", git_provider: "github", is_public: true }, + { id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true }, + { id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true }, +]; + +const renderTaskCard = (task = MOCK_TASK_1) => { + const RouterStub = createRoutesStub([ + { + Component: () => , + path: "/", + }, + { + Component: () =>
, + path: "/conversations/:conversationId", + }, + ]); + + return render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); +}; + +describe("TaskCard", () => { + it("format the issue id", async () => { + renderTaskCard(); + + const taskId = screen.getByTestId("task-id"); + expect(taskId).toHaveTextContent(/#123/i); + }); + + it("should call createConversation when clicking the launch button", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderTaskCard(); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalled(); + }); + + describe("creating conversation prompts", () => { + beforeEach(() => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + }); + + it("should call create conversation with the merge conflict prompt", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderTaskCard(MOCK_TASK_1); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledWith( + MOCK_RESPOSITORIES[0], + getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo), + [], + undefined, + ); + }); + + it("should call create conversation with the failing checks prompt", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderTaskCard(MOCK_TASK_2); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledWith( + MOCK_RESPOSITORIES[1], + getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo), + [], + undefined, + ); + }); + + it("should call create conversation with the unresolved comments prompt", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderTaskCard(MOCK_TASK_3); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledWith( + MOCK_RESPOSITORIES[2], + getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo), + [], + undefined, + ); + }); + + it("should call create conversation with the open issue prompt", async () => { + const createConversationSpy = vi.spyOn(OpenHands, "createConversation"); + + renderTaskCard(MOCK_TASK_4); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(createConversationSpy).toHaveBeenCalledWith( + MOCK_RESPOSITORIES[3], + getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo), + [], + undefined, + ); + }); + }); + + it("should disable the launch button and update text content when creating a conversation", async () => { + renderTaskCard(); + + const launchButton = screen.getByTestId("task-launch-button"); + await userEvent.click(launchButton); + + expect(launchButton).toHaveTextContent(/Loading/i); + expect(launchButton).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/features/home/task-suggestions.test.tsx b/frontend/__tests__/components/features/home/task-suggestions.test.tsx new file mode 100644 index 0000000000..d5d08119d1 --- /dev/null +++ b/frontend/__tests__/components/features/home/task-suggestions.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Provider } from "react-redux"; +import { createRoutesStub } from "react-router"; +import { setupStore } from "test-utils"; +import userEvent from "@testing-library/user-event"; +import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions"; +import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api"; +import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers"; +import { AuthProvider } from "#/context/auth-context"; + +const renderTaskSuggestions = (initialProvidersAreSet = true) => { + const RouterStub = createRoutesStub([ + { + Component: TaskSuggestions, + path: "/", + }, + { + Component: () =>
, + path: "/conversations/:conversationId", + }, + { + Component: () =>
, + path: "/settings", + }, + ]); + + return render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); +}; + +describe("TaskSuggestions", () => { + const getSuggestedTasksSpy = vi.spyOn( + SuggestionsService, + "getSuggestedTasks", + ); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the task suggestions section", () => { + renderTaskSuggestions(); + screen.getByTestId("task-suggestions"); + }); + + it("should render an empty message if there are no tasks", async () => { + getSuggestedTasksSpy.mockResolvedValue([]); + renderTaskSuggestions(); + await screen.findByText(/No tasks available/i); + }); + + it("should render the task groups with the correct titles", async () => { + getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); + renderTaskSuggestions(); + + await waitFor(() => { + MOCK_TASKS.forEach((taskGroup) => { + screen.getByText(taskGroup.title); + }); + }); + }); + + it("should render the task cards with the correct task details", async () => { + getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); + renderTaskSuggestions(); + + await waitFor(() => { + MOCK_TASKS.forEach((task) => { + screen.getByText(task.title); + }); + }); + }); + + it("should render skeletons when loading", async () => { + getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); + renderTaskSuggestions(); + + const skeletons = screen.getAllByTestId("task-group-skeleton"); + expect(skeletons.length).toBeGreaterThan(0); + + await waitFor(() => { + MOCK_TASKS.forEach((taskGroup) => { + screen.getByText(taskGroup.title); + }); + }); + + expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument(); + }); + + it("should display a button to settings if the user needs to sign in with their git provider", async () => { + renderTaskSuggestions(false); + + expect(getSuggestedTasksSpy).not.toHaveBeenCalled(); + const goToSettingsButton = await screen.findByTestId( + "navigate-to-settings-button", + ); + expect(goToSettingsButton).toBeInTheDocument(); + + await userEvent.click(goToSettingsButton); + await screen.findByTestId("settings-screen"); + }); +}); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx new file mode 100644 index 0000000000..2df63ca62a --- /dev/null +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -0,0 +1,370 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { Provider } from "react-redux"; +import { setupStore } from "test-utils"; +import { AxiosError } from "axios"; +import HomeScreen from "#/routes/home"; +import { AuthProvider } from "#/context/auth-context"; +import * as GitService from "#/api/git"; +import { GitRepository } from "#/types/git"; +import OpenHands from "#/api/open-hands"; +import MainApp from "#/routes/root-layout"; + +const createAxiosNotFoundErrorObject = () => + new AxiosError( + "Request failed with status code 404", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 404, + statusText: "Not Found", + data: { message: "Settings not found" }, + headers: {}, + // @ts-expect-error - we only need the response object for this test + config: {}, + }, + ); + +const RouterStub = createRoutesStub([ + { + Component: MainApp, + path: "/", + children: [ + { + Component: HomeScreen, + path: "/", + }, + { + Component: () =>
, + path: "/conversations/:conversationId", + }, + { + Component: () =>
, + path: "/settings", + }, + ], + }, +]); + +const renderHomeScreen = (initialProvidersAreSet = true) => + render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }); + +const MOCK_RESPOSITORIES: GitRepository[] = [ + { + id: 1, + full_name: "octocat/hello-world", + git_provider: "github", + is_public: true, + }, + { + id: 2, + full_name: "octocat/earth", + git_provider: "github", + is_public: true, + }, +]; + +describe("HomeScreen", () => { + it("should render", () => { + renderHomeScreen(); + screen.getByTestId("home-screen"); + }); + + it("should render the repository connector and suggested tasks sections", async () => { + renderHomeScreen(); + + screen.getByTestId("repo-connector"); + screen.getByTestId("task-suggestions"); + }); + + it("should filter the suggested tasks based on the selected repository", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + + renderHomeScreen(); + + const taskSuggestions = screen.getByTestId("task-suggestions"); + + // Initially, all tasks should be visible + await waitFor(() => { + within(taskSuggestions).getByText("octocat/hello-world"); + within(taskSuggestions).getByText("octocat/earth"); + }); + + // Select a repository from the dropdown + const repoConnector = screen.getByTestId("repo-connector"); + + const dropdown = within(repoConnector).getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + + const repoOption = screen.getAllByText("octocat/hello-world")[1]; + await userEvent.click(repoOption); + + // After selecting a repository, only tasks related to that repository should be visible + await waitFor(() => { + within(taskSuggestions).getByText("octocat/hello-world"); + expect( + within(taskSuggestions).queryByText("octocat/earth"), + ).not.toBeInTheDocument(); + }); + }); + + it("should reset the filtered tasks when the selected repository is cleared", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + + renderHomeScreen(); + + const taskSuggestions = screen.getByTestId("task-suggestions"); + + // Initially, all tasks should be visible + await waitFor(() => { + within(taskSuggestions).getByText("octocat/hello-world"); + within(taskSuggestions).getByText("octocat/earth"); + }); + + // Select a repository from the dropdown + const repoConnector = screen.getByTestId("repo-connector"); + + const dropdown = within(repoConnector).getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + + const repoOption = screen.getAllByText("octocat/hello-world")[1]; + await userEvent.click(repoOption); + + // After selecting a repository, only tasks related to that repository should be visible + await waitFor(() => { + within(taskSuggestions).getByText("octocat/hello-world"); + expect( + within(taskSuggestions).queryByText("octocat/earth"), + ).not.toBeInTheDocument(); + }); + + // Clear the selected repository + await userEvent.clear(dropdown); + + // All tasks should be visible again + await waitFor(() => { + within(taskSuggestions).getByText("octocat/hello-world"); + within(taskSuggestions).getByText("octocat/earth"); + }); + }); + + describe("launch buttons", () => { + const setupLaunchButtons = async () => { + let headerLaunchButton = screen.getByTestId("header-launch-button"); + let repoLaunchButton = screen.getByTestId("repo-launch-button"); + let tasksLaunchButtons = + await screen.findAllByTestId("task-launch-button"); + + // Select a repository from the dropdown to enable the repo launch button + const repoConnector = screen.getByTestId("repo-connector"); + const dropdown = within(repoConnector).getByTestId("repo-dropdown"); + await userEvent.click(dropdown); + const repoOption = screen.getAllByText("octocat/hello-world")[1]; + await userEvent.click(repoOption); + + expect(headerLaunchButton).not.toBeDisabled(); + expect(repoLaunchButton).not.toBeDisabled(); + tasksLaunchButtons.forEach((button) => { + expect(button).not.toBeDisabled(); + }); + + headerLaunchButton = screen.getByTestId("header-launch-button"); + repoLaunchButton = screen.getByTestId("repo-launch-button"); + tasksLaunchButtons = await screen.findAllByTestId("task-launch-button"); + + return { + headerLaunchButton, + repoLaunchButton, + tasksLaunchButtons, + }; + }; + + beforeEach(() => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + GitService, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockResolvedValue({ + data: MOCK_RESPOSITORIES, + nextPage: null, + }); + }); + + it("should disable the other launch buttons when the header launch button is clicked", async () => { + renderHomeScreen(); + const { headerLaunchButton, repoLaunchButton } = + await setupLaunchButtons(); + + const tasksLaunchButtonsAfter = + await screen.findAllByTestId("task-launch-button"); + + // All other buttons should be disabled when the header button is clicked + await userEvent.click(headerLaunchButton); + + expect(headerLaunchButton).toBeDisabled(); + expect(repoLaunchButton).toBeDisabled(); + tasksLaunchButtonsAfter.forEach((button) => { + expect(button).toBeDisabled(); + }); + }); + + it("should disable the other launch buttons when the repo launch button is clicked", async () => { + renderHomeScreen(); + const { headerLaunchButton, repoLaunchButton } = + await setupLaunchButtons(); + + const tasksLaunchButtonsAfter = + await screen.findAllByTestId("task-launch-button"); + + // All other buttons should be disabled when the repo button is clicked + await userEvent.click(repoLaunchButton); + + expect(headerLaunchButton).toBeDisabled(); + expect(repoLaunchButton).toBeDisabled(); + tasksLaunchButtonsAfter.forEach((button) => { + expect(button).toBeDisabled(); + }); + }); + + it("should disable the other launch buttons when any task launch button is clicked", async () => { + renderHomeScreen(); + const { headerLaunchButton, repoLaunchButton, tasksLaunchButtons } = + await setupLaunchButtons(); + + const tasksLaunchButtonsAfter = + await screen.findAllByTestId("task-launch-button"); + + // All other buttons should be disabled when the task button is clicked + await userEvent.click(tasksLaunchButtons[0]); + + expect(headerLaunchButton).toBeDisabled(); + expect(repoLaunchButton).toBeDisabled(); + tasksLaunchButtonsAfter.forEach((button) => { + expect(button).toBeDisabled(); + }); + }); + }); + + it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => { + renderHomeScreen(false); + + const taskSuggestions = screen.queryByTestId("task-suggestions"); + const repoConnector = screen.getByTestId("repo-connector"); + + expect(taskSuggestions).not.toBeInTheDocument(); + expect(repoConnector).toBeInTheDocument(); + }); +}); + +describe("Settings 404", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + + it("should open the settings modal if GET /settings fails with a 404", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + renderHomeScreen(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + }); + + it("should navigate to the settings screen when clicking the advanced settings button", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + const user = userEvent.setup(); + renderHomeScreen(); + + const settingsScreen = screen.queryByTestId("settings-screen"); + expect(settingsScreen).not.toBeInTheDocument(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + + const advancedSettingsButton = await screen.findByTestId( + "advanced-settings-link", + ); + await user.click(advancedSettingsButton); + + const settingsScreenAfter = await screen.findByTestId("settings-screen"); + expect(settingsScreenAfter).toBeInTheDocument(); + + const settingsModalAfter = screen.queryByTestId("ai-config-modal"); + expect(settingsModalAfter).not.toBeInTheDocument(); + }); + + it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => { + // @ts-expect-error - we only need APP_MODE for this test + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + FEATURE_FLAGS: { + ENABLE_BILLING: false, + HIDE_LLM_SETTINGS: false, + }, + }); + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + renderHomeScreen(); + + // small hack to wait for the modal to not appear + await expect( + screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }), + ).rejects.toThrow(); + }); +}); + +describe("Setup Payment modal", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + + it("should only render if SaaS mode and is new user", async () => { + // @ts-expect-error - we only need the APP_MODE for this test + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + FEATURE_FLAGS: { + ENABLE_BILLING: true, + HIDE_LLM_SETTINGS: false, + }, + }); + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + renderHomeScreen(); + + const setupPaymentModal = await screen.findByTestId( + "proceed-to-stripe-button", + ); + expect(setupPaymentModal).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/routes/home.test.tsx b/frontend/__tests__/routes/home.test.tsx deleted file mode 100644 index 9233f54863..0000000000 --- a/frontend/__tests__/routes/home.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { createRoutesStub } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import userEvent from "@testing-library/user-event"; -import { screen } from "@testing-library/react"; -import { AxiosError } from "axios"; -import MainApp from "#/routes/root-layout"; -import SettingsScreen from "#/routes/settings"; -import Home from "#/routes/home"; -import OpenHands from "#/api/open-hands"; - -const createAxiosNotFoundErrorObject = () => - new AxiosError( - "Request failed with status code 404", - "ERR_BAD_REQUEST", - undefined, - undefined, - { - status: 404, - statusText: "Not Found", - data: { message: "Settings not found" }, - headers: {}, - // @ts-expect-error - we only need the response object for this test - config: {}, - }, - ); - -const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - -const RouterStub = createRoutesStub([ - { - // layout route - Component: MainApp, - path: "/", - children: [ - { - // home route - Component: Home, - path: "/", - }, - { - Component: SettingsScreen, - path: "/settings", - }, - ], - }, -]); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe("Home Screen", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); - - it("should render the home screen", () => { - renderWithProviders(); - }); - - it("should navigate to the settings screen when the settings button is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - const settingsButton = await screen.findByTestId("settings-button"); - await user.click(settingsButton); - - const settingsScreen = await screen.findByTestId("settings-screen"); - expect(settingsScreen).toBeInTheDocument(); - }); - - it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => { - // @ts-expect-error - we only need APP_MODE for this test - getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - }, - }); - const user = userEvent.setup(); - renderWithProviders(); - - const connectToGitHubButton = - await screen.findByTestId("connect-to-github"); - await user.click(connectToGitHubButton); - - const settingsScreen = await screen.findByTestId("settings-screen"); - expect(settingsScreen).toBeInTheDocument(); - }); -}); - -describe("Settings 404", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); - - it("should open the settings modal if GET /settings fails with a 404", async () => { - const error = createAxiosNotFoundErrorObject(); - getSettingsSpy.mockRejectedValue(error); - - renderWithProviders(); - - const settingsModal = await screen.findByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - }); - - it("should navigate to the settings screen when clicking the advanced settings button", async () => { - const error = createAxiosNotFoundErrorObject(); - getSettingsSpy.mockRejectedValue(error); - - const user = userEvent.setup(); - renderWithProviders(); - - const settingsScreen = screen.queryByTestId("settings-screen"); - expect(settingsScreen).not.toBeInTheDocument(); - - const settingsModal = await screen.findByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - - const advancedSettingsButton = await screen.findByTestId( - "advanced-settings-link", - ); - await user.click(advancedSettingsButton); - - const settingsScreenAfter = await screen.findByTestId("settings-screen"); - expect(settingsScreenAfter).toBeInTheDocument(); - - const settingsModalAfter = screen.queryByTestId("ai-config-modal"); - expect(settingsModalAfter).not.toBeInTheDocument(); - }); - - it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => { - // @ts-expect-error - we only need APP_MODE for this test - getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - }, - }); - const error = createAxiosNotFoundErrorObject(); - getSettingsSpy.mockRejectedValue(error); - - renderWithProviders(); - - // small hack to wait for the modal to not appear - await expect( - screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }), - ).rejects.toThrow(); - }); -}); - -describe("Setup Payment modal", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it("should only render if SaaS mode and is new user", async () => { - // @ts-expect-error - we only need the APP_MODE for this test - getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - FEATURE_FLAGS: { - ENABLE_BILLING: true, - HIDE_LLM_SETTINGS: false, - }, - }); - const error = createAxiosNotFoundErrorObject(); - getSettingsSpy.mockRejectedValue(error); - - renderWithProviders(); - - const setupPaymentModal = await screen.findByTestId( - "proceed-to-stripe-button", - ); - expect(setupPaymentModal).toBeInTheDocument(); - }); -}); diff --git a/frontend/__tests__/utils/group-suggested-tasks.test.ts b/frontend/__tests__/utils/group-suggested-tasks.test.ts new file mode 100644 index 0000000000..52837e079b --- /dev/null +++ b/frontend/__tests__/utils/group-suggested-tasks.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "vitest"; +import { + SuggestedTask, + SuggestedTaskGroup, +} from "#/components/features/home/tasks/task.types"; +import { groupSuggestedTasks } from "#/utils/group-suggested-tasks"; + +const rawTasks: SuggestedTask[] = [ + { + issue_number: 1, + repo: "repo1", + title: "Task 1", + task_type: "MERGE_CONFLICTS", + }, + { + issue_number: 2, + repo: "repo1", + title: "Task 2", + task_type: "FAILING_CHECKS", + }, + { + issue_number: 3, + repo: "repo2", + title: "Task 3", + task_type: "UNRESOLVED_COMMENTS", + }, + { + issue_number: 4, + repo: "repo2", + title: "Task 4", + task_type: "OPEN_ISSUE", + }, + { + issue_number: 5, + repo: "repo3", + title: "Task 5", + task_type: "FAILING_CHECKS", + }, +]; + +const groupedTasks: SuggestedTaskGroup[] = [ + { + title: "repo1", + tasks: [ + { + issue_number: 1, + repo: "repo1", + title: "Task 1", + task_type: "MERGE_CONFLICTS", + }, + { + issue_number: 2, + repo: "repo1", + title: "Task 2", + task_type: "FAILING_CHECKS", + }, + ], + }, + { + title: "repo2", + tasks: [ + { + issue_number: 3, + repo: "repo2", + title: "Task 3", + task_type: "UNRESOLVED_COMMENTS", + }, + { + issue_number: 4, + repo: "repo2", + title: "Task 4", + task_type: "OPEN_ISSUE", + }, + ], + }, + { + title: "repo3", + tasks: [ + { + issue_number: 5, + repo: "repo3", + title: "Task 5", + task_type: "FAILING_CHECKS", + }, + ], + }, +]; + +test("groupSuggestedTasks", () => { + expect(groupSuggestedTasks(rawTasks)).toEqual(groupedTasks); +}); diff --git a/frontend/scripts/check-unlocalized-strings.cjs b/frontend/scripts/check-unlocalized-strings.cjs index 89384d5ee8..c092b58af7 100755 --- a/frontend/scripts/check-unlocalized-strings.cjs +++ b/frontend/scripts/check-unlocalized-strings.cjs @@ -105,6 +105,7 @@ function isRawTranslationKey(str) { // Specific technical strings that should be excluded from localization const EXCLUDED_TECHNICAL_STRINGS = [ "openid email profile", // OAuth scope string - not user-facing + "OPEN_ISSUE", // Task type identifier, not a UI string ]; function isExcludedTechnicalString(str) { diff --git a/frontend/src/api/suggestions-service/suggestions-service.api.ts b/frontend/src/api/suggestions-service/suggestions-service.api.ts new file mode 100644 index 0000000000..e697278770 --- /dev/null +++ b/frontend/src/api/suggestions-service/suggestions-service.api.ts @@ -0,0 +1,9 @@ +import { SuggestedTask } from "#/components/features/home/tasks/task.types"; +import { openHands } from "../open-hands-axios"; + +export class SuggestionsService { + static async getSuggestedTasks(): Promise { + const { data } = await openHands.get("/api/user/suggested-tasks"); + return data; + } +} diff --git a/frontend/src/assets/branding/all-hands-logo-spark.svg b/frontend/src/assets/branding/all-hands-logo-spark.svg index bb4070944a..00cf1097c0 100644 --- a/frontend/src/assets/branding/all-hands-logo-spark.svg +++ b/frontend/src/assets/branding/all-hands-logo-spark.svg @@ -15,13 +15,13 @@ fill="black" /> + fill="black" /> + fill="black" /> + fill="black" /> diff --git a/frontend/src/components/features/home/connect-to-provider-message.tsx b/frontend/src/components/features/home/connect-to-provider-message.tsx new file mode 100644 index 0000000000..605f00b8dd --- /dev/null +++ b/frontend/src/components/features/home/connect-to-provider-message.tsx @@ -0,0 +1,21 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { useSettings } from "#/hooks/query/use-settings"; + +export function ConnectToProviderMessage() { + const { isLoading } = useSettings(); + const { t } = useTranslation(); + + return ( +
+

{t("HOME$CONNECT_PROVIDER_MESSAGE")}

+ + + {!isLoading && t("SETTINGS$TITLE")} + {isLoading && t("HOME$LOADING")} + + +
+ ); +} diff --git a/frontend/src/components/features/home/home-header.tsx b/frontend/src/components/features/home/home-header.tsx new file mode 100644 index 0000000000..ea60c65036 --- /dev/null +++ b/frontend/src/components/features/home/home-header.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { BrandButton } from "../settings/brand-button"; +import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react"; + +export function HomeHeader() { + const { + mutate: createConversation, + isPending, + isSuccess, + } = useCreateConversation(); + const isCreatingConversationElsewhere = useIsCreatingConversation(); + const { t } = useTranslation(); + + // We check for isSuccess because the app might require time to render + // into the new conversation screen after the conversation is created. + const isCreatingConversation = + isPending || isSuccess || isCreatingConversationElsewhere; + + return ( +
+ + +
+

{t("HOME$LETS_START_BUILDING")}

+ createConversation({})} + isDisabled={isCreatingConversation} + > + {!isCreatingConversation && "Launch from Scratch"} + {isCreatingConversation && t("HOME$LOADING")} + +
+ +
+

+ {t("HOME$OPENHANDS_DESCRIPTION")} +

+

+ {t("HOME$NOT_SURE_HOW_TO_START")}{" "} + + Read this + +

+
+
+ ); +} diff --git a/frontend/src/components/features/home/repo-connector.tsx b/frontend/src/components/features/home/repo-connector.tsx new file mode 100644 index 0000000000..6dc91a50d4 --- /dev/null +++ b/frontend/src/components/features/home/repo-connector.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; +import { ConnectToProviderMessage } from "./connect-to-provider-message"; +import { useAuth } from "#/context/auth-context"; +import { RepositorySelectionForm } from "./repo-selection-form"; +import { useConfig } from "#/hooks/query/use-config"; +import { RepoProviderLinks } from "./repo-provider-links"; + +interface RepoConnectorProps { + onRepoSelection: (repoTitle: string | null) => void; +} + +export function RepoConnector({ onRepoSelection }: RepoConnectorProps) { + const { providersAreSet } = useAuth(); + const { data: config } = useConfig(); + const { t } = useTranslation(); + + const isSaaS = config?.APP_MODE === "saas"; + + return ( +
+

{t("HOME$CONNECT_TO_REPOSITORY")}

+ + {!providersAreSet && } + {providersAreSet && ( + + )} + + {isSaaS && providersAreSet && } +
+ ); +} diff --git a/frontend/src/components/features/home/repo-provider-links.tsx b/frontend/src/components/features/home/repo-provider-links.tsx new file mode 100644 index 0000000000..e7e23b6a7a --- /dev/null +++ b/frontend/src/components/features/home/repo-provider-links.tsx @@ -0,0 +1,17 @@ +import { useConfig } from "#/hooks/query/use-config"; + +export function RepoProviderLinks() { + const { data: config } = useConfig(); + + const githubHref = config + ? `https://github.com/apps/${config.APP_SLUG}/installations/new` + : ""; + + return ( + + ); +} diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx new file mode 100644 index 0000000000..997878979a --- /dev/null +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useUserRepositories } from "#/hooks/query/use-user-repositories"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { GitRepository } from "#/types/git"; +import { BrandButton } from "../settings/brand-button"; +import { SettingsDropdownInput } from "../settings/settings-dropdown-input"; + +interface RepositorySelectionFormProps { + onRepoSelection: (repoTitle: string | null) => void; +} + +export function RepositorySelectionForm({ + onRepoSelection, +}: RepositorySelectionFormProps) { + const [selectedRepository, setSelectedRepository] = + React.useState(null); + const { data: repositories } = useUserRepositories(); + const { + mutate: createConversation, + isPending, + isSuccess, + } = useCreateConversation(); + const isCreatingConversationElsewhere = useIsCreatingConversation(); + const { t } = useTranslation(); + + // We check for isSuccess because the app might require time to render + // into the new conversation screen after the conversation is created. + const isCreatingConversation = + isPending || isSuccess || isCreatingConversationElsewhere; + + const repositoriesList = repositories?.pages.flatMap((page) => page.data); + const repositoriesItems = repositoriesList?.map((repo) => ({ + key: repo.id, + label: repo.full_name, + })); + + const handleRepoSelection = (key: React.Key | null) => { + const selectedRepo = repositoriesList?.find( + (repo) => repo.id.toString() === key, + ); + + if (selectedRepo) onRepoSelection(selectedRepo.full_name); + setSelectedRepository(selectedRepo || null); + }; + + const handleInputChange = (value: string) => { + if (value === "") { + setSelectedRepository(null); + onRepoSelection(null); + } + }; + + return ( + <> + + + createConversation({ selectedRepository })} + > + {!isCreatingConversation && "Launch"} + {isCreatingConversation && t("HOME$LOADING")} + + + ); +} diff --git a/frontend/src/components/features/home/tasks/get-prompt-for-query.ts b/frontend/src/components/features/home/tasks/get-prompt-for-query.ts new file mode 100644 index 0000000000..ec9167070b --- /dev/null +++ b/frontend/src/components/features/home/tasks/get-prompt-for-query.ts @@ -0,0 +1,49 @@ +import { SuggestedTaskType } from "./task.types"; + +export const getMergeConflictPrompt = ( + issueNumber: number, + repo: string, +) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts. +Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. +Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`; + +export const getFailingChecksPrompt = ( + issueNumber: number, + repo: string, +) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks. +Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. +Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally. +Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`; + +export const getUnresolvedCommentsPrompt = ( + issueNumber: number, + repo: string, +) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers. +Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention. +Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`; + +export const getOpenIssuePrompt = ( + issueNumber: number, + repo: string, +) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue +Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made +Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`; + +export const getPromptForQuery = ( + type: SuggestedTaskType, + issueNumber: number, + repo: string, +) => { + switch (type) { + case "MERGE_CONFLICTS": + return getMergeConflictPrompt(issueNumber, repo); + case "FAILING_CHECKS": + return getFailingChecksPrompt(issueNumber, repo); + case "UNRESOLVED_COMMENTS": + return getUnresolvedCommentsPrompt(issueNumber, repo); + case "OPEN_ISSUE": + return getOpenIssuePrompt(issueNumber, repo); + default: + return ""; + } +}; diff --git a/frontend/src/components/features/home/tasks/task-card.tsx b/frontend/src/components/features/home/tasks/task-card.tsx new file mode 100644 index 0000000000..8d17b1bf96 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-card.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "react-i18next"; +import { SuggestedTask } from "./task.types"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { cn } from "#/utils/utils"; +import { useUserRepositories } from "#/hooks/query/use-user-repositories"; +import { getPromptForQuery } from "./get-prompt-for-query"; +import { TaskIssueNumber } from "./task-issue-number"; + +const getTaskTypeMap = ( + t: (key: string) => string, +): Record => ({ + FAILING_CHECKS: t("HOME$FIX_FAILING_CHECKS"), + MERGE_CONFLICTS: t("HOME$RESOLVE_MERGE_CONFLICTS"), + OPEN_ISSUE: t("HOME$OPEN_ISSUE"), + UNRESOLVED_COMMENTS: t("HOME$RESOLVE_UNRESOLVED_COMMENTS"), +}); + +interface TaskCardProps { + task: SuggestedTask; +} + +export function TaskCard({ task }: TaskCardProps) { + const { data: repositories } = useUserRepositories(); + const { mutate: createConversation, isPending } = useCreateConversation(); + const isCreatingConversation = useIsCreatingConversation(); + const { t } = useTranslation(); + + const getRepo = (repo: string) => { + const repositoriesList = repositories?.pages.flatMap((page) => page.data); + const selectedRepo = repositoriesList?.find( + (repository) => repository.full_name === repo, + ); + + return selectedRepo; + }; + + const handleLaunchConversation = () => { + const repo = getRepo(task.repo); + const query = getPromptForQuery( + task.task_type, + task.issue_number, + task.repo, + ); + + return createConversation({ + selectedRepository: repo, + q: query, + }); + }; + + const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull"; + const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`; + + return ( +
  • + + +
    +

    {getTaskTypeMap(t)[task.task_type]}

    +

    {task.title}

    +
    + + +
  • + ); +} diff --git a/frontend/src/components/features/home/tasks/task-group.tsx b/frontend/src/components/features/home/tasks/task-group.tsx new file mode 100644 index 0000000000..03ffdbf045 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-group.tsx @@ -0,0 +1,22 @@ +import { TaskCard } from "./task-card"; +import { TaskItemTitle } from "./task-item-title"; +import { SuggestedTask } from "./task.types"; + +interface TaskGroupProps { + title: string; + tasks: SuggestedTask[]; +} + +export function TaskGroup({ title, tasks }: TaskGroupProps) { + return ( +
    + {title} + +
      + {tasks.map((task) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/src/components/features/home/tasks/task-issue-number.tsx b/frontend/src/components/features/home/tasks/task-issue-number.tsx new file mode 100644 index 0000000000..893b2255b0 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-issue-number.tsx @@ -0,0 +1,17 @@ +interface TaskIssueNumberProps { + issueNumber: number; + href: string; +} + +export function TaskIssueNumber({ href, issueNumber }: TaskIssueNumberProps) { + return ( + + #{issueNumber} + + ); +} diff --git a/frontend/src/components/features/home/tasks/task-item-title.tsx b/frontend/src/components/features/home/tasks/task-item-title.tsx new file mode 100644 index 0000000000..1bf2c83605 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-item-title.tsx @@ -0,0 +1,7 @@ +export function TaskItemTitle({ children: title }: React.PropsWithChildren) { + return ( +
    +

    {title}

    +
    + ); +} diff --git a/frontend/src/components/features/home/tasks/task-suggestions-skeleton.tsx b/frontend/src/components/features/home/tasks/task-suggestions-skeleton.tsx new file mode 100644 index 0000000000..8691734a9f --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-suggestions-skeleton.tsx @@ -0,0 +1,50 @@ +import { cn } from "#/utils/utils"; + +const VALID_WIDTHS = ["w-1/4", "w-1/2", "w-3/4"]; + +const getRandomWidth = () => + VALID_WIDTHS[Math.floor(Math.random() * VALID_WIDTHS.length)]; + +const getRandomNumber = (from = 3, to = 5) => + Math.floor(Math.random() * (to - from + 1)) + from; + +function TaskCardSkeleton() { + return ( +
  • +
    + +
    +
    +
    +
    + +
    +
  • + ); +} + +interface TaskGroupSkeletonProps { + items?: number; +} + +function TaskGroupSkeleton({ items = 3 }: TaskGroupSkeletonProps) { + return ( +
    +
    +
    +
    + +
      + {Array.from({ length: items }).map((_, index) => ( + + ))} +
    +
    + ); +} + +export function TaskSuggestionsSkeleton() { + return Array.from({ length: getRandomNumber(2, 3) }).map((_, index) => ( + + )); +} diff --git a/frontend/src/components/features/home/tasks/task-suggestions.tsx b/frontend/src/components/features/home/tasks/task-suggestions.tsx new file mode 100644 index 0000000000..680a78d342 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task-suggestions.tsx @@ -0,0 +1,43 @@ +import { TaskGroup } from "./task-group"; +import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks"; +import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton"; +import { useAuth } from "#/context/auth-context"; +import { cn } from "#/utils/utils"; +import { ConnectToProviderMessage } from "../connect-to-provider-message"; + +interface TaskSuggestionsProps { + filterFor?: string | null; +} + +export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) { + const { providersAreSet } = useAuth(); + + const { data: tasks, isLoading } = useSuggestedTasks(); + const suggestedTasks = filterFor + ? tasks?.filter((task) => task.title === filterFor) + : tasks; + + const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0; + + return ( +
    +

    Suggested Tasks

    + +
    + {!providersAreSet && } + {isLoading && } + {!hasSuggestedTasks && !isLoading &&

    No tasks available

    } + {suggestedTasks?.map((taskGroup, index) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/src/components/features/home/tasks/task.types.ts b/frontend/src/components/features/home/tasks/task.types.ts new file mode 100644 index 0000000000..40877fadc4 --- /dev/null +++ b/frontend/src/components/features/home/tasks/task.types.ts @@ -0,0 +1,17 @@ +export type SuggestedTaskType = + | "MERGE_CONFLICTS" + | "FAILING_CHECKS" + | "UNRESOLVED_COMMENTS" + | "OPEN_ISSUE"; // This is a task type identifier, not a UI string + +export interface SuggestedTask { + issue_number: number; + repo: string; + title: string; + task_type: SuggestedTaskType; +} + +export interface SuggestedTaskGroup { + title: string; + tasks: SuggestedTask[]; +} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 799f93a9ea..03210f46e9 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -29,7 +29,7 @@ export function BrandButton({ type={type} onClick={onClick} className={cn( - "w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed", + "w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80", variant === "primary" && "bg-primary text-[#0D0F11]", variant === "secondary" && "border border-primary text-primary", startContent && "flex items-center justify-center gap-2", diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx index 2a74ad82c9..d5d2779578 100644 --- a/frontend/src/components/features/settings/settings-dropdown-input.tsx +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -1,42 +1,56 @@ import { Autocomplete, AutocompleteItem } from "@heroui/react"; import { ReactNode } from "react"; import { OptionalTag } from "./optional-tag"; +import { cn } from "#/utils/utils"; interface SettingsDropdownInputProps { testId: string; - label: ReactNode; name: string; items: { key: React.Key; label: string }[]; + label?: ReactNode; + wrapperClassName?: string; + placeholder?: string; showOptionalTag?: boolean; isDisabled?: boolean; defaultSelectedKey?: string; isClearable?: boolean; + onSelectionChange?: (key: React.Key | null) => void; + onInputChange?: (value: string) => void; } export function SettingsDropdownInput({ testId, label, + wrapperClassName, name, items, + placeholder, showOptionalTag, isDisabled, defaultSelectedKey, isClearable, + onSelectionChange, + onInputChange, }: SettingsDropdownInputProps) { return ( -