فهرست منبع

test(frontend): Test, refactor, and improve the chat input (#4535)

sp.wack 1 سال پیش
والد
کامیت
e878741ae7

+ 0 - 119
frontend/__tests__/components/chat/ChatInput.test.tsx

@@ -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(<ChatInput onSendMessage={onSendMessage} />);
-    expect(screen.getByRole("textbox")).toBeInTheDocument();
-  });
-
-  it("should be able to be set as disabled", async () => {
-    const user = userEvent.setup();
-    render(<ChatInput disabled onSendMessage={onSendMessage} />);
-
-    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(<ChatInput onSendMessage={onSendMessage} />);
-
-    const textarea = screen.getByPlaceholderText(
-      /CHAT_INTERFACE\$INPUT_PLACEHOLDER/i,
-    );
-    expect(textarea).toBeInTheDocument();
-  });
-
-  it("should render a send button", () => {
-    render(<ChatInput onSendMessage={onSendMessage} />);
-    expect(screen.getByRole("button")).toBeInTheDocument();
-  });
-
-  it("should call sendChatMessage with the input when the send button is clicked", async () => {
-    const user = userEvent.setup();
-    render(<ChatInput onSendMessage={onSendMessage} />);
-
-    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(<ChatInput onSendMessage={onSendMessage} />);
-    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(<ChatInput onSendMessage={onSendMessage} />);
-    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(<ChatInput onSendMessage={onSendMessage} />);
-    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(<ChatInput onSendMessage={onSendMessage} />);
-    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",
-  );
-});

+ 161 - 0
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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput disabled onSubmit={onSubmitMock} />);
+
+    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(
+      <ChatInput placeholder="Enter your message" onSubmit={onSubmitMock} />,
+    );
+
+    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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput onSubmit={onSubmitMock} />);
+    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(<ChatInput onSubmit={onSubmitMock} showButton={false} />);
+    expect(screen.queryByRole("button")).not.toBeInTheDocument();
+  });
+
+  it("should call onChange when the user types", async () => {
+    const user = userEvent.setup();
+    const onChangeMock = vi.fn();
+    render(<ChatInput onSubmit={onSubmitMock} onChange={onChangeMock} />);
+    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(<ChatInput value="Hello, world!" onSubmit={onSubmitMock} />);
+    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(
+      <ChatInput onSubmit={onSubmitMock} button="stop" onStop={onStopMock} />,
+    );
+    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(
+      <ChatInput
+        onSubmit={onSubmitMock}
+        onFocus={onFocusMock}
+        onBlur={onBlurMock}
+      />,
+    );
+    const textarea = screen.getByRole("textbox");
+
+    await user.click(textarea);
+    expect(onFocusMock).toHaveBeenCalledOnce();
+
+    await user.tab();
+    expect(onBlurMock).toHaveBeenCalledOnce();
+  });
+});

+ 32 - 0
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(
+      <ImagePreview src="https://example.com/image.jpg" onRemove={vi.fn} />,
+    );
+    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(
+      <ImagePreview
+        src="https://example.com/image.jpg"
+        onRemove={onRemoveMock}
+      />,
+    );
+
+    const closeButton = screen.getByRole("button");
+    await user.click(closeButton);
+
+    expect(onRemoveMock).toHaveBeenCalledOnce();
+  });
+});

+ 119 - 0
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(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
+
+    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(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
+
+    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(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
+
+    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(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
+
+    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(
+      <InteractiveChatBox
+        isDisabled
+        onSubmit={onSubmitMock}
+        onStop={onStopMock}
+      />,
+    );
+
+    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(
+      <InteractiveChatBox
+        mode="stop"
+        onSubmit={onSubmitMock}
+        onStop={onStopMock}
+      />,
+    );
+
+    const stopButton = screen.getByTestId("stop-button");
+    expect(stopButton).toBeInTheDocument();
+
+    await user.click(stopButton);
+    expect(onStopMock).toHaveBeenCalledOnce();
+  });
+});

