mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
test(frontend): Test, refactor, and improve the chat input (#4535)
This commit is contained in:
parent
90e2bf4883
commit
e878741ae7
@ -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
frontend/__tests__/components/chat/chat-input.test.tsx
Normal file
161
frontend/__tests__/components/chat/chat-input.test.tsx
Normal file
@ -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
frontend/__tests__/components/image-preview.test.tsx
Normal file
32
frontend/__tests__/components/image-preview.test.tsx
Normal file
@ -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
frontend/__tests__/components/interactive-chat-box.test.tsx
Normal file
119
frontend/__tests__/components/interactive-chat-box.test.tsx
Normal file
@ -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
frontend/__tests__/components/upload-image-input.test.tsx
Normal file
71
frontend/__tests__/components/upload-image-input.test.tsx
Normal file
@ -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
frontend/src/assets/chevron-left.tsx
Normal file
28
frontend/src/assets/chevron-left.tsx
Normal file
@ -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
frontend/src/assets/chevron-right.tsx
Normal file
28
frontend/src/assets/chevron-right.tsx
Normal file
@ -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
frontend/src/assets/close.svg
Normal file
5
frontend/src/assets/close.svg
Normal file
@ -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>
|
||||
|
After Width: | Height: | Size: 387 B |
10
frontend/src/components/attach-image-label.tsx
Normal file
10
frontend/src/components/attach-image-label.tsx
Normal file
@ -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
frontend/src/components/chat-input.tsx
Normal file
108
frontend/src/components/chat-input.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatInput;
|
||||
@ -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
frontend/src/components/image-carousel.tsx
Normal file
74
frontend/src/components/image-carousel.tsx
Normal file
@ -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
frontend/src/components/image-preview.tsx
Normal file
39
frontend/src/components/image-preview.tsx
Normal file
@ -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
frontend/src/components/interactive-chat-box.tsx
Normal file
69
frontend/src/components/interactive-chat-box.tsx
Normal file
@ -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
frontend/src/components/upload-image-input.tsx
Normal file
26
frontend/src/components/upload-image-input.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
value={text}
|
||||
formRef={formRef}
|
||||
/>
|
||||
{!!text && (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Submit"
|
||||
className="absolute right-4 top-4"
|
||||
disabled={navigation.state === "loading"}
|
||||
>
|
||||
<Send width={24} height={24} />
|
||||
</button>
|
||||
<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}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5"
|
||||
disabled={navigation.state === "submitting"}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img src={file} alt="" className="w-16 h-16 aspect-auto rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
frontend/src/utils/convert-zip-to-base64.ts
Normal file
10
frontend/src/utils/convert-zip-to-base64.ts
Normal file
@ -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
frontend/src/utils/get-random-key.ts
Normal file
6
frontend/src/utils/get-random-key.ts
Normal file
@ -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
package-lock.json
generated
Normal file
111
package-lock.json
generated
Normal file
@ -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
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"react-textarea-autosize": "^8.5.4"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user