From d8b33c4e78a3dd9796c5def833877a5d91e4e42b Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:23:26 +0400 Subject: [PATCH 1/5] Fix: Allow form submission with empty query if repo/files present (#5919) Co-authored-by: openhands --- frontend/src/components/shared/task-form.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/shared/task-form.tsx b/frontend/src/components/shared/task-form.tsx index bc6b53c24748..b10044e7a826 100644 --- a/frontend/src/components/shared/task-form.tsx +++ b/frontend/src/components/shared/task-form.tsx @@ -44,7 +44,11 @@ export function TaskForm({ ref }: TaskFormProps) { const [inputIsFocused, setInputIsFocused] = React.useState(false); const newConversationMutation = useMutation({ mutationFn: (variables: { q?: string }) => { - if (variables.q) dispatch(setInitialQuery(variables.q)); + if (!variables.q?.trim() && !selectedRepository && files.length === 0) { + throw new Error("No query provided"); + } + + if (variables.q?.trim()) dispatch(setInitialQuery(variables.q)); return OpenHands.newConversation({ githubToken: gitHubToken || undefined, selectedRepository: selectedRepository || undefined, @@ -90,9 +94,7 @@ export function TaskForm({ ref }: TaskFormProps) { const formData = new FormData(event.currentTarget); const q = formData.get("q")?.toString(); - if (q?.trim()) { - newConversationMutation.mutate({ q }); - } + newConversationMutation.mutate({ q }); }; return ( From bb578a2e9dfe467d4451664b2eb793222e4fb4b6 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Mon, 30 Dec 2024 13:15:51 -0500 Subject: [PATCH 2/5] Fix remote runtime (#5923) --- openhands/runtime/impl/remote/remote_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py index ba59f2281d77..a4d268c3d3a4 100644 --- a/openhands/runtime/impl/remote/remote_runtime.py +++ b/openhands/runtime/impl/remote/remote_runtime.py @@ -363,7 +363,7 @@ def _send_runtime_api_request(self, method, url, **kwargs): def _send_action_server_request(self, method, url, **kwargs): try: - super()._send_action_server_request(method, url, **kwargs) + return super()._send_action_server_request(method, url, **kwargs) except requests.Timeout: self.log('error', 'No response received within the timeout period.') raise From d7a3ec69d9006438f03f45ada6b2a7e638ad96ff Mon Sep 17 00:00:00 2001 From: tofarr Date: Mon, 30 Dec 2024 11:51:56 -0700 Subject: [PATCH 3/5] Refactor to make FileConversationStore more extendable (#5922) --- .../storage/conversation/file_conversation_store.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py index c45dd9b08bab..b77555fcd51e 100644 --- a/openhands/storage/conversation/file_conversation_store.py +++ b/openhands/storage/conversation/file_conversation_store.py @@ -18,22 +18,25 @@ class FileConversationStore(ConversationStore): async def save_metadata(self, metadata: ConversationMetadata): json_str = json.dumps(metadata.__dict__) - path = get_conversation_metadata_filename(metadata.conversation_id) + path = self.get_conversation_metadata_filename(metadata.conversation_id) await call_sync_from_async(self.file_store.write, path, json_str) async def get_metadata(self, conversation_id: str) -> ConversationMetadata: - path = get_conversation_metadata_filename(conversation_id) + path = self.get_conversation_metadata_filename(conversation_id) json_str = await call_sync_from_async(self.file_store.read, path) return ConversationMetadata(**json.loads(json_str)) async def exists(self, conversation_id: str) -> bool: - path = get_conversation_metadata_filename(conversation_id) + path = self.get_conversation_metadata_filename(conversation_id) try: await call_sync_from_async(self.file_store.read, path) return True except FileNotFoundError: return False + def get_conversation_metadata_filename(self, conversation_id: str) -> str: + return get_conversation_metadata_filename(conversation_id) + @classmethod async def get_instance(cls, config: AppConfig, token: str | None): file_store = get_file_store(config.file_store, config.file_store_path) 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 4/5] 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 ( + ); +} diff --git a/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx b/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx new file mode 100644 index 000000000000..6442598cbbc4 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx @@ -0,0 +1,34 @@ +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { ModalButton } from "#/components/shared/buttons/modal-button"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; + +interface ExitConversationModalProps { + onConfirm: () => void; + onClose: () => void; +} + +export function ExitConversationModal({ + onConfirm, + onClose, +}: ExitConversationModalProps) { + return ( + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/new-conversation-button.tsx b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx new file mode 100644 index 000000000000..b7563952cfce --- /dev/null +++ b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx @@ -0,0 +1,16 @@ +interface NewConversationButtonProps { + onClick: () => void; +} + +export function NewConversationButton({ onClick }: NewConversationButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/conversation-panel/state-indicators/cold.svg b/frontend/src/components/features/conversation-panel/state-indicators/cold.svg new file mode 100644 index 000000000000..95b513851439 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/cold.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg b/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg new file mode 100644 index 000000000000..ef65bfa11c06 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/finished.svg b/frontend/src/components/features/conversation-panel/state-indicators/finished.svg new file mode 100644 index 000000000000..311d524d1774 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/finished.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/running.svg b/frontend/src/components/features/conversation-panel/state-indicators/running.svg new file mode 100644 index 000000000000..5537583da544 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/running.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg b/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg new file mode 100644 index 000000000000..a73aa2b27653 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/warm.svg b/frontend/src/components/features/conversation-panel/state-indicators/warm.svg new file mode 100644 index 000000000000..e7432e75315d --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/warm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx index 227ef8a5bdcb..f5bd2a740068 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -31,17 +31,6 @@ export function GitHubRepositoriesSuggestionBox({ } }; - if (isGitHubErrorReponse(repositories)) { - return ( - {repositories.message}

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