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