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

This commit is contained in:
sp.wack 2024-10-24 18:19:41 +04:00 committed by GitHub
parent 90e2bf4883
commit e878741ae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 983 additions and 486 deletions

View File

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

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

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

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

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

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

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

View 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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View 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
View 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
View File

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