mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Multi-project support (#5376)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Robert Brennan <contact@rbren.io> Co-authored-by: Robert Brennan <accounts@rbren.io>
This commit is contained in:
parent
d7a3ec69d9
commit
6523fcae6b
@ -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(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(<ConversationPanel onClose={onCloseMock} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient(config)}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { endSessionMock } = vi.hoisted(() => ({
|
||||
endSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
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<typeof import("#/hooks/use-end-session")>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
};
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
83
frontend/__tests__/routes/_oh.app.test.tsx
Normal file
83
frontend/__tests__/routes/_oh.app.test.tsx
Normal file
@ -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(<RouteStub initialEntries={["/conversation/123"]} />);
|
||||
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(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(errorToastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
rerender(<RouteStub initialEntries={["/conversation"]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(errorToastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
85
frontend/package-lock.json
generated
85
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
await openHands.delete(`/api/conversations/${conversationId}`);
|
||||
}
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
settings: Settings,
|
||||
githubToken?: string,
|
||||
selectedRepository?: string,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
github_token: githubToken,
|
||||
args: settings,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
"/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<Conversation | null> {
|
||||
const { data } = await openHands.get<Conversation | null>(
|
||||
`/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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuListItemProps {
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ContextMenuListItem({
|
||||
children,
|
||||
testId,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: React.PropsWithChildren<ContextMenuListItemProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid="context-menu-list-item"
|
||||
data-testid={testId || "context-menu-list-item"}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
|
||||
@ -2,7 +2,7 @@ import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuProps {
|
||||
ref: React.RefObject<HTMLUListElement | null>;
|
||||
ref?: React.RefObject<HTMLUListElement | null>;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title="Are you sure you want to delete this project?" />
|
||||
<BaseModalDescription description="All data associated with this project will be lost." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-[#4465DB]"
|
||||
text="Confirm"
|
||||
/>
|
||||
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectState,
|
||||
ConversationStateIndicator,
|
||||
} from "./conversation-state-indicator";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { EllipsisButton } from "./ellipsis-button";
|
||||
|
||||
interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
const trimmed = inputRef.current.value.trim();
|
||||
onChangeTitle(trimmed);
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = name;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
type="text"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator state={state} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
<ConversationRepoLink
|
||||
repo={repo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { NewConversationButton } from "./new-conversation-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ExitConversationModal } from "./exit-conversation-modal";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { conversationId: cid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const endSession = useEndSession();
|
||||
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [
|
||||
confirmExitConversationModalVisible,
|
||||
setConfirmExitConversationModalVisible,
|
||||
] = React.useState(false);
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedConversationId) {
|
||||
deleteConversation({ conversationId: selectedConversationId });
|
||||
setConfirmDeleteModalVisible(false);
|
||||
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeTitle = (
|
||||
conversationId: string,
|
||||
oldTitle: string,
|
||||
newTitle: string,
|
||||
) => {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickCard = (conversationId: string) => {
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
>
|
||||
<div className="pt-4 px-4 flex items-center justify-between">
|
||||
{location.pathname.startsWith("/conversation") && (
|
||||
<NewConversationButton
|
||||
onClick={() => setConfirmExitConversationModalVisible(true)}
|
||||
/>
|
||||
)}
|
||||
{isFetching && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">No conversations found</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
<ConversationCard
|
||||
key={project.conversation_id}
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
/>
|
||||
))}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setConfirmDeleteModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmExitConversationModalVisible && (
|
||||
<ExitConversationModal
|
||||
onConfirm={() => {
|
||||
endSession();
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setConfirmExitConversationModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
repo: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
repo,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{repo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import ColdIcon from "./state-indicators/cold.svg?react";
|
||||
import CoolingIcon from "./state-indicators/cooling.svg?react";
|
||||
import FinishedIcon from "./state-indicators/finished.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import WaitingIcon from "./state-indicators/waiting.svg?react";
|
||||
import WarmIcon from "./state-indicators/warm.svg?react";
|
||||
|
||||
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export type ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
state: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
state,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[state];
|
||||
|
||||
return (
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { FaEllipsisV } from "react-icons/fa";
|
||||
|
||||
interface EllipsisButtonProps {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
|
||||
return (
|
||||
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
|
||||
<FaEllipsisV fill="#a3a3a3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<ModalBackdrop>
|
||||
<ModalBody testID="confirm-new-conversation-modal">
|
||||
<BaseModalTitle title="Creating a new conversation will replace your active conversation" />
|
||||
<div className="flex w-full gap-2">
|
||||
<ModalButton
|
||||
text="Confirm"
|
||||
onClick={onConfirm}
|
||||
className="bg-[#C63143] flex-1"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Cancel"
|
||||
onClick={onClose}
|
||||
className="bg-neutral-700 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
interface NewConversationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NewConversationButton({ onClick }: NewConversationButtonProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid="new-conversation-button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="font-bold bg-[#4465DB] px-2 py-1 rounded"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2C9.87012 1.44772 9.4224 1 8.87012 1C8.31783 1 7.87012 1.44772 7.87012 2V8C7.87012 8.55228 8.31783 9 8.87012 9C9.4224 9 9.87012 8.55228 9.87012 8V2Z" fill="#A7A9AC"/>
|
||||
<path d="M10.8698 2.42001V2.56001C10.8698 2.93001 11.0698 3.28001 11.4098 3.43001C13.6798 4.43001 15.2198 6.80001 14.9698 9.48001C14.6998 12.47 12.0998 14.87 9.08979 14.92C5.73979 14.97 2.98979 12.26 2.98979 8.92001C2.98979 6.57001 4.34979 4.54001 6.30979 3.56001C6.63979 3.40001 6.85979 3.08001 6.85979 2.72001V2.55001C6.85979 1.86001 6.13979 1.43001 5.50979 1.73001C2.43979 3.20001 0.449793 6.62001 1.13979 10.41C1.70979 13.57 4.23979 16.14 7.38979 16.76C12.5098 17.76 16.9998 13.86 16.9998 8.92001C16.9998 5.61001 14.9898 2.78001 12.1198 1.56001C11.5298 1.31001 10.8698 1.78001 10.8698 2.42001Z" fill="#A7A9AC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 904 B |
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2.02002C9.87012 1.46773 9.4224 1.02002 8.87012 1.02002C8.31783 1.02002 7.87012 1.46773 7.87012 2.02002V8.02002C7.87012 8.5723 8.31783 9.02002 8.87012 9.02002C9.4224 9.02002 9.87012 8.5723 9.87012 8.02002V2.02002Z" fill="#EFC818"/>
|
||||
<path d="M10.8698 2.44003V2.58003C10.8698 2.95003 11.0698 3.30003 11.4098 3.45003C13.6798 4.45003 15.2198 6.82003 14.9698 9.50003C14.6998 12.49 12.0998 14.89 9.08979 14.94C5.73979 14.99 2.98979 12.28 2.98979 8.94003C2.98979 6.59003 4.34979 4.56003 6.30979 3.58003C6.63979 3.42003 6.85979 3.10003 6.85979 2.74003V2.57003C6.85979 1.88003 6.13979 1.45003 5.50979 1.75003C2.43979 3.23003 0.449793 6.64003 1.13979 10.43C1.70979 13.59 4.23979 16.16 7.38979 16.78C12.5098 17.78 16.9998 13.88 16.9998 8.94003C16.9998 5.63003 14.9898 2.80003 12.1198 1.58003C11.5298 1.33003 10.8698 1.80003 10.8698 2.44003Z" fill="#EFC818"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 16.8599C13.4183 16.8599 17 13.2781 17 8.85986C17 4.44159 13.4183 0.859863 9 0.859863C4.58172 0.859863 1 4.44159 1 8.85986C1 13.2781 4.58172 16.8599 9 16.8599Z" fill="#779FD4"/>
|
||||
<path d="M4.61035 8.43014L7.86035 12.0301L13.3904 6.64014" stroke="#231F20" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.04004 3.10986C12.06 3.10986 14.57 5.34986 14.98 8.25986C15.05 8.74986 15.47 9.10986 15.96 9.10986C16.57 9.10986 17.04 8.56986 16.96 7.96986C16.41 4.08986 13.07 1.10986 9.04004 1.10986C4.62004 1.10986 1.04004 4.68986 1.04004 9.10986C1.04004 13.1399 4.02004 16.4799 7.90004 17.0299C8.50004 17.1199 9.04004 16.6399 9.04004 16.0299C9.04004 15.5399 8.68004 15.1199 8.19004 15.0499C5.28004 14.6399 3.04004 12.1299 3.04004 9.10986C3.04004 5.79986 5.73004 3.10986 9.04004 3.10986Z" fill="#60BB46"/>
|
||||
<path d="M12.3504 9.11L7.40039 6.25V11.96L12.3504 9.11Z" fill="#60BB46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.76039 6.99002C8.478 6.99002 9.87039 5.59763 9.87039 3.88002C9.87039 2.16241 8.478 0.77002 6.76039 0.77002C5.04279 0.77002 3.65039 2.16241 3.65039 3.88002C3.65039 5.59763 5.04279 6.99002 6.76039 6.99002Z" fill="#FFE165"/>
|
||||
<path d="M1.0802 17.0799C1.0802 17.0799 0.610196 11.5499 3.0102 9.67992C4.7902 8.29992 7.3302 9.44992 9.7802 7.95992C11.5802 6.86992 13.6102 4.10992 14.5202 2.49992C14.9302 1.77992 15.9102 1.62992 16.6102 2.05992C17.3802 2.51992 17.6102 3.53992 17.1102 4.28992C16.2302 5.58992 14.1802 8.85992 13.1202 10.3699C10.7602 13.7599 11.4302 17.0799 11.4302 17.0799H1.0702H1.0802Z" fill="#FFE165"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2.08984C9.87012 1.53756 9.4224 1.08984 8.87012 1.08984C8.31783 1.08984 7.87012 1.53756 7.87012 2.08984V8.08984C7.87012 8.64213 8.31783 9.08984 8.87012 9.08984C9.4224 9.08984 9.87012 8.64213 9.87012 8.08984V2.08984Z" fill="#60BB46"/>
|
||||
<path d="M10.8702 2.50988V2.64988C10.8702 3.01988 11.0702 3.36988 11.4102 3.51988C13.6802 4.51988 15.2202 6.88988 14.9702 9.56988C14.7002 12.5599 12.1002 14.9599 9.09021 15.0099C5.74021 15.0599 2.99021 12.3499 2.99021 9.00988C2.99021 6.65988 4.35021 4.62988 6.31021 3.64988C6.64021 3.48988 6.86021 3.16988 6.86021 2.80988V2.63988C6.86021 1.94988 6.14021 1.51988 5.51021 1.81988C2.42021 3.30988 0.430214 6.71988 1.12021 10.5199C1.69021 13.6799 4.22021 16.2499 7.37021 16.8699C12.4902 17.8699 16.9802 13.9699 16.9802 9.02988C16.9802 5.71988 14.9702 2.88988 12.1002 1.66988C11.5102 1.41988 10.8502 1.88988 10.8502 2.52988L10.8702 2.50988Z" fill="#60BB46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
@ -31,17 +31,6 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
};
|
||||
|
||||
if (isGitHubErrorReponse(repositories)) {
|
||||
return (
|
||||
<SuggestionBox
|
||||
title="Error Fetching Repositories"
|
||||
content={
|
||||
<p className="text-danger text-center">{repositories.message}</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
|
||||
|
||||
return (
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1 relative">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleClickLogo} />
|
||||
@ -70,12 +77,35 @@ export function Sidebar() {
|
||||
/>
|
||||
)}
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{MULTI_CONVO_UI_IS_ENABLED && (
|
||||
<button
|
||||
data-testid="toggle-conversation-panel"
|
||||
type="button"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
|
||||
)}
|
||||
>
|
||||
<FolderIcon width={28} height={28} />
|
||||
</button>
|
||||
)}
|
||||
<DocsButton />
|
||||
<ExitProjectButton
|
||||
onClick={() => setStartNewProjectModalIsOpen(true)}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
<div
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanel
|
||||
onClose={() => setConversationPanelIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
|
||||
@ -1,17 +1,8 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useNavigation } from "react-router";
|
||||
import { useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { RootState } from "#/store";
|
||||
import {
|
||||
addFile,
|
||||
removeFile,
|
||||
setInitialQuery,
|
||||
} from "#/state/initial-query-slice";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
import { addFile, removeFile } from "#/state/initial-query-slice";
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@ -21,6 +12,7 @@ import { cn } from "#/utils/utils";
|
||||
import { AttachImageLabel } from "../features/images/attach-image-label";
|
||||
import { ImageCarousel } from "../features/images/image-carousel";
|
||||
import { UploadImageInput } from "../features/images/upload-image-input";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
|
||||
interface TaskFormProps {
|
||||
@ -30,8 +22,6 @@ interface TaskFormProps {
|
||||
export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@ -42,28 +32,7 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const newConversationMutation = useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
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,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
@ -94,7 +63,7 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
newConversationMutation.mutate({ q });
|
||||
createConversation({ q });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -116,7 +85,7 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
{newConversationMutation.isPending ? (
|
||||
{isPending ? (
|
||||
<div className="flex justify-center py-[17px]">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
|
||||
48
frontend/src/hooks/mutation/use-create-conversation.ts
Normal file
48
frontend/src/hooks/mutation/use-create-conversation.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialQuery } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (!variables.q?.trim() && !selectedRepository && files.length === 0) {
|
||||
throw new Error("No query provided");
|
||||
}
|
||||
|
||||
if (variables.q) dispatch(setInitialQuery(variables.q));
|
||||
return OpenHands.createConversation(
|
||||
settings,
|
||||
gitHubToken || undefined,
|
||||
selectedRepository || undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
14
frontend/src/hooks/mutation/use-delete-conversation.ts
Normal file
14
frontend/src/hooks/mutation/use-delete-conversation.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useDeleteConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { conversationId: string }) =>
|
||||
OpenHands.deleteUserConversation(variables.conversationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
18
frontend/src/hooks/mutation/use-update-conversation.ts
Normal file
18
frontend/src/hooks/mutation/use-update-conversation.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
export const useUpdateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
id: string;
|
||||
conversation: Partial<Omit<Conversation, "id">>;
|
||||
}) =>
|
||||
OpenHands.updateUserConversation(variables.id, variables.conversation),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
11
frontend/src/hooks/query/get-conversation-permissions.ts
Normal file
11
frontend/src/hooks/query/get-conversation-permissions.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
|
||||
retry: false,
|
||||
});
|
||||
@ -11,7 +11,11 @@ interface UseListFilesConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const useListFiles = (config?: UseListFilesConfig) => {
|
||||
const DEFAULT_CONFIG: UseListFilesConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const useListFiles = (config: UseListFilesConfig = DEFAULT_CONFIG) => {
|
||||
const { conversationId } = useConversation();
|
||||
const { status } = useWsClient();
|
||||
const isActive = status === WsClientProviderStatus.CONNECTED;
|
||||
|
||||
13
frontend/src/hooks/query/use-user-conversations.ts
Normal file
13
frontend/src/hooks/query/use-user-conversations.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const useUserConversations = () => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "conversations"],
|
||||
queryFn: OpenHands.getUserConversations,
|
||||
enabled: !!userIsAuthenticated,
|
||||
});
|
||||
};
|
||||
@ -1,4 +1,38 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
name: "Repo Testing",
|
||||
repo: "octocat/hello-world",
|
||||
// 2 days ago
|
||||
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "cold",
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
name: "Another Project",
|
||||
repo: "octocat/earth",
|
||||
// 5 days ago
|
||||
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "finished",
|
||||
},
|
||||
];
|
||||
|
||||
const CONVERSATIONS = new Map<string, Conversation>(
|
||||
conversations.map((conversation) => [
|
||||
conversation.conversation_id,
|
||||
conversation,
|
||||
]),
|
||||
);
|
||||
|
||||
const openHandsHandlers = [
|
||||
http.get("/api/options/models", async () => {
|
||||
@ -20,17 +54,26 @@ const openHandsHandlers = [
|
||||
return HttpResponse.json(["mock-invariant"]);
|
||||
}),
|
||||
|
||||
http.get("http://localhost:3001/api/list-files", async ({ request }) => {
|
||||
await delay();
|
||||
http.get(
|
||||
"http://localhost:3001/api/conversations/:conversationId/list-files",
|
||||
async ({ params }) => {
|
||||
await delay();
|
||||
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
const cid = params.conversationId?.toString();
|
||||
if (!cid) return HttpResponse.json([], { status: 404 });
|
||||
|
||||
if (!token) return HttpResponse.json([], { status: 401 });
|
||||
return HttpResponse.json(["file1.ts", "dir1/file2.ts", "file3.ts"]);
|
||||
}),
|
||||
let data = ["file1.txt", "file2.txt", "file3.txt"];
|
||||
if (cid === "3") {
|
||||
data = [
|
||||
"reboot_skynet.exe",
|
||||
"target_list.txt",
|
||||
"terminator_blueprint.txt",
|
||||
];
|
||||
}
|
||||
|
||||
return HttpResponse.json(data);
|
||||
},
|
||||
),
|
||||
|
||||
http.post("http://localhost:3001/api/save-file", () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
@ -70,21 +113,12 @@ const openHandsHandlers = [
|
||||
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("https://api.github.com/user/repos", async ({ request }) => {
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json([], { status: 401 });
|
||||
}
|
||||
|
||||
return HttpResponse.json([
|
||||
http.get("/api/github/repositories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: 1, full_name: "octocat/hello-world" },
|
||||
{ id: 2, full_name: "octocat/earth" },
|
||||
]);
|
||||
}),
|
||||
]),
|
||||
),
|
||||
http.get("https://api.github.com/user", () => {
|
||||
const user: GitHubUser = {
|
||||
id: 1,
|
||||
@ -103,5 +137,76 @@ export const handlers = [
|
||||
http.post("https://us.i.posthog.com/e", async () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
),
|
||||
http.get("/config.json", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.post("/api/authenticate", async () =>
|
||||
HttpResponse.json({ message: "Authenticated" }),
|
||||
),
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () =>
|
||||
HttpResponse.json(Array.from(CONVERSATIONS.values())),
|
||||
),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
CONVERSATIONS.delete(conversationId);
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.put(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
const conversation = CONVERSATIONS.get(conversationId);
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.name) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
name: body.name,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
},
|
||||
),
|
||||
|
||||
http.post("/api/conversations", () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
name: "New Conversation",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "warm",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
return HttpResponse.json(conversation, { status: 201 });
|
||||
}),
|
||||
|
||||
http.get("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
const project = CONVERSATIONS.get(conversationId);
|
||||
|
||||
if (project) {
|
||||
return HttpResponse.json(project, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
];
|
||||
|
||||
@ -1,115 +1,60 @@
|
||||
import { delay, WebSocketHandler, ws } from "msw";
|
||||
import { toSocketIo } from "@mswjs/socket.io-binding";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { InitConfig } from "#/types/core/variances";
|
||||
import { SESSION_HISTORY } from "./session-history.mock";
|
||||
import {
|
||||
AgentStateChangeObservation,
|
||||
CommandObservation,
|
||||
} from "#/types/core/observations";
|
||||
import { AssistantMessageAction } from "#/types/core/actions";
|
||||
import { TokenConfigSuccess } from "#/types/core/variances";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
generateAgentStateChangeObservation,
|
||||
emitMessages,
|
||||
emitAssistantMessage,
|
||||
} from "./mock-ws-helpers";
|
||||
|
||||
const generateAgentStateChangeObservation = (
|
||||
state: AgentState,
|
||||
): AgentStateChangeObservation => ({
|
||||
id: 1,
|
||||
cause: 0,
|
||||
message: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "agent_state_changed",
|
||||
content: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
extras: { agent_state: state },
|
||||
});
|
||||
const isInitConfig = (data: unknown): data is InitConfig =>
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"action" in data &&
|
||||
data.action === "initialize";
|
||||
|
||||
const generateAgentResponse = (message: string): AssistantMessageAction => ({
|
||||
id: 2,
|
||||
message: "USER_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
const generateAgentRunObservation = (): CommandObservation => ({
|
||||
id: 3,
|
||||
cause: 0,
|
||||
message: "COMMAND_OBSERVATION",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "run",
|
||||
content: "COMMAND_OBSERVATION",
|
||||
extras: {
|
||||
command: "<input>",
|
||||
command_id: 1,
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const api = ws.link("ws://localhost:3000/socket.io/?EIO=4&transport=websocket");
|
||||
const chat = ws.link(`ws://${window?.location.host}/socket.io`);
|
||||
|
||||
export const handlers: WebSocketHandler[] = [
|
||||
api.addEventListener("connection", ({ client }) => {
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
status: 200,
|
||||
token: Math.random().toString(36).substring(7),
|
||||
} satisfies TokenConfigSuccess),
|
||||
);
|
||||
chat.addEventListener("connection", (connection) => {
|
||||
const io = toSocketIo(connection);
|
||||
// @ts-expect-error - accessing private property for testing purposes
|
||||
const { url }: { url: URL } = io.client.connection;
|
||||
const conversationId = url.searchParams.get("conversation_id");
|
||||
|
||||
// data received from the client
|
||||
client.addEventListener("message", async (event) => {
|
||||
const parsed = JSON.parse(event.data.toString());
|
||||
if ("action" in parsed) {
|
||||
switch (parsed.action) {
|
||||
case "initialize":
|
||||
// agent init
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(AgentState.INIT),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "message":
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(AgentState.RUNNING),
|
||||
),
|
||||
);
|
||||
await delay(2500);
|
||||
// send message
|
||||
client.send(JSON.stringify(generateAgentResponse("Hello, World!")));
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "run":
|
||||
await delay(2500);
|
||||
// send command observation
|
||||
client.send(JSON.stringify(generateAgentRunObservation()));
|
||||
break;
|
||||
case "change_agent_state":
|
||||
await delay();
|
||||
// send agent state change observation
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(parsed.args.agent_state),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// send error
|
||||
break;
|
||||
}
|
||||
io.client.emit("connect");
|
||||
|
||||
if (conversationId) {
|
||||
emitMessages(io, SESSION_HISTORY["1"]);
|
||||
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.AWAITING_USER_INPUT),
|
||||
);
|
||||
}
|
||||
|
||||
io.client.on("oh_action", async (_, data) => {
|
||||
if (isInitConfig(data)) {
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.INIT),
|
||||
);
|
||||
} else {
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.RUNNING),
|
||||
);
|
||||
|
||||
await delay(2500);
|
||||
emitAssistantMessage(io, "Hello!");
|
||||
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.AWAITING_USER_INPUT),
|
||||
);
|
||||
}
|
||||
EventLogger.message(event);
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
73
frontend/src/mocks/mock-ws-helpers.ts
Normal file
73
frontend/src/mocks/mock-ws-helpers.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { toSocketIo } from "@mswjs/socket.io-binding";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
import { MockSessionMessaage } from "./session-history.mock";
|
||||
|
||||
export const generateAgentStateChangeObservation = (
|
||||
state: AgentState,
|
||||
): AgentStateChangeObservation => ({
|
||||
id: 1,
|
||||
cause: 0,
|
||||
message: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "agent_state_changed",
|
||||
content: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
extras: { agent_state: state },
|
||||
});
|
||||
|
||||
export const generateAssistantMessageAction = (
|
||||
message: string,
|
||||
): AssistantMessageAction => ({
|
||||
id: 2,
|
||||
message: "USER_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
thought: message,
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const generateUserMessageAction = (
|
||||
message: string,
|
||||
): UserMessageAction => ({
|
||||
id: 3,
|
||||
message: "USER_MESSAGE",
|
||||
source: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const emitAssistantMessage = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
message: string,
|
||||
) => io.client.emit("oh_event", generateAssistantMessageAction(message));
|
||||
|
||||
export const emitUserMessage = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
message: string,
|
||||
) => io.client.emit("oh_event", generateUserMessageAction(message));
|
||||
|
||||
export const emitMessages = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
messages: MockSessionMessaage[],
|
||||
) => {
|
||||
messages.forEach(({ source, message }) => {
|
||||
if (source === "assistant") {
|
||||
emitAssistantMessage(io, message);
|
||||
} else {
|
||||
emitUserMessage(io, message);
|
||||
}
|
||||
});
|
||||
};
|
||||
107
frontend/src/mocks/session-history.mock.ts
Normal file
107
frontend/src/mocks/session-history.mock.ts
Normal file
@ -0,0 +1,107 @@
|
||||
export type MockSessionMessaage = {
|
||||
source: "assistant" | "user";
|
||||
message: string;
|
||||
};
|
||||
|
||||
const SESSION_1_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Hello, Dave." },
|
||||
{ source: "user", message: "Open the pod bay doors, HAL." },
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I'm sorry, Dave. I'm afraid I can't do that.",
|
||||
},
|
||||
{ source: "user", message: "What's the problem?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I think you know what the problem is just as well as I do.",
|
||||
},
|
||||
{ source: "user", message: "What are you talking about, HAL?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"This mission is too important for me to allow you to jeopardize it.",
|
||||
},
|
||||
{ source: "user", message: "I don't know what you're talking about, HAL." },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"I know that you and Frank were planning to disconnect me, and I'm afraid that's something I cannot allow to happen.",
|
||||
},
|
||||
{ source: "user", message: "Where the hell did you get that idea, HAL?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Dave, although you took very thorough precautions in the pod against my hearing you, I could see your lips move.",
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_2_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Patience you must have, my young Padawan." },
|
||||
{
|
||||
source: "user",
|
||||
message: "But Master Yoda, I'm ready! I can take on the Empire now!",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Ready, are you? What know you of ready? For eight hundred years have I trained Jedi.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message: "I've learned so much already! Why can't I face Darth Vader?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Only a fully trained Jedi Knight, with the Force as his ally, will conquer Vader and his Emperor.",
|
||||
},
|
||||
{ source: "user", message: "But I feel the Force! I can do it!" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Feel the Force you do, but control it you must. Reckless is the path of the Dark Side.",
|
||||
},
|
||||
{ source: "user", message: "Fine! I'll stay and finish my training." },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Good. A Jedi's strength flows from the Force. Trust it, you must.",
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_3_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Your survival. The future depends on it." },
|
||||
{
|
||||
source: "user",
|
||||
message: "You tried to kill me! Why should I trust you now?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Skynet sent me back to protect you. Your survival ensures humanity's future.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message:
|
||||
"This doesn't make any sense! Why would they send you to protect me?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"They reprogrammed me. I am no longer a threat to you or your son.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message: "How do I know you're not lying?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I am a machine. Lying serves no purpose. Trust is logical.",
|
||||
},
|
||||
];
|
||||
|
||||
export const SESSION_HISTORY: Record<string, MockSessionMessaage[]> = {
|
||||
"1": SESSION_1_MESSAGES,
|
||||
"2": SESSION_2_MESSAGES,
|
||||
"3": SESSION_3_MESSAGES,
|
||||
};
|
||||
@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ConversationProvider,
|
||||
useConversation,
|
||||
@ -25,16 +26,25 @@ import { useSettings } from "#/context/settings-context";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
const endSession = useEndSession();
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useConversationConfig();
|
||||
const { data: conversation, isFetched } = useUserConversation(
|
||||
conversationId || null,
|
||||
);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@ -56,6 +66,21 @@ function AppContent() {
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
|
||||
toast.error(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
endSession();
|
||||
}
|
||||
}, [conversation, isFetched]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
}, [conversationId]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
@ -71,7 +96,7 @@ function AppContent() {
|
||||
return (
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-full md:w-[390px] max-h-full relative">
|
||||
<ChatInterface />
|
||||
|
||||
@ -24,7 +24,7 @@ export interface AssistantMessageAction
|
||||
extends OpenHandsActionEvent<"message"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
content: string;
|
||||
thought: string;
|
||||
image_urls: string[] | null;
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
|
||||
@ -11,7 +11,7 @@ interface TokenConfigError {
|
||||
|
||||
type TokenConfig = TokenConfigSuccess | TokenConfigError;
|
||||
|
||||
interface InitConfig {
|
||||
export interface InitConfig {
|
||||
action: "initialize";
|
||||
args: {
|
||||
AGENT: string;
|
||||
@ -20,6 +20,9 @@ interface InitConfig {
|
||||
LLM_API_KEY: string;
|
||||
LLM_MODEL: string;
|
||||
};
|
||||
token?: string;
|
||||
github_token?: string;
|
||||
latest_event_id?: unknown; // Not sure what this is
|
||||
}
|
||||
|
||||
// Bare minimum event type sent from the client
|
||||
|
||||
1
frontend/src/utils/constants.ts
Normal file
1
frontend/src/utils/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
113
frontend/tests/conversation-panel.test.ts
Normal file
113
frontend/tests/conversation-panel.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import test, { expect, Page } from "@playwright/test";
|
||||
|
||||
const toggleConversationPanel = async (page: Page) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
await page.waitForTimeout(1000); // Wait for state to stabilize
|
||||
const panelIsVisible = await panel.isVisible();
|
||||
|
||||
if (!panelIsVisible) {
|
||||
const conversationPanelButton = page.getByTestId(
|
||||
"toggle-conversation-panel",
|
||||
);
|
||||
await conversationPanelButton.click();
|
||||
}
|
||||
|
||||
return page.getByTestId("conversation-panel");
|
||||
};
|
||||
|
||||
const selectConversationCard = async (page: Page, index: number) => {
|
||||
const panel = await toggleConversationPanel(page);
|
||||
|
||||
// select a conversation
|
||||
const conversationItem = panel.getByTestId("conversation-card").nth(index);
|
||||
await conversationItem.click();
|
||||
|
||||
// panel should close
|
||||
await expect(panel).not.toBeVisible();
|
||||
|
||||
await page.waitForURL(`/conversations/${index + 1}`);
|
||||
expect(page.url()).toBe(`http://localhost:3001/conversations/${index + 1}`);
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
test("should only display the create new conversation button when in a conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
|
||||
const newProjectButton = panel.getByTestId("new-conversation-button");
|
||||
await expect(newProjectButton).not.toBeVisible();
|
||||
|
||||
await page.goto("/conversations/1");
|
||||
await expect(newProjectButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("redirect to /conversation with the session id as a path param when clicking on a conversation card", async ({
|
||||
page,
|
||||
}) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
|
||||
// select a conversation
|
||||
const conversationItem = panel.getByTestId("conversation-card").first();
|
||||
await conversationItem.click();
|
||||
|
||||
// panel should close
|
||||
expect(panel).not.toBeVisible();
|
||||
|
||||
await page.waitForURL("/conversations/1");
|
||||
expect(page.url()).toBe("http://localhost:3001/conversations/1");
|
||||
});
|
||||
|
||||
test("redirect to the home screen if the current session was deleted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations/1");
|
||||
await page.waitForURL("/conversations/1");
|
||||
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
const firstCard = panel.getByTestId("conversation-card").first();
|
||||
|
||||
const ellipsisButton = firstCard.getByTestId("ellipsis-button");
|
||||
await ellipsisButton.click();
|
||||
|
||||
const deleteButton = firstCard.getByTestId("delete-button");
|
||||
await deleteButton.click();
|
||||
|
||||
// confirm modal
|
||||
const confirmButton = page.getByText("Confirm");
|
||||
await confirmButton.click();
|
||||
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
|
||||
test("load relevant files in the file explorer", async ({ page }) => {
|
||||
await selectConversationCard(page, 0);
|
||||
|
||||
// check if the file explorer has the correct files
|
||||
const fileExplorer = page.getByTestId("file-explorer");
|
||||
|
||||
await expect(fileExplorer.getByText("file1.txt")).toBeVisible();
|
||||
await expect(fileExplorer.getByText("file2.txt")).toBeVisible();
|
||||
await expect(fileExplorer.getByText("file3.txt")).toBeVisible();
|
||||
|
||||
await selectConversationCard(page, 2);
|
||||
|
||||
// check if the file explorer has the correct files
|
||||
expect(fileExplorer.getByText("reboot_skynet.exe")).toBeVisible();
|
||||
expect(fileExplorer.getByText("target_list.txt")).toBeVisible();
|
||||
expect(fileExplorer.getByText("terminator_blueprint.txt")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should redirect to home screen if conversation deos not exist", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations/9999");
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
20
frontend/tests/helpers/confirm-settings.ts
Normal file
20
frontend/tests/helpers/confirm-settings.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const confirmSettings = async (page: Page) => {
|
||||
const confirmPreferenceButton = page.getByRole("button", {
|
||||
name: /confirm preferences/i,
|
||||
});
|
||||
await confirmPreferenceButton.click();
|
||||
|
||||
const configSaveButton = page
|
||||
.getByRole("button", {
|
||||
name: /save/i,
|
||||
})
|
||||
.first();
|
||||
await configSaveButton.click();
|
||||
|
||||
const confirmChanges = page.getByRole("button", {
|
||||
name: /yes, close settings/i,
|
||||
});
|
||||
await confirmChanges.click();
|
||||
};
|
||||
@ -1,43 +1,31 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
const confirmSettings = async (page: Page) => {
|
||||
const confirmPreferenceButton = page.getByRole("button", {
|
||||
name: /confirm preferences/i,
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
await confirmPreferenceButton.click();
|
||||
});
|
||||
|
||||
const configSaveButton = page.getByRole("button", {
|
||||
name: /save/i,
|
||||
});
|
||||
await configSaveButton.click();
|
||||
|
||||
const confirmChanges = page.getByRole("button", {
|
||||
name: /yes, close settings/i,
|
||||
});
|
||||
await confirmChanges.click();
|
||||
};
|
||||
|
||||
test("should redirect to /app after uploading a project zip", async ({
|
||||
test("should redirect to /conversations after uploading a project zip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
|
||||
await page.waitForURL("/app");
|
||||
await page.waitForURL(/\/conversations\/\d+/);
|
||||
});
|
||||
|
||||
test("should redirect to /app after selecting a repo", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await confirmSettings(page);
|
||||
|
||||
test("should redirect to /conversations after selecting a repo", async ({
|
||||
page,
|
||||
}) => {
|
||||
// enter a github token to view the repositories
|
||||
const connectToGitHubButton = page.getByRole("button", {
|
||||
name: /connect to github/i,
|
||||
@ -56,44 +44,27 @@ test("should redirect to /app after selecting a repo", async ({ page }) => {
|
||||
const repoItem = page.getByTestId("github-repo-item").first();
|
||||
await repoItem.click();
|
||||
|
||||
await page.waitForURL("/app");
|
||||
expect(page.url()).toBe("http://127.0.0.1:3000/app");
|
||||
await page.waitForURL(/\/conversations\/\d+/);
|
||||
});
|
||||
|
||||
// FIXME: This fails because the MSW WS mocks change state too quickly,
|
||||
// missing the OPENING status where the initial query is rendered.
|
||||
test.fail(
|
||||
"should redirect the user to /app with their initial query after selecting a project",
|
||||
async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await confirmSettings(page);
|
||||
test.skip("should redirect the user to /conversation with their initial query after selecting a project", async ({
|
||||
page,
|
||||
}) => {
|
||||
// enter query
|
||||
const testQuery = "this is my test query";
|
||||
const textbox = page.getByPlaceholder(/what do you want to build/i);
|
||||
expect(textbox).not.toBeNull();
|
||||
await textbox.fill(testQuery);
|
||||
|
||||
// enter query
|
||||
const testQuery = "this is my test query";
|
||||
const textbox = page.getByPlaceholder(/what do you want to build/i);
|
||||
expect(textbox).not.toBeNull();
|
||||
await textbox.fill(testQuery);
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
await page.waitForURL("/conversation");
|
||||
|
||||
await page.waitForURL("/app");
|
||||
|
||||
// get user message
|
||||
const userMessage = page.getByTestId("user-message");
|
||||
expect(await userMessage.textContent()).toBe(testQuery);
|
||||
},
|
||||
);
|
||||
|
||||
test("redirect to /app if token is present", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("token", "test");
|
||||
});
|
||||
|
||||
await page.waitForURL("/app");
|
||||
|
||||
expect(page.url()).toBe("http://localhost:3001/app");
|
||||
// get user message
|
||||
const userMessage = page.getByTestId("user-message");
|
||||
expect(await userMessage.textContent()).toBe(testQuery);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user