test(frontend): Test, refactor, and improve the chat interface (#4549)

This commit is contained in:
sp.wack 2024-10-28 17:26:28 +04:00 committed by GitHub
parent ae188458ef
commit 6cf3728247
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1083 additions and 1122 deletions

View File

@ -0,0 +1,73 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, test } from "vitest";
import { ChatMessage } from "#/components/chat-message";
describe("ChatMessage", () => {
it("should render a user message", () => {
render(<ChatMessage type="user" message="Hello, World!" />);
expect(screen.getByTestId("user-message")).toBeInTheDocument();
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
});
it("should render an assistant message", () => {
render(<ChatMessage type="assistant" message="Hello, World!" />);
expect(screen.getByTestId("assistant-message")).toBeInTheDocument();
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
});
it.skip("should support code syntax highlighting", () => {
const code = "```js\nconsole.log('Hello, World!')\n```";
render(<ChatMessage type="user" message={code} />);
// SyntaxHighlighter breaks the code blocks into "tokens"
expect(screen.getByText("console")).toBeInTheDocument();
expect(screen.getByText("log")).toBeInTheDocument();
expect(screen.getByText("'Hello, World!'")).toBeInTheDocument();
});
it.todo("should support markdown content");
it("should render the copy to clipboard button when the user hovers over the message", async () => {
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const message = screen.getByText("Hello, World!");
expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible();
await user.hover(message);
expect(screen.getByTestId("copy-to-clipboard")).toBeVisible();
});
it("should copy content to clipboard", async () => {
const user = userEvent.setup();
render(<ChatMessage type="user" message="Hello, World!" />);
const copyToClipboardButton = screen.getByTestId("copy-to-clipboard");
await user.click(copyToClipboardButton);
expect(navigator.clipboard.readText()).resolves.toBe("Hello, World!");
});
// BUG: vi.useFakeTimers() seems to break the tests
it.todo(
"should display a checkmark for 200ms and disable the button after copying content to clipboard",
);
it("should display an error toast if copying content to clipboard fails", async () => {});
test.todo("push a toast after successfully copying content to clipboard");
it("should render a component passed as a prop", () => {
function Component() {
return <div data-testid="custom-component">Custom Component</div>;
}
render(
<ChatMessage type="user" message="Hello, World">
<Component />
</ChatMessage>,
);
expect(screen.getByTestId("custom-component")).toBeInTheDocument();
});
});

View File

@ -1,28 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils";
import Chat from "#/components/chat/Chat";
const MESSAGES: Message[] = [
{
sender: "assistant",
content: "Hello!",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
sender: "user",
content: "Hi!",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
describe("Chat", () => {
it("should render chat messages", () => {
renderWithProviders(<Chat messages={MESSAGES} />);
const messages = screen.getAllByTestId("article");
expect(messages).toHaveLength(MESSAGES.length);
});
});

View File

@ -1,148 +0,0 @@
import { screen, act } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { addAssistantMessage } from "#/state/chatSlice";
import AgentState from "#/types/AgentState";
import ChatInterface from "#/components/chat/ChatInterface";
const router = createMemoryRouter([
{
path: "/",
element: <ChatInterface />,
},
]);
/// <reference types="vitest" />
interface CustomMatchers<R = unknown> {
toMatchMessageEvent(expected: string): R;
}
declare module "vitest" {
interface Assertion<T> extends CustomMatchers<T> {}
// @ts-expect-error - recursively references itself
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
// This is for the scrollview ref in Chat.tsx
// TODO: Move this into test setup
HTMLElement.prototype.scrollTo = vi.fn().mockImplementation(() => {});
const TEST_TIMESTAMP = new Date().toISOString();
describe.skip("ChatInterface", () => {
// TODO: replace below with e.g. fake timers
// https://vitest.dev/guide/mocking#timers
// https://vitest.dev/api/vi.html#vi-usefaketimers
// Custom matcher for testing message events
expect.extend({
toMatchMessageEvent(received, expected) {
const receivedObj = JSON.parse(received);
const expectedObj = JSON.parse(expected);
// Compare everything except the timestamp
const { timestamp: receivedTimestamp, ...receivedRest } =
receivedObj.args;
const { timestamp: expectedTimestamp, ...expectedRest } =
expectedObj.args;
const pass =
this.equals(receivedRest, expectedRest) &&
typeof receivedTimestamp === "string";
return {
pass,
message: () =>
pass
? `expected ${received} not to match the structure of ${expected} (ignoring exact timestamp)`
: `expected ${received} to match the structure of ${expected} (ignoring exact timestamp)`,
};
},
});
it("should render empty message list and input", () => {
renderWithProviders(<ChatInterface />);
expect(screen.queryAllByTestId("article")).toHaveLength(0);
});
it("should render user and assistant messages", () => {
const { store } = renderWithProviders(<RouterProvider router={router} />, {
preloadedState: {
chat: {
messages: [
{
sender: "user",
content: "Hello",
imageUrls: [],
timestamp: TEST_TIMESTAMP,
},
],
},
},
});
expect(screen.getAllByTestId("article")).toHaveLength(1);
expect(screen.getByText("Hello")).toBeInTheDocument();
act(() => {
// simulate assistant response
store.dispatch(addAssistantMessage("Hello to you!"));
});
expect(screen.getAllByTestId("article")).toHaveLength(2);
expect(screen.getByText("Hello to you!")).toBeInTheDocument();
});
it("should send the user message as an event to the Session when the agent state is INIT", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterProvider router={router} />, {
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
});
const input = screen.getByRole("textbox");
await user.type(input, "my message");
await user.keyboard("{Enter}");
});
it("should send the user message as an event to the Session when the agent state is AWAITING_USER_INPUT", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterProvider router={router} />, {
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
},
});
const input = screen.getByRole("textbox");
await user.type(input, "my message");
await user.keyboard("{Enter}");
});
it("should disable the user input if agent is not initialized", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterProvider router={router} />, {
preloadedState: {
agent: {
curAgentState: AgentState.LOADING,
},
},
});
const input = screen.getByRole("textbox");
await user.type(input, "my message");
await user.keyboard("{Enter}");
const submitButton = screen.getByLabelText(
"CHAT_INTERFACE$TOOLTIP_SEND_MESSAGE",
);
expect(submitButton).toBeDisabled();
});
it.todo("test scroll-related behaviour");
});

View File

