mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
chore: Better home screen (#7784)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
986b90be0a
commit
e9f2b72ea5
@ -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: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={setupStore()}>
|
||||
<AuthProvider initialProvidersAreSet>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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: () => <RepoConnector onRepoSelection={mockRepoSelection} />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={setupStore()}>
|
||||
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
186
frontend/__tests__/components/features/home/task-card.test.tsx
Normal file
186
frontend/__tests__/components/features/home/task-card.test.tsx
Normal file
@ -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: () => <TaskCard task={task} />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={setupStore()}>
|
||||
<AuthProvider initialProvidersAreSet>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={setupStore()}>
|
||||
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
370
frontend/__tests__/routes/home-screen.test.tsx
Normal file
370
frontend/__tests__/routes/home-screen.test.tsx
Normal file
@ -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: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderHomeScreen = (initialProvidersAreSet = true) =>
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={setupStore()}>
|
||||
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
),
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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(<RouterStub initialEntries={["/"]} />);
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when the settings button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
// 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(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const setupPaymentModal = await screen.findByTestId(
|
||||
"proceed-to-stripe-button",
|
||||
);
|
||||
expect(setupPaymentModal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
91
frontend/__tests__/utils/group-suggested-tasks.test.ts
Normal file
91
frontend/__tests__/utils/group-suggested-tasks.test.ts
Normal file
@ -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);
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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<SuggestedTask[]> {
|
||||
const { data } = await openHands.get("/api/user/suggested-tasks");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@ -15,13 +15,13 @@
|
||||
fill="black" />
|
||||
<path
|
||||
d="M38.7381 10.5084C38.5759 10.5084 38.4106 10.4788 38.2545 10.4076C37.6821 10.1526 37.4312 9.49736 37.6944 8.94289C38.5453 7.1431 39.791 5.48266 41.2938 4.14245C41.7559 3.73031 42.4782 3.75699 42.9037 4.20768C43.3291 4.65541 43.3016 5.35516 42.8363 5.76731C41.5539 6.91182 40.4919 8.32912 39.7634 9.86502C39.5737 10.2653 39.1666 10.5055 38.7381 10.5084Z"
|
||||
fill="white" />
|
||||
fill="black" />
|
||||
<path
|
||||
d="M34.898 9.87074C34.3073 9.87667 33.8023 9.43784 33.7533 8.85669C33.536 6.25633 33.5268 3.62039 33.7319 1.02003C33.7808 0.412188 34.3287 -0.0414663 34.9531 0.00300963C35.5805 0.0504507 36.0488 0.578232 36.0029 1.18607C35.807 3.67079 35.8162 6.1911 36.0243 8.67582C36.0763 9.28366 35.6081 9.81737 34.9806 9.86481C34.9531 9.86481 34.9255 9.86778 34.898 9.86778V9.87074Z"
|
||||
fill="white" />
|
||||
fill="black" />
|
||||
<path
|
||||
d="M30.976 10.5558C30.4649 10.5618 29.9935 10.2267 29.8619 9.7256C29.3783 7.88726 28.4632 6.14084 27.2175 4.67906C26.8165 4.20762 26.8869 3.51379 27.3705 3.12537C27.8572 2.73695 28.5734 2.80514 28.9743 3.27362C30.4312 4.98743 31.5024 7.03036 32.0656 9.18003C32.2217 9.77008 31.8514 10.372 31.2423 10.5232C31.1505 10.5469 31.0617 10.5558 30.9699 10.5588L30.976 10.5558Z"
|
||||
fill="white" />
|
||||
fill="black" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
|
||||
<Link data-testid="navigate-to-settings-button" to="/settings">
|
||||
<BrandButton type="button" variant="primary" isDisabled={isLoading}>
|
||||
{!isLoading && t("SETTINGS$TITLE")}
|
||||
{isLoading && t("HOME$LOADING")}
|
||||
</BrandButton>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/features/home/home-header.tsx
Normal file
57
frontend/src/components/features/home/home-header.tsx
Normal file
@ -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 (
|
||||
<header className="flex flex-col gap-5">
|
||||
<AllHandsLogo />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="heading">{t("HOME$LETS_START_BUILDING")}</h1>
|
||||
<BrandButton
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && "Launch from Scratch"}
|
||||
{isCreatingConversation && t("HOME$LOADING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm max-w-[424px]">
|
||||
{t("HOME$OPENHANDS_DESCRIPTION")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("HOME$NOT_SURE_HOW_TO_START")}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/modules/usage/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Read this
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/features/home/repo-connector.tsx
Normal file
34
frontend/src/components/features/home/repo-connector.tsx
Normal file
@ -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 (
|
||||
<section
|
||||
data-testid="repo-connector"
|
||||
className="w-full flex flex-col gap-6"
|
||||
>
|
||||
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
|
||||
|
||||
{!providersAreSet && <ConnectToProviderMessage />}
|
||||
{providersAreSet && (
|
||||
<RepositorySelectionForm onRepoSelection={onRepoSelection} />
|
||||
)}
|
||||
|
||||
{isSaaS && providersAreSet && <RepoProviderLinks />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
|
||||
<a href={githubHref} target="_blank" rel="noopener noreferrer">
|
||||
Add GitHub repos
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<GitRepository | null>(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 (
|
||||
<>
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder="Select a repo"
|
||||
items={repositoriesItems || []}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<BrandButton
|
||||
testId="repo-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
isDisabled={!selectedRepository || isCreatingConversation}
|
||||
onClick={() => createConversation({ selectedRepository })}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
{isCreatingConversation && t("HOME$LOADING")}
|
||||
</BrandButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
};
|
||||
79
frontend/src/components/features/home/tasks/task-card.tsx
Normal file
79
frontend/src/components/features/home/tasks/task-card.tsx
Normal file
@ -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<SuggestedTask["task_type"], string> => ({
|
||||
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 (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
|
||||
|
||||
<div className="w-full pl-8">
|
||||
<p className="font-semibold">{getTaskTypeMap(t)[task.task_type]}</p>
|
||||
<p>{task.title}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="task-launch-button"
|
||||
className={cn(
|
||||
"underline underline-offset-2 disabled:opacity-80",
|
||||
isPending && "no-underline font-bold",
|
||||
)}
|
||||
disabled={isCreatingConversation}
|
||||
onClick={handleLaunchConversation}
|
||||
>
|
||||
{!isPending && t("HOME$LAUNCH")}
|
||||
{isPending && t("HOME$LOADING")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/features/home/tasks/task-group.tsx
Normal file
22
frontend/src/components/features/home/tasks/task-group.tsx
Normal file
@ -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 (
|
||||
<div className="text-content-2">
|
||||
<TaskItemTitle>{title}</TaskItemTitle>
|
||||
|
||||
<ul className="text-sm">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.issue_number} task={task} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
interface TaskIssueNumberProps {
|
||||
issueNumber: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function TaskIssueNumber({ href, issueNumber }: TaskIssueNumberProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="task-id"
|
||||
>
|
||||
#<span className="underline underline-offset-2">{issueNumber}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="py-3 border-b-1 border-[#717888]">
|
||||
<h3 className="text-[16px] leading-6 font-[500]">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
<div className="h-5 w-8 skeleton" />
|
||||
|
||||
<div className="w-full pl-8">
|
||||
<div className="h-5 w-24 skeleton mb-2" />
|
||||
<div className={cn("h-5 skeleton", getRandomWidth())} />
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-16 skeleton" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskGroupSkeletonProps {
|
||||
items?: number;
|
||||
}
|
||||
|
||||
function TaskGroupSkeleton({ items = 3 }: TaskGroupSkeletonProps) {
|
||||
return (
|
||||
<div data-testid="task-group-skeleton">
|
||||
<div className="py-3 border-b border-[#717888]">
|
||||
<div className="h-6 w-40 skeleton" />
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{Array.from({ length: items }).map((_, index) => (
|
||||
<TaskCardSkeleton key={index} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSuggestionsSkeleton() {
|
||||
return Array.from({ length: getRandomNumber(2, 3) }).map((_, index) => (
|
||||
<TaskGroupSkeleton key={index} items={getRandomNumber(3, 5)} />
|
||||
));
|
||||
}
|
||||
@ -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 (
|
||||
<section
|
||||
data-testid="task-suggestions"
|
||||
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
|
||||
>
|
||||
<h2 className="heading">Suggested Tasks</h2>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{!providersAreSet && <ConnectToProviderMessage />}
|
||||
{isLoading && <TaskSuggestionsSkeleton />}
|
||||
{!hasSuggestedTasks && !isLoading && <p>No tasks available</p>}
|
||||
{suggestedTasks?.map((taskGroup, index) => (
|
||||
<TaskGroup
|
||||
key={index}
|
||||
title={taskGroup.title}
|
||||
tasks={taskGroup.tasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/features/home/tasks/task.types.ts
Normal file
17
frontend/src/components/features/home/tasks/task.types.ts
Normal file
@ -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[];
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
<label className="flex flex-col gap-2.5 w-[680px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
)}
|
||||
<Autocomplete
|
||||
aria-label={typeof label === "string" ? label : name}
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
defaultItems={items}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
|
||||
@ -10,6 +10,7 @@ interface AuthContextType {
|
||||
|
||||
interface AuthContextProps extends React.PropsWithChildren {
|
||||
initialProviderTokens?: Provider[];
|
||||
initialProvidersAreSet?: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
@ -17,12 +18,15 @@ const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
|
||||
function AuthProvider({
|
||||
children,
|
||||
initialProviderTokens = [],
|
||||
initialProvidersAreSet = false,
|
||||
}: AuthContextProps) {
|
||||
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
|
||||
initialProviderTokens,
|
||||
);
|
||||
|
||||
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
|
||||
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(
|
||||
initialProvidersAreSet,
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@ -5,6 +5,7 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -16,11 +17,15 @@ export const useCreateConversation = () => {
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: { q?: string }) => {
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
q?: string;
|
||||
selectedRepository?: GitRepository | null;
|
||||
}) => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
|
||||
return OpenHands.createConversation(
|
||||
selectedRepository || undefined,
|
||||
variables.selectedRepository || undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
|
||||
@ -29,5 +29,10 @@ export const useLogout = () => {
|
||||
navigate("/");
|
||||
window.location.reload();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Home screen suggested tasks
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
queryClient.removeQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
15
frontend/src/hooks/query/use-suggested-tasks.ts
Normal file
15
frontend/src/hooks/query/use-suggested-tasks.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useSuggestedTasks = () => {
|
||||
const { providersAreSet } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: SuggestionsService.getSuggestedTasks,
|
||||
select: groupSuggestedTasks,
|
||||
enabled: providersAreSet,
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/use-is-creating-conversation.ts
Normal file
14
frontend/src/hooks/use-is-creating-conversation.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { useNavigation } from "react-router";
|
||||
|
||||
export const useIsCreatingConversation = () => {
|
||||
const navigation = useNavigation();
|
||||
const numberOfPendingMutations = useIsMutating({
|
||||
mutationKey: ["create-conversation"],
|
||||
});
|
||||
|
||||
const isNavigating = Boolean(navigation.location);
|
||||
const hasPendingMutations = numberOfPendingMutations > 0;
|
||||
|
||||
return hasPendingMutations || isNavigating;
|
||||
};
|
||||
@ -1,5 +1,16 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
HOME$CONNECT_PROVIDER_MESSAGE = "HOME$CONNECT_PROVIDER_MESSAGE",
|
||||
HOME$LETS_START_BUILDING = "HOME$LETS_START_BUILDING",
|
||||
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
|
||||
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$OPEN_ISSUE = "HOME$OPEN_ISSUE",
|
||||
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
|
||||
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
|
||||
HOME$RESOLVE_UNRESOLVED_COMMENTS = "HOME$RESOLVE_UNRESOLVED_COMMENTS",
|
||||
HOME$LAUNCH = "HOME$LAUNCH",
|
||||
CHAT$RESOLVER_INSTRUCTIONS = "CHAT$RESOLVER_INSTRUCTIONS",
|
||||
SETTINGS$ADVANCED = "SETTINGS$ADVANCED",
|
||||
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
|
||||
|
||||
@ -1,4 +1,169 @@
|
||||
{
|
||||
"HOME$CONNECT_PROVIDER_MESSAGE": {
|
||||
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
|
||||
"zh-CN": "要开始使用建议的任务,请连接您的GitHub或GitLab账户。",
|
||||
"zh-TW": "要開始使用建議的任務,請連接您的GitHub或GitLab帳戶。",
|
||||
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
|
||||
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
|
||||
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
|
||||
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
|
||||
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
|
||||
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
|
||||
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
|
||||
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
|
||||
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto."
|
||||
},
|
||||
"HOME$LETS_START_BUILDING": {
|
||||
"en": "Let's Start Building!",
|
||||
"ja": "構築を始めましょう!",
|
||||
"zh-CN": "让我们开始构建!",
|
||||
"zh-TW": "讓我們開始構建!",
|
||||
"ko-KR": "구축을 시작합시다!",
|
||||
"no": "La oss begynne å bygge!",
|
||||
"it": "Iniziamo a costruire!",
|
||||
"pt": "Vamos começar a construir!",
|
||||
"es": "¡Comencemos a construir!",
|
||||
"ar": "لنبدأ البناء!",
|
||||
"fr": "Commençons à construire !",
|
||||
"tr": "Hadi İnşa Etmeye Başlayalım!",
|
||||
"de": "Lass uns anfangen zu bauen!"
|
||||
},
|
||||
"HOME$OPENHANDS_DESCRIPTION": {
|
||||
"en": "OpenHands makes it easy to build and maintain software using AI-driven development.",
|
||||
"ja": "OpenHandsはAI駆動の開発を使用してソフトウェアの構築と維持を容易にします。",
|
||||
"zh-CN": "OpenHands使用AI驱动的开发方式,轻松构建和维护软件。",
|
||||
"zh-TW": "OpenHands使用AI驅動的開發方式,輕鬆構建和維護軟件。",
|
||||
"ko-KR": "OpenHands는 AI 기반 개발을 사용하여 소프트웨어를 쉽게 구축하고 유지할 수 있게 합니다.",
|
||||
"no": "OpenHands gjør det enkelt å bygge og vedlikeholde programvare ved hjelp av AI-drevet utvikling.",
|
||||
"it": "OpenHands rende facile costruire e mantenere software utilizzando lo sviluppo guidato dall'IA.",
|
||||
"pt": "OpenHands facilita a construção e manutenção de software usando desenvolvimento orientado por IA.",
|
||||
"es": "OpenHands facilita la construcción y el mantenimiento de software utilizando desarrollo impulsado por IA.",
|
||||
"ar": "يجعل OpenHands من السهل بناء وصيانة البرمجيات باستخدام التطوير المدعوم بالذكاء الاصطناعي.",
|
||||
"fr": "OpenHands facilite la création et la maintenance de logiciels grâce au développement piloté par l'IA.",
|
||||
"tr": "OpenHands, yapay zeka destekli geliştirme kullanarak yazılım oluşturmayı ve sürdürmeyi kolaylaştırır.",
|
||||
"de": "OpenHands macht es einfach, Software mit KI-gesteuerter Entwicklung zu erstellen und zu warten."
|
||||
},
|
||||
"HOME$NOT_SURE_HOW_TO_START": {
|
||||
"en": "Not sure how to start?",
|
||||
"ja": "始め方がわからない?",
|
||||
"zh-CN": "不确定如何开始?",
|
||||
"zh-TW": "不確定如何開始?",
|
||||
"ko-KR": "시작 방법을 모르시나요?",
|
||||
"no": "Usikker på hvordan du skal starte?",
|
||||
"it": "Non sei sicuro di come iniziare?",
|
||||
"pt": "Não tem certeza de como começar?",
|
||||
"es": "¿No está seguro de cómo empezar?",
|
||||
"ar": "غير متأكد من كيفية البدء؟",
|
||||
"fr": "Vous ne savez pas par où commencer ?",
|
||||
"tr": "Nasıl başlayacağınızdan emin değil misiniz?",
|
||||
"de": "Nicht sicher, wie man anfängt?"
|
||||
},
|
||||
"HOME$CONNECT_TO_REPOSITORY": {
|
||||
"en": "Connect to a Repository",
|
||||
"ja": "リポジトリに接続",
|
||||
"zh-CN": "连接到仓库",
|
||||
"zh-TW": "連接到存儲庫",
|
||||
"ko-KR": "저장소에 연결",
|
||||
"no": "Koble til et repository",
|
||||
"it": "Connetti a un repository",
|
||||
"pt": "Conectar a um repositório",
|
||||
"es": "Conectar a un repositorio",
|
||||
"ar": "الاتصال بمستودع",
|
||||
"fr": "Se connecter à un dépôt",
|
||||
"tr": "Bir Depoya Bağlan",
|
||||
"de": "Mit einem Repository verbinden"
|
||||
},
|
||||
"HOME$LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
"zh-CN": "加载中...",
|
||||
"zh-TW": "載入中...",
|
||||
"ko-KR": "로딩 중...",
|
||||
"no": "Laster...",
|
||||
"it": "Caricamento in corso...",
|
||||
"pt": "Carregando...",
|
||||
"es": "Cargando...",
|
||||
"ar": "جار التحميل...",
|
||||
"fr": "Chargement...",
|
||||
"tr": "Yükleniyor...",
|
||||
"de": "Wird geladen..."
|
||||
},
|
||||
"HOME$OPEN_ISSUE": {
|
||||
"en": "Open issue",
|
||||
"ja": "オープンな課題",
|
||||
"zh-CN": "打开问题",
|
||||
"zh-TW": "開放議題",
|
||||
"ko-KR": "열린 이슈",
|
||||
"no": "Åpent problem",
|
||||
"it": "Problema aperto",
|
||||
"pt": "Problema aberto",
|
||||
"es": "Problema abierto",
|
||||
"ar": "مشكلة مفتوحة",
|
||||
"fr": "Problème ouvert",
|
||||
"tr": "Açık sorun",
|
||||
"de": "Offenes Problem"
|
||||
},
|
||||
"HOME$FIX_FAILING_CHECKS": {
|
||||
"en": "Fix failing checks",
|
||||
"ja": "失敗したチェックを修正",
|
||||
"zh-CN": "修复失败的检查",
|
||||
"zh-TW": "修復失敗的檢查",
|
||||
"ko-KR": "실패한 검사 수정",
|
||||
"no": "Fikse mislykkede kontroller",
|
||||
"it": "Correggere i controlli falliti",
|
||||
"pt": "Corrigir verificações com falha",
|
||||
"es": "Corregir comprobaciones fallidas",
|
||||
"ar": "إصلاح الفحوصات الفاشلة",
|
||||
"fr": "Corriger les vérifications échouées",
|
||||
"tr": "Başarısız kontrolleri düzelt",
|
||||
"de": "Fehlgeschlagene Prüfungen beheben"
|
||||
},
|
||||
"HOME$RESOLVE_MERGE_CONFLICTS": {
|
||||
"en": "Resolve merge conflicts",
|
||||
"ja": "マージ競合を解決",
|
||||
"zh-CN": "解决合并冲突",
|
||||
"zh-TW": "解決合併衝突",
|
||||
"ko-KR": "병합 충돌 해결",
|
||||
"no": "Løse fletteproblemer",
|
||||
"it": "Risolvere i conflitti di unione",
|
||||
"pt": "Resolver conflitos de mesclagem",
|
||||
"es": "Resolver conflictos de fusión",
|
||||
"ar": "حل تعارضات الدمج",
|
||||
"fr": "Résoudre les conflits de fusion",
|
||||
"tr": "Birleştirme çakışmalarını çöz",
|
||||
"de": "Merge-Konflikte lösen"
|
||||
},
|
||||
"HOME$RESOLVE_UNRESOLVED_COMMENTS": {
|
||||
"en": "Resolve unresolved comments",
|
||||
"ja": "未解決のコメントを解決",
|
||||
"zh-CN": "解决未解决的评论",
|
||||
"zh-TW": "解決未解決的評論",
|
||||
"ko-KR": "미해결 댓글 해결",
|
||||
"no": "Løse uløste kommentarer",
|
||||
"it": "Risolvere i commenti non risolti",
|
||||
"pt": "Resolver comentários não resolvidos",
|
||||
"es": "Resolver comentarios no resueltos",
|
||||
"ar": "حل التعليقات غير المحلولة",
|
||||
"fr": "Résoudre les commentaires non résolus",
|
||||
"tr": "Çözülmemiş yorumları çöz",
|
||||
"de": "Ungelöste Kommentare beheben"
|
||||
},
|
||||
"HOME$LAUNCH": {
|
||||
"en": "Launch",
|
||||
"ja": "起動",
|
||||
"zh-CN": "启动",
|
||||
"zh-TW": "啟動",
|
||||
"ko-KR": "실행",
|
||||
"no": "Start",
|
||||
"it": "Avvia",
|
||||
"pt": "Iniciar",
|
||||
"es": "Iniciar",
|
||||
"ar": "تشغيل",
|
||||
"fr": "Lancer",
|
||||
"tr": "Başlat",
|
||||
"de": "Starten"
|
||||
},
|
||||
"CHAT$RESOLVER_INSTRUCTIONS": {
|
||||
"en": "Resolver Instructions",
|
||||
"ja": "リゾルバの指示",
|
||||
|
||||
18
frontend/src/icons/hands.svg
Normal file
18
frontend/src/icons/hands.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<svg width="102" height="69" viewBox="0 0 102 69" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_40000615_2266)">
|
||||
<g clip-path="url(#clip1_40000615_2266)">
|
||||
<path d="M97.9659 20.9744C94.7109 18.9968 92.5363 22.0276 92.8031 26.1428L92.7759 26.1739C92.785 21.8765 92.1973 17.1303 90.2443 13.2906C89.5526 11.9307 88.1511 9.69537 85.3798 10.7486C84.1637 11.2108 83.0606 12.6018 83.6302 16.1926C83.6302 16.1926 84.2631 19.9611 84.1456 24.6985V24.7651C83.3544 11.6996 80.3707 7.71333 76.103 7.97108C74.7376 8.21106 72.8705 8.79323 73.4989 12.8107C73.4989 12.8107 74.1816 17.0014 74.4031 20.3389L74.4167 20.5077H74.4031C72.3958 13.3039 69.6924 13.2062 67.7348 13.4861C65.9581 13.7395 64.0187 15.5615 64.9997 19.1523C68.0784 30.418 67.4771 43.9857 67.2466 45.9277C66.6182 44.5989 66.4238 43.5457 65.5512 42.088C62.043 36.2352 60.3748 35.8042 58.3269 35.7153C56.2925 35.6264 54.0953 36.8663 54.24 39.2261C54.3892 41.5859 55.6053 41.9769 57.3323 45.2655C58.6795 47.8253 59.0638 51.1806 61.7763 57.2778C64.0232 62.3262 69.8958 67.8635 80.5922 67.2058C89.2587 66.9214 102.202 63.9261 99.9506 44.2567C99.39 40.8393 99.8104 37.9773 100.104 35.0442C100.561 30.4935 101.23 22.952 97.9704 20.9699L97.9659 20.9744Z" fill="#E3D495"/>
|
||||
<path d="M43.8511 35.8532C41.8032 35.9821 40.1485 36.4398 36.7488 42.3548C35.9034 43.8258 35.7316 44.8835 35.1258 46.2212C34.8591 44.2835 34.0046 30.7292 36.8709 19.4102C37.7841 15.8061 35.813 14.0196 34.0318 13.7974C32.0697 13.553 29.3617 13.6996 27.4901 20.9745H27.4675L27.4946 20.7656C27.6528 17.4237 28.2586 13.2241 28.2586 13.2241C28.8056 9.19334 26.934 8.64672 25.5642 8.42896C21.3055 8.2512 18.4031 12.2553 17.8425 25.2052H17.8335C17.6391 20.5168 18.1951 16.7838 18.1951 16.7838C18.697 13.1797 17.5667 11.8109 16.3416 11.3709C13.5522 10.3666 12.1914 12.6286 11.5268 14.0018C9.64616 17.877 9.14434 22.6321 9.23476 26.9295L9.20763 26.8984C9.39299 22.7788 7.1642 19.788 3.94533 21.8233C0.726467 23.8631 1.5357 31.3914 2.07821 35.9332C2.43084 38.8618 2.90101 41.7149 2.40371 45.1413C0.523028 64.8462 13.5205 67.6059 22.1916 67.7348C32.897 68.2014 38.6657 62.553 40.8176 57.4646C43.4126 51.3229 43.7381 47.9588 45.0356 45.3768C46.6993 42.0571 47.9109 41.6438 48.0148 39.284C48.1188 36.9242 45.8991 35.7243 43.8647 35.8487L43.8511 35.8532Z" fill="#E3D495"/>
|
||||
<path d="M48.1374 35.8835C47.0524 34.8392 45.4204 34.2837 43.7612 34.3815C41.1029 34.5459 39.2177 35.5014 36.3198 40.1676C36.243 34.8303 36.5459 26.8177 38.3271 19.7694C38.9962 17.1208 38.3226 15.392 37.6354 14.4099C36.8352 13.2633 35.592 12.5078 34.2176 12.339C32.9653 12.1834 31.3288 12.1745 29.7329 13.4633C29.7329 13.4455 29.7374 13.4233 29.7374 13.4233C30.2528 9.63699 28.9237 7.4683 25.7907 6.97945L25.6189 6.96167C23.6885 6.87724 22.0339 7.4994 20.7002 8.80595C19.9859 9.50367 19.3665 10.4058 18.8286 11.5213C18.2273 10.6547 17.4542 10.2147 16.8439 9.99251C13.1142 8.64597 11.0527 11.5302 10.1575 13.37C9.11772 15.512 8.47123 17.9118 8.10956 20.3605C8.03271 20.3116 7.96037 20.2627 7.88352 20.2183C7.06072 19.7516 5.34731 19.1828 3.12304 20.5916C-0.647376 22.9825 -0.168163 29.853 0.582303 36.1102C0.622991 36.4435 0.663679 36.7724 0.704367 37.1057C1.02535 39.6699 1.32825 42.0919 0.912327 44.9405L0.903285 45.0116C0.148299 52.9176 1.66279 59.0015 5.40608 63.1033C9.0047 67.0497 14.6422 69.1028 22.1152 69.2139C22.6623 69.2361 23.1957 69.245 23.7156 69.2406C36.4826 69.125 41.0622 60.7036 42.1879 58.0371C43.6437 54.5885 44.3986 52.0154 44.9999 49.9445C45.4656 48.3447 45.8363 47.0825 46.3607 46.0337C46.9575 44.8427 47.4819 44.0695 47.943 43.3851C48.7296 42.2208 49.4078 41.2164 49.4892 39.3499C49.5479 37.9989 49.0777 36.799 48.1238 35.8835H48.1374ZM22.8205 10.8947C23.5393 10.1925 24.3666 9.88141 25.4155 9.90363C26.3242 10.0503 27.147 10.268 26.7717 13.0234C26.7446 13.1967 26.1569 17.3341 25.9987 20.7027C25.9987 20.7249 25.9987 20.7471 25.9987 20.7693C25.1714 24.0579 24.4797 28.7953 24.1089 35.7191C22.504 35.8169 20.9036 35.9858 19.3485 36.208C18.8466 22.1603 20.0085 13.6411 22.816 10.8947H22.8205ZM12.87 14.641C13.9957 12.3301 14.9406 12.4456 15.8177 12.7612C17.0338 13.2011 16.8439 15.5876 16.7038 16.5786C16.6811 16.7386 16.1386 20.4583 16.3285 25.1912C16.1884 28.5109 16.2065 32.3372 16.3737 36.7146C14.8864 37.0034 13.4758 37.3367 12.1874 37.6967C11.5771 35.6791 8.87811 22.8625 12.87 14.6454V14.641ZM45.4565 41.7541C44.9683 42.4741 44.3625 43.3718 43.6798 44.7316C43.0333 46.016 42.6355 47.3936 42.1246 49.1313C41.5414 51.1266 40.8136 53.6109 39.4257 56.9039C38.4401 59.2326 34.299 66.7963 22.2057 66.2631C15.465 66.1653 10.7 64.4854 7.63939 61.1302C4.48382 57.6727 3.2225 52.3487 3.88706 45.3138C4.34819 42.0963 4.00913 39.3721 3.6791 36.7412C3.63842 36.4124 3.59773 36.088 3.55704 35.7591C3.19537 32.7238 2.2279 24.6579 4.74603 23.0625C5.42416 22.6314 5.98023 22.5336 6.39163 22.7647C7.09237 23.1602 7.79762 24.6001 7.69816 26.831C7.69364 26.951 7.7072 27.0666 7.72981 27.1821C7.85639 32.3816 8.80126 36.9057 9.33924 38.6033C8.45767 38.9277 7.68008 39.2655 7.02907 39.6032C6.29669 39.9854 6.02092 40.8742 6.40971 41.5941C6.68097 42.0963 7.20539 42.3807 7.74789 42.3763C7.97846 42.3763 8.21354 42.3185 8.43507 42.203C12.0925 40.2965 20.768 38.3589 28.4128 38.5677C29.2447 38.5811 29.9273 37.95 29.9499 37.1368C29.9725 36.3235 29.3215 35.648 28.4942 35.6258C28.0376 35.6125 27.5765 35.6125 27.1153 35.6125C27.8794 21.6493 29.9816 17.0941 31.6181 15.7476C32.2963 15.1921 32.9427 15.1476 33.8379 15.2587C34.0865 15.2898 34.7104 15.432 35.158 16.072C35.6417 16.7697 35.7321 17.8007 35.4157 19.0539C32.6489 29.9907 33.2728 42.8518 33.5892 45.9715C33.535 46.0782 33.4852 46.1848 33.4265 46.2959C32.78 47.4914 31.5774 48.7357 30.1443 48.6513C29.326 48.6113 28.6072 49.2246 28.5575 50.0334C28.5078 50.8467 29.1362 51.5444 29.9635 51.5933C32.3867 51.7355 34.6697 50.2734 36.0712 47.6825C36.2204 47.407 36.347 47.1448 36.46 46.8914C36.469 46.8737 36.4781 46.8515 36.4871 46.8337C36.7493 46.256 36.9392 45.7271 37.1065 45.2516C37.3687 44.5139 37.5947 43.8739 38.0468 43.0829C41.2566 37.4923 42.5586 37.4123 43.9375 37.3279C44.7468 37.279 45.5515 37.5323 46.0262 37.9945C46.3652 38.3189 46.5144 38.7233 46.4918 39.2299C46.4466 40.2743 46.1527 40.7098 45.443 41.7586L45.4565 41.7541Z" fill="#0D0F11"/>
|
||||
<path d="M101.42 44.0245C100.954 41.1848 101.212 38.7583 101.483 36.1896C101.519 35.8563 101.556 35.5275 101.587 35.1942C102.22 28.9281 102.573 22.0442 98.7526 19.7244C96.5013 18.3557 94.7969 18.9556 93.9831 19.4356C93.9063 19.48 93.8339 19.5333 93.7571 19.5822C93.3457 17.1424 92.6585 14.756 91.578 12.6317C90.6512 10.8097 88.54 7.96104 84.8329 9.37425C84.2271 9.60534 83.4676 10.0586 82.8798 10.9386C82.3193 9.83198 81.6818 8.94317 80.954 8.25879C79.5977 6.9789 77.9295 6.3834 76.0036 6.50339L75.8318 6.52117C72.7079 7.06779 71.4194 9.2587 72.0071 13.045C72.0071 13.045 72.0071 13.0628 72.0117 13.0761C70.3932 11.814 68.7566 11.854 67.5089 12.0318C66.139 12.2273 64.9094 13.005 64.1318 14.1649C63.4672 15.1604 62.8207 16.898 63.5395 19.5333C65.4564 26.5505 65.9085 34.5587 65.9311 39.896C62.9473 35.2831 61.044 34.3631 58.3857 34.2476C56.7311 34.1765 55.0991 34.7675 54.0366 35.8297C53.1008 36.7629 52.6533 37.9717 52.7392 39.3182C52.8567 41.1803 53.5529 42.1758 54.3622 43.3223C54.8368 43.9978 55.3748 44.7622 55.9942 45.9399C56.5412 46.9798 56.9345 48.233 57.4318 49.824C58.0738 51.8816 58.874 54.4413 60.3975 57.8633C61.573 60.5075 66.3108 68.849 79.0416 68.729C79.557 68.7245 80.0905 68.7067 80.633 68.6712C88.1467 68.4268 93.7435 66.267 97.2698 62.2584C100.932 58.0899 102.333 51.9793 101.434 44.0867L101.424 44.0156L101.42 44.0245ZM85.1041 15.9692C84.9459 14.9649 84.7063 12.5829 85.9179 12.1251C86.7859 11.7918 87.7353 11.6629 88.9017 13.9516C93.0473 22.0976 90.5925 34.9631 90.0183 36.9896C88.7208 36.6518 87.3058 36.3452 85.8139 36.083C85.8953 31.7056 85.841 27.8748 85.6421 24.5596C85.7416 19.8266 85.1312 16.1159 85.1041 15.9692ZM76.2658 9.44091C77.3192 9.39647 78.151 9.69422 78.8789 10.3875C81.7361 13.085 83.0607 21.5776 82.8211 35.6341C81.2614 35.4386 79.661 35.3008 78.0515 35.2297C77.5543 28.3103 76.7721 23.5908 75.8815 20.3155C75.8815 20.2933 75.8815 20.2711 75.8815 20.2488C75.66 16.8802 74.9909 12.7562 74.9638 12.5962C74.5343 9.83643 75.3526 9.60534 76.2613 9.44091H76.2658ZM98.4588 44.46C99.2545 51.4816 98.0926 56.8278 95.0048 60.343C92.0075 63.7516 87.2741 65.5204 80.4928 65.7426C68.4673 66.4892 64.1725 59.0054 63.1462 56.6945C61.6905 53.4237 60.9174 50.9572 60.2981 48.9707C59.7556 47.2375 59.3306 45.8732 58.6615 44.5978C57.9562 43.2512 57.3324 42.3669 56.8306 41.6558C56.1027 40.6204 55.7998 40.1893 55.732 39.1449C55.7003 38.6383 55.845 38.2295 56.175 37.9006C56.6452 37.4295 57.4409 37.1584 58.2546 37.1984C59.6335 37.2607 60.9355 37.314 64.2538 42.8468C64.724 43.629 64.9591 44.2645 65.2349 44.9977C65.4157 45.4733 65.6146 46.0021 65.8904 46.5754C65.8994 46.5932 65.904 46.6109 65.913 46.6243C66.0351 46.8776 66.1662 47.1353 66.3199 47.4109C67.7711 49.9751 70.0812 51.3972 72.4999 51.2105C73.3227 51.1483 73.9421 50.4373 73.8788 49.6284C73.8155 48.8196 73.0967 48.2197 72.2693 48.273C70.8362 48.3797 69.6111 47.1576 68.942 45.9754C68.8787 45.8643 68.829 45.7621 68.7747 45.6555C69.0324 42.5357 69.4167 29.6613 66.4419 18.7779C66.0984 17.5291 66.1707 16.4981 66.6409 15.7915C67.0794 15.1426 67.6987 14.9871 67.9474 14.9515C68.838 14.8227 69.489 14.8582 70.1762 15.4004C71.8399 16.7203 74.028 21.2354 75.0497 35.1808C74.5886 35.1853 74.1274 35.1986 73.6753 35.2208C72.848 35.2564 72.2106 35.9452 72.2467 36.7585C72.2829 37.5717 72.9701 38.185 73.811 38.1628C81.4467 37.8162 90.163 39.5938 93.852 41.4381C94.0735 41.5492 94.3086 41.598 94.5437 41.598C95.0862 41.5936 95.6061 41.3003 95.8683 40.7892C96.2436 40.0649 95.9497 39.176 95.2083 38.8072C94.5528 38.4783 93.7661 38.1584 92.88 37.8473C93.3864 36.1408 94.2498 31.5989 94.277 26.3994C94.2996 26.2839 94.3086 26.1683 94.2996 26.0483C94.1549 23.8218 94.8376 22.3686 95.5293 21.9598C95.9361 21.7198 96.4922 21.8087 97.1794 22.2264C99.7292 23.7774 98.9154 31.8567 98.608 34.9009C98.5763 35.2297 98.5402 35.5541 98.504 35.883C98.2237 38.5228 97.9344 41.247 98.4588 44.46Z" fill="#0D0F11"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_40000615_2266">
|
||||
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_40000615_2266">
|
||||
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@ -8,7 +8,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { GitUser } from "#/types/git";
|
||||
import { GitRepository, GitUser } from "#/types/git";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
@ -105,13 +106,26 @@ const openHandsHandlers = [
|
||||
export const handlers = [
|
||||
...STRIPE_BILLING_HANDLERS,
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...openHandsHandlers,
|
||||
http.get("/api/user/repositories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: 1, full_name: "octocat/hello-world" },
|
||||
{ id: 2, full_name: "octocat/earth" },
|
||||
]),
|
||||
),
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: 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,
|
||||
},
|
||||
];
|
||||
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
http.get("/api/user/info", () => {
|
||||
const user: GitUser = {
|
||||
id: 1,
|
||||
@ -231,7 +245,9 @@ export const handlers = [
|
||||
},
|
||||
),
|
||||
|
||||
http.post("/api/conversations", () => {
|
||||
http.post("/api/conversations", async () => {
|
||||
await delay();
|
||||
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
title: "New Conversation",
|
||||
|
||||
76
frontend/src/mocks/task-suggestions-handlers.ts
Normal file
76
frontend/src/mocks/task-suggestions-handlers.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
|
||||
const TASKS_1: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 6968,
|
||||
title: "Fix merge conflicts",
|
||||
repo: "octocat/hello-world",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
},
|
||||
];
|
||||
|
||||
const TASKS_2: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 268,
|
||||
title: "Fix broken CI checks",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 281,
|
||||
title: "Fix issue",
|
||||
repo: "octocat/earth",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
},
|
||||
{
|
||||
issue_number: 293,
|
||||
title: "Update documentation",
|
||||
repo: "octocat/earth",
|
||||
task_type: "OPEN_ISSUE",
|
||||
},
|
||||
{
|
||||
issue_number: 305,
|
||||
title: "Refactor user service",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 312,
|
||||
title: "Fix styling bug",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 327,
|
||||
title: "Add unit tests",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 331,
|
||||
title: "Implement dark mode",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 345,
|
||||
title: "Optimize build process",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
{
|
||||
issue_number: 352,
|
||||
title: "Update dependencies",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_TASKS = [...TASKS_1, ...TASKS_2];
|
||||
|
||||
export const TASK_SUGGESTIONS_HANDLERS = [
|
||||
http.get("/api/user/suggested-tasks", async () =>
|
||||
HttpResponse.json(MOCK_TASKS),
|
||||
),
|
||||
];
|
||||
@ -310,6 +310,7 @@ function AccountSettings() {
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
wrapperClassName="w-[680px]"
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
@ -502,6 +503,7 @@ function AccountSettings() {
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
wrapperClassName="w-[680px]"
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,68 +1,35 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { setReplayJson } from "#/state/initial-query-slice";
|
||||
import { useGitUser } from "#/hooks/query/use-git-user";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { ReplaySuggestionBox } from "#/components/features/suggestions/replay-suggestion-box";
|
||||
import { GitRepositoriesSuggestionBox } from "#/components/features/git/git-repositories-suggestion-box";
|
||||
import { CodeNotInGitLink } from "#/components/features/git/code-not-in-github-link";
|
||||
import { HeroHeading } from "#/components/shared/hero-heading";
|
||||
import { TaskForm } from "#/components/shared/task-form";
|
||||
import { convertFileToText } from "#/utils/convert-file-to-text";
|
||||
import { ENABLE_TRAJECTORY_REPLAY } from "#/utils/feature-flags";
|
||||
import { PrefetchPageLinks } from "react-router";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
import { RepoConnector } from "#/components/features/home/repo-connector";
|
||||
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
function Home() {
|
||||
const dispatch = useDispatch();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
<PrefetchPageLinks page="/conversations/:conversationId" />;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
const { data: user } = useGitUser();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
function HomeScreen() {
|
||||
const { providersAreSet } = useAuth();
|
||||
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="home-screen"
|
||||
className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
|
||||
className="bg-base-secondary h-full flex flex-col rounded-xl px-[42px] pt-[42px] gap-8 overflow-y-auto"
|
||||
>
|
||||
<HeroHeading />
|
||||
<div className="flex flex-col gap-1 w-full mt-8 md:w-[600px] items-center">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<TaskForm ref={formRef} />
|
||||
</div>
|
||||
<HomeHeader />
|
||||
|
||||
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
|
||||
<GitRepositoriesSuggestionBox
|
||||
handleSubmit={() => formRef.current?.requestSubmit()}
|
||||
gitHubAuthUrl={gitHubAuthUrl}
|
||||
user={user || null}
|
||||
/>
|
||||
{ENABLE_TRAJECTORY_REPLAY() && (
|
||||
<ReplaySuggestionBox
|
||||
onChange={async (event) => {
|
||||
if (event.target.files) {
|
||||
const json = event.target.files[0];
|
||||
dispatch(setReplayJson(await convertFileToText(json)));
|
||||
posthog.capture("json_file_uploaded");
|
||||
formRef.current?.requestSubmit();
|
||||
} else {
|
||||
// TODO: handle error
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex justify-start mt-2 ml-2">
|
||||
<CodeNotInGitLink />
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex justify-between gap-4">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
export default HomeScreen;
|
||||
|
||||
@ -5,3 +5,11 @@
|
||||
.button-base {
|
||||
@apply bg-tertiary border border-neutral-600 rounded;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-400 rounded-md animate-pulse;
|
||||
}
|
||||
|
||||
28
frontend/src/utils/group-suggested-tasks.ts
Normal file
28
frontend/src/utils/group-suggested-tasks.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {
|
||||
SuggestedTask,
|
||||
SuggestedTaskGroup,
|
||||
} from "#/components/features/home/tasks/task.types";
|
||||
|
||||
/**
|
||||
* Groups suggested tasks by their repository.
|
||||
* @param tasks Array of suggested tasks
|
||||
* @returns Array of suggested task groups
|
||||
*/
|
||||
export function groupSuggestedTasks(
|
||||
tasks: SuggestedTask[],
|
||||
): SuggestedTaskGroup[] {
|
||||
const groupsMap: Record<string, SuggestedTaskGroup> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!groupsMap[task.repo]) {
|
||||
groupsMap[task.repo] = {
|
||||
title: task.repo,
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
groupsMap[task.repo].tasks.push(task);
|
||||
}
|
||||
|
||||
return Object.values(groupsMap);
|
||||
}
|
||||
@ -38,7 +38,7 @@ i18n.use(initReactI18next).init({
|
||||
},
|
||||
});
|
||||
|
||||
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
|
||||
export const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
|
||||
configureStore({
|
||||
reducer: rootReducer,
|
||||
preloadedState,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user