Browse Source

feat(frontend): Keep prompt after project upload or repo selection (#4925)

sp.wack 1 year ago
parent
commit
ffc4d32440

+ 3 - 6
frontend/src/components/event-handler.tsx

@@ -20,6 +20,7 @@ import {
 } from "#/services/terminalService";
 import {
   clearFiles,
+  clearInitialQuery,
   clearSelectedRepository,
   setImportedProjectZip,
 } from "#/state/initial-query-slice";
@@ -52,13 +53,10 @@ export function EventHandler({ children }: React.PropsWithChildren) {
   const runtimeActive = status === WsClientProviderStatus.ACTIVE;
   const fetcher = useFetcher();
   const dispatch = useDispatch();
-  const { files, importedProjectZip } = useSelector(
+  const { files, importedProjectZip, initialQuery } = useSelector(
     (state: RootState) => state.initalQuery,
   );
   const { ghToken, repo } = useLoaderData<typeof appClientLoader>();
-  const initialQueryRef = React.useRef<string | null>(
-    store.getState().initalQuery.initialQuery,
-  );
 
   const sendInitialQuery = (query: string, base64Files: string[]) => {
     const timestamp = new Date().toISOString();
@@ -119,7 +117,6 @@ export function EventHandler({ children }: React.PropsWithChildren) {
       return; // This is a check because of strict mode - if the status did not change, don't do anything
     }
     statusRef.current = status;
-    const initialQuery = initialQueryRef.current;
 
     if (status === WsClientProviderStatus.ACTIVE) {
       let additionalInfo = "";
@@ -140,7 +137,7 @@ export function EventHandler({ children }: React.PropsWithChildren) {
           sendInitialQuery(initialQuery, files);
         }
         dispatch(clearFiles()); // reset selected files
-        initialQueryRef.current = null;
+        dispatch(clearInitialQuery()); // reset initial query
       }
     }
 

+ 17 - 30
frontend/src/components/github-repositories-suggestion-box.tsx

@@ -10,32 +10,8 @@ import { GitHubRepositorySelector } from "#/routes/_oh._index/github-repo-select
 import ModalButton from "./buttons/ModalButton";
 import GitHubLogo from "#/assets/branding/github-logo.svg?react";
 
-interface GitHubAuthProps {
-  onConnectToGitHub: () => void;
-  repositories: GitHubRepository[];
-  isLoggedIn: boolean;
-}
-
-function GitHubAuth({
-  onConnectToGitHub,
-  repositories,
-  isLoggedIn,
-}: GitHubAuthProps) {
-  if (isLoggedIn) {
-    return <GitHubRepositorySelector repositories={repositories} />;
-  }
-
-  return (
-    <ModalButton
-      text="Connect to GitHub"
-      icon={<GitHubLogo width={20} height={20} />}
-      className="bg-[#791B80] w-full"
-      onClick={onConnectToGitHub}
-    />
-  );
-}
-
 interface GitHubRepositoriesSuggestionBoxProps {
+  handleSubmit: () => void;
   repositories: Awaited<
     ReturnType<typeof retrieveAllGitHubUserRepositories>
   > | null;
@@ -44,6 +20,7 @@ interface GitHubRepositoriesSuggestionBoxProps {
 }
 
 export function GitHubRepositoriesSuggestionBox({
+  handleSubmit,
   repositories,
   gitHubAuthUrl,
   user,
@@ -70,16 +47,26 @@ export function GitHubRepositoriesSuggestionBox({
     );
   }
 
+  const isLoggedIn = !!user && !isGitHubErrorReponse(user);
+
   return (
     <>
       <SuggestionBox
         title="Open a Repo"
         content={
-          <GitHubAuth
-            isLoggedIn={!!user && !isGitHubErrorReponse(user)}
-            repositories={repositories || []}
-            onConnectToGitHub={handleConnectToGitHub}
-          />
+          isLoggedIn ? (
+            <GitHubRepositorySelector
+              onSelect={handleSubmit}
+              repositories={repositories || []}
+            />
+          ) : (
+            <ModalButton
+              text="Connect to GitHub"
+              icon={<GitHubLogo width={20} height={20} />}
+              className="bg-[#791B80] w-full"
+              onClick={handleConnectToGitHub}
+            />
+          )
         }
       />
       {connectToGitHubModalOpen && (

+ 3 - 3
frontend/src/routes/_oh._index/github-repo-selector.tsx

@@ -1,16 +1,16 @@
 import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
 import { useDispatch } from "react-redux";
-import { useNavigate } from "react-router-dom";
 import { setSelectedRepository } from "#/state/initial-query-slice";
 
 interface GitHubRepositorySelectorProps {
+  onSelect: () => void;
   repositories: GitHubRepository[];
 }
 
 export function GitHubRepositorySelector({
+  onSelect,
   repositories,
 }: GitHubRepositorySelectorProps) {
-  const navigate = useNavigate();
   const dispatch = useDispatch();
 
   const handleRepoSelection = (id: string | null) => {
@@ -18,7 +18,7 @@ export function GitHubRepositorySelector({
     if (repo) {
       // set query param
       dispatch(setSelectedRepository(repo.full_name));
-      navigate("/app");
+      onSelect();
     }
   };
 

+ 4 - 4
frontend/src/routes/_oh._index/route.tsx

@@ -5,7 +5,6 @@ import {
   defer,
   redirect,
   useLoaderData,
-  useNavigate,
   useRouteLoaderData,
 } from "@remix-run/react";
 import React from "react";
@@ -73,10 +72,10 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
 };
 
 function Home() {
-  const navigate = useNavigate();
   const dispatch = useDispatch();
   const rootData = useRouteLoaderData<typeof rootClientLoader>("routes/_oh");
   const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
+  const formRef = React.useRef<HTMLFormElement>(null);
 
   return (
     <div
@@ -86,7 +85,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 />
+          <TaskForm ref={formRef} />
         </div>
         <div className="flex gap-4 w-full">
           <React.Suspense
@@ -100,6 +99,7 @@ function Home() {
             <Await resolve={repositories}>
               {(resolvedRepositories) => (
                 <GitHubRepositoriesSuggestionBox
+                  handleSubmit={() => formRef.current?.requestSubmit()}
                   repositories={resolvedRepositories}
                   gitHubAuthUrl={githubAuthUrl}
                   user={rootData?.user || null}
@@ -129,7 +129,7 @@ function Home() {
                       dispatch(
                         setImportedProjectZip(await convertZipToBase64(zip)),
                       );
-                      navigate("/app");
+                      formRef.current?.requestSubmit();
                     } else {
                       // TODO: handle error
                     }

+ 6 - 5
frontend/src/routes/_oh._index/task-form.tsx

@@ -13,7 +13,7 @@ import { getRandomKey } from "#/utils/get-random-key";
 import { AttachImageLabel } from "#/components/attach-image-label";
 import { cn } from "#/utils/utils";
 
-export function TaskForm() {
+export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
   const dispatch = useDispatch();
   const navigation = useNavigation();
 
@@ -21,7 +21,6 @@ export function TaskForm() {
     (state: RootState) => state.initalQuery,
   );
 
-  const formRef = React.useRef<HTMLFormElement>(null);
   const [text, setText] = React.useState("");
   const [suggestion, setSuggestion] = React.useState(
     getRandomKey(SUGGESTIONS["non-repo"]),
@@ -55,7 +54,7 @@ export function TaskForm() {
   return (
     <div className="flex flex-col gap-2 w-full">
       <Form
-        ref={formRef}
+        ref={ref}
         method="post"
         className="flex flex-col items-center gap-2"
         replace
@@ -75,7 +74,7 @@ export function TaskForm() {
           <ChatInput
             name="q"
             onSubmit={() => {
-              formRef.current?.requestSubmit();
+              if (typeof ref !== "function") ref?.current?.requestSubmit();
             }}
             onChange={(message) => setText(message)}
             onFocus={() => setInputIsFocused(true)}
@@ -116,4 +115,6 @@ export function TaskForm() {
       )}
     </div>
   );
-}
+});
+
+TaskForm.displayName = "TaskForm";

+ 4 - 4
frontend/src/routes/_oh.app._index/code-editor-component.tsx

@@ -7,15 +7,15 @@ import { I18nKey } from "#/i18n/declaration";
 import { useFiles } from "#/context/files";
 import OpenHands from "#/api/open-hands";
 
-interface CodeEditorCompoonentProps {
+interface CodeEditorComponentProps {
   onMount: EditorProps["onMount"];
   isReadOnly: boolean;
 }
 
-function CodeEditorCompoonent({
+function CodeEditorComponent({
   onMount,
   isReadOnly,
-}: CodeEditorCompoonentProps) {
+}: CodeEditorComponentProps) {
   const { t } = useTranslation();
   const {
     files,
@@ -107,4 +107,4 @@ function CodeEditorCompoonent({
   );
 }
 
-export default React.memo(CodeEditorCompoonent);
+export default React.memo(CodeEditorComponent);

+ 2 - 2
frontend/src/routes/_oh.app._index/route.tsx

@@ -8,7 +8,7 @@ import { RootState } from "#/store";
 import AgentState from "#/types/AgentState";
 import FileExplorer from "#/components/file-explorer/FileExplorer";
 import OpenHands from "#/api/open-hands";
-import CodeEditorCompoonent from "./code-editor-component";
+import CodeEditorComponent from "./code-editor-component";
 import { useFiles } from "#/context/files";
 import { EditorActions } from "#/components/editor-actions";
 
@@ -138,7 +138,7 @@ function CodeEditor() {
             />
           </div>
         )}
-        <CodeEditorCompoonent
+        <CodeEditorComponent
           onMount={handleEditorDidMount}
           isReadOnly={!isEditingAllowed}
         />

+ 0 - 5
frontend/src/routes/_oh.app.tsx

@@ -18,7 +18,6 @@ import { useEffectOnce } from "#/utils/use-effect-once";
 import CodeIcon from "#/icons/code.svg?react";
 import GlobeIcon from "#/icons/globe.svg?react";
 import ListIcon from "#/icons/list-type-number.svg?react";
-import { clearInitialQuery } from "#/state/initial-query-slice";
 import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
 import { clearJupyter } from "#/state/jupyterSlice";
 import { FilesProvider } from "#/context/files";
@@ -28,8 +27,6 @@ import { EventHandler } from "#/components/event-handler";
 
 export const clientLoader = async () => {
   const ghToken = localStorage.getItem("ghToken");
-
-  const q = store.getState().initalQuery.initialQuery;
   const repo =
     store.getState().initalQuery.selectedRepository ||
     localStorage.getItem("repo");
@@ -55,7 +52,6 @@ export const clientLoader = async () => {
     token,
     ghToken,
     repo,
-    q,
     lastCommit,
   });
 };
@@ -91,7 +87,6 @@ function App() {
     dispatch(clearMessages());
     dispatch(clearTerminal());
     dispatch(clearJupyter());
-    dispatch(clearInitialQuery()); // Clear initial query when navigating to /app
   });
 
   const {

+ 26 - 0
frontend/tests/redirect.spec.ts

@@ -59,3 +59,29 @@ test("should redirect to /app after selecting a repo", async ({ page }) => {
   await page.waitForURL("/app");
   expect(page.url()).toBe("http://127.0.0.1:3000/app");
 });
+
+// FIXME: This fails because the MSW WS mocks change state too quickly,
+// missing the OPENING status where the initial query is rendered.
+test.fail(
+  "should redirect the user to /app with their initial query after selecting a project",
+  async ({ page }) => {
+    await page.goto("/");
+    await confirmSettings(page);
+
+    // enter query
+    const testQuery = "this is my test query";
+    const textbox = page.getByPlaceholder(/what do you want to build/i);
+    expect(textbox).not.toBeNull();
+    await textbox.fill(testQuery);
+
+    const fileInput = page.getByLabel("Upload a .zip");
+    const filePath = path.join(dirname, "fixtures/project.zip");
+    await fileInput.setInputFiles(filePath);
+
+    await page.waitForURL("/app");
+
+    // get user message
+    const userMessage = page.getByTestId("user-message");
+    expect(await userMessage.textContent()).toBe(testQuery);
+  },
+);