+ 71 - 0
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(<UploadImageInput onUpload={onUploadMock} />);
+    expect(screen.getByTestId("upload-image-input")).toBeInTheDocument();
+  });
+
+  it("should call onUpload when a file is selected", async () => {
+    render(<UploadImageInput onUpload={onUploadMock} />);
+
+    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(<UploadImageInput onUpload={onUploadMock} />);
+
+    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(<UploadImageInput onUpload={onUploadMock} />);
+
+    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(<UploadImageInput onUpload={onUploadMock} />);
+    expect(screen.getByTestId("default-label")).toBeInTheDocument();
+
+    function CustomLabel() {
+      return <span>Custom label</span>;
+    }
+    rerender(
+      <UploadImageInput onUpload={onUploadMock} label={<CustomLabel />} />,
+    );
+
+    expect(screen.getByText("Custom label")).toBeInTheDocument();
+    expect(screen.queryByTestId("default-label")).not.toBeInTheDocument();
+  });
+});

+ 28 - 0
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 (
+    <svg
+      width={width}
+      height={height}
+      viewBox={`0 0 ${width} ${height}`}
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M11.204 15.0037L6.65511 9.99993L11.204 4.99617L12.1289 5.83701L8.34444 9.99993L12.1289 14.1628L11.204 15.0037Z"
+        fill={active ? "#D4D4D4" : "#525252"}
+      />
+    </svg>
+  );
+}

+ 28 - 0
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 (
+    <svg
+      width={width}
+      height={height}
+      viewBox={`0 0 ${width} ${height}`}
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M8.79602 4.99634L13.3449 10.0001L8.79602 15.0038L7.87109 14.163L11.6556 10.0001L7.87109 5.83718L8.79602 4.99634Z"
+        fill={active ? "#D4D4D4" : "#525252"}
+      />
+    </svg>
+  );
+}

+ 5 - 0
frontend/src/assets/close.svg

@@ -0,0 +1,5 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd"
+    d="M5.69949 5.72974L7.91965 7.9505L8.35077 7.51999L6.13001 5.29922L8.35077 3.07907L7.92026 2.64795L5.69949 4.86871L3.47934 2.64795L3.04883 3.07907L5.26898 5.29922L3.04883 7.51938L3.47934 7.9505L5.69949 5.72974Z"
+    fill="black" />
+</svg>

+ 10 - 0
frontend/src/components/attach-image-label.tsx

@@ -0,0 +1,10 @@
+import Clip from "#/assets/clip.svg?react";
+
+export function AttachImageLabel() {
+  return (
+    <div className="flex self-start items-center text-[#A3A3A3] text-xs leading-[18px] -tracking-[0.08px] cursor-pointer">
+      <Clip width={16} height={16} />
+      Attach images
+    </div>
+  );
+}

+ 108 - 0
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<HTMLDivElement>["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<HTMLTextAreaElement>(null);
+
+  const handleSubmitMessage = () => {
+    if (textareaRef.current?.value) {
+      onSubmit(textareaRef.current.value);
+      textareaRef.current.value = "";
+    }
+  };
+
+  const handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault();
+      handleSubmitMessage();
+    }
+  };
+
+  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
+    onChange?.(event.target.value);
+  };
+
+  return (
+    <div
+      data-testid="chat-input"
+      className="flex items-end justify-end grow gap-1 min-h-6"
+    >
+      <TextareaAutosize
+        ref={textareaRef}
+        name={name}
+        placeholder={placeholder}
+        onKeyDown={handleKeyPress}
+        onChange={handleChange}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        value={value}
+        minRows={1}
+        maxRows={maxRows}
+        className={cn(
+          "grow text-sm self-center placeholder:text-neutral-400 text-white resize-none bg-transparent outline-none ring-0",
+          "transition-[height] duration-200 ease-in-out",
+          className,
+        )}
+      />
+      {showButton && (
+        <>
+          {button === "submit" && (
+            <button
+              aria-label="Send"
+              disabled={disabled}
+              onClick={handleSubmitMessage}
+              type="submit"
+              className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
+            >
+              <ArrowSendIcon />
+            </button>
+          )}
+          {button === "stop" && (
+            <button
+              data-testid="stop-button"
+              aria-label="Stop"
+              disabled={disabled}
+              onClick={onStop}
+              type="button"
+              className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
+            >
+              <div className="w-[10px] h-[10px] bg-white" />
+            </button>
+          )}
+        </>
+      )}
+    </div>
+  );
+}

