From 6523fcae6b9480b6a103f799e0586f0a7ce5492d Mon Sep 17 00:00:00 2001
From: "sp.wack" <83104063+amanape@users.noreply.github.com>
Date: Tue, 31 Dec 2024 00:53:27 +0400
Subject: [PATCH] feat(frontend): Multi-project support (#5376)
Co-authored-by: openhands
Co-authored-by: Robert Brennan
Co-authored-by: Robert Brennan
---
.../conversation-card.test.tsx | 274 ++++++++++++++++++
.../conversation-panel.test.tsx | 267 +++++++++++++++++
.../features/sidebar/sidebar.test.tsx | 46 +++
frontend/__tests__/routes/_oh.app.test.tsx | 83 ++++++
frontend/package-lock.json | 85 +++++-
frontend/package.json | 1 +
frontend/src/api/open-hands.ts | 65 ++++-
frontend/src/api/open-hands.types.ts | 10 +
.../context-menu/context-menu-list-item.tsx | 6 +-
.../features/context-menu/context-menu.tsx | 2 +-
.../confirm-delete-modal.tsx | 36 +++
.../conversation-panel/conversation-card.tsx | 102 +++++++
.../conversation-panel/conversation-panel.tsx | 128 ++++++++
.../conversation-repo-link.tsx | 21 ++
.../conversation-state-indicator.tsx | 40 +++
.../conversation-panel/ellipsis-button.tsx | 13 +
.../exit-conversation-modal.tsx | 34 +++
.../new-conversation-button.tsx | 16 +
.../state-indicators/cold.svg | 4 +
.../state-indicators/cooling.svg | 4 +
.../state-indicators/finished.svg | 4 +
.../state-indicators/running.svg | 4 +
.../state-indicators/waiting.svg | 4 +
.../state-indicators/warm.svg | 4 +
.../github-repositories-suggestion-box.tsx | 11 -
.../components/features/sidebar/sidebar.tsx | 32 +-
frontend/src/components/shared/task-form.tsx | 43 +--
.../hooks/mutation/use-create-conversation.ts | 48 +++
.../hooks/mutation/use-delete-conversation.ts | 14 +
.../hooks/mutation/use-update-conversation.ts | 18 ++
.../query/get-conversation-permissions.ts | 11 +
frontend/src/hooks/query/use-list-files.ts | 6 +-
.../src/hooks/query/use-user-conversations.ts | 13 +
frontend/src/mocks/handlers.ts | 155 ++++++++--
frontend/src/mocks/handlers.ws.ts | 149 +++-------
frontend/src/mocks/mock-ws-helpers.ts | 73 +++++
frontend/src/mocks/session-history.mock.ts | 107 +++++++
frontend/src/routes/_oh.app/route.tsx | 27 +-
frontend/src/types/core/actions.ts | 2 +-
frontend/src/types/core/variances.ts | 5 +-
frontend/src/utils/constants.ts | 1 +
frontend/tests/conversation-panel.test.ts | 113 ++++++++
frontend/tests/helpers/confirm-settings.ts | 20 ++
frontend/tests/redirect.spec.ts | 85 ++----
44 files changed, 1921 insertions(+), 265 deletions(-)
create mode 100644 frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
create mode 100644 frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
create mode 100644 frontend/__tests__/components/features/sidebar/sidebar.test.tsx
create mode 100644 frontend/__tests__/routes/_oh.app.test.tsx
create mode 100644 frontend/src/components/features/conversation-panel/confirm-delete-modal.tsx
create mode 100644 frontend/src/components/features/conversation-panel/conversation-card.tsx
create mode 100644 frontend/src/components/features/conversation-panel/conversation-panel.tsx
create mode 100644 frontend/src/components/features/conversation-panel/conversation-repo-link.tsx
create mode 100644 frontend/src/components/features/conversation-panel/conversation-state-indicator.tsx
create mode 100644 frontend/src/components/features/conversation-panel/ellipsis-button.tsx
create mode 100644 frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx
create mode 100644 frontend/src/components/features/conversation-panel/new-conversation-button.tsx
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/cold.svg
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/cooling.svg
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/finished.svg
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/running.svg
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/waiting.svg
create mode 100644 frontend/src/components/features/conversation-panel/state-indicators/warm.svg
create mode 100644 frontend/src/hooks/mutation/use-create-conversation.ts
create mode 100644 frontend/src/hooks/mutation/use-delete-conversation.ts
create mode 100644 frontend/src/hooks/mutation/use-update-conversation.ts
create mode 100644 frontend/src/hooks/query/get-conversation-permissions.ts
create mode 100644 frontend/src/hooks/query/use-user-conversations.ts
create mode 100644 frontend/src/mocks/mock-ws-helpers.ts
create mode 100644 frontend/src/mocks/session-history.mock.ts
create mode 100644 frontend/src/utils/constants.ts
create mode 100644 frontend/tests/conversation-panel.test.ts
create mode 100644 frontend/tests/helpers/confirm-settings.ts
diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
new file mode 100644
index 000000000000..749bc6c48de0
--- /dev/null
+++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
@@ -0,0 +1,274 @@
+import { render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, test, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { formatTimeDelta } from "#/utils/format-time-delta";
+import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
+
+describe("ConversationCard", () => {
+ const onClick = vi.fn();
+ const onDelete = vi.fn();
+ const onChangeTitle = vi.fn();
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render the conversation card", () => {
+ render(
+ ,
+ );
+ const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
+
+ const card = screen.getByTestId("conversation-card");
+ const title = within(card).getByTestId("conversation-card-title");
+
+ expect(title).toHaveValue("Conversation 1");
+ within(card).getByText(expectedDate);
+ });
+
+ it("should render the repo if available", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-repo"),
+ ).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ screen.getByTestId("conversation-card-repo");
+ });
+
+ it("should call onClick when the card is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const card = screen.getByTestId("conversation-card");
+ await user.click(card);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ it("should toggle a context menu when clicking the ellipsis button", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ screen.getByTestId("context-menu");
+
+ await user.click(ellipsisButton);
+
+ expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
+ });
+
+ it("should call onDelete when the delete button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onDelete).toHaveBeenCalled();
+ });
+
+ test("clicking the repo should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const repo = screen.getByTestId("conversation-card-repo");
+ await user.click(repo);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("conversation title should call onChangeTitle when changed and blurred", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.type(title, "New Conversation Name ");
+ await user.tab();
+
+ expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
+ expect(title).toHaveValue("New Conversation Name");
+ });
+
+ it("should reset title and not call onChangeTitle when the title is empty", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.tab();
+
+ expect(onChangeTitle).not.toHaveBeenCalled();
+ expect(title).toHaveValue("Conversation 1");
+ });
+
+ test("clicking the title should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+ await user.click(title);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("clicking the delete button should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ describe("state indicator", () => {
+ it("should render the 'cold' indicator by default", () => {
+ render(
+ ,
+ );
+
+ screen.getByTestId("cold-indicator");
+ });
+
+ it("should render the other indicators when provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
+ screen.getByTestId("warm-indicator");
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
new file mode 100644
index 000000000000..5a1d703b22ce
--- /dev/null
+++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
@@ -0,0 +1,267 @@
+import { render, screen, within } from "@testing-library/react";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ QueryClientProvider,
+ QueryClient,
+ QueryClientConfig,
+} from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
+import OpenHands from "#/api/open-hands";
+import { AuthProvider } from "#/context/auth-context";
+
+describe("ConversationPanel", () => {
+ const onCloseMock = vi.fn();
+
+ const renderConversationPanel = (config?: QueryClientConfig) =>
+ render(, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ const { endSessionMock } = vi.hoisted(() => ({
+ endSessionMock: vi.fn(),
+ }));
+
+ beforeAll(() => {
+ vi.mock("react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Link: ({ children }: React.PropsWithChildren) => children,
+ useNavigate: vi.fn(() => vi.fn()),
+ useLocation: vi.fn(() => ({ pathname: "/conversation" })),
+ useParams: vi.fn(() => ({ conversationId: "2" })),
+ }));
+
+ vi.mock("#/hooks/use-end-session", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useEndSession: vi.fn(() => endSessionMock),
+ }));
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it("should render the conversations", async () => {
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ expect(cards).toHaveLength(3);
+ });
+
+ it("should display an empty state when there are no conversations", async () => {
+ const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
+ getUserConversationsSpy.mockResolvedValue([]);
+
+ renderConversationPanel();
+
+ const emptyState = await screen.findByText("No conversations found");
+ expect(emptyState).toBeInTheDocument();
+ });
+
+ it("should handle an error when fetching conversations", async () => {
+ const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
+ getUserConversationsSpy.mockRejectedValue(
+ new Error("Failed to fetch conversations"),
+ );
+
+ renderConversationPanel({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ const error = await screen.findByText("Failed to fetch conversations");
+ expect(error).toBeInTheDocument();
+ });
+
+ it("should cancel deleting a conversation", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ expect(
+ within(cards[0]).queryByTestId("delete-button"),
+ ).not.toBeInTheDocument();
+
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Cancel the deletion
+ const cancelButton = screen.getByText("Cancel");
+ await user.click(cancelButton);
+
+ expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
+
+ // Ensure the conversation is not deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ });
+
+ it("should call endSession after deleting a conversation that is the current session", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ const ellipsisButton = within(cards[1]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the second delete button
+ await user.click(deleteButton);
+
+ // Confirm the deletion
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
+
+ // Ensure the conversation is deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ });
+
+ it("should delete a conversation", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Confirm the deletion
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
+
+ // Ensure the conversation is deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(1);
+ });
+
+ it("should rename a conversation", async () => {
+ const updateUserConversationSpy = vi.spyOn(
+ OpenHands,
+ "updateUserConversation",
+ );
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const title = within(cards[0]).getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.type(title, "Conversation 1 Renamed");
+ await user.tab();
+
+ // Ensure the conversation is renamed
+ expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
+ name: "Conversation 1 Renamed",
+ });
+ });
+
+ it("should not rename a conversation when the name is unchanged", async () => {
+ const updateUserConversationSpy = vi.spyOn(
+ OpenHands,
+ "updateUserConversation",
+ );
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const title = within(cards[0]).getByTestId("conversation-card-title");
+
+ await user.click(title);
+ await user.tab();
+
+ // Ensure the conversation is not renamed
+ expect(updateUserConversationSpy).not.toHaveBeenCalled();
+
+ await user.type(title, "Conversation 1");
+ await user.click(title);
+ await user.tab();
+
+ expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
+
+ await user.click(title);
+ await user.tab();
+
+ expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose after clicking a card", async () => {
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const firstCard = cards[0];
+
+ await userEvent.click(firstCard);
+
+ expect(onCloseMock).toHaveBeenCalledOnce();
+ });
+
+ describe("New Conversation Button", () => {
+ it("should display a confirmation modal when clicking", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ expect(
+ screen.queryByTestId("confirm-new-conversation-modal"),
+ ).not.toBeInTheDocument();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const modal = screen.getByTestId("confirm-new-conversation-modal");
+ expect(modal).toBeInTheDocument();
+ });
+
+ it("should call endSession and close panel after confirming", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ expect(onCloseMock).toHaveBeenCalledOnce();
+ });
+
+ it("should close the modal when cancelling", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ await user.click(cancelButton);
+
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(
+ screen.queryByTestId("confirm-new-conversation-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx
new file mode 100644
index 000000000000..40d0ea4a48bc
--- /dev/null
+++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx
@@ -0,0 +1,46 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { createRoutesStub } from "react-router";
+import { Sidebar } from "#/components/features/sidebar/sidebar";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
+
+const renderSidebar = () => {
+ const RouterStub = createRoutesStub([
+ {
+ path: "/conversation/:conversationId",
+ Component: Sidebar,
+ },
+ ]);
+
+ renderWithProviders();
+};
+
+describe("Sidebar", () => {
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should have the conversation panel open by default",
+ () => {
+ renderSidebar();
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ },
+ );
+
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should toggle the conversation panel",
+ async () => {
+ const user = userEvent.setup();
+ renderSidebar();
+
+ const projectPanelButton = screen.getByTestId(
+ "toggle-conversation-panel",
+ );
+
+ await user.click(projectPanelButton);
+
+ expect(
+ screen.queryByTestId("conversation-panel"),
+ ).not.toBeInTheDocument();
+ },
+ );
+});
diff --git a/frontend/__tests__/routes/_oh.app.test.tsx b/frontend/__tests__/routes/_oh.app.test.tsx
new file mode 100644
index 000000000000..2addbc5fe604
--- /dev/null
+++ b/frontend/__tests__/routes/_oh.app.test.tsx
@@ -0,0 +1,83 @@
+import { createRoutesStub } from "react-router";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { screen, waitFor } from "@testing-library/react";
+import toast from "react-hot-toast";
+import App from "#/routes/_oh.app/route";
+import OpenHands from "#/api/open-hands";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
+
+describe("App", () => {
+ const RouteStub = createRoutesStub([
+ { Component: App, path: "/conversation/:conversationId" },
+ ]);
+
+ const { endSessionMock } = vi.hoisted(() => ({
+ endSessionMock: vi.fn(),
+ }));
+
+ beforeAll(() => {
+ vi.mock("#/hooks/use-end-session", () => ({
+ useEndSession: vi.fn(() => endSessionMock),
+ }));
+
+ vi.mock("#/hooks/use-terminal", () => ({
+ useTerminal: vi.fn(),
+ }));
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render", async () => {
+ renderWithProviders();
+ await screen.findByTestId("app-route");
+ });
+
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should call endSession if the user does not have permission to view conversation",
+ async () => {
+ const errorToastSpy = vi.spyOn(toast, "error");
+ const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
+
+ getConversationSpy.mockResolvedValue(null);
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ expect(errorToastSpy).toHaveBeenCalledOnce();
+ });
+ },
+ );
+
+ it("should not call endSession if the user has permission", async () => {
+ const errorToastSpy = vi.spyOn(toast, "error");
+ const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
+
+ getConversationSpy.mockResolvedValue({
+ conversation_id: "9999",
+ lastUpdated: "",
+ name: "",
+ repo: "",
+ state: "cold",
+ });
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(errorToastSpy).not.toHaveBeenCalled();
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(errorToastSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 138620acc36a..973a16d01049 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -48,6 +48,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
+ "@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
@@ -1626,6 +1627,21 @@
"node": ">=18"
}
},
+ "node_modules/@mswjs/socket.io-binding": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@mswjs/socket.io-binding/-/socket.io-binding-0.1.1.tgz",
+ "integrity": "sha512-mtFDHC5XMeti43toe3HBynD4uBxvUA2GfJVC6TDfhOQlH+G2hf5znNTSa75A30XdWL0P6aNqUKpcNo6L0Wop+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mswjs/interceptors": "^0.37.1",
+ "engine.io-parser": "^5.2.3",
+ "socket.io-parser": "^4.2.4"
+ },
+ "peerDependencies": {
+ "@mswjs/interceptors": "*"
+ }
+ },
"node_modules/@nextui-org/accordion": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@nextui-org/accordion/-/accordion-2.2.6.tgz",
@@ -5358,6 +5374,7 @@
"version": "5.62.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz",
"integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==",
+ "license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.9"
},
@@ -8133,9 +8150,9 @@
"license": "MIT"
},
"node_modules/es-abstract": {
- "version": "1.23.7",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz",
- "integrity": "sha512-OygGC8kIcDhXX+6yAZRGLqwi2CmEXCbLQixeGUgYeR+Qwlppqmo7DIDr8XibtEBZp+fJcoYpoatp5qwLMEdcqQ==",
+ "version": "1.23.8",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.8.tgz",
+ "integrity": "sha512-lfab8IzDn6EpI1ibZakcgS6WsfEBiB+43cuJo+wgylx1xKXf+Sp+YR3vFuQwC/u3sxYwV8Cxe3B0DpVUu/WiJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8174,8 +8191,10 @@
"object-inspect": "^1.13.3",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
+ "own-keys": "^1.0.0",
"regexp.prototype.flags": "^1.5.3",
"safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
@@ -11192,6 +11211,7 @@
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
"integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"chalk": "~5.4.1",
"commander": "~12.1.0",
@@ -11219,6 +11239,7 @@
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
@@ -13277,6 +13298,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13560,14 +13599,14 @@
}
},
"node_modules/pkg-types": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz",
- "integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
+ "integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
- "mlly": "^1.7.2",
+ "mlly": "^1.7.3",
"pathe": "^1.1.2"
}
},
@@ -13774,6 +13813,7 @@
"version": "1.203.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.2.tgz",
"integrity": "sha512-3aLpEhM4i9sQQtobRmDttJ3rTW1+gwQ9HL7QiOeDueE2T7CguYibYS7weY1UhXMerx5lh1A7+szlOJTTibifLQ==",
+ "license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
@@ -13788,9 +13828,9 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
- "version": "10.25.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.3.tgz",
- "integrity": "sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==",
+ "version": "10.25.4",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz",
+ "integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -14123,6 +14163,7 @@
"version": "15.4.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz",
"integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==",
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
@@ -14890,6 +14931,30 @@
],
"license": "MIT"
},
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 735aa2930d7f..1048fea0df75 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -75,6 +75,7 @@
]
},
"devDependencies": {
+ "@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts
index 84d254102c08..a65301f8a101 100644
--- a/frontend/src/api/open-hands.ts
+++ b/frontend/src/api/open-hands.ts
@@ -8,8 +8,10 @@ import {
GetConfigResponse,
GetVSCodeUrlResponse,
AuthenticateResponse,
+ Conversation,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
+import { Settings } from "#/services/settings";
class OpenHands {
/**
@@ -219,6 +221,54 @@ class OpenHands {
return data;
}
+ static async getUserConversations(): Promise {
+ const { data } = await openHands.get("/api/conversations");
+ return data;
+ }
+
+ static async deleteUserConversation(conversationId: string): Promise {
+ await openHands.delete(`/api/conversations/${conversationId}`);
+ }
+
+ static async updateUserConversation(
+ conversationId: string,
+ conversation: Partial>,
+ ): Promise {
+ await openHands.put(`/api/conversations/${conversationId}`, conversation);
+ }
+
+ static async createConversation(
+ settings: Settings,
+ githubToken?: string,
+ selectedRepository?: string,
+ ): Promise {
+ const body = {
+ github_token: githubToken,
+ args: settings,
+ selected_repository: selectedRepository,
+ };
+
+ const { data } = await openHands.post(
+ "/api/conversations",
+ body,
+ );
+
+ // TODO: remove this once we have a multi-conversation UI
+ localStorage.setItem("latest_conversation_id", data.conversation_id);
+
+ return data;
+ }
+
+ static async getConversation(
+ conversationId: string,
+ ): Promise {
+ const { data } = await openHands.get(
+ `/api/conversations/${conversationId}`,
+ );
+
+ return data;
+ }
+
static async searchEvents(
conversationId: string,
params: {
@@ -247,21 +297,6 @@ class OpenHands {
});
return data;
}
-
- static async newConversation(params: {
- githubToken?: string;
- selectedRepository?: string;
- }): Promise<{ conversation_id: string }> {
- const { data } = await openHands.post<{
- conversation_id: string;
- }>("/api/conversations", {
- github_token: params.githubToken,
- selected_repository: params.selectedRepository,
- });
- // TODO: remove this once we have a multi-conversation UI
- localStorage.setItem("latest_conversation_id", data.conversation_id);
- return data;
- }
}
export default OpenHands;
diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts
index 919d370751ca..c17d2016816d 100644
--- a/frontend/src/api/open-hands.types.ts
+++ b/frontend/src/api/open-hands.types.ts
@@ -1,3 +1,5 @@
+import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
+
export interface ErrorResponse {
error: string;
}
@@ -57,3 +59,11 @@ export interface AuthenticateResponse {
message?: string;
error?: string;
}
+
+export interface Conversation {
+ conversation_id: string;
+ name: string;
+ repo: string | null;
+ lastUpdated: string;
+ state: ProjectState;
+}
diff --git a/frontend/src/components/features/context-menu/context-menu-list-item.tsx b/frontend/src/components/features/context-menu/context-menu-list-item.tsx
index 606090229cd9..b35ca44395a1 100644
--- a/frontend/src/components/features/context-menu/context-menu-list-item.tsx
+++ b/frontend/src/components/features/context-menu/context-menu-list-item.tsx
@@ -1,18 +1,20 @@
import { cn } from "#/utils/utils";
interface ContextMenuListItemProps {
- onClick: () => void;
+ testId?: string;
+ onClick: (event: React.MouseEvent) => void;
isDisabled?: boolean;
}
export function ContextMenuListItem({
children,
+ testId,
onClick,
isDisabled,
}: React.PropsWithChildren) {
return (
- }
- />
- );
- }
-
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
return (
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx
index 5afc1aa9d24e..85a004f0a336 100644
--- a/frontend/src/components/features/sidebar/sidebar.tsx
+++ b/frontend/src/components/features/sidebar/sidebar.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { useLocation } from "react-router";
+import FolderIcon from "#/icons/docs.svg?react";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
@@ -13,6 +14,9 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
+import { ConversationPanel } from "../conversation-panel/conversation-panel";
+import { cn } from "#/utils/utils";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export function Sidebar() {
const location = useLocation();
@@ -28,6 +32,9 @@ export function Sidebar() {
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
+ const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
+ MULTI_CONVO_UI_IS_ENABLED,
+ );
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
@@ -54,7 +61,7 @@ export function Sidebar() {
return (
<>
-