diff --git a/evaluation/miniwob/README.md b/evaluation/miniwob/README.md
index b0b0545406..e6a80543c7 100644
--- a/evaluation/miniwob/README.md
+++ b/evaluation/miniwob/README.md
@@ -1,4 +1,4 @@
-# WebArena Evaluation with OpenHands Browsing Agents
+# Mini-World of Bits Evaluation with OpenHands Browsing Agents
This folder contains evaluation for [MiniWoB++](https://miniwob.farama.org/) benchmark, powered by [BrowserGym](https://github.com/ServiceNow/BrowserGym) for easy evaluation of how well an agent capable of browsing can perform on synthetic web browsing tasks.
diff --git a/frontend/__tests__/components/chat/ChatInput.test.tsx b/frontend/__tests__/components/chat/ChatInput.test.tsx
deleted file mode 100644
index 75b565f848..0000000000
--- a/frontend/__tests__/components/chat/ChatInput.test.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import userEvent from "@testing-library/user-event";
-import { render, screen } from "@testing-library/react";
-import { describe, afterEach, vi, it, expect } from "vitest";
-import ChatInput from "#/components/chat/ChatInput";
-
-describe.skip("ChatInput", () => {
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- const onSendMessage = vi.fn();
-
- it("should render a textarea", () => {
- render();
- expect(screen.getByRole("textbox")).toBeInTheDocument();
- });
-
- it("should be able to be set as disabled", async () => {
- const user = userEvent.setup();
- render();
-
- const textarea = screen.getByRole("textbox");
- const button = screen.getByRole("button");
-
- expect(textarea).not.toBeDisabled(); // user can still type
- expect(button).toBeDisabled(); // user cannot submit
-
- await user.type(textarea, "Hello, world!");
- await user.keyboard("{Enter}");
-
- expect(onSendMessage).not.toHaveBeenCalled();
- });
-
- it("should render with a placeholder", () => {
- render();
-
- const textarea = screen.getByPlaceholderText(
- /CHAT_INTERFACE\$INPUT_PLACEHOLDER/i,
- );
- expect(textarea).toBeInTheDocument();
- });
-
- it("should render a send button", () => {
- render();
- expect(screen.getByRole("button")).toBeInTheDocument();
- });
-
- it("should call sendChatMessage with the input when the send button is clicked", async () => {
- const user = userEvent.setup();
- render();
-
- const textarea = screen.getByRole("textbox");
- const button = screen.getByRole("button");
-
- await user.type(textarea, "Hello, world!");
- await user.click(button);
-
- expect(onSendMessage).toHaveBeenCalledWith("Hello, world!", []);
- // Additionally, check if it was called exactly once
- expect(onSendMessage).toHaveBeenCalledTimes(1);
- });
-
- it("should be able to send a message when the enter key is pressed", async () => {
- const user = userEvent.setup();
- render();
- const textarea = screen.getByRole("textbox");
-
- await user.type(textarea, "Hello, world!");
- await user.keyboard("{Enter}");
-
- expect(onSendMessage).toHaveBeenCalledWith("Hello, world!", []);
- });
-
- it("should NOT send a message when shift + enter is pressed", async () => {
- const user = userEvent.setup();
- render();
- const textarea = screen.getByRole("textbox");
-
- await user.type(textarea, "Hello, world!");
- await user.keyboard("{Shift>} {Enter}"); // Shift + Enter
-
- expect(onSendMessage).not.toHaveBeenCalled();
- });
-
- it("should NOT send an empty message", async () => {
- const user = userEvent.setup();
- render();
- const textarea = screen.getByRole("textbox");
- const button = screen.getByRole("button");
-
- await user.type(textarea, " ");
-
- // with enter key
- await user.keyboard("{Enter}");
- expect(onSendMessage).not.toHaveBeenCalled();
-
- // with button click
- await user.click(button);
- expect(onSendMessage).not.toHaveBeenCalled();
- });
-
- it("should clear the input message after sending a message", async () => {
- const user = userEvent.setup();
- render();
- const textarea = screen.getByRole("textbox");
- const button = screen.getByRole("button");
-
- await user.type(textarea, "Hello, world!");
- expect(textarea).toHaveValue("Hello, world!");
-
- await user.click(button);
- expect(textarea).toHaveValue("");
- });
-
- // this is already implemented but need to figure out how to test it
- it.todo(
- "should NOT send a message when the enter key is pressed while composing",
- );
-});
diff --git a/frontend/__tests__/components/chat/chat-input.test.tsx b/frontend/__tests__/components/chat/chat-input.test.tsx
new file mode 100644
index 0000000000..e10e3d26f3
--- /dev/null
+++ b/frontend/__tests__/components/chat/chat-input.test.tsx
@@ -0,0 +1,161 @@
+import userEvent from "@testing-library/user-event";
+import { render, screen } from "@testing-library/react";
+import { describe, afterEach, vi, it, expect } from "vitest";
+import { ChatInput } from "#/components/chat-input";
+
+describe("ChatInput", () => {
+ const onSubmitMock = vi.fn();
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render a textarea", () => {
+ render();
+ expect(screen.getByTestId("chat-input")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
+
+ it("should call onSubmit when the user types and presses enter", async () => {
+ const user = userEvent.setup();
+ render();
+ const textarea = screen.getByRole("textbox");
+
+ await user.type(textarea, "Hello, world!");
+ await user.keyboard("{Enter}");
+
+ expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
+ });
+
+ it("should call onSubmit when pressing the submit button", async () => {
+ const user = userEvent.setup();
+ render();
+ const textarea = screen.getByRole("textbox");
+ const button = screen.getByRole("button");
+
+ await user.type(textarea, "Hello, world!");
+ await user.click(button);
+
+ expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
+ });
+
+ it("should not call onSubmit when the message is empty", async () => {
+ const user = userEvent.setup();
+ render();
+ const button = screen.getByRole("button");
+
+ await user.click(button);
+ expect(onSubmitMock).not.toHaveBeenCalled();
+
+ await user.keyboard("{Enter}");
+ expect(onSubmitMock).not.toHaveBeenCalled();
+ });
+
+ it("should disable submit", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const button = screen.getByRole("button");
+ const textarea = screen.getByRole("textbox");
+
+ await user.type(textarea, "Hello, world!");
+
+ expect(button).toBeDisabled();
+ await user.click(button);
+ expect(onSubmitMock).not.toHaveBeenCalled();
+
+ await user.keyboard("{Enter}");
+ expect(onSubmitMock).not.toHaveBeenCalled();
+ });
+
+ it("should render a placeholder", () => {
+ render(
+ ,
+ );
+
+ const textarea = screen.getByPlaceholderText("Enter your message");
+ expect(textarea).toBeInTheDocument();
+ });
+
+ it("should create a newline instead of submitting when shift + enter is pressed", async () => {
+ const user = userEvent.setup();
+ render();
+ const textarea = screen.getByRole("textbox");
+
+ await user.type(textarea, "Hello, world!");
+ await user.keyboard("{Shift>} {Enter}"); // Shift + Enter
+
+ expect(onSubmitMock).not.toHaveBeenCalled();
+ // expect(textarea).toHaveValue("Hello, world!\n");
+ });
+
+ it("should clear the input message after sending a message", async () => {
+ const user = userEvent.setup();
+ render();
+ const textarea = screen.getByRole("textbox");
+ const button = screen.getByRole("button");
+
+ await user.type(textarea, "Hello, world!");
+ await user.keyboard("{Enter}");
+ expect(textarea).toHaveValue("");
+
+ await user.type(textarea, "Hello, world!");
+ await user.click(button);
+ expect(textarea).toHaveValue("");
+ });
+
+ it("should hide the submit button", () => {
+ render();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("should call onChange when the user types", async () => {
+ const user = userEvent.setup();
+ const onChangeMock = vi.fn();
+ render();
+ const textarea = screen.getByRole("textbox");
+
+ await user.type(textarea, "Hello, world!");
+
+ expect(onChangeMock).toHaveBeenCalledTimes("Hello, world!".length);
+ });
+
+ it("should have set the passed value", () => {
+ render();
+ const textarea = screen.getByRole("textbox");
+
+ expect(textarea).toHaveValue("Hello, world!");
+ });
+
+ it("should display the stop button and trigger the callback", async () => {
+ const user = userEvent.setup();
+ const onStopMock = vi.fn();
+ render(
+ ,
+ );
+ const stopButton = screen.getByTestId("stop-button");
+
+ await user.click(stopButton);
+ expect(onStopMock).toHaveBeenCalledOnce();
+ });
+
+ it("should call onFocus and onBlur when the textarea is focused and blurred", async () => {
+ const user = userEvent.setup();
+ const onFocusMock = vi.fn();
+ const onBlurMock = vi.fn();
+ render(
+ ,
+ );
+ const textarea = screen.getByRole("textbox");
+
+ await user.click(textarea);
+ expect(onFocusMock).toHaveBeenCalledOnce();
+
+ await user.tab();
+ expect(onBlurMock).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/__tests__/components/image-preview.test.tsx b/frontend/__tests__/components/image-preview.test.tsx
new file mode 100644
index 0000000000..ef39fb0f47
--- /dev/null
+++ b/frontend/__tests__/components/image-preview.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ImagePreview } from "#/components/image-preview";
+
+describe("ImagePreview", () => {
+ it("should render an image", () => {
+ render(
+ ,
+ );
+ const img = screen.getByRole("img");
+
+ expect(screen.getByTestId("image-preview")).toBeInTheDocument();
+ expect(img).toHaveAttribute("src", "https://example.com/image.jpg");
+ });
+
+ it("should call onRemove when the close button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRemoveMock = vi.fn();
+ render(
+ ,
+ );
+
+ const closeButton = screen.getByRole("button");
+ await user.click(closeButton);
+
+ expect(onRemoveMock).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx
new file mode 100644
index 0000000000..5d775682cb
--- /dev/null
+++ b/frontend/__tests__/components/interactive-chat-box.test.tsx
@@ -0,0 +1,119 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { InteractiveChatBox } from "#/components/interactive-chat-box";
+
+describe("InteractiveChatBox", () => {
+ const onSubmitMock = vi.fn();
+ const onStopMock = vi.fn();
+
+ beforeAll(() => {
+ global.URL.createObjectURL = vi
+ .fn()
+ .mockReturnValue("blob:http://example.com");
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render", () => {
+ render();
+
+ const chatBox = screen.getByTestId("interactive-chat-box");
+ within(chatBox).getByTestId("chat-input");
+ within(chatBox).getByTestId("upload-image-input");
+ });
+
+ it("should display the image previews when images are uploaded", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
+ const input = screen.getByTestId("upload-image-input");
+
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
+
+ await user.upload(input, file);
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
+
+ const files = [
+ new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
+ new File(["(⌐□_□)"], "chucknorris3.png", { type: "image/png" }),
+ ];
+
+ await user.upload(input, files);
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(3);
+ });
+
+ it("should remove the image preview when the close button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
+ const input = screen.getByTestId("upload-image-input");
+
+ await user.upload(input, file);
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
+
+ const imagePreview = screen.getByTestId("image-preview");
+ const closeButton = within(imagePreview).getByRole("button");
+ await user.click(closeButton);
+
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
+ });
+
+ it("should call onSubmit with the message and images", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const textarea = within(screen.getByTestId("chat-input")).getByRole(
+ "textbox",
+ );
+ const input = screen.getByTestId("upload-image-input");
+ const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
+
+ await user.upload(input, file);
+ await user.type(textarea, "Hello, world!");
+ await user.keyboard("{Enter}");
+
+ expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]);
+
+ // clear images after submission
+ expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
+ });
+
+ it("should disable the submit button", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+
+ await user.click(button);
+ expect(onSubmitMock).not.toHaveBeenCalled();
+ });
+
+ it("should display the stop button if set and call onStop when clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const stopButton = screen.getByTestId("stop-button");
+ expect(stopButton).toBeInTheDocument();
+
+ await user.click(stopButton);
+ expect(onStopMock).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/__tests__/components/upload-image-input.test.tsx b/frontend/__tests__/components/upload-image-input.test.tsx
new file mode 100644
index 0000000000..77f89ee885
--- /dev/null
+++ b/frontend/__tests__/components/upload-image-input.test.tsx
@@ -0,0 +1,71 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { UploadImageInput } from "#/components/upload-image-input";
+
+describe("UploadImageInput", () => {
+ const user = userEvent.setup();
+ const onUploadMock = vi.fn();
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render an input", () => {
+ render();
+ expect(screen.getByTestId("upload-image-input")).toBeInTheDocument();
+ });
+
+ it("should call onUpload when a file is selected", async () => {
+ render();
+
+ const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
+ const input = screen.getByTestId("upload-image-input");
+
+ await user.upload(input, file);
+
+ expect(onUploadMock).toHaveBeenNthCalledWith(1, [file]);
+ });
+
+ it("should call onUpload when multiple files are selected", async () => {
+ render();
+
+ const files = [
+ new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }),
+ new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
+ ];
+ const input = screen.getByTestId("upload-image-input");
+
+ await user.upload(input, files);
+
+ expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
+ });
+
+ it("should not upload any file that is not an image", async () => {
+ render();
+
+ const file = new File(["(⌐□_□)"], "chucknorris.txt", {
+ type: "text/plain",
+ });
+ const input = screen.getByTestId("upload-image-input");
+
+ await user.upload(input, file);
+
+ expect(onUploadMock).not.toHaveBeenCalled();
+ });
+
+ it("should render custom labels", () => {
+ const { rerender } = render();
+ expect(screen.getByTestId("default-label")).toBeInTheDocument();
+
+ function CustomLabel() {
+ return Custom label;
+ }
+ rerender(
+ } />,
+ );
+
+ expect(screen.getByText("Custom label")).toBeInTheDocument();
+ expect(screen.queryByTestId("default-label")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/assets/chevron-left.tsx b/frontend/src/assets/chevron-left.tsx
new file mode 100644
index 0000000000..b2ae9abdde
--- /dev/null
+++ b/frontend/src/assets/chevron-left.tsx
@@ -0,0 +1,28 @@
+interface ChevronLeftProps {
+ width?: number;
+ height?: number;
+ active?: boolean;
+}
+
+export function ChevronLeft({
+ width = 20,
+ height = 20,
+ active,
+}: ChevronLeftProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/assets/chevron-right.tsx b/frontend/src/assets/chevron-right.tsx
new file mode 100644
index 0000000000..4d7119d4a9
--- /dev/null
+++ b/frontend/src/assets/chevron-right.tsx
@@ -0,0 +1,28 @@
+interface ChevronRightProps {
+ width?: number;
+ height?: number;
+ active?: boolean;
+}
+
+export function ChevronRight({
+ width = 20,
+ height = 20,
+ active,
+}: ChevronRightProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/assets/close.svg b/frontend/src/assets/close.svg
new file mode 100644
index 0000000000..d43761a6f1
--- /dev/null
+++ b/frontend/src/assets/close.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/components/attach-image-label.tsx b/frontend/src/components/attach-image-label.tsx
new file mode 100644
index 0000000000..f3b9c7ebc1
--- /dev/null
+++ b/frontend/src/components/attach-image-label.tsx
@@ -0,0 +1,10 @@
+import Clip from "#/assets/clip.svg?react";
+
+export function AttachImageLabel() {
+ return (
+
+
+ Attach images
+
+ );
+}
diff --git a/frontend/src/components/chat-input.tsx b/frontend/src/components/chat-input.tsx
new file mode 100644
index 0000000000..0aa4348e15
--- /dev/null
+++ b/frontend/src/components/chat-input.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import TextareaAutosize from "react-textarea-autosize";
+import ArrowSendIcon from "#/assets/arrow-send.svg?react";
+import { cn } from "#/utils/utils";
+
+interface ChatInputProps {
+ name?: string;
+ button?: "submit" | "stop";
+ disabled?: boolean;
+ placeholder?: string;
+ showButton?: boolean;
+ value?: string;
+ maxRows?: number;
+ onSubmit: (message: string) => void;
+ onStop?: () => void;
+ onChange?: (message: string) => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ className?: React.HTMLAttributes["className"];
+}
+
+export function ChatInput({
+ name,
+ button = "submit",
+ disabled,
+ placeholder,
+ showButton = true,
+ value,
+ maxRows = 4,
+ onSubmit,
+ onStop,
+ onChange,
+ onFocus,
+ onBlur,
+ className,
+}: ChatInputProps) {
+ const textareaRef = React.useRef(null);
+
+ const handleSubmitMessage = () => {
+ if (textareaRef.current?.value) {
+ onSubmit(textareaRef.current.value);
+ textareaRef.current.value = "";
+ }
+ };
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmitMessage();
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ onChange?.(event.target.value);
+ };
+
+ return (
+
+
+ {showButton && (
+ <>
+ {button === "submit" && (
+
+ )}
+ {button === "stop" && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx
deleted file mode 100644
index a228f7fff7..0000000000
--- a/frontend/src/components/chat/ChatInput.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { Textarea } from "@nextui-org/react";
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { useSelector } from "react-redux";
-import { I18nKey } from "#/i18n/declaration";
-import Clip from "#/assets/clip.svg?react";
-import { RootState } from "#/store";
-import AgentState from "#/types/AgentState";
-import { useSocket } from "#/context/socket";
-import { generateAgentStateChangeEvent } from "#/services/agentStateService";
-import { cn } from "#/utils/utils";
-import ArrowSendIcon from "#/assets/arrow-send.svg?react";
-import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
-
-interface ChatInputProps {
- disabled?: boolean;
- onSendMessage: (message: string, image_urls: string[]) => void;
-}
-
-function ChatInput({ disabled = false, onSendMessage }: ChatInputProps) {
- const { send } = useSocket();
- const { t } = useTranslation();
- const { curAgentState } = useSelector((state: RootState) => state.agent);
-
- const [message, setMessage] = React.useState("");
- const [files, setFiles] = React.useState([]);
- // This is true when the user is typing in an IME (e.g., Chinese, Japanese)
- const [isComposing, setIsComposing] = React.useState(false);
-
- const handleSendChatMessage = async () => {
- if (curAgentState === AgentState.RUNNING) {
- send(generateAgentStateChangeEvent(AgentState.STOPPED));
- return;
- }
-
- if (message.trim()) {
- let base64images: string[] = [];
- if (files.length > 0) {
- base64images = await Promise.all(
- files.map((file) => convertImageToBase64(file)),
- );
- }
- onSendMessage(message, base64images);
- setMessage("");
- setFiles([]);
- }
- };
-
- const onKeyPress = (event: React.KeyboardEvent) => {
- if (event.key === "Enter" && !event.shiftKey && !isComposing) {
- event.preventDefault(); // prevent a new line
- if (!disabled) {
- handleSendChatMessage();
- }
- }
- };
-
- const handleFileChange = (event: React.ChangeEvent) => {
- if (event.target.files) {
- setFiles((prev) => [...prev, ...Array.from(event.target.files!)]);
- }
- };
-
- const removeFile = (index: number) => {
- setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
- };
-
- const handlePaste = (event: React.ClipboardEvent) => {
- const clipboardItems = Array.from(event.clipboardData.items);
- const pastedFiles: File[] = [];
- clipboardItems.forEach((item) => {
- if (item.type.startsWith("image/")) {
- const file = item.getAsFile();
- if (file) {
- pastedFiles.push(file);
- }
- }
- });
- if (pastedFiles.length > 0) {
- setFiles((prevFiles) => [...prevFiles, ...pastedFiles]);
- event.preventDefault();
- }
- };
-
- return (
-
- );
-}
-
-export default ChatInput;
diff --git a/frontend/src/components/chat/ChatInterface.tsx b/frontend/src/components/chat/ChatInterface.tsx
index 1b84645cf2..6542b468eb 100644
--- a/frontend/src/components/chat/ChatInterface.tsx
+++ b/frontend/src/components/chat/ChatInterface.tsx
@@ -4,7 +4,6 @@ import { RiArrowRightDoubleLine } from "react-icons/ri";
import { useTranslation } from "react-i18next";
import { VscArrowDown } from "react-icons/vsc";
import { useDisclosure } from "@nextui-org/react";
-import ChatInput from "./ChatInput";
import Chat from "./Chat";
import TypingIndicator from "./TypingIndicator";
import { RootState } from "#/store";
@@ -18,6 +17,9 @@ 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;
@@ -63,12 +65,19 @@ function ChatInterface() {
onOpenChange: onFeedbackModalOpenChange,
} = useDisclosure();
- const handleSendMessage = (content: string, imageUrls: string[]) => {
+ 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);
@@ -100,7 +109,7 @@ function ChatInterface() {
-
+
{feedbackShared !== messages.length && messages.length > 3 && (
-
void;
+}
+
+export function ImageCarousel({
+ size = "small",
+ images,
+ onRemove,
+}: ImageCarouselProps) {
+ const scrollContainerRef = React.useRef(null);
+ const [isScrollable, setIsScrollable] = React.useState(false);
+ const [isAtStart, setIsAtStart] = React.useState(true);
+ const [isAtEnd, setIsAtEnd] = React.useState(false);
+
+ React.useEffect(() => {
+ const scrollContainer = scrollContainerRef.current;
+
+ if (scrollContainer) {
+ const hasScroll =
+ scrollContainer.scrollWidth > scrollContainer.clientWidth;
+ setIsScrollable(hasScroll);
+ }
+ }, [images]);
+
+ const handleScroll = (event: React.UIEvent) => {
+ const scrollContainer = event.currentTarget;
+ setIsAtStart(scrollContainer.scrollLeft === 0);
+ setIsAtEnd(
+ scrollContainer.scrollLeft + scrollContainer.clientWidth ===
+ scrollContainer.scrollWidth,
+ );
+ };
+
+ return (
+
+ {isScrollable && (
+
+
+
+ )}
+
+ {images.map((src, index) => (
+ onRemove(index)}
+ />
+ ))}
+
+ {isScrollable && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/image-preview.tsx b/frontend/src/components/image-preview.tsx
new file mode 100644
index 0000000000..910fc57024
--- /dev/null
+++ b/frontend/src/components/image-preview.tsx
@@ -0,0 +1,39 @@
+import CloseIcon from "#/assets/close.svg?react";
+import { cn } from "#/utils/utils";
+
+interface ImagePreviewProps {
+ src: string;
+ onRemove: () => void;
+ size?: "small" | "large";
+}
+
+export function ImagePreview({
+ src,
+ onRemove,
+ size = "small",
+}: ImagePreviewProps) {
+ return (
+
+

+
+
+ );
+}
diff --git a/frontend/src/components/interactive-chat-box.tsx b/frontend/src/components/interactive-chat-box.tsx
new file mode 100644
index 0000000000..640a6e1ad4
--- /dev/null
+++ b/frontend/src/components/interactive-chat-box.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { UploadImageInput } from "./upload-image-input";
+import { ChatInput } from "./chat-input";
+import { cn } from "#/utils/utils";
+import { ImageCarousel } from "./image-carousel";
+
+interface InteractiveChatBoxProps {
+ isDisabled?: boolean;
+ mode?: "stop" | "submit";
+ onSubmit: (message: string, images: File[]) => void;
+ onStop: () => void;
+}
+
+export function InteractiveChatBox({
+ isDisabled,
+ mode = "submit",
+ onSubmit,
+ onStop,
+}: InteractiveChatBoxProps) {
+ const [images, setImages] = React.useState([]);
+
+ const handleUpload = (files: File[]) => {
+ setImages((prevImages) => [...prevImages, ...files]);
+ };
+
+ const handleRemoveImage = (index: number) => {
+ setImages((prevImages) => {
+ const newImages = [...prevImages];
+ newImages.splice(index, 1);
+ return newImages;
+ });
+ };
+
+ const handleSubmit = (message: string) => {
+ onSubmit(message, images);
+ setImages([]);
+ };
+
+ return (
+
+ {images.length > 0 && (
+
URL.createObjectURL(image))}
+ onRemove={handleRemoveImage}
+ />
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/upload-image-input.tsx b/frontend/src/components/upload-image-input.tsx
new file mode 100644
index 0000000000..e97d1f427f
--- /dev/null
+++ b/frontend/src/components/upload-image-input.tsx
@@ -0,0 +1,26 @@
+import Clip from "#/assets/clip.svg?react";
+
+interface UploadImageInputProps {
+ onUpload: (files: File[]) => void;
+ label?: React.ReactNode;
+}
+
+export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
+ const handleUpload = (event: React.ChangeEvent) => {
+ if (event.target.files) onUpload(Array.from(event.target.files));
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx
index 023bf18161..cae632c076 100644
--- a/frontend/src/routes/_oh._index/route.tsx
+++ b/frontend/src/routes/_oh._index/route.tsx
@@ -7,7 +7,6 @@ import {
useRouteLoaderData,
} from "@remix-run/react";
import React from "react";
-import { useDispatch, useSelector } from "react-redux";
import { SuggestionBox } from "./suggestion-box";
import { TaskForm } from "./task-form";
import { HeroHeading } from "./hero-heading";
@@ -20,29 +19,9 @@ import ModalButton from "#/components/buttons/ModalButton";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { ConnectToGitHubModal } from "#/components/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
-import store, { RootState } from "#/store";
-import { removeFile, setInitialQuery } from "#/state/initial-query-slice";
+import store from "#/store";
+import { setInitialQuery } from "#/state/initial-query-slice";
import { clientLoader as rootClientLoader } from "#/routes/_oh";
-import { UploadedFilePreview } from "./uploaded-file-preview";
-
-interface AttachedFilesSliderProps {
- files: string[];
- onRemove: (file: string) => void;
-}
-
-function AttachedFilesSlider({ files, onRemove }: AttachedFilesSliderProps) {
- return (
-
- {files.map((file, index) => (
- onRemove(file)}
- />
- ))}
-
- );
-}
interface GitHubAuthProps {
onConnectToGitHub: () => void;
@@ -107,10 +86,6 @@ function Home() {
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [importedFile, setImportedFile] = React.useState(null);
- const textareaRef = React.useRef(null);
-
- const dispatch = useDispatch();
- const { files } = useSelector((state: RootState) => state.initalQuery);
const handleConnectToGitHub = () => {
if (githubAuthUrl) {
@@ -125,16 +100,7 @@ function Home() {
-
- {files.length > 0 && (
-
dispatch(removeFile(file))}
- />
- )}
+
{
- const reader = new FileReader();
-
- return new Promise((resolve) => {
- reader.onload = () => {
- resolve(reader.result as string);
- };
- reader.readAsDataURL(file);
- });
-};
-
-interface MainTextareaInputProps {
- disabled: boolean;
- placeholder: string;
- value: string;
- onChange: (e: React.ChangeEvent) => void;
- formRef: React.RefObject;
-}
-
-const MainTextareaInput = React.forwardRef<
- HTMLTextAreaElement,
- MainTextareaInputProps
->(({ disabled, placeholder, value, onChange, formRef }, ref) => {
- const adjustHeight = () => {
- const MAX_LINES = 15;
-
- // ref can either be a callback ref or a MutableRefObject
- const textarea = typeof ref === "function" ? null : ref?.current;
- if (textarea) {
- textarea.style.height = "auto"; // Reset to auto to recalculate scroll height
- const { scrollHeight } = textarea;
-
- // Calculate based on line height and max lines
- const lineHeight = parseInt(
- window.getComputedStyle(textarea).lineHeight,
- 10,
- );
- const maxHeight = lineHeight * MAX_LINES;
-
- textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
- }
- };
-
- React.useEffect(() => {
- adjustHeight();
- }, [value]);
-
- return (
-