task-form.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import React from "react";
  2. import { useNavigate, useNavigation } from "react-router";
  3. import { useDispatch, useSelector } from "react-redux";
  4. import { useMutation } from "@tanstack/react-query";
  5. import posthog from "posthog-js";
  6. import { RootState } from "#/store";
  7. import {
  8. addFile,
  9. removeFile,
  10. setInitialQuery,
  11. } from "#/state/initial-query-slice";
  12. import OpenHands from "#/api/open-hands";
  13. import { useAuth } from "#/context/auth-context";
  14. import { useSettings } from "#/context/settings-context";
  15. import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
  16. import { SUGGESTIONS } from "#/utils/suggestions";
  17. import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
  18. import { ChatInput } from "#/components/features/chat/chat-input";
  19. import { getRandomKey } from "#/utils/get-random-key";
  20. import { cn } from "#/utils/utils";
  21. import { AttachImageLabel } from "../features/images/attach-image-label";
  22. import { ImageCarousel } from "../features/images/image-carousel";
  23. import { UploadImageInput } from "../features/images/upload-image-input";
  24. import { LoadingSpinner } from "./loading-spinner";
  25. interface TaskFormProps {
  26. ref: React.RefObject<HTMLFormElement | null>;
  27. }
  28. export function TaskForm({ ref }: TaskFormProps) {
  29. const dispatch = useDispatch();
  30. const navigation = useNavigation();
  31. const navigate = useNavigate();
  32. const { gitHubToken } = useAuth();
  33. const { settings } = useSettings();
  34. const { selectedRepository, files } = useSelector(
  35. (state: RootState) => state.initialQuery,
  36. );
  37. const [text, setText] = React.useState("");
  38. const [suggestion, setSuggestion] = React.useState(
  39. getRandomKey(SUGGESTIONS["non-repo"]),
  40. );
  41. const [inputIsFocused, setInputIsFocused] = React.useState(false);
  42. const newConversationMutation = useMutation({
  43. mutationFn: (variables: { q?: string }) => {
  44. if (variables.q) dispatch(setInitialQuery(variables.q));
  45. return OpenHands.newConversation({
  46. githubToken: gitHubToken || undefined,
  47. selectedRepository: selectedRepository || undefined,
  48. args: settings || undefined,
  49. });
  50. },
  51. onSuccess: ({ conversation_id: conversationId }, { q }) => {
  52. posthog.capture("initial_query_submitted", {
  53. entry_point: "task_form",
  54. query_character_length: q?.length,
  55. has_repository: !!selectedRepository,
  56. has_files: files.length > 0,
  57. });
  58. navigate(`/conversations/${conversationId}`);
  59. },
  60. });
  61. const onRefreshSuggestion = () => {
  62. const suggestions = SUGGESTIONS["non-repo"];
  63. // remove current suggestion to avoid refreshing to the same suggestion
  64. const suggestionCopy = { ...suggestions };
  65. delete suggestionCopy[suggestion];
  66. const key = getRandomKey(suggestionCopy);
  67. setSuggestion(key);
  68. };
  69. const onClickSuggestion = () => {
  70. const suggestions = SUGGESTIONS["non-repo"];
  71. const value = suggestions[suggestion];
  72. setText(value);
  73. };
  74. const placeholder = React.useMemo(() => {
  75. if (selectedRepository) {
  76. return `What would you like to change in ${selectedRepository}?`;
  77. }
  78. return "What do you want to build?";
  79. }, [selectedRepository]);
  80. const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  81. event.preventDefault();
  82. const formData = new FormData(event.currentTarget);
  83. const q = formData.get("q")?.toString();
  84. newConversationMutation.mutate({ q });
  85. };
  86. return (
  87. <div className="flex flex-col gap-2 w-full">
  88. <form
  89. ref={ref}
  90. onSubmit={handleSubmit}
  91. className="flex flex-col items-center gap-2"
  92. >
  93. <SuggestionBubble
  94. suggestion={suggestion}
  95. onClick={onClickSuggestion}
  96. onRefresh={onRefreshSuggestion}
  97. />
  98. <div
  99. className={cn(
  100. "border border-neutral-600 px-4 rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
  101. inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
  102. "hover:border-neutral-500 focus-within:border-neutral-500",
  103. )}
  104. >
  105. {newConversationMutation.isPending ? (
  106. <div className="flex justify-center py-[17px]">
  107. <LoadingSpinner size="small" />
  108. </div>
  109. ) : (
  110. <ChatInput
  111. name="q"
  112. onSubmit={() => {
  113. if (typeof ref !== "function") ref?.current?.requestSubmit();
  114. }}
  115. onChange={(message) => setText(message)}
  116. onFocus={() => setInputIsFocused(true)}
  117. onBlur={() => setInputIsFocused(false)}
  118. onImagePaste={async (imageFiles) => {
  119. const promises = imageFiles.map(convertImageToBase64);
  120. const base64Images = await Promise.all(promises);
  121. base64Images.forEach((base64) => {
  122. dispatch(addFile(base64));
  123. });
  124. }}
  125. placeholder={placeholder}
  126. value={text}
  127. maxRows={15}
  128. showButton={!!text}
  129. className="text-[17px] leading-5 py-[17px]"
  130. buttonClassName="pb-[17px]"
  131. disabled={navigation.state === "submitting"}
  132. />
  133. )}
  134. </div>
  135. </form>
  136. <UploadImageInput
  137. onUpload={async (uploadedFiles) => {
  138. const promises = uploadedFiles.map(convertImageToBase64);
  139. const base64Images = await Promise.all(promises);
  140. base64Images.forEach((base64) => {
  141. dispatch(addFile(base64));
  142. });
  143. }}
  144. label={<AttachImageLabel />}
  145. />
  146. {files.length > 0 && (
  147. <ImageCarousel
  148. size="large"
  149. images={files}
  150. onRemove={(index) => dispatch(removeFile(index))}
  151. />
  152. )}
  153. </div>
  154. );
  155. }