+ 0 - 162
frontend/src/components/chat/ChatInput.tsx

@@ -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<File[]>([]);
-  // 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<HTMLInputElement>) => {
-    if (event.key === "Enter" && !event.shiftKey && !isComposing) {
-      event.preventDefault(); // prevent a new line
-      if (!disabled) {
-        handleSendChatMessage();
-      }
-    }
-  };
-
-  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    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<HTMLInputElement>) => {
-    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 (
-    <div className="w-full relative text-base flex">
-      <Textarea
-        value={message}
-        startContent={
-          <label
-            htmlFor="file-input"
-            className="cursor-pointer"
-            aria-label={t(I18nKey.CHAT_INTERFACE$TOOLTIP_UPLOAD_IMAGE)}
-          >
-            <Clip width={24} height={24} />
-            <input
-              type="file"
-              accept="image/*"
-              onChange={handleFileChange}
-              className="hidden"
-              id="file-input"
-              multiple
-            />
-          </label>
-        }
-        onChange={(e) => setMessage(e.target.value)}
-        onKeyDown={onKeyPress}
-        onCompositionStart={() => setIsComposing(true)}
-        onCompositionEnd={() => setIsComposing(false)}
-        placeholder={t(I18nKey.CHAT_INTERFACE$INPUT_PLACEHOLDER)}
-        onPaste={handlePaste}
-        className="pb-3 px-3"
-        classNames={{
-          inputWrapper: "bg-neutral-700 border border-neutral-600 rounded-lg",
-          input: "pr-16 text-neutral-400",
-        }}
-        maxRows={10}
-        minRows={1}
-        variant="bordered"
-      />
-      <button
-        type="button"
-        onClick={handleSendChatMessage}
-        disabled={disabled}
-        className={cn(
-          "bg-transparent border rounded-lg p-[7px] border-white hover:opacity-80 cursor-pointer select-none absolute right-5 bottom-[19px] transition active:bg-white active:text-black",
-          "w-6 h-6 flex items-center justify-center",
-          "disabled:cursor-not-allowed disabled:border-neutral-400 disabled:text-neutral-400",
-          "hover:bg-neutral-500",
-        )}
-        aria-label={t(I18nKey.CHAT_INTERFACE$TOOLTIP_SEND_MESSAGE)}
-      >
-        {curAgentState !== AgentState.RUNNING && <ArrowSendIcon />}
-        {curAgentState === AgentState.RUNNING && (
-          <div className="w-[10px] h-[10px] bg-white" />
-        )}
-      </button>
-      {files.length > 0 && (
-        <div className="absolute bottom-16 right-5 flex space-x-2 p-4 border-1 border-neutral-500 bg-neutral-800 rounded-lg">
-          {files.map((file, index) => (
-            <div key={index} className="relative">
-              <img
-                src={URL.createObjectURL(file)}
-                alt="upload preview"
-                className="w-24 h-24 object-contain rounded bg-white"
-              />
-              <button
-                type="button"
-                onClick={() => removeFile(index)}
-                className="absolute top-0 right-0 bg-black border border-grey-200 text-white rounded-full w-5 h-5 flex pb-1 items-center justify-center"
-              >
-                &times;
-              </button>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  );
-}
-
-export default ChatInput;

+ 17 - 6
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() {
         <Chat messages={messages} curAgentState={curAgentState} />
       </div>
 
-      <div>
+      <div className="px-4 pb-4">
         <div className="relative">
           {feedbackShared !== messages.length && messages.length > 3 && (
             <div
@@ -156,12 +165,14 @@ function ChatInterface() {
           </div>
         </div>
 
-        <ChatInput
-          disabled={
+        <InteractiveChatBox
+          isDisabled={
             curAgentState === AgentState.LOADING ||
             curAgentState === AgentState.AWAITING_USER_CONFIRMATION
           }
-          onSendMessage={handleSendMessage}
+          mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
+          onSubmit={handleSendMessage}
+          onStop={handleStop}
         />
       </div>
       <FeedbackModal

+ 74 - 0
frontend/src/components/image-carousel.tsx

@@ -0,0 +1,74 @@
+import React from "react";
+import { ChevronLeft } from "#/assets/chevron-left";
+import { ChevronRight } from "#/assets/chevron-right";
+import { ImagePreview } from "./image-preview";
+import { cn } from "#/utils/utils";
+
+interface ImageCarouselProps {
+  size: "small" | "large";
+  images: string[];
+  onRemove: (index: number) => void;
+}
+
+export function ImageCarousel({
+  size = "small",
+  images,
+  onRemove,
+}: ImageCarouselProps) {
+  const scrollContainerRef = React.useRef<HTMLDivElement>(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<HTMLDivElement>) => {
+    const scrollContainer = event.currentTarget;
+    setIsAtStart(scrollContainer.scrollLeft === 0);
+    setIsAtEnd(
+      scrollContainer.scrollLeft + scrollContainer.clientWidth ===
+        scrollContainer.scrollWidth,
+    );
+  };
+
+  return (
+    <div className="relative">
+      {isScrollable && (
+        <div className="absolute right-full transform top-1/2 -translate-y-1/2">
+          <ChevronLeft active={!isAtStart} />
+        </div>
+      )}
+      <div
+        ref={scrollContainerRef}
+        onScroll={handleScroll}
+        className={cn(
+          "flex overflow-x-auto",
+          size === "small" && "gap-2",
+          size === "large" && "gap-4",
+        )}
+      >
+        {images.map((src, index) => (
+          <ImagePreview
+            key={index}
+            size={size}
+            src={src}
+            onRemove={() => onRemove(index)}
+          />
+        ))}
+      </div>
+      {isScrollable && (
+        <div className="absolute left-full transform top-1/2 -translate-y-1/2">
+          <ChevronRight active={!isAtEnd} />
+        </div>
+      )}
+    </div>
+  );
+}

+ 39 - 0
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 (
+    <div data-testid="image-preview" className="relative w-fit shrink-0">
+      <img
+        role="img"
+        src={src}
+        alt=""
+        className={cn(
+          "rounded object-cover",
+          size === "small" && "w-[62px] h-[62px]",
+          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>
+    </div>
+  );
+}

+ 69 - 0
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<File[]>([]);
+
+  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 (
+    <div
+      data-testid="interactive-chat-box"
+      className="flex flex-col gap-[10px]"
+    >
+      {images.length > 0 && (
+        <ImageCarousel
+          size="small"
+          images={images.map((image) => URL.createObjectURL(image))}
+          onRemove={handleRemoveImage}
+        />
+      )}
+
+      <div
+        className={cn(
+          "flex items-end gap-1",
+          "bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
+        )}
+      >
+        <UploadImageInput onUpload={handleUpload} />
+        <ChatInput
+          disabled={isDisabled}
+          button={mode}
+          placeholder="What do you want to build?"
+          onSubmit={handleSubmit}
+          onStop={onStop}
+        />
+      </div>
+    </div>
+  );
+}

+ 26 - 0
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<HTMLInputElement>) => {
+    if (event.target.files) onUpload(Array.from(event.target.files));
+  };
+
+  return (
+    <label className="cursor-pointer">
+      {label || <Clip data-testid="default-label" width={24} height={24} />}
+      <input
+        data-testid="upload-image-input"
+        type="file"
+        accept="image/*"
+        multiple
+        hidden
+        onChange={handleUpload}
+      />
+    </label>
+  );
+}

+ 3 - 39
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 (
-    <div className="flex gap-2 overflow-auto">
-      {files.map((file, index) => (
-        <UploadedFilePreview
-          key={index}
-          file={file}
-          onRemove={() => onRemove(file)}
-        />
-      ))}
-    </div>
-  );
-}
 
 interface GitHubAuthProps {
   onConnectToGitHub: () => void;
@@ -107,10 +86,6 @@ function Home() {
   const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
     React.useState(false);
   const [importedFile, setImportedFile] = React.useState<File | null>(null);
-  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
-
-  const dispatch = useDispatch();
-  const { files } = useSelector((state: RootState) => state.initalQuery);
 
   const handleConnectToGitHub = () => {
     if (githubAuthUrl) {
@@ -125,16 +100,7 @@ function Home() {
       <HeroHeading />
       <div className="flex flex-col gap-16 w-[600px] items-center">
         <div className="flex flex-col gap-2 w-full">
-          <TaskForm
-            importedProjectZip={importedFile}
-            textareaRef={textareaRef}
-          />
-          {files.length > 0 && (
-            <AttachedFilesSlider
-              files={files}
-              onRemove={(file) => dispatch(removeFile(file))}
-            />
-          )}
+          <TaskForm importedProjectZip={importedFile} />
         </div>
         <div className="flex gap-4 w-full">
           <SuggestionBox
@@ -170,8 +136,6 @@ function Home() {
                       if (event.target.files) {
                         const zip = event.target.files[0];
                         setImportedFile(zip);
-                        // focus on the task form
-                        textareaRef.current?.focus();
                       } else {
                         // TODO: handle error
                       }

+ 58 - 134
frontend/src/routes/_oh._index/task-form.tsx

@@ -1,106 +1,32 @@
 import React from "react";
 import { Form, useNavigation } from "@remix-run/react";
 import { useDispatch, useSelector } from "react-redux";
-import Send from "#/assets/send.svg?react";
-import Clip from "#/assets/clip.svg?react";
-import { cn } from "#/utils/utils";
 import { RootState } from "#/store";
-import { addFile, setImportedProjectZip } from "#/state/initial-query-slice";
+import {
+  addFile,
+  removeFile,
+  setImportedProjectZip,
+} from "#/state/initial-query-slice";
 import { SuggestionBubble } from "#/components/suggestion-bubble";
 import { SUGGESTIONS } from "#/utils/suggestions";
 import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
-
-const convertZipToBase64 = async (file: File) => {
-  const reader = new FileReader();
-
-  return new Promise<string>((resolve) => {
-    reader.onload = () => {
-      resolve(reader.result as string);
-    };
-    reader.readAsDataURL(file);
-  });
-};
-
-interface MainTextareaInputProps {
-  disabled: boolean;
-  placeholder: string;
-  value: string;
-  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
-  formRef: React.RefObject<HTMLFormElement>;
-}
-
-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 (
-    <textarea
-      ref={ref}
-      disabled={disabled}
-      name="q"
-      rows={1}
-      placeholder={placeholder}
-      onChange={onChange}
-      onKeyDown={(e) => {
-        if (e.key === "Enter" && !e.shiftKey) {
-          e.preventDefault();
-          formRef.current?.requestSubmit();
-        }
-      }}
-      value={value}
-      className={cn(
-        "bg-[#404040] placeholder:text-[#A3A3A3] border border-[#525252] w-full rounded-lg px-4 py-[18px] text-[17px] leading-5",
-        "pr-[calc(16px+24px)]", // 24px for the send button
-        "focus:bg-[#525252]",
-        "resize-none",
-      )}
-    />
-  );
-});
-
-MainTextareaInput.displayName = "MainTextareaInput";
-
-const getRandomKey = (obj: Record<string, string>) => {
-  const keys = Object.keys(obj);
-  const randomKey = keys[Math.floor(Math.random() * keys.length)];
-
-  return randomKey;
-};
+import { ChatInput } from "#/components/chat-input";
+import { UploadImageInput } from "#/components/upload-image-input";
+import { ImageCarousel } from "#/components/image-carousel";
+import { getRandomKey } from "#/utils/get-random-key";
+import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
+import { AttachImageLabel } from "#/components/attach-image-label";
+import { cn } from "#/utils/utils";
 
 interface TaskFormProps {
   importedProjectZip: File | null;
-  textareaRef?: React.RefObject<HTMLTextAreaElement>;
 }
 
-export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
+export function TaskForm({ importedProjectZip }: TaskFormProps) {
   const dispatch = useDispatch();
   const navigation = useNavigation();
 
-  const { selectedRepository } = useSelector(
+  const { selectedRepository, files } = useSelector(
     (state: RootState) => state.initalQuery,
   );
 
@@ -114,6 +40,7 @@ export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
   const [suggestion, setSuggestion] = React.useState(
     getRandomKey(hasLoadedProject ? SUGGESTIONS.repo : SUGGESTIONS["non-repo"]),
   );
+  const [inputIsFocused, setInputIsFocused] = React.useState(false);
 
   React.useEffect(() => {
     // Display a suggestion based on whether a repository is selected
@@ -140,10 +67,6 @@ export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
     setText(value);
   };
 
-  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
-    setText(e.target.value);
-  };
-
   const handleSubmitForm = async () => {
     // This is handled on top of the form submission
     if (importedProjectZip) {
@@ -153,6 +76,14 @@ export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
     }
   };
 
+  const placeholder = React.useMemo(() => {
+    if (selectedRepository) {
+      return `What would you like to change in ${selectedRepository}?`;
+    }
+
+    return "What do you want to build?";
+  }, [selectedRepository]);
+
   return (
     <div className="flex flex-col gap-2 w-full">
       <Form
@@ -167,53 +98,46 @@ export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
           onClick={onClickSuggestion}
           onRefresh={onRefreshSuggestion}
         />
-        <div className="relative w-full">
-          <MainTextareaInput
-            ref={textareaRef}
-            disabled={navigation.state === "submitting"}
-            placeholder={
-              selectedRepository
-                ? `What would you like to change in ${selectedRepository}?`
-                : "What do you want to build?"
-            }
-            onChange={handleChange}
+        <div
+          className={cn(
+            "border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full",
+            inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
+          )}
+        >
+          <ChatInput
+            name="q"
+            onSubmit={() => {
+              formRef.current?.requestSubmit();
+            }}
+            onChange={(message) => setText(message)}
+            onFocus={() => setInputIsFocused(true)}
+            onBlur={() => setInputIsFocused(false)}
+            placeholder={placeholder}
             value={text}
-            formRef={formRef}
+            maxRows={15}
+            showButton={!!text}
+            className="text-[17px] leading-5"
+            disabled={navigation.state === "submitting"}
           />
-          {!!text && (
-            <button
-              type="submit"
-              aria-label="Submit"
-              className="absolute right-4 top-4"
-              disabled={navigation.state === "loading"}
-            >
-              <Send width={24} height={24} />
-            </button>
-          )}
         </div>
       </Form>
-      <label className="flex self-start items-center text-[#A3A3A3] text-xs leading-[18px] -tracking-[0.08px] cursor-pointer">
-        <Clip width={16} height={16} />
-        Attach images
-        <input
-          hidden
-          type="file"
-          accept="image/*"
-          id="file-input"
-          multiple
-          onChange={(event) => {
-            if (event.target.files) {
-              Array.from(event.target.files).forEach((file) => {
-                convertImageToBase64(file).then((base64) => {
-                  dispatch(addFile(base64));
-                });
-              });
-            } else {
-              // TODO: handle error
-            }
-          }}
+      <UploadImageInput
+        onUpload={async (uploadedFiles) => {
+          const promises = uploadedFiles.map(convertImageToBase64);
+          const base64Images = await Promise.all(promises);
+          base64Images.forEach((base64) => {
+            dispatch(addFile(base64));
+          });
+        }}
+        label={<AttachImageLabel />}
+      />
+      {files.length > 0 && (
+        <ImageCarousel
+          size="large"
+          images={files}
+          onRemove={(index) => dispatch(removeFile(index))}
         />
-      </label>
+      )}
     </div>
   );
 }

+ 0 - 23
frontend/src/routes/_oh._index/uploaded-file-preview.tsx

@@ -1,23 +0,0 @@
-interface UploadedFilePreviewProps {
-  file: string; // base64
-  onRemove: () => void;
-}
-
-export function UploadedFilePreview({
-  file,
-  onRemove,
-}: UploadedFilePreviewProps) {
-  return (
-    <div className="relative flex-shrink-0">
-      <button
-        type="button"
-        aria-label="Remove"
-        onClick={onRemove}
-        className="absolute right-1 top-1 text-[#A3A3A3] hover:text-danger"
-      >
-        &times;
-      </button>
-      <img src={file} alt="" className="w-16 h-16 aspect-auto rounded" />
-    </div>
-  );
-}

+ 2 - 2
frontend/src/state/initial-query-slice.ts

@@ -21,8 +21,8 @@ export const selectedFilesSlice = createSlice({
     addFile(state, action: PayloadAction<string>) {
       state.files.push(action.payload);
     },
-    removeFile(state, action: PayloadAction<string>) {
-      state.files = state.files.filter((file) => file !== action.payload);
+    removeFile(state, action: PayloadAction<number>) {
+      state.files.splice(action.payload, 1);
     },
     clearFiles(state) {
       state.files = [];

+ 10 - 0
frontend/src/utils/convert-zip-to-base64.ts

@@ -0,0 +1,10 @@
+export const convertZipToBase64 = async (file: File) => {
+  const reader = new FileReader();
+
+  return new Promise<string>((resolve) => {
+    reader.onload = () => {
+      resolve(reader.result as string);
+    };
+    reader.readAsDataURL(file);
+  });
+};

+ 6 - 0
frontend/src/utils/get-random-key.ts

@@ -0,0 +1,6 @@
+export const getRandomKey = (obj: Record<string, string>) => {
+  const keys = Object.keys(obj);
+  const randomKey = keys[Math.floor(Math.random() * keys.length)];
+
+  return randomKey;
+};

+ 111 - 0
package-lock.json

@@ -0,0 +1,111 @@
+{
+  "name": "OpenHands",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "react-textarea-autosize": "^8.5.4"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz",
+      "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==",
+      "dependencies": {
+        "regenerator-runtime": "^0.14.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "peer": true
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "peer": true,
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "peer": true,
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-textarea-autosize": {
+      "version": "8.5.4",
+      "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.4.tgz",
+      "integrity": "sha512-eSSjVtRLcLfFwFcariT77t9hcbVJHQV76b51QjQGarQIHml2+gM2lms0n3XrhnDmgK5B+/Z7TmQk5OHNzqYm/A==",
+      "dependencies": {
+        "@babel/runtime": "^7.20.13",
+        "use-composed-ref": "^1.3.0",
+        "use-latest": "^1.2.1"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.14.1",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+      "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+    },
+    "node_modules/use-composed-ref": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz",
+      "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/use-isomorphic-layout-effect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
+      "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/use-latest": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz",
+      "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==",
+      "dependencies": {
+        "use-isomorphic-layout-effect": "^1.1.1"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 5 - 0
package.json

@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "react-textarea-autosize": "^8.5.4"
+  }
+}