Przeglądaj źródła

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

sp.wack 1 rok temu
rodzic
commit
6cf3728247
34 zmienionych plików z 1083 dodań i 1122 usunięć
  1. 73 0
      frontend/__tests__/components/chat-message.test.tsx
  2. 0 28
      frontend/__tests__/components/chat/Chat.test.tsx
  3. 0 148
      frontend/__tests__/components/chat/ChatInterface.test.tsx
  4. 0 200
      frontend/__tests__/components/chat/ChatMessage.test.tsx
  5. 185 0
      frontend/__tests__/components/chat/chat-interface.test.tsx
  6. 55 0
      frontend/__tests__/components/feedback-actions.test.tsx
  7. 108 0
      frontend/__tests__/components/feedback-form.test.tsx
  8. 5 0
      frontend/__tests__/components/image-preview.test.tsx
  9. 0 193
      frontend/__tests__/components/modals/feeback/FeedbackModal.test.tsx
  10. 1 1
      frontend/src/components/chat-input.tsx
  11. 172 0
      frontend/src/components/chat-interface.tsx
  12. 85 0
      frontend/src/components/chat-message.tsx
  13. 0 39
      frontend/src/components/chat/Chat.tsx
  14. 0 188
      frontend/src/components/chat/ChatInterface.tsx
  15. 0 122
      frontend/src/components/chat/ChatMessage.tsx
  16. 23 0
      frontend/src/components/continue-button.tsx
  17. 15 0
      frontend/src/components/error-message.tsx
  18. 50 0
      frontend/src/components/feedback-actions.tsx
  19. 72 0
      frontend/src/components/feedback-form.tsx
  20. 102 0
      frontend/src/components/feedback-modal.tsx
  21. 3 3
      frontend/src/components/image-carousel.tsx
  22. 13 11
      frontend/src/components/image-preview.tsx
  23. 0 183
      frontend/src/components/modals/feedback/FeedbackModal.tsx
  24. 1 1
      frontend/src/components/modals/modal-backdrop.tsx
  25. 26 0
      frontend/src/components/scroll-button.tsx
  26. 18 0
      frontend/src/components/scroll-to-bottom-button.tsx
  27. 5 0
      frontend/src/icons/checkmark.svg
  28. 5 0
      frontend/src/icons/chevron-double-right.svg
  29. 5 0
      frontend/src/icons/copy.svg
  30. 1 1
      frontend/src/icons/thumbs-down.svg
  31. 1 1
      frontend/src/icons/thumbs-up.svg
  32. 9 0
      frontend/src/mocks/handlers.ts
  33. 3 3
      frontend/src/routes/_oh.app.tsx
  34. 47 0
      frontend/src/routes/submit-feedback.ts

+ 73 - 0
frontend/__tests__/components/chat-message.test.tsx

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

+ 0 - 28
frontend/__tests__/components/chat/Chat.test.tsx

@@ -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);
-  });
-});

+ 0 - 148
frontend/__tests__/components/chat/ChatInterface.test.tsx

@@ -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");
-});

+ 0 - 200
frontend/__tests__/components/chat/ChatMessage.test.tsx

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

+ 185 - 0
frontend/__tests__/components/chat/chat-interface.test.tsx

@@ -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");
+  });
+});

+ 55 - 0
frontend/__tests__/components/feedback-actions.test.tsx

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

+ 108 - 0
frontend/__tests__/components/feedback-form.test.tsx

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

+ 5 - 0
frontend/__tests__/components/image-preview.test.tsx

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

+ 0 - 193
frontend/__tests__/components/modals/feeback/FeedbackModal.test.tsx

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

+ 1 - 1
frontend/src/components/chat-input.tsx

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

+ 172 - 0
frontend/src/components/chat-interface.tsx

@@ -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>
+  );
+}

+ 85 - 0
frontend/src/components/chat-message.tsx

@@ -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>
+  );
+}

+ 0 - 39
frontend/src/components/chat/Chat.tsx

@@ -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;

+ 0 - 188
frontend/src/components/chat/ChatInterface.tsx

@@ -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;

+ 0 - 122
frontend/src/components/chat/ChatMessage.tsx

@@ -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;

+ 23 - 0
frontend/src/components/continue-button.tsx

@@ -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>
+  );
+}

+ 15 - 0
frontend/src/components/error-message.tsx

@@ -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>
+  );
+}

+ 50 - 0
frontend/src/components/feedback-actions.tsx

@@ -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>
+  );
+}

+ 72 - 0
frontend/src/components/feedback-form.tsx

@@ -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>
+  );
+}

+ 102 - 0
frontend/src/components/feedback-modal.tsx

@@ -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>
+  );
+}

+ 3 - 3
frontend/src/components/image-carousel.tsx

@@ -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>

+ 13 - 11
frontend/src/components/image-preview.tsx

@@ -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>
   );
 }

+ 0 - 183
frontend/src/components/modals/feedback/FeedbackModal.tsx

@@ -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;

+ 1 - 1
frontend/src/components/modals/modal-backdrop.tsx

@@ -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"

+ 26 - 0
frontend/src/components/scroll-button.tsx

@@ -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>
+  );
+}

+ 18 - 0
frontend/src/components/scroll-to-bottom-button.tsx

@@ -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>
+  );
+}

+ 5 - 0
frontend/src/icons/checkmark.svg

@@ -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>

+ 5 - 0
frontend/src/icons/chevron-double-right.svg

@@ -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>

+ 5 - 0
frontend/src/icons/copy.svg

@@ -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>

+ 1 - 1
frontend/src/assets/thumbs-down.svg → frontend/src/icons/thumbs-down.svg

@@ -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>

+ 1 - 1
frontend/src/assets/thumbs-up.svg → frontend/src/icons/thumbs-up.svg

@@ -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>

+ 9 - 0
frontend/src/mocks/handlers.ts

@@ -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 = [

+ 3 - 3
frontend/src/routes/_oh.app.tsx

@@ -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={[

+ 47 - 0
frontend/src/routes/submit-feedback.ts

@@ -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 });
+};