@ -1,200 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import toast from "#/utils/toast";
import ChatMessage from "#/components/chat/ChatMessage";
describe("Message", () => {
it("should render a user message", () => {
render(
<ChatMessage
message={{
sender: "user",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
/>,
);
expect(screen.getByTestId("article")).toBeInTheDocument();
expect(screen.getByTestId("article")).toHaveClass("self-end"); // user message should be on the right side
});
it("should render an assistant message", () => {
render(
<ChatMessage
message={{
sender: "assistant",
content: "Hi",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
/>,
);
expect(screen.getByTestId("article")).toBeInTheDocument();
expect(screen.getByTestId("article")).not.toHaveClass("self-end"); // assistant message should be on the left side
});
it("should render markdown content", () => {
render(
<ChatMessage
message={{
sender: "user",
content: "```js\nconsole.log('Hello')\n```",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
/>,
);
// SyntaxHighlighter breaks the code blocks into "tokens"
expect(screen.getByText("console")).toBeInTheDocument();
expect(screen.getByText("log")).toBeInTheDocument();
expect(screen.getByText("'Hello'")).toBeInTheDocument();
});
describe("copy to clipboard", () => {
const toastInfoSpy = vi.spyOn(toast, "info");
const toastErrorSpy = vi.spyOn(toast, "error");
it("should copy any message to clipboard", async () => {
const user = userEvent.setup();
render(
<ChatMessage
message={{
sender: "user",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
/>,
);
const message = screen.getByTestId("article");
let copyButton = within(message).queryByTestId("copy-button");
expect(copyButton).not.toBeInTheDocument();
// I am using `fireEvent` here because `userEvent.hover()` seems to interfere with the
// `userEvent.click()` call later on
fireEvent.mouseEnter(message);
copyButton = within(message).getByTestId("copy-button");
await user.click(copyButton);
expect(navigator.clipboard.readText()).resolves.toBe("Hello");
expect(toastInfoSpy).toHaveBeenCalled();
});
it("should show an error message when the message cannot be copied", async () => {
const user = userEvent.setup();
render(
<ChatMessage
message={{
sender: "user",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
/>,
);
const message = screen.getByTestId("article");
fireEvent.mouseEnter(message);
const copyButton = within(message).getByTestId("copy-button");
const clipboardSpy = vi
.spyOn(navigator.clipboard, "writeText")
.mockRejectedValue(new Error("Failed to copy"));
await user.click(copyButton);
expect(clipboardSpy).toHaveBeenCalled();
expect(toastErrorSpy).toHaveBeenCalled();
});
});
describe("confirmation buttons", () => {
const expectButtonsNotToBeRendered = () => {
expect(
screen.queryByTestId("action-confirm-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("action-reject-button"),
).not.toBeInTheDocument();
};
it.skip("should display confirmation buttons for the last assistant message", () => {
// it should not render buttons if the message is not the last one
const { rerender } = render(
<ChatMessage
message={{
sender: "assistant",
content: "Are you sure?",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage={false}
awaitingUserConfirmation
/>,
);
expectButtonsNotToBeRendered();
// it should not render buttons if the message is not from the assistant
rerender(
<ChatMessage
message={{
sender: "user",
content: "Yes",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage
awaitingUserConfirmation
/>,
);
expectButtonsNotToBeRendered();
// it should not render buttons if the message is not awaiting user confirmation
rerender(
<ChatMessage
message={{
sender: "assistant",
content: "Are you sure?",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage
awaitingUserConfirmation={false}
/>,
);
expectButtonsNotToBeRendered();
// it should render buttons if all conditions are met
rerender(
<ChatMessage
message={{
sender: "assistant",
content: "Are you sure?",
imageUrls: [],
timestamp: new Date().toISOString(),
}}
isLastMessage
awaitingUserConfirmation
/>,
);
const confirmButton = screen.getByTestId("action-confirm-button");
const rejectButton = screen.getByTestId("action-reject-button");
expect(confirmButton).toBeInTheDocument();
expect(rejectButton).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatInterface } from "#/components/chat-interface";
import { SocketProvider } from "#/context/socket";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
render(<ChatInterface />, { wrapper: SocketProvider });
describe.skip("ChatInterface", () => {
afterEach(() => {
vi.clearAllMocks();
});
it.todo("should render suggestions if empty");
it("should render messages", () => {
const messages: Message[] = [
{
sender: "user",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
sender: "assistant",
content: "Hi",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
renderChatInterface(messages);
expect(screen.getAllByTestId(/-message/)).toHaveLength(2);
});
it("should render a chat input", () => {
const messages: Message[] = [];
renderChatInterface(messages);
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
});
it.todo("should call socket send when submitting a message", async () => {
const user = userEvent.setup();
const messages: Message[] = [];
renderChatInterface(messages);
const input = screen.getByTestId("chat-input");
await user.type(input, "Hello");
await user.keyboard("{Enter}");
// spy on send and expect to have been called
});
it("should render an image carousel with a message", () => {
let messages: Message[] = [
{
sender: "assistant",
content: "Here are some images",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
const { rerender } = renderChatInterface(messages);
expect(screen.queryByTestId("image-carousel")).not.toBeInTheDocument();
messages = [
{
sender: "assistant",
content: "Here are some images",
imageUrls: ["image1", "image2"],
timestamp: new Date().toISOString(),
},
];
rerender(<ChatInterface />);
const imageCarousel = screen.getByTestId("image-carousel");
expect(imageCarousel).toBeInTheDocument();
expect(within(imageCarousel).getAllByTestId("image-preview")).toHaveLength(
2,
);
});
it.todo("should render confirmation buttons");
it("should render a 'continue' action when there are more than 2 messages and awaiting user input", () => {
const messages: Message[] = [
{
sender: "assistant",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
sender: "user",
content: "Hi",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
const { rerender } = renderChatInterface(messages);
expect(
screen.queryByTestId("continue-action-button"),
).not.toBeInTheDocument();
messages.push({
sender: "assistant",
content: "How can I help you?",
imageUrls: [],
timestamp: new Date().toISOString(),
});
rerender(<ChatInterface />);
expect(screen.getByTestId("continue-action-button")).toBeInTheDocument();
});
it("should render inline errors", () => {
const messages: (Message | ErrorMessage)[] = [
{
sender: "assistant",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
error: "Woops!",
message: "Something went wrong",
},
];
renderChatInterface(messages);
const error = screen.getByTestId("error-message");
expect(within(error).getByText("Woops!")).toBeInTheDocument();
expect(within(error).getByText("Something went wrong")).toBeInTheDocument();
});
it("should render feedback actions if there are more than 3 messages", () => {
const messages: Message[] = [
{
sender: "assistant",
content: "Hello",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
sender: "user",
content: "Hi",
imageUrls: [],
timestamp: new Date().toISOString(),
},
{
sender: "assistant",
content: "How can I help you?",
imageUrls: [],
timestamp: new Date().toISOString(),
},
];
const { rerender } = renderChatInterface(messages);
expect(screen.queryByTestId("feedback-actions")).not.toBeInTheDocument();
messages.push({
sender: "user",
content: "I need help",
imageUrls: [],
timestamp: new Date().toISOString(),
});
rerender(<ChatInterface />);
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
});
describe("feedback", () => {
it.todo("should open the feedback modal when a feedback action is clicked");
it.todo(
"should submit feedback and hide the actions when feedback is shared",
);
it.todo("should render the actions once more after new messages are added");
});
});

View File

@ -0,0 +1,55 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeedbackActions } from "#/components/feedback-actions";
describe("FeedbackActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
render(
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
render(
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const positiveFeedback = screen.getByTestId("positive-feedback");
await user.click(positiveFeedback);
expect(onPositiveFeedback).toHaveBeenCalled();
});
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
render(
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
/>,
);
const negativeFeedback = screen.getByTestId("negative-feedback");
await user.click(negativeFeedback);
expect(onNegativeFeedback).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,108 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeedbackForm } from "#/components/feedback-form";
describe("FeedbackForm", () => {
const user = userEvent.setup();
const onSubmitMock = vi.fn();
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render correctly", () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
screen.getByLabelText("Email");
screen.getByLabelText("Private");
screen.getByLabelText("Public");
screen.getByRole("button", { name: "Submit" });
screen.getByRole("button", { name: "Cancel" });
});
it("should switch between private and public permissions", async () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
const privateRadio = screen.getByLabelText("Private");
const publicRadio = screen.getByLabelText("Public");
expect(privateRadio).toBeChecked(); // private is the default value
expect(publicRadio).not.toBeChecked();
await user.click(publicRadio);
expect(publicRadio).toBeChecked();
expect(privateRadio).not.toBeChecked();
await user.click(privateRadio);
expect(privateRadio).toBeChecked();
expect(publicRadio).not.toBeChecked();
});
it("should call onSubmit when the form is submitted", async () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
const email = screen.getByLabelText("Email");
await user.type(email, "test@test.test");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmitMock).toHaveBeenCalledWith("private", "test@test.test"); // private is the default value
});
it("should not call onSubmit when the email is invalid", async () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
const email = screen.getByLabelText("Email");
const submitButton = screen.getByRole("button", { name: "Submit" });
await user.click(submitButton);
expect(onSubmitMock).not.toHaveBeenCalled();
await user.type(email, "test");
await user.click(submitButton);
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should submit public permissions when the public radio is checked", async () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
const email = screen.getByLabelText("Email");
const publicRadio = screen.getByLabelText("Public");
await user.type(email, "test@test.test");
await user.click(publicRadio);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmitMock).toHaveBeenCalledWith("public", "test@test.test");
});
it("should call onClose when the close button is clicked", async () => {
render(<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onSubmitMock).not.toHaveBeenCalled();
expect(onCloseMock).toHaveBeenCalled();
});
it("should disable the buttons if isSubmitting is true", () => {
const { rerender } = render(
<FeedbackForm onSubmit={onSubmitMock} onClose={onCloseMock} />,
);
const submitButton = screen.getByRole("button", { name: "Submit" });
const cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(submitButton).not.toBeDisabled();
expect(cancelButton).not.toBeDisabled();
rerender(
<FeedbackForm
onSubmit={onSubmitMock}
onClose={onCloseMock}
isSubmitting
/>,
);
expect(submitButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
});
});

View File

@ -29,4 +29,9 @@ describe("ImagePreview", () => {
expect(onRemoveMock).toHaveBeenCalledOnce();
});
it("shoud not display the close button when onRemove is not provided", () => {
render(<ImagePreview src="https://example.com/image.jpg" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});

View File

@ -1,193 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import { Mock, afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import FeedbackModal from "#/components/modals/feedback/FeedbackModal";
import OpenHands from "#/api/open-hands";
describe.skip("FeedbackModal", () => {
Storage.prototype.setItem = vi.fn();
Storage.prototype.getItem = vi.fn();
vi.mock("#/services/feedbackService", () => ({
sendFeedback: vi.fn(),
}));
vi.mock("#/services/auth", () => ({
getToken: vi.fn().mockReturnValue("some-token"),
}));
// mock Session class
vi.mock("#/services/session", () => ({
default: {
_history: [
{ args: { LLM_API_KEY: "DANGER-key-should-not-be-here" } },
{ content: "Hello" },
],
},
}));
afterEach(() => {
vi.clearAllMocks();
});
it("should render the feedback model when open", () => {
const { rerender } = render(
<FeedbackModal
polarity="positive"
isOpen={false}
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
expect(screen.queryByTestId("feedback-modal")).not.toBeInTheDocument();
rerender(
<FeedbackModal
polarity="positive"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
expect(screen.getByTestId("feedback-modal")).toBeInTheDocument();
});
it("should display an error if the email is invalid when submitting", async () => {
const user = userEvent.setup();
render(
<FeedbackModal
polarity="positive"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
const submitButton = screen.getByRole("button", {
name: "FEEDBACK$SHARE_LABEL",
});
await user.click(submitButton);
expect(screen.getByTestId("invalid-email-message")).toBeInTheDocument();
expect(OpenHands.sendFeedback).not.toHaveBeenCalled();
});
it("should call sendFeedback with the correct data when the share button is clicked", async () => {
const user = userEvent.setup();
render(
<FeedbackModal
polarity="negative"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
const submitButton = screen.getByRole("button", {
name: "FEEDBACK$SHARE_LABEL",
});
const email = "example@example.com";
const emailInput = screen.getByTestId("email-input");
await user.type(emailInput, email);
// select public
const permissionsGroup = screen.getByTestId("permissions-group");
const publicOption = within(permissionsGroup).getByRole("radio", {
name: "FEEDBACK$PUBLIC_LABEL",
});
expect(publicOption).not.toBeChecked();
await user.click(publicOption);
expect(publicOption).toBeChecked();
await user.click(submitButton);
expect(
screen.queryByTestId("invalid-email-message"),
).not.toBeInTheDocument();
expect(OpenHands.sendFeedback).toHaveBeenCalledWith({
email,
permissions: "public",
feedback: "negative",
trajectory: [{ args: {} }, { content: "Hello" }], // api key should be removed
token: "some-token",
version: "1.0",
});
});
it("should store the users email in local state for later use", async () => {
const email = "example@example.com";
const user = userEvent.setup();
const { rerender } = render(
<FeedbackModal
polarity="negative"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
expect(localStorage.getItem).toHaveBeenCalledWith("feedback-email");
const emailInput = screen.getByTestId("email-input");
expect(emailInput).toHaveValue("");
await user.type(emailInput, email);
expect(emailInput).toHaveValue(email);
const submitButton = screen.getByRole("button", {
name: "FEEDBACK$SHARE_LABEL",
});
await user.click(submitButton);
expect(localStorage.setItem).toHaveBeenCalledWith("feedback-email", email);
rerender(
<FeedbackModal
polarity="positive"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
const emailInputAfterClose = screen.getByTestId("email-input");
expect(emailInputAfterClose).toHaveValue(email);
});
// TODO: figure out how to properly mock toast
it.skip("should display a success toast when the feedback is shared successfully", async () => {
(OpenHands.sendFeedback as Mock).mockResolvedValue({
statusCode: 200,
body: {
message: "Feedback shared",
feedback_id: "some-id",
password: "some-password",
},
});
const user = userEvent.setup();
render(
<FeedbackModal
polarity="negative"
isOpen
onOpenChange={vi.fn}
onSendFeedback={vi.fn}
/>,
);
const submitButton = screen.getByRole("button", {
name: "FEEDBACK$SHARE_LABEL",
});
const email = "example@example.com";
const emailInput = screen.getByTestId("email-input");
await user.type(emailInput, email);
await user.click(submitButton);
expect(toast).toHaveBeenCalled();
});
});

View File

@ -44,7 +44,7 @@ export function ChatInput({
};
const handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key === "Enter" && !event.shiftKey && !disabled) {
event.preventDefault();
handleSubmitMessage();
}

View File

@ -0,0 +1,172 @@
import { useDispatch, useSelector } from "react-redux";
import React from "react";
import { useFetcher } from "@remix-run/react";
import { useSocket } from "#/context/socket";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { ChatMessage } from "./chat-message";
import { FeedbackActions } from "./feedback-actions";
import { ImageCarousel } from "./image-carousel";
import { createChatMessage } from "#/services/chatService";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chatSlice";
import { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
import { FeedbackModal } from "./feedback-modal";
import { Feedback } from "#/api/open-hands.types";
import { getToken } from "#/services/auth";
import { removeApiKey, removeUnwantedKeys } from "#/utils/utils";
import { clientAction } from "#/routes/submit-feedback";
import { useScrollToBottom } from "#/hooks/useScrollToBottom";
import TypingIndicator from "./chat/TypingIndicator";
import ConfirmationButtons from "./chat/ConfirmationButtons";
import { ErrorMessage } from "./error-message";
import { ContinueButton } from "./continue-button";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
const FEEDBACK_VERSION = "1.0";
const isErrorMessage = (
message: Message | ErrorMessage,
): message is ErrorMessage => "error" in message;
export function ChatInterface() {
const { send, events } = useSocket();
const dispatch = useDispatch();
const fetcher = useFetcher<typeof clientAction>({ key: "feedback" });
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackShared, setFeedbackShared] = React.useState(0);
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const handleSendMessage = async (content: string, files: File[]) => {
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
dispatch(addUserMessage({ content, imageUrls, timestamp }));
send(createChatMessage(content, imageUrls, timestamp));
};
const handleStop = () => {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
setFeedbackModalIsOpen(true);
setFeedbackPolarity(polarity);
};
const handleSubmitFeedback = (
permissions: "private" | "public",
email: string,
) => {
const feedback: Feedback = {
version: FEEDBACK_VERSION,
feedback: feedbackPolarity,
email,
permissions,
token: getToken(),
trajectory: removeApiKey(removeUnwantedKeys(events)),
};
const formData = new FormData();
formData.append("feedback", JSON.stringify(feedback));
fetcher.submit(formData, {
action: "/submit-feedback",
method: "POST",
});
setFeedbackShared(messages.length);
};
return (
<div className="h-full flex flex-col justify-between">
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{messages.map((message, index) =>
isErrorMessage(message) ? (
<ErrorMessage
key={index}
error={message.error}
message={message.message}
/>
) : (
<ChatMessage
key={index}
type={message.sender}
message={message.content}
>
{message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{messages.length - 1 === index &&
message.sender === "assistant" &&
curAgentState === AgentState.AWAITING_USER_CONFIRMATION && (
<ConfirmationButtons />
)}
</ChatMessage>
),
)}
</div>
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
{feedbackShared !== messages.length && messages.length > 3 && (
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
)}
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
{!hitBottom && <ScrollToBottomButton onClick={scrollDomToBottom} />}
</div>
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
/>
</div>
<FeedbackModal
isOpen={feedbackModalIsOpen}
isSubmitting={fetcher.state === "submitting"}
onClose={() => setFeedbackModalIsOpen(false)}
onSubmit={handleSubmitFeedback}
/>
</div>
);
}

View File

@ -0,0 +1,85 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CheckmarkIcon from "#/icons/checkmark.svg?react";
import CopyIcon from "#/icons/copy.svg?react";
import { code } from "./markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "./markdown/list";
interface ChatMessageProps {
type: "user" | "assistant";
message: string;
}
export function ChatMessage({
type,
message,
children,
}: React.PropsWithChildren<ChatMessageProps>) {
const [isHovering, setIsHovering] = React.useState(false);
const [isCopy, setIsCopy] = React.useState(false);
const handleCopyToClipboard = async () => {
await navigator.clipboard.writeText(message);
setIsCopy(true);
};
React.useEffect(() => {
let timeout: NodeJS.Timeout;
if (isCopy) {
timeout = setTimeout(() => {
setIsCopy(false);
}, 2000);
}
return () => {
clearTimeout(timeout);
};
}, [isCopy]);
return (
<article
data-testid={`${type}-message`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"rounded-xl relative",
"flex flex-col gap-2",
type === "user" && " max-w-[305px] p-4 bg-neutral-700 self-end",
type === "assistant" && "pb-4 max-w-full bg-tranparent",
)}
>
<button
hidden={!isHovering}
disabled={isCopy}
data-testid="copy-to-clipboard"
type="button"
onClick={handleCopyToClipboard}
className={cn(
"bg-neutral-700 border border-neutral-600 rounded p-1",
"absolute top-1 right-1",
)}
>
{!isCopy ? (
<CopyIcon width={15} height={15} />
) : (
<CheckmarkIcon width={15} height={15} />
)}
</button>
<Markdown
className="text-sm overflow-auto"
components={{
code,
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
>
{message}
</Markdown>
{children}
</article>
);
}

View File

@ -1,39 +0,0 @@
import ChatMessage from "./ChatMessage";
import AgentState from "#/types/AgentState";
const isMessage = (message: Message | ErrorMessage): message is Message =>
"sender" in message;
interface ChatProps {
messages: (Message | ErrorMessage)[];
curAgentState?: AgentState;
}
function Chat({ messages, curAgentState }: ChatProps) {
return (
<div className="flex flex-col gap-3 px-3 pt-3 mb-6">
{messages.map((message, index) =>
isMessage(message) ? (
<ChatMessage
key={index}
message={message}
isLastMessage={messages && index === messages.length - 1}
awaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
) : (
<div key={index} className="flex gap-2 items-center justify-start">
<div className="bg-danger w-2 h-full rounded" />
<div className="text-sm leading-4 flex flex-col gap-2">
<p className="text-danger font-bold">{message.error}</p>
<p className="text-neutral-300">{message.message}</p>
</div>
</div>
),
)}
</div>
);
}
export default Chat;

View File

@ -1,188 +0,0 @@
import React, { useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RiArrowRightDoubleLine } from "react-icons/ri";
import { useTranslation } from "react-i18next";
import { VscArrowDown } from "react-icons/vsc";
import { useDisclosure } from "@nextui-org/react";
import Chat from "./Chat";
import TypingIndicator from "./TypingIndicator";
import { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import { createChatMessage } from "#/services/chatService";
import { addUserMessage, addAssistantMessage } from "#/state/chatSlice";
import { I18nKey } from "#/i18n/declaration";
import { useScrollToBottom } from "#/hooks/useScrollToBottom";
import FeedbackModal from "../modals/feedback/FeedbackModal";
import { useSocket } from "#/context/socket";
import ThumbsUpIcon from "#/assets/thumbs-up.svg?react";
import ThumbsDownIcon from "#/assets/thumbs-down.svg?react";
import { cn } from "#/utils/utils";
import { InteractiveChatBox } from "../interactive-chat-box";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { generateAgentStateChangeEvent } from "#/services/agentStateService";
interface ScrollButtonProps {
onClick: () => void;
icon: JSX.Element;
label: string;
disabled?: boolean;
}
function ScrollButton({
onClick,
icon,
label,
disabled = false,
}: ScrollButtonProps): JSX.Element {
return (
<button
type="button"
className="relative border-1 text-xs rounded px-2 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
onClick={onClick}
disabled={disabled}
>
<div className="flex items-center">
{icon} <span className="inline-block">{label}</span>
</div>
</button>
);
}
function ChatInterface() {
const dispatch = useDispatch();
const { send } = useSocket();
const { messages } = useSelector((state: RootState) => state.chat);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
>("positive");
const [feedbackShared, setFeedbackShared] = React.useState(0);
const {
isOpen: feedbackModalIsOpen,
onOpen: onFeedbackModalOpen,
onOpenChange: onFeedbackModalOpenChange,
} = useDisclosure();
const handleSendMessage = async (content: string, files: File[]) => {
const promises = files.map((file) => convertImageToBase64(file));
const imageUrls = await Promise.all(promises);
const timestamp = new Date().toISOString();
dispatch(addUserMessage({ content, imageUrls, timestamp }));
send(createChatMessage(content, imageUrls, timestamp));
};
const handleStop = () => {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const shareFeedback = async (polarity: "positive" | "negative") => {
onFeedbackModalOpen();
setFeedbackPolarity(polarity);
};
const { t } = useTranslation();
const handleSendContinueMsg = () => {
handleSendMessage(t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE), []);
};
const scrollRef = useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);
React.useEffect(() => {
if (curAgentState === AgentState.INIT && messages.length === 0) {
dispatch(addAssistantMessage(t(I18nKey.CHAT_INTERFACE$INITIAL_MESSAGE)));
}
}, [curAgentState, dispatch, messages.length, t]);
return (
<div className="flex flex-col h-full justify-between">
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="flex flex-col max-h-full overflow-y-auto"
>
<Chat messages={messages} curAgentState={curAgentState} />
</div>
<div className="px-4 pb-4">
<div className="relative">
{feedbackShared !== messages.length && messages.length > 3 && (
<div
className={cn(
"flex justify-start gap-[7px]",
"absolute left-3 bottom-[6.5px]",
)}
>
<button
type="button"
onClick={() => shareFeedback("positive")}
className="p-1 bg-neutral-700 border border-neutral-600 rounded"
>
<ThumbsUpIcon width={15} height={15} />
</button>
<button
type="button"
onClick={() => shareFeedback("negative")}
className="p-1 bg-neutral-700 border border-neutral-600 rounded"
>
<ThumbsDownIcon width={15} height={15} />
</button>
</div>
)}
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-[6.5px]">
{!hitBottom && (
<ScrollButton
onClick={scrollDomToBottom}
icon={<VscArrowDown className="inline mr-2 w-3 h-3" />}
label={t(I18nKey.CHAT_INTERFACE$TO_BOTTOM)}
/>
)}
{hitBottom && (
<>
{curAgentState === AgentState.AWAITING_USER_INPUT && (
<button
type="button"
onClick={handleSendContinueMsg}
className={cn(
"px-2 py-1 bg-neutral-700 border border-neutral-600 rounded",
"text-[11px] leading-4 tracking-[0.01em] font-[500]",
"flex items-center gap-2",
)}
>
<RiArrowRightDoubleLine className="w-3 h-3" />
{t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE)}
</button>
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</>
)}
</div>
</div>
<InteractiveChatBox
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
onSubmit={handleSendMessage}
onStop={handleStop}
/>
</div>
<FeedbackModal
polarity={feedbackPolarity}
isOpen={feedbackModalIsOpen}
onOpenChange={onFeedbackModalOpenChange}
onSendFeedback={() => setFeedbackShared(messages.length)}
/>
</div>
);
}
export default ChatInterface;

View File

@ -1,122 +0,0 @@
import React, { useState } from "react";
import Markdown from "react-markdown";
import { FaClipboard, FaClipboardCheck } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import remarkGfm from "remark-gfm";
import { code } from "../markdown/code";
import toast from "#/utils/toast";
import { I18nKey } from "#/i18n/declaration";
import ConfirmationButtons from "./ConfirmationButtons";
import { cn, formatTimestamp } from "#/utils/utils";
import { ol, ul } from "../markdown/list";
interface MessageProps {
message: Message;
isLastMessage?: boolean;
awaitingUserConfirmation?: boolean;
}
function ChatMessage({
message,
isLastMessage,
awaitingUserConfirmation,
}: MessageProps) {
const { t } = useTranslation();
const [isCopy, setIsCopy] = useState(false);
const [isHovering, setIsHovering] = useState(false);
React.useEffect(() => {
let timeout: NodeJS.Timeout;
if (isCopy) {
timeout = setTimeout(() => {
setIsCopy(false);
}, 1500);
}
return () => {
clearTimeout(timeout);
};
}, [isCopy]);
const className = cn(
"markdown-body text-sm",
"p-4 text-white max-w-[90%] overflow-y-auto rounded-xl relative",
message.sender === "user" && "bg-neutral-700 self-end",
);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(message.content);
setIsCopy(true);
toast.info(t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPIED));
} catch {
toast.error(
"copy-error",
t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED),
);
}
};
const copyButtonTitle = message.timestamp
? `${t(I18nKey.CHAT_INTERFACE$TOOLTIP_COPY_MESSAGE)} - ${formatTimestamp(message.timestamp)}`
: t(I18nKey.CHAT_INTERFACE$TOOLTIP_COPY_MESSAGE);
return (
<article
data-testid="article"
className={className}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
aria-label={t(I18nKey.CHAT_INTERFACE$MESSAGE_ARIA_LABEL, {
sender: message.sender
? message.sender.charAt(0).toUpperCase() +
message.sender.slice(1).toLowerCase()
: t(I18nKey.CHAT_INTERFACE$UNKNOWN_SENDER),
})}
>
{isHovering && (
<button
data-testid="copy-button"
onClick={copyToClipboard}
className="absolute top-1 right-1 p-1 bg-neutral-600 rounded hover:bg-neutral-700"
aria-label={copyButtonTitle}
title={copyButtonTitle}
type="button"
>
{isCopy ? <FaClipboardCheck /> : <FaClipboard />}
</button>
)}
<Markdown
className="-space-y-4"
components={{
code,
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
>
{message.content}
</Markdown>
{(message.imageUrls?.length ?? 0) > 0 && (
<div className="flex space-x-2 mt-2">
{message.imageUrls?.map((url, index) => (
<img
key={index}
src={url}
alt={`upload preview ${index}`}
className="w-24 h-24 object-contain rounded bg-white"
/>
))}
</div>
)}
{isLastMessage &&
message.sender === "assistant" &&
awaitingUserConfirmation && <ConfirmationButtons />}
</article>
);
}
export default ChatMessage;

View File

@ -0,0 +1,23 @@
import ChevronDoubleRight from "#/icons/chevron-double-right.svg?react";
import { cn } from "#/utils/utils";
interface ContinueButtonProps {
onClick: () => void;
}
export function ContinueButton({ onClick }: ContinueButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"px-2 py-1 bg-neutral-700 border border-neutral-600 rounded",
"text-[11px] leading-4 tracking-[0.01em] font-[500]",
"flex items-center gap-2",
)}
>
<ChevronDoubleRight width={12} height={12} />
Continue
</button>
);
}

View File

@ -0,0 +1,15 @@
interface ErrorMessageProps {
error: string;
message: string;
}
export function ErrorMessage({ error, message }: ErrorMessageProps) {
return (
<div className="flex gap-2 items-center justify-start border-l-2 border-danger pl-2 my-2 py-2">
<div className="text-sm leading-4 flex flex-col gap-2">
<p className="text-danger font-bold">{error}</p>
<p className="text-neutral-300">{message}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
interface FeedbackActionButtonProps {
testId?: string;
onClick: () => void;
icon: React.ReactNode;
}
function FeedbackActionButton({
testId,
onClick,
icon,
}: FeedbackActionButtonProps) {
return (
<button
type="button"
data-testid={testId}
onClick={onClick}
className="p-1 bg-neutral-700 border border-neutral-600 rounded hover:bg-neutral-500"
>
{icon}
</button>
);
}
interface FeedbackActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
}
export function FeedbackActions({
onPositiveFeedback,
onNegativeFeedback,
}: FeedbackActionsProps) {
return (
<div data-testid="feedback-actions" className="flex gap-1">
<FeedbackActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
/>
<FeedbackActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
/>
</div>
);
}

View File

@ -0,0 +1,72 @@
import ModalButton from "./buttons/ModalButton";
interface FeedbackFormProps {
onSubmit: (permissions: "private" | "public", email: string) => void;
onClose: () => void;
isSubmitting?: boolean;
}
export function FeedbackForm({
onSubmit,
onClose,
isSubmitting,
}: FeedbackFormProps) {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
const email = formData.get("email")?.toString();
const permissions = formData.get("permissions")?.toString() as
| "private"
| "public"
| undefined;
if (email) onSubmit(permissions || "private", email);
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6 w-full">
<label className="flex flex-col gap-2">
<span className="text-xs text-neutral-400">Email</span>
<input
required
name="email"
type="email"
placeholder="Please enter your email"
className="bg-[#27272A] px-3 py-[10px] rounded"
/>
</label>
<div className="flex gap-4 text-neutral-400">
<label className="flex gap-2 cursor-pointer">
<input
name="permissions"
value="private"
type="radio"
defaultChecked
/>
Private
</label>
<label className="flex gap-2 cursor-pointer">
<input name="permissions" value="public" type="radio" />
Public
</label>
</div>
<div className="flex gap-2">
<ModalButton
disabled={isSubmitting}
type="submit"
text="Submit"
className="bg-[#4465DB] grow"
/>
<ModalButton
disabled={isSubmitting}
text="Cancel"
onClick={onClose}
className="bg-[#737373] grow"
/>
</div>
</form>
);
}

View File

@ -0,0 +1,102 @@
import React from "react";
import hotToast, { toast } from "react-hot-toast";
import { useFetcher } from "@remix-run/react";
import { FeedbackForm } from "./feedback-form";
import {
BaseModalTitle,
BaseModalDescription,
} from "./modals/confirmation-modals/BaseModal";
import { ModalBackdrop } from "./modals/modal-backdrop";
import ModalBody from "./modals/ModalBody";
import { clientAction } from "#/routes/submit-feedback";
interface FeedbackModalProps {
onSubmit: (permissions: "private" | "public", email: string) => void;
onClose: () => void;
isOpen: boolean;
isSubmitting?: boolean;
}
export function FeedbackModal({
onSubmit,
onClose,
isOpen,
isSubmitting,
}: FeedbackModalProps) {
const fetcher = useFetcher<typeof clientAction>({ key: "feedback" });
const isInitialRender = React.useRef(true);
const copiedToClipboardToast = () => {
hotToast("Password copied to clipboard", {
icon: "📋",
position: "bottom-right",
});
};
const onPressToast = (password: string) => {
navigator.clipboard.writeText(password);
copiedToClipboardToast();
};
const shareFeedbackToast = (
message: string,
link: string,
password: string,
) => {
hotToast(
<div className="flex flex-col gap-1">
<span>{message}</span>
<a
data-testid="toast-share-url"
className="text-blue-500 underline"
onClick={() => onPressToast(password)}
href={link}
target="_blank"
rel="noreferrer"
>
Go to shared feedback
</a>
<span onClick={() => onPressToast(password)} className="cursor-pointer">
Password: {password} <span className="text-gray-500">(copy)</span>
</span>
</div>,
{ duration: 5000 },
);
};
React.useEffect(() => {
if (isInitialRender.current) {
isInitialRender.current = false;
return;
}
// Handle feedback submission
if (fetcher.state === "idle" && fetcher.data) {
if (!fetcher.data.success) {
toast.error("Error submitting feedback");
} else if (fetcher.data.data) {
const { data } = fetcher.data;
const { message, link, password } = data;
shareFeedbackToast(message, link, password);
}
onClose();
}
}, [fetcher.state, fetcher.data?.success]);
if (!isOpen) return null;
return (
<ModalBackdrop onClose={onClose}>
<ModalBody>
<BaseModalTitle title="Feedback" />
<BaseModalDescription description="To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." />
<FeedbackForm
onSubmit={onSubmit}
onClose={onClose}
isSubmitting={isSubmitting}
/>
</ModalBody>
</ModalBackdrop>
);
}

View File

@ -7,7 +7,7 @@ import { cn } from "#/utils/utils";
interface ImageCarouselProps {
size: "small" | "large";
images: string[];
onRemove: (index: number) => void;
onRemove?: (index: number) => void;
}
export function ImageCarousel({
@ -40,7 +40,7 @@ export function ImageCarousel({
};
return (
<div className="relative">
<div data-testid="image-carousel" className="relative">
{isScrollable && (
<div className="absolute right-full transform top-1/2 -translate-y-1/2">
<ChevronLeft active={!isAtStart} />
@ -60,7 +60,7 @@ export function ImageCarousel({
key={index}
size={size}
src={src}
onRemove={() => onRemove(index)}
onRemove={onRemove && (() => onRemove(index))}
/>
))}
</div>

View File

@ -3,7 +3,7 @@ import { cn } from "#/utils/utils";
interface ImagePreviewProps {
src: string;
onRemove: () => void;
onRemove?: () => void;
size?: "small" | "large";
}
@ -24,16 +24,18 @@ export function ImagePreview({
size === "large" && "w-[100px] h-[100px]",
)}
/>
<button
type="button"
onClick={onRemove}
className={cn(
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
"absolute right-[3px] top-[3px]",
)}
>
<CloseIcon width={10} height={10} />
</button>
{onRemove && (
<button
type="button"
onClick={onRemove}
className={cn(
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
"absolute right-[3px] top-[3px]",
)}
>
<CloseIcon width={10} height={10} />
</button>
)}
</div>
);
}

View File

@ -1,183 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Input, Radio, RadioGroup } from "@nextui-org/react";
import hotToast from "react-hot-toast";
import { I18nKey } from "#/i18n/declaration";
import BaseModal from "../base-modal/BaseModal";
import toast from "#/utils/toast";
import { getToken } from "#/services/auth";
import { removeApiKey, removeUnwantedKeys } from "#/utils/utils";
import { useSocket } from "#/context/socket";
import OpenHands from "#/api/open-hands";
import { Feedback } from "#/api/open-hands.types";
const isEmailValid = (email: string) => {
// Regular expression to validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const VIEWER_PAGE = "https://www.all-hands.dev/share";
const FEEDBACK_VERSION = "1.0";
interface FeedbackModalProps {
polarity: "positive" | "negative";
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onSendFeedback: () => void;
}
function FeedbackModal({
polarity,
isOpen,
onOpenChange,
onSendFeedback,
}: FeedbackModalProps) {
const { events } = useSocket();
const { t } = useTranslation();
const [email, setEmail] = React.useState("");
const [permissions, setPermissions] = React.useState<"public" | "private">(
"private",
);
React.useEffect(() => {
// check if email is stored in local storage
const storedEmail = localStorage.getItem("feedback-email");
if (storedEmail) setEmail(storedEmail);
}, []);
const handleEmailChange = (newEmail: string) => {
setEmail(newEmail);
};
const copiedToClipboardToast = () => {
hotToast("Password copied to clipboard", {
icon: "📋",
position: "bottom-right",
});
};
const onPressToast = (password: string) => {
navigator.clipboard.writeText(password);
copiedToClipboardToast();
};
const shareFeedbackToast = (
message: string,
link: string,
password: string,
) => {
hotToast(
<div className="flex flex-col gap-1">
<span>{message}</span>
<a
data-testid="toast-share-url"
className="text-blue-500 underline"
onClick={() => onPressToast(password)}
href={link}
target="_blank"
rel="noreferrer"
>
Go to shared feedback
</a>
<span onClick={() => onPressToast(password)} className="cursor-pointer">
Password: {password} <span className="text-gray-500">(copy)</span>
</span>
</div>,
{ duration: 5000 },
);
};
const handleSendFeedback = async () => {
onSendFeedback();
const feedback: Feedback = {
version: FEEDBACK_VERSION,
feedback: polarity,
email,
permissions,
token: getToken(),
trajectory: removeApiKey(removeUnwantedKeys(events)),
};
try {
localStorage.setItem("feedback-email", email); // store email in local storage
// TODO: Move to data loader
const token = localStorage.getItem("token");
if (token) {
const response = await OpenHands.sendFeedback(token, feedback);
if (response.statusCode === 200) {
const { message, feedback_id: feedbackId, password } = response.body;
const link = `${VIEWER_PAGE}?share_id=${feedbackId}`;
shareFeedbackToast(message, link, password);
} else {
toast.error(
"share-error",
`Failed to share, please contact the developers: ${response.body.message}`,
);
}
}
} catch (error) {
toast.error(
"share-error",
`Failed to share, please contact the developers: ${error}`,
);
}
};
return (
<BaseModal
testID="feedback-modal"
isOpen={isOpen}
title={t(I18nKey.FEEDBACK$MODAL_TITLE)}
onOpenChange={onOpenChange}
isDismissable={false} // prevent unnecessary messages from being stored (issue #1285)
actions={[
{
label: t(I18nKey.FEEDBACK$SHARE_LABEL),
className: "bg-primary rounded-lg",
action: handleSendFeedback,
isDisabled: !isEmailValid(email),
closeAfterAction: true,
},
{
label: t(I18nKey.FEEDBACK$CANCEL_LABEL),
className: "bg-neutral-500 rounded-lg",
action() {},
closeAfterAction: true,
},
]}
>
<p>{t(I18nKey.FEEDBACK$MODAL_CONTENT)}</p>
<Input
label="Email"
aria-label="email"
data-testid="email-input"
placeholder={t(I18nKey.FEEDBACK$EMAIL_PLACEHOLDER)}
type="text"
value={email}
onChange={(e) => {
handleEmailChange(e.target.value);
}}
/>
{!isEmailValid(email) && (
<p data-testid="invalid-email-message" className="text-red-500">
Invalid email format
</p>
)}
<RadioGroup
data-testid="permissions-group"
label="Sharing settings"
orientation="horizontal"
value={permissions}
onValueChange={(value) => setPermissions(value as "public" | "private")}
>
<Radio value="private">{t(I18nKey.FEEDBACK$PRIVATE_LABEL)}</Radio>
<Radio value="public">{t(I18nKey.FEEDBACK$PUBLIC_LABEL)}</Radio>
</RadioGroup>
</BaseModal>
);
}
export default FeedbackModal;

View File

@ -20,7 +20,7 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
};
return (
<div className="fixed inset-0 flex items-center justify-center">
<div className="fixed inset-0 flex items-center justify-center z-10">
<div
onClick={handleClick}
className="fixed inset-0 bg-black bg-opacity-80"

View File

@ -0,0 +1,26 @@
interface ScrollButtonProps {
onClick: () => void;
icon: JSX.Element;
label: string;
disabled?: boolean;
}
export function ScrollButton({
onClick,
icon,
label,
disabled = false,
}: ScrollButtonProps): JSX.Element {
return (
<button
type="button"
className="relative border-1 text-xs rounded px-2 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
onClick={onClick}
disabled={disabled}
>
<div className="flex items-center">
{icon} <span className="inline-block">{label}</span>
</div>
</button>
);
}

View File

@ -0,0 +1,18 @@
import ArrowSendIcon from "#/assets/arrow-send.svg?react";
interface ScrollToBottomButtonProps {
onClick: () => void;
}
export function ScrollToBottomButton({ onClick }: ScrollToBottomButtonProps) {
return (
<button
type="button"
onClick={onClick}
data-testid="scroll-to-bottom"
className="p-1 bg-neutral-700 border border-neutral-600 rounded hover:bg-neutral-500 rotate-180"
>
<ArrowSendIcon width={15} height={15} />
</button>
);
}

View File

@ -0,0 +1,5 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.6938 4.50616C11.6357 4.44758 11.5666 4.40109 11.4904 4.36936C11.4142 4.33763 11.3325 4.32129 11.25 4.32129C11.1675 4.32129 11.0858 4.33763 11.0097 4.36936C10.9335 4.40109 10.8644 4.44758 10.8063 4.50616L6.15003 9.16866L4.19378 7.20616C4.13345 7.14789 4.06224 7.10207 3.98421 7.07132C3.90617 7.04056 3.82284 7.02548 3.73898 7.02693C3.65512 7.02838 3.57236 7.04634 3.49544 7.07977C3.41851 7.1132 3.34893 7.16146 3.29065 7.22179C3.23238 7.28211 3.18656 7.35333 3.15581 7.43136C3.12505 7.5094 3.10997 7.59272 3.11142 7.67659C3.11287 7.76045 3.13083 7.84321 3.16426 7.92013C3.1977 7.99705 3.24595 8.06664 3.30628 8.12491L5.70628 10.5249C5.76438 10.5835 5.83351 10.63 5.90967 10.6617C5.98583 10.6935 6.06752 10.7098 6.15003 10.7098C6.23254 10.7098 6.31423 10.6935 6.39039 10.6617C6.46655 10.63 6.53568 10.5835 6.59378 10.5249L11.6938 5.42491C11.7572 5.36639 11.8078 5.29535 11.8425 5.21629C11.8771 5.13723 11.895 5.05185 11.895 4.96554C11.895 4.87922 11.8771 4.79385 11.8425 4.71478C11.8078 4.63572 11.7572 4.56469 11.6938 4.50616Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.14645 2.64645C6.34171 2.45118 6.65829 2.45118 6.85355 2.64645L9.85355 5.64645C10.0488 5.84171 10.0488 6.15829 9.85355 6.35355L6.85355 9.35355C6.65829 9.54882 6.34171 9.54882 6.14645 9.35355C5.95118 9.15829 5.95118 8.84171 6.14645 8.64645L8.79289 6L6.14645 3.35355C5.95118 3.15829 5.95118 2.84171 6.14645 2.64645ZM3.14645 2.64645C3.34171 2.45118 3.65829 2.45118 3.85355 2.64645L6.85355 5.64645C6.94732 5.74022 7 5.86739 7 6C7 6.13261 6.94732 6.25979 6.85355 6.35355L3.85355 9.35355C3.65829 9.54882 3.34171 9.54882 3.14645 9.35355C2.95118 9.15829 2.95118 8.84171 3.14645 8.64645L5.79289 6L3.14645 3.35355C2.95118 3.15829 2.95118 2.84171 3.14645 2.64645Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@ -0,0 +1,5 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.25 2.5C1.25 1.80964 1.80964 1.25 2.5 1.25H8.75C9.44036 1.25 10 1.80964 10 2.5V5H12.5C13.1904 5 13.75 5.55964 13.75 6.25V12.5C13.75 13.1904 13.1904 13.75 12.5 13.75H6.25C5.55964 13.75 5 13.1904 5 12.5V10H2.5C1.80964 10 1.25 9.44036 1.25 8.75V2.5ZM6.25 10V12.5H12.5V6.25H10V8.75C10 9.44036 9.44036 10 8.75 10H6.25ZM8.75 8.75V2.5L2.5 2.5V8.75H8.75Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@ -2,4 +2,4 @@
<path
d="M11.8749 1.75H3.91861C3.47998 1.75015 3.05528 1.90407 2.71841 2.18499C2.38154 2.4659 2.15382 2.85603 2.07486 3.2875L1.28111 7.6625C1.23166 7.93277 1.2422 8.21062 1.31201 8.47636C1.38182 8.74211 1.50917 8.98927 1.68507 9.20035C1.86097 9.41142 2.08111 9.58126 2.32991 9.69785C2.57872 9.81443 2.8501 9.87491 3.12486 9.875H5.97486L5.62486 10.7688C5.47928 11.1601 5.4308 11.5809 5.48357 11.995C5.53635 12.4092 5.68881 12.8044 5.92787 13.1467C6.16694 13.489 6.48547 13.7683 6.85615 13.9604C7.22683 14.1526 7.63859 14.2519 8.05611 14.25C8.17634 14.2497 8.29394 14.2148 8.39482 14.1494C8.4957 14.084 8.57557 13.9909 8.62486 13.8813L10.4061 9.875H11.8749C12.3721 9.875 12.8491 9.67746 13.2007 9.32583C13.5523 8.97419 13.7499 8.49728 13.7499 8V3.625C13.7499 3.12772 13.5523 2.65081 13.2007 2.29917C12.8491 1.94754 12.3721 1.75 11.8749 1.75ZM9.37486 9.11875L7.67486 12.9438C7.50092 12.8911 7.3396 12.8034 7.20083 12.6861C7.06206 12.5688 6.94878 12.4242 6.86798 12.2615C6.78717 12.0987 6.74055 11.9211 6.73099 11.7396C6.72143 11.5581 6.74912 11.3766 6.81236 11.2062L7.14361 10.3125C7.2142 10.1236 7.23803 9.92041 7.21307 9.72029C7.18811 9.52018 7.1151 9.32907 7.00028 9.16329C6.88546 8.9975 6.73223 8.86196 6.55367 8.76823C6.37511 8.67449 6.17653 8.62535 5.97486 8.625H3.12486C3.03304 8.62515 2.94232 8.60507 2.85914 8.56618C2.77597 8.52729 2.70238 8.47055 2.64361 8.4C2.58341 8.33042 2.5393 8.24841 2.51445 8.15982C2.4896 8.07123 2.48462 7.97824 2.49986 7.8875L3.29361 3.5125C3.32024 3.3669 3.39767 3.23548 3.51212 3.14162C3.62657 3.04777 3.77062 2.99759 3.91861 3H9.37486V9.11875ZM12.4999 8C12.4999 8.16576 12.434 8.32473 12.3168 8.44194C12.1996 8.55915 12.0406 8.625 11.8749 8.625H10.6249V3H11.8749C12.0406 3 12.1996 3.06585 12.3168 3.18306C12.434 3.30027 12.4999 3.45924 12.4999 3.625V8Z"
fill="white" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -2,4 +2,4 @@
<path
d="M13.3125 6.80003C13.1369 6.58918 12.9171 6.41945 12.6687 6.30282C12.4204 6.18619 12.1494 6.1255 11.875 6.12503H9.025L9.375 5.23128C9.52058 4.83995 9.56907 4.41916 9.51629 4.00498C9.46351 3.5908 9.31106 3.19561 9.07199 2.8533C8.83293 2.51099 8.51439 2.23178 8.14371 2.03962C7.77303 1.84746 7.36127 1.74809 6.94375 1.75003C6.82352 1.75028 6.70592 1.7852 6.60504 1.8506C6.50417 1.91601 6.42429 2.00912 6.375 2.11878L4.59375 6.12503H3.125C2.62772 6.12503 2.15081 6.32257 1.79917 6.6742C1.44754 7.02583 1.25 7.50275 1.25 8.00003V12.375C1.25 12.8723 1.44754 13.3492 1.79917 13.7009C2.15081 14.0525 2.62772 14.25 3.125 14.25H11.0812C11.5199 14.2499 11.9446 14.096 12.2815 13.815C12.6183 13.5341 12.846 13.144 12.925 12.7125L13.7188 8.33753C13.7678 8.06714 13.7569 7.78927 13.6867 7.52358C13.6165 7.25788 13.4887 7.01087 13.3125 6.80003ZM4.375 13H3.125C2.95924 13 2.80027 12.9342 2.68306 12.817C2.56585 12.6998 2.5 12.5408 2.5 12.375V8.00003C2.5 7.83427 2.56585 7.6753 2.68306 7.55809C2.80027 7.44088 2.95924 7.37503 3.125 7.37503H4.375V13ZM12.5 8.11253L11.7062 12.4875C11.6796 12.6331 11.6022 12.7646 11.4877 12.8584C11.3733 12.9523 11.2292 13.0024 11.0812 13H5.625V6.88128L7.325 3.05628C7.49999 3.10729 7.6625 3.19403 7.80229 3.31102C7.94207 3.428 8.05608 3.57269 8.13712 3.73596C8.21817 3.89923 8.26449 4.07752 8.27316 4.25959C8.28183 4.44166 8.25266 4.62355 8.1875 4.79378L7.85625 5.68753C7.78567 5.87644 7.76184 6.07962 7.7868 6.27973C7.81176 6.47985 7.88476 6.67095 7.99958 6.83674C8.11441 7.00253 8.26763 7.13807 8.44619 7.2318C8.62475 7.32554 8.82333 7.37468 9.025 7.37503H11.875C11.9668 7.37488 12.0575 7.39496 12.1407 7.43385C12.2239 7.47274 12.2975 7.52948 12.3563 7.60003C12.4165 7.66961 12.4606 7.75162 12.4854 7.84021C12.5103 7.9288 12.5152 8.02179 12.5 8.11253Z"
fill="white" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -57,6 +57,15 @@ const openHandsHandlers = [
return HttpResponse.json(null, { status: 404 });
}),
http.post("http://localhost:3000/api/submit-feedback", async () => {
await delay(1200);
return HttpResponse.json({
statusCode: 200,
body: { message: "Success", link: "fake-url.com", password: "abc123" },
});
}),
];
export const handlers = [

View File

@ -12,7 +12,6 @@ import {
import { useDispatch, useSelector } from "react-redux";
import WebSocket from "ws";
import toast from "react-hot-toast";
import ChatInterface from "#/components/chat/ChatInterface";
import { getSettings } from "#/services/settings";
import Security from "../components/modals/security/Security";
import { Controls } from "#/components/controls";
@ -51,6 +50,7 @@ import { FilesProvider } from "#/context/files";
import { clearSession } from "#/utils/clear-session";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { ErrorObservation } from "#/types/core/observations";
import { ChatInterface } from "#/components/chat-interface";
interface ServerError {
error: boolean | string;
@ -295,11 +295,11 @@ function App() {
return (
<div className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto gap-3">
<Container className="w-1/4 max-h-full">
<Container className="w-[375px] max-h-full">
<ChatInterface />
</Container>
<div className="flex flex-col w-3/4 gap-3">
<div className="flex flex-col grow gap-3">
<Container
className="h-2/3"
labels={[

View File

@ -0,0 +1,47 @@
import { ClientActionFunctionArgs, json } from "@remix-run/react";
import { Feedback } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
const isFeedback = (feedback: unknown): feedback is Feedback => {
if (typeof feedback !== "object" || feedback === null) {
return false;
}
return (
"version" in feedback &&
"email" in feedback &&
"token" in feedback &&
"feedback" in feedback &&
"permissions" in feedback &&
"trajectory" in feedback
);
};
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const formData = await request.formData();
const feedback = formData.get("feedback")?.toString();
const token = localStorage.getItem("token");
if (token && feedback) {
const parsed = JSON.parse(feedback);
if (isFeedback(parsed)) {
try {
const response = await OpenHands.sendFeedback(token, parsed);
if (response.statusCode === 200) {
const { message, feedback_id: feedbackId, password } = response.body;
const link = `${VIEWER_PAGE}?share_id=${feedbackId}`;
return json({
success: true,
data: { message, link, password },
});
}
} catch (error) {
return json({ success: false, data: null });
}
}
}
return json({ success: false, data: null });
};