فهرست منبع

feat(frontend): Chat interface empty state (#4737)

sp.wack 1 سال پیش
والد
کامیت
118957235d

+ 143 - 6
frontend/__tests__/components/chat/chat-interface.test.tsx

@@ -1,19 +1,156 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { render, screen, within } from "@testing-library/react";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { act, screen, waitFor, within } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
 import { ChatInterface } from "#/components/chat-interface";
-import { SocketProvider } from "#/context/socket";
+import { addUserMessage } from "#/state/chatSlice";
+import { SUGGESTIONS } from "#/utils/suggestions";
+import * as ChatSlice from "#/state/chatSlice";
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const renderChatInterface = (messages: (Message | ErrorMessage)[]) =>
-  render(<ChatInterface />, { wrapper: SocketProvider });
+  renderWithProviders(<ChatInterface />);
+
+describe("Empty state", () => {
+  const { send: sendMock } = vi.hoisted(() => ({
+    send: vi.fn(),
+  }));
+
+  const { useSocket: useSocketMock } = vi.hoisted(() => ({
+    useSocket: vi.fn(() => ({ send: sendMock, runtimeActive: true })),
+  }));
+
+  beforeAll(() => {
+    vi.mock("#/context/socket", async (importActual) => ({
+      ...(await importActual<typeof import("#/context/socket")>()),
+      useSocket: useSocketMock,
+    }));
+  });
 
-describe.skip("ChatInterface", () => {
   afterEach(() => {
     vi.clearAllMocks();
   });
 
-  it.todo("should render suggestions if empty");
+  it("should render suggestions if empty", () => {
+    const { store } = renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        chat: { messages: [] },
+      },
+    });
+
+    expect(screen.getByTestId("suggestions")).toBeInTheDocument();
+
+    act(() => {
+      store.dispatch(
+        addUserMessage({
+          content: "Hello",
+          imageUrls: [],
+          timestamp: new Date().toISOString(),
+        }),
+      );
+    });
+
+    expect(screen.queryByTestId("suggestions")).not.toBeInTheDocument();
+  });
+
+  it("should render the default suggestions", () => {
+    renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        chat: { messages: [] },
+      },
+    });
+
+    const suggestions = screen.getByTestId("suggestions");
+    const repoSuggestions = Object.keys(SUGGESTIONS.repo);
+
+    // check that there are at most 4 suggestions displayed
+    const displayedSuggestions = within(suggestions).getAllByRole("button");
+    expect(displayedSuggestions.length).toBeLessThanOrEqual(4);
+
+    // Check that each displayed suggestion is one of the repo suggestions
+    displayedSuggestions.forEach((suggestion) => {
+      expect(repoSuggestions).toContain(suggestion.textContent);
+    });
+  });
+
+  it.fails(
+    "should load the a user message to the input when selecting",
+    async () => {
+      // this is to test that the message is in the UI before the socket is called
+      useSocketMock.mockImplementation(() => ({
+        send: sendMock,
+        runtimeActive: false, // mock an inactive runtime setup
+      }));
+      const addUserMessageSpy = vi.spyOn(ChatSlice, "addUserMessage");
+      const user = userEvent.setup();
+      const { store } = renderWithProviders(<ChatInterface />, {
+        preloadedState: {
+          chat: { messages: [] },
+        },
+      });
+
+      const suggestions = screen.getByTestId("suggestions");
+      const displayedSuggestions = within(suggestions).getAllByRole("button");
+      const input = screen.getByTestId("chat-input");
+
+      await user.click(displayedSuggestions[0]);
+
+      // user message loaded to input
+      expect(addUserMessageSpy).not.toHaveBeenCalled();
+      expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
+      expect(store.getState().chat.messages).toHaveLength(0);
+      expect(input).toHaveValue(displayedSuggestions[0].textContent);
+    },
+  );
+
+  it.fails(
+    "should send the message to the socket only if the runtime is active",
+    async () => {
+      useSocketMock.mockImplementation(() => ({
+        send: sendMock,
+        runtimeActive: false, // mock an inactive runtime setup
+      }));
+      const user = userEvent.setup();
+      const { rerender } = renderWithProviders(<ChatInterface />, {
+        preloadedState: {
+          chat: { messages: [] },
+        },
+      });
+
+      const suggestions = screen.getByTestId("suggestions");
+      const displayedSuggestions = within(suggestions).getAllByRole("button");
+
+      await user.click(displayedSuggestions[0]);
+      expect(sendMock).not.toHaveBeenCalled();
+
+      useSocketMock.mockImplementation(() => ({
+        send: sendMock,
+        runtimeActive: true, // mock an active runtime setup
+      }));
+      rerender(<ChatInterface />);
+
+      await waitFor(() =>
+        expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
+      );
+    },
+  );
+});
+
+describe.skip("ChatInterface", () => {
+  beforeAll(() => {
+    // mock useScrollToBottom hook
+    vi.mock("#/hooks/useScrollToBottom", () => ({
+      useScrollToBottom: vi.fn(() => ({
+        scrollDomToBottom: vi.fn(),
+        onChatBodyScroll: vi.fn(),
+        hitBottom: vi.fn(),
+      })),
+    }));
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
 
   it("should render messages", () => {
     const messages: Message[] = [

+ 15 - 0
frontend/__tests__/components/interactive-chat-box.test.tsx

@@ -25,6 +25,21 @@ describe("InteractiveChatBox", () => {
     within(chatBox).getByTestId("upload-image-input");
   });
 
+  it.fails("should set custom values", () => {
+    render(
+      <InteractiveChatBox
+        onSubmit={onSubmitMock}
+        onStop={onStopMock}
+        value="Hello, world!"
+      />,
+    );
+
+    const chatBox = screen.getByTestId("interactive-chat-box");
+    const chatInput = within(chatBox).getByTestId("chat-input");
+
+    expect(chatInput).toHaveValue("Hello, world!");
+  });
+
   it("should display the image previews when images are uploaded", async () => {
     const user = userEvent.setup();
     render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);

+ 30 - 0
frontend/__tests__/components/suggestion-item.test.tsx

@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { SuggestionItem } from "#/components/suggestion-item";
+
+describe("SuggestionItem", () => {
+  const suggestionItem = { label: "suggestion1", value: "a long text value" };
+  const onClick = vi.fn();
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("should render a suggestion", () => {
+    render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
+
+    expect(screen.getByTestId("suggestion")).toBeInTheDocument();
+    expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
+  });
+
+  it("should call onClick when clicking a suggestion", async () => {
+    const user = userEvent.setup();
+    render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
+
+    const suggestion = screen.getByTestId("suggestion");
+    await user.click(suggestion);
+
+    expect(onClick).toHaveBeenCalledWith("a long text value");
+  });
+});

+ 60 - 0
frontend/__tests__/components/suggestions.test.tsx

@@ -0,0 +1,60 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Suggestions } from "#/components/suggestions";
+
+describe("Suggestions", () => {
+  const firstSuggestion = {
+    label: "first-suggestion",
+    value: "value-of-first-suggestion",
+  };
+  const secondSuggestion = {
+    label: "second-suggestion",
+    value: "value-of-second-suggestion",
+  };
+  const suggestions = [firstSuggestion, secondSuggestion];
+
+  const onSuggestionClickMock = vi.fn();
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it("should render suggestions", () => {
+    render(
+      <Suggestions
+        suggestions={suggestions}
+        onSuggestionClick={onSuggestionClickMock}
+      />,
+    );
+
+    expect(screen.getByTestId("suggestions")).toBeInTheDocument();
+    const suggestionElements = screen.getAllByTestId("suggestion");
+
+    expect(suggestionElements).toHaveLength(2);
+    expect(suggestionElements[0]).toHaveTextContent("first-suggestion");
+    expect(suggestionElements[1]).toHaveTextContent("second-suggestion");
+  });
+
+  it("should call onSuggestionClick when clicking a suggestion", async () => {
+    const user = userEvent.setup();
+    render(
+      <Suggestions
+        suggestions={suggestions}
+        onSuggestionClick={onSuggestionClickMock}
+      />,
+    );
+
+    const suggestionElements = screen.getAllByTestId("suggestion");
+
+    await user.click(suggestionElements[0]);
+    expect(onSuggestionClickMock).toHaveBeenCalledWith(
+      "value-of-first-suggestion",
+    );
+
+    await user.click(suggestionElements[1]);
+    expect(onSuggestionClickMock).toHaveBeenCalledWith(
+      "value-of-second-suggestion",
+    );
+  });
+});

+ 5 - 0
frontend/__tests__/routes/_oh.app.test.tsx

@@ -0,0 +1,5 @@
+import { describe, it } from "vitest";
+
+describe("App", () => {
+  it.todo("should render");
+});

+ 29 - 0
frontend/src/components/chat-interface.tsx

@@ -18,6 +18,9 @@ import ConfirmationButtons from "./chat/ConfirmationButtons";
 import { ErrorMessage } from "./error-message";
 import { ContinueButton } from "./continue-button";
 import { ScrollToBottomButton } from "./scroll-to-bottom-button";
+import { Suggestions } from "./suggestions";
+import { SUGGESTIONS } from "#/utils/suggestions";
+import BuildIt from "#/assets/build-it.svg?react";
 
 const isErrorMessage = (
   message: Message | ErrorMessage,
@@ -37,6 +40,7 @@ export function ChatInterface() {
     "positive" | "negative"
   >("positive");
   const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
+  const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
 
   const handleSendMessage = async (content: string, files: File[]) => {
     const promises = files.map((file) => convertImageToBase64(file));
@@ -45,6 +49,7 @@ export function ChatInterface() {
     const timestamp = new Date().toISOString();
     dispatch(addUserMessage({ content, imageUrls, timestamp }));
     send(createChatMessage(content, imageUrls, timestamp));
+    setMessageToSend(null);
   };
 
   const handleStop = () => {
@@ -64,6 +69,28 @@ export function ChatInterface() {
 
   return (
     <div className="h-full flex flex-col justify-between">
+      {messages.length === 0 && (
+        <div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
+          <div className="flex flex-col items-center p-4 bg-neutral-700 rounded-xl w-full">
+            <BuildIt width={45} height={54} />
+            <span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
+              Let&apos;s start building!
+            </span>
+          </div>
+          <Suggestions
+            suggestions={Object.entries(SUGGESTIONS.repo)
+              .slice(0, 4)
+              .map(([label, value]) => ({
+                label,
+                value,
+              }))}
+            onSuggestionClick={(value) => {
+              setMessageToSend(value);
+            }}
+          />
+        </div>
+      )}
+
       <div
         ref={scrollRef}
         onScroll={(e) => onChatBodyScroll(e.currentTarget)}
@@ -123,6 +150,8 @@ export function ChatInterface() {
             curAgentState === AgentState.AWAITING_USER_CONFIRMATION
           }
           mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
+          value={messageToSend ?? undefined}
+          onChange={setMessageToSend}
         />
       </div>
 

+ 6 - 0
frontend/src/components/interactive-chat-box.tsx

@@ -9,6 +9,8 @@ interface InteractiveChatBoxProps {
   mode?: "stop" | "submit";
   onSubmit: (message: string, images: File[]) => void;
   onStop: () => void;
+  value?: string;
+  onChange?: (message: string) => void;
 }
 
 export function InteractiveChatBox({
@@ -16,6 +18,8 @@ export function InteractiveChatBox({
   mode = "submit",
   onSubmit,
   onStop,
+  value,
+  onChange,
 }: InteractiveChatBoxProps) {
   const [images, setImages] = React.useState<File[]>([]);
 
@@ -67,8 +71,10 @@ export function InteractiveChatBox({
           disabled={isDisabled}
           button={mode}
           placeholder="What do you want to build?"
+          onChange={onChange}
           onSubmit={handleSubmit}
           onStop={onStop}
+          value={value}
           onImagePaste={handleUpload}
         />
       </div>

+ 21 - 0
frontend/src/components/suggestion-item.tsx

@@ -0,0 +1,21 @@
+export type Suggestion = { label: string; value: string };
+
+interface SuggestionItemProps {
+  suggestion: Suggestion;
+  onClick: (value: string) => void;
+}
+
+export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
+  return (
+    <li className="border border-neutral-600 rounded-xl hover:bg-neutral-700">
+      <button
+        type="button"
+        data-testid="suggestion"
+        onClick={() => onClick(suggestion.value)}
+        className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-4 font-semibold"
+      >
+        {suggestion.label}
+      </button>
+    </li>
+  );
+}

+ 23 - 0
frontend/src/components/suggestions.tsx

@@ -0,0 +1,23 @@
+import { SuggestionItem, type Suggestion } from "./suggestion-item";
+
+interface SuggestionsProps {
+  suggestions: Suggestion[];
+  onSuggestionClick: (value: string) => void;
+}
+
+export function Suggestions({
+  suggestions,
+  onSuggestionClick,
+}: SuggestionsProps) {
+  return (
+    <ul data-testid="suggestions" className="flex flex-col gap-4 w-full">
+      {suggestions.map((suggestion, index) => (
+        <SuggestionItem
+          key={index}
+          suggestion={suggestion}
+          onClick={onSuggestionClick}
+        />
+      ))}
+    </ul>
+  );
+}

+ 38 - 33
frontend/src/routes/_oh._index/route.tsx

@@ -8,17 +8,22 @@ import {
   useNavigate,
   useRouteLoaderData,
 } from "@remix-run/react";
-import React, { Suspense } from "react";
+import React from "react";
+import { useDispatch } from "react-redux";
 import { SuggestionBox } from "./suggestion-box";
 import { TaskForm } from "./task-form";
 import { HeroHeading } from "./hero-heading";
 import { retrieveAllGitHubUserRepositories } from "#/api/github";
 import store from "#/store";
-import { setInitialQuery } from "#/state/initial-query-slice";
+import {
+  setImportedProjectZip,
+  setInitialQuery,
+} from "#/state/initial-query-slice";
 import { clientLoader as rootClientLoader } from "#/routes/_oh";
 import OpenHands from "#/api/open-hands";
 import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
 import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box";
+import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
 
 export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
   let isSaas = false;
@@ -64,9 +69,9 @@ 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 [importedFile, setImportedFile] = React.useState<File | null>(null);
 
   return (
     <div
@@ -76,10 +81,10 @@ 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} />
+          <TaskForm />
         </div>
         <div className="flex gap-4 w-full">
-          <Suspense
+          <React.Suspense
             fallback={
               <SuggestionBox
                 title="Open a Repo"
@@ -96,36 +101,36 @@ function Home() {
                 />
               )}
             </Await>
-          </Suspense>
+          </React.Suspense>
           <SuggestionBox
-            title={importedFile ? "Project Loaded" : "+ Import Project"}
+            title="+ Import Project"
             content={
-              importedFile?.name ?? (
-                <label
-                  htmlFor="import-project"
-                  className="w-full flex justify-center"
-                >
-                  <span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
-                    Upload a .zip
-                  </span>
-                  <input
-                    hidden
-                    type="file"
-                    accept="application/zip"
-                    id="import-project"
-                    multiple={false}
-                    onChange={(event) => {
-                      if (event.target.files) {
-                        const zip = event.target.files[0];
-                        setImportedFile(zip);
-                        navigate("/app");
-                      } else {
-                        // TODO: handle error
-                      }
-                    }}
-                  />
-                </label>
-              )
+              <label
+                htmlFor="import-project"
+                className="w-full flex justify-center"
+              >
+                <span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
+                  Upload a .zip
+                </span>
+                <input
+                  hidden
+                  type="file"
+                  accept="application/zip"
+                  id="import-project"
+                  multiple={false}
+                  onChange={async (event) => {
+                    if (event.target.files) {
+                      const zip = event.target.files[0];
+                      dispatch(
+                        setImportedProjectZip(await convertZipToBase64(zip)),
+                      );
+                      navigate("/app");
+                    } else {
+                      // TODO: handle error
+                    }
+                  }}
+                />
+              </label>
             }
           />
         </div>

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

@@ -2,11 +2,7 @@ import React from "react";
 import { Form, useNavigation } from "@remix-run/react";
 import { useDispatch, useSelector } from "react-redux";
 import { RootState } from "#/store";
-import {
-  addFile,
-  removeFile,
-  setImportedProjectZip,
-} from "#/state/initial-query-slice";
+import { addFile, removeFile } 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";
@@ -14,15 +10,10 @@ 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;
-}
-
-export function TaskForm({ importedProjectZip }: TaskFormProps) {
+export function TaskForm() {
   const dispatch = useDispatch();
   const navigation = useNavigation();
 
@@ -30,29 +21,15 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
     (state: RootState) => state.initalQuery,
   );
 
-  const hasLoadedProject = React.useMemo(
-    () => importedProjectZip || selectedRepository,
-    [importedProjectZip, selectedRepository],
-  );
-
   const formRef = React.useRef<HTMLFormElement>(null);
   const [text, setText] = React.useState("");
   const [suggestion, setSuggestion] = React.useState(
-    getRandomKey(hasLoadedProject ? SUGGESTIONS.repo : SUGGESTIONS["non-repo"]),
+    getRandomKey(SUGGESTIONS["non-repo"]),
   );
   const [inputIsFocused, setInputIsFocused] = React.useState(false);
 
-  React.useEffect(() => {
-    // Display a suggestion based on whether a repository is selected
-    if (hasLoadedProject) {
-      setSuggestion(getRandomKey(SUGGESTIONS.repo));
-    } else {
-      setSuggestion(getRandomKey(SUGGESTIONS["non-repo"]));
-    }
-  }, [selectedRepository, importedProjectZip]);
-
   const onRefreshSuggestion = () => {
-    const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
+    const suggestions = SUGGESTIONS["non-repo"];
     // remove current suggestion to avoid refreshing to the same suggestion
     const suggestionCopy = { ...suggestions };
     delete suggestionCopy[suggestion];
@@ -62,20 +39,11 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
   };
 
   const onClickSuggestion = () => {
-    const suggestions = SUGGESTIONS[hasLoadedProject ? "repo" : "non-repo"];
+    const suggestions = SUGGESTIONS["non-repo"];
     const value = suggestions[suggestion];
     setText(value);
   };
 
-  const handleSubmitForm = async () => {
-    // This is handled on top of the form submission
-    if (importedProjectZip) {
-      dispatch(
-        setImportedProjectZip(await convertZipToBase64(importedProjectZip)),
-      );
-    }
-  };
-
   const placeholder = React.useMemo(() => {
     if (selectedRepository) {
       return `What would you like to change in ${selectedRepository}?`;
@@ -90,7 +58,6 @@ export function TaskForm({ importedProjectZip }: TaskFormProps) {
         ref={formRef}
         method="post"
         className="flex flex-col items-center gap-2"
-        onSubmit={handleSubmitForm}
         replace
       >
         <SuggestionBubble

+ 11 - 1
frontend/src/routes/_oh.app.tsx

@@ -48,6 +48,7 @@ import { clearJupyter } from "#/state/jupyterSlice";
 import { FilesProvider } from "#/context/files";
 import { ErrorObservation } from "#/types/core/observations";
 import { ChatInterface } from "#/components/chat-interface";
+import { cn } from "#/utils/utils";
 
 interface ServerError {
   error: boolean | string;
@@ -292,7 +293,16 @@ function App() {
   return (
     <div className="flex flex-col h-full gap-3">
       <div className="flex h-full overflow-auto gap-3">
-        <Container className="w-[390px] max-h-full">
+        <Container className="w-[390px] max-h-full relative">
+          <div
+            className={cn(
+              "w-2 h-2 rounded-full border",
+              "absolute left-3 top-3",
+              runtimeActive
+                ? "bg-green-800 border-green-500"
+                : "bg-red-800 border-red-500",
+            )}
+          />
           <ChatInterface />
         </Container>