Просмотр исходного кода

(refactor|test)(frontend): chat (#1488)

* initial commit

* update tests and feat-markdown

* rename

* introduce chat

* initial commit

* extend chatinterface, add
new store, and utilize new reducers

* improve styles, code markdown, and adjust styles

* update actions to use new reducers

* scroll down for new messages

* add fade and action banner

* support with jupyter

* refactor to pass tests

* remove unused import

* remove outdated files/folders

* create simple useTyping hook

* remove action banner, extend tests, move files

* extend tests

* disable error
sp.wack 1 год назад
Родитель
Сommit
718730a2b9

+ 1 - 1
frontend/src/App.tsx

@@ -2,7 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
 import React, { useEffect, useState } from "react";
 import { Toaster } from "react-hot-toast";
 import CogTooth from "#/assets/cog-tooth";
-import ChatInterface from "#/components/ChatInterface";
+import ChatInterface from "#/components/chat/ChatInterface";
 import Errors from "#/components/Errors";
 import { Container, Orientation } from "#/components/Resizable";
 import Workspace from "#/components/Workspace";

+ 1 - 1
frontend/src/components/AgentControlBar.tsx

@@ -6,10 +6,10 @@ import PauseIcon from "#/assets/pause";
 import PlayIcon from "#/assets/play";
 import { changeTaskState } from "#/services/agentStateService";
 import { clearMsgs } from "#/services/session";
-import { clearMessages } from "#/state/chatSlice";
 import store, { RootState } from "#/store";
 import AgentTaskAction from "#/types/AgentTaskAction";
 import AgentTaskState from "#/types/AgentTaskState";
+import { clearMessages } from "#/state/chatSlice";
 
 const TaskStateActionMap = {
   [AgentTaskAction.START]: AgentTaskState.RUNNING,

+ 0 - 140
frontend/src/components/ChatInterface.tsx

@@ -1,140 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import { IoMdChatbubbles } from "react-icons/io";
-import Markdown from "react-markdown";
-import { useSelector } from "react-redux";
-import { useTypingEffect } from "#/hooks/useTypingEffect";
-import AgentTaskState from "../types/AgentTaskState";
-import {
-  addAssistantMessageToChat,
-  sendChatMessage,
-  setTypingActive,
-  takeOneAndType,
-} from "#/services/chatService";
-import { Message } from "#/state/chatSlice";
-import { RootState } from "#/store";
-import ChatInput from "./ChatInput";
-import { code } from "./markdown/code";
-
-interface IChatBubbleProps {
-  msg: Message;
-}
-
-/**
- * @returns jsx
- *
- * component used for typing effect when assistant replies
- *
- * makes uses of useTypingEffect hook
- *
- */
-function TypingChat() {
-  const { typeThis } = useSelector((state: RootState) => state.chat);
-
-  const messageContent = useTypingEffect([typeThis?.content], {
-    loop: false,
-    setTypingActive,
-    playbackRate: 0.099,
-    addAssistantMessageToChat,
-    takeOneAndType,
-    typeThis,
-  });
-
-  return (
-    <div className="flex max-w-[90%]">
-      <div className="flex mb-0 min-w-0">
-        <div className="bg-neutral-500 rounded-lg">
-          <div className="p-3">{messageContent}</div>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function ChatBubble({ msg }: IChatBubbleProps): JSX.Element {
-  return (
-    <div
-      className={`flex max-w-[90%] ${msg?.sender === "user" ? "self-end" : ""}`}
-    >
-      <div
-        className={`flex mb-0 min-w-0 ${msg?.sender === "user" && "flex-row-reverse ml-auto"}`}
-      >
-        <div
-          className={`overflow-y-auto ${msg?.sender === "user" ? "bg-neutral-700" : "bg-neutral-500"} rounded-lg`}
-        >
-          <div className="p-3 prose prose-invert text-white">
-            <Markdown components={{ code }}>{msg?.content}</Markdown>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function MessageList(): JSX.Element {
-  const messagesEndRef = useRef<HTMLDivElement>(null);
-  const { typingActive, newChatSequence, typeThis } = useSelector(
-    (state: RootState) => state.chat,
-  );
-
-  const messageScroll = () => {
-    messagesEndRef.current?.scrollIntoView({
-      behavior: "auto",
-      block: "end",
-    });
-  };
-
-  useEffect(() => {
-    messageScroll();
-    if (!typingActive) return;
-
-    const interval = setInterval(() => {
-      messageScroll();
-    }, 100);
-
-    // eslint-disable-next-line consistent-return
-    return () => clearInterval(interval);
-  }, [newChatSequence, typingActive]);
-
-  useEffect(() => {
-    if (typeThis.content === "") return;
-
-    if (!typingActive) setTypingActive(true);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [typeThis]);
-
-  return (
-    <div className="flex-1 flex flex-col gap-3 pt-3 px-3 relative min-h-0">
-      <div className="flex overflow-x-auto flex-col h-full gap-3">
-        {newChatSequence.map((msg, index) => (
-          <ChatBubble key={index} msg={msg} />
-        ))}
-        {typingActive && <TypingChat />}
-        <div ref={messagesEndRef} />
-      </div>
-      <div className="absolute bottom-0 left-0 right-0 h-4 bg-gradient-to-b from-transparent to-neutral-800" />
-    </div>
-  );
-}
-
-function ChatInterface(): JSX.Element {
-  const { initialized } = useSelector((state: RootState) => state.task);
-  const { curTaskState } = useSelector((state: RootState) => state.agent);
-
-  const onUserMessage = (msg: string) => {
-    const isNewTask = curTaskState === AgentTaskState.INIT;
-    sendChatMessage(msg, isNewTask);
-  };
-
-  return (
-    <div className="flex flex-col h-full p-0 bg-neutral-800">
-      <div className="flex items-center gap-2 border-b border-neutral-600 text-sm px-4 py-2">
-        <IoMdChatbubbles />
-        Chat
-      </div>
-      <MessageList />
-      <ChatInput disabled={!initialized} onSendMessage={onUserMessage} />
-    </div>
-  );
-}
-
-export default ChatInterface;

+ 37 - 0
frontend/src/components/chat/Chat.test.tsx

@@ -0,0 +1,37 @@
+import React from "react";
+import { act, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import Chat from "./Chat";
+
+const MESSAGES: Message[] = [
+  { sender: "assistant", content: "Hello!" },
+  { sender: "user", content: "Hi!" },
+  { sender: "assistant", content: "How can I help you today?" },
+];
+
+HTMLElement.prototype.scrollIntoView = vi.fn();
+
+describe("Chat", () => {
+  it("should render chat messages", () => {
+    render(<Chat messages={MESSAGES} />);
+
+    const messages = screen.getAllByTestId("message");
+
+    expect(messages).toHaveLength(MESSAGES.length);
+  });
+
+  it("should scroll to the newest message", () => {
+    const { rerender } = render(<Chat messages={MESSAGES} />);
+
+    const newMessages: Message[] = [
+      ...MESSAGES,
+      { sender: "user", content: "Create a spaceship" },
+    ];
+
+    act(() => {
+      rerender(<Chat messages={newMessages} />);
+    });
+
+    expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+  });
+});

+ 25 - 0
frontend/src/components/chat/Chat.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+import ChatMessage from "./ChatMessage";
+
+interface ChatProps {
+  messages: Message[];
+}
+
+function Chat({ messages }: ChatProps) {
+  const endOfMessagesRef = React.useRef<HTMLDivElement>(null);
+
+  React.useEffect(() => {
+    endOfMessagesRef.current?.scrollIntoView({ behavior: "smooth" });
+  }, [messages]);
+
+  return (
+    <div className="flex flex-col gap-3">
+      {messages.map((message, index) => (
+        <ChatMessage key={index} message={message} />
+      ))}
+      <div ref={endOfMessagesRef} />
+    </div>
+  );
+}
+
+export default Chat;

+ 0 - 0
frontend/src/components/ChatInput.test.tsx → frontend/src/components/chat/ChatInput.test.tsx


+ 1 - 1
frontend/src/components/ChatInput.tsx → frontend/src/components/chat/ChatInput.tsx

@@ -42,7 +42,7 @@ function ChatInput({ disabled, onSendMessage }: ChatInputProps) {
         onCompositionStart={() => setIsComposing(true)}
         onCompositionEnd={() => setIsComposing(false)}
         placeholder={t(I18nKey.CHAT_INTERFACE$INPUT_PLACEHOLDER)}
-        className="pt-2 pb-3 px-3"
+        className="pb-3 px-3"
         classNames={{
           inputWrapper: "bg-neutral-700 border border-neutral-600 rounded-lg",
           input: "pr-16 text-neutral-400",

+ 133 - 0
frontend/src/components/chat/ChatInterface.test.tsx

@@ -0,0 +1,133 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { act } from "react-dom/test-utils";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "test-utils";
+import ChatInterface from "./ChatInterface";
+import Socket from "#/services/socket";
+import ActionType from "#/types/ActionType";
+import { addAssistantMessage } from "#/state/chatSlice";
+import AgentTaskState from "#/types/AgentTaskState";
+
+// avoid typing side-effect
+vi.mock("#/hooks/useTyping", () => ({
+  useTyping: vi.fn((text: string) => text),
+}));
+
+const socketSpy = vi.spyOn(Socket, "send");
+
+// This is for the scrollview ref in Chat.tsx
+// TODO: Move this into test setup
+HTMLElement.prototype.scrollIntoView = vi.fn();
+
+const renderChatInterface = () =>
+  renderWithProviders(<ChatInterface />, {
+    preloadedState: {
+      task: {
+        initialized: true,
+        completed: false,
+      },
+    },
+  });
+
+describe("ChatInterface", () => {
+  it("should render the messages and input", () => {
+    renderChatInterface();
+    expect(screen.queryAllByTestId("message")).toHaveLength(1); // initial welcome message only
+  });
+
+  it("should render the new message the user has typed", async () => {
+    renderChatInterface();
+
+    const input = screen.getByRole("textbox");
+
+    act(() => {
+      userEvent.type(input, "my message{enter}");
+    });
+
+    expect(screen.getByText("my message")).toBeInTheDocument();
+  });
+
+  it("should render user and assistant messages", () => {
+    const { store } = renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        chat: {
+          messages: [{ sender: "user", content: "Hello" }],
+        },
+      },
+    });
+
+    expect(screen.getAllByTestId("message")).toHaveLength(1);
+    expect(screen.getByText("Hello")).toBeInTheDocument();
+
+    act(() => {
+      store.dispatch(addAssistantMessage("Hello to you!"));
+    });
+
+    expect(screen.getAllByTestId("message")).toHaveLength(2);
+    expect(screen.getByText("Hello to you!")).toBeInTheDocument();
+  });
+
+  it("should send the a start event to the Socket", () => {
+    renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        task: {
+          initialized: true,
+          completed: false,
+        },
+        agent: {
+          curTaskState: AgentTaskState.INIT,
+        },
+      },
+    });
+
+    const input = screen.getByRole("textbox");
+    act(() => {
+      userEvent.type(input, "my message{enter}");
+    });
+
+    const event = { action: ActionType.START, args: { task: "my message" } };
+    expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event));
+  });
+
+  it("should send the a user message event to the Socket", () => {
+    renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        task: {
+          initialized: true,
+          completed: false,
+        },
+        agent: {
+          curTaskState: AgentTaskState.AWAITING_USER_INPUT,
+        },
+      },
+    });
+
+    const input = screen.getByRole("textbox");
+    act(() => {
+      userEvent.type(input, "my message{enter}");
+    });
+
+    const event = {
+      action: ActionType.USER_MESSAGE,
+      args: { message: "my message" },
+    };
+    expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event));
+  });
+
+  it("should disable the user input if agent is not initialized", () => {
+    renderWithProviders(<ChatInterface />, {
+      preloadedState: {
+        task: {
+          initialized: false,
+          completed: false,
+        },
+      },
+    });
+
+    const submitButton = screen.getByLabelText(/send message/i);
+
+    expect(submitButton).toBeDisabled();
+  });
+});

+ 50 - 0
frontend/src/components/chat/ChatInterface.tsx

@@ -0,0 +1,50 @@
+import React from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { IoMdChatbubbles } from "react-icons/io";
+import ChatInput from "./ChatInput";
+import Chat from "./Chat";
+import { RootState } from "#/store";
+import AgentTaskState from "#/types/AgentTaskState";
+import { addUserMessage } from "#/state/chatSlice";
+import ActionType from "#/types/ActionType";
+import Socket from "#/services/socket";
+
+function ChatInterface() {
+  const { initialized } = useSelector((state: RootState) => state.task);
+  const { messages } = useSelector((state: RootState) => state.chat);
+  const { curTaskState } = useSelector((state: RootState) => state.agent);
+
+  const dispatch = useDispatch();
+
+  const handleSendMessage = (content: string) => {
+    dispatch(addUserMessage(content));
+
+    let event;
+    if (curTaskState === AgentTaskState.INIT) {
+      event = { action: ActionType.START, args: { task: content } };
+    } else {
+      event = { action: ActionType.USER_MESSAGE, args: { message: content } };
+    }
+
+    Socket.send(JSON.stringify(event));
+  };
+
+  return (
+    <div className="flex flex-col h-full bg-neutral-800">
+      <div className="flex items-center gap-2 border-b border-neutral-600 text-sm px-4 py-2">
+        <IoMdChatbubbles />
+        Chat
+      </div>
+      <div className="flex-1 flex flex-col relative min-h-0">
+        <div className="overflow-x-auto p-3">
+          <Chat messages={messages} />
+        </div>
+        {/* Fade between messages and input */}
+        <div className="absolute bottom-0 left-0 right-0 h-4 bg-gradient-to-b from-transparent to-neutral-800" />
+      </div>
+      <ChatInput disabled={!initialized} onSendMessage={handleSendMessage} />
+    </div>
+  );
+}
+
+export default ChatInterface;

+ 41 - 0
frontend/src/components/chat/ChatMessage.test.tsx

@@ -0,0 +1,41 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import React from "react";
+import ChatMessage from "./ChatMessage";
+
+// avoid typing side-effect
+vi.mock("#/hooks/useTyping", () => ({
+  useTyping: vi.fn((text: string) => text),
+}));
+
+describe("Message", () => {
+  it("should render a user message", () => {
+    render(<ChatMessage message={{ sender: "user", content: "Hello" }} />);
+
+    expect(screen.getByTestId("message")).toBeInTheDocument();
+    expect(screen.getByTestId("message")).toHaveClass("self-end"); // user message should be on the right side
+  });
+
+  it("should render an assistant message", () => {
+    render(<ChatMessage message={{ sender: "assistant", content: "Hi" }} />);
+
+    expect(screen.getByTestId("message")).toBeInTheDocument();
+    expect(screen.getByTestId("message")).not.toHaveClass("self-end"); // assistant message should be on the left side
+  });
+
+  it("should render markdown content", () => {
+    render(
+      <ChatMessage
+        message={{
+          sender: "user",
+          content: "```js\nconsole.log('Hello')\n```",
+        }}
+      />,
+    );
+
+    // SyntaxHighlighter breaks the code blocks into "tokens"
+    expect(screen.getByText("console")).toBeInTheDocument();
+    expect(screen.getByText("log")).toBeInTheDocument();
+    expect(screen.getByText("'Hello'")).toBeInTheDocument();
+  });
+});

+ 26 - 0
frontend/src/components/chat/ChatMessage.tsx

@@ -0,0 +1,26 @@
+import React from "react";
+import Markdown from "react-markdown";
+import { twMerge } from "tailwind-merge";
+import { code } from "../markdown/code";
+import { useTyping } from "#/hooks/useTyping";
+
+interface MessageProps {
+  message: Message;
+}
+
+function ChatMessage({ message }: MessageProps) {
+  const text = useTyping(message.content);
+
+  const className = twMerge(
+    "p-3 text-white max-w-[90%] overflow-y-auto rounded-lg",
+    message.sender === "user" ? "bg-neutral-700 self-end" : "bg-neutral-500",
+  );
+
+  return (
+    <div data-testid="message" className={className}>
+      <Markdown components={{ code }}>{text}</Markdown>
+    </div>
+  );
+}
+
+export default ChatMessage;

+ 4 - 0
frontend/src/components/chat/message.d.ts

@@ -0,0 +1,4 @@
+type Message = {
+  sender: "user" | "assistant";
+  content: string;
+};

+ 41 - 0
frontend/src/hooks/useTyping.test.ts

@@ -0,0 +1,41 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, it, vi } from "vitest";
+import { useTyping } from "./useTyping";
+
+vi.useFakeTimers();
+
+describe("useTyping", () => {
+  it("should 'type' a given message", () => {
+    const text = "Hello, World!";
+    const typingSpeed = 10;
+
+    const { result } = renderHook(() => useTyping(text));
+    expect(result.current).toBe("H");
+
+    act(() => {
+      vi.advanceTimersByTime(typingSpeed);
+    });
+
+    expect(result.current).toBe("He");
+
+    act(() => {
+      vi.advanceTimersByTime(typingSpeed);
+    });
+
+    expect(result.current).toBe("Hel");
+
+    for (let i = 3; i < text.length; i += 1) {
+      act(() => {
+        vi.advanceTimersByTime(typingSpeed);
+      });
+    }
+
+    expect(result.current).toBe("Hello, World!");
+
+    act(() => {
+      vi.advanceTimersByTime(typingSpeed);
+    });
+
+    expect(result.current).toBe("Hello, World!");
+  });
+});

+ 23 - 0
frontend/src/hooks/useTyping.ts

@@ -0,0 +1,23 @@
+import React from "react";
+
+export const useTyping = (text: string) => {
+  const [message, setMessage] = React.useState(text[0]);
+
+  const advance = () =>
+    setTimeout(() => {
+      if (message.length < text.length) {
+        setMessage(text.slice(0, message.length + 1));
+      }
+    }, 10);
+
+  React.useEffect(() => {
+    const timeout = advance();
+
+    return () => {
+      clearTimeout(timeout);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [message]);
+
+  return message;
+};

+ 0 - 156
frontend/src/hooks/useTypingEffect.test.ts

@@ -1,156 +0,0 @@
-import { renderHook, act } from "@testing-library/react";
-import { useTypingEffect } from "./useTypingEffect";
-
-describe("useTypingEffect", () => {
-  beforeEach(() => {
-    vi.useFakeTimers();
-  });
-
-  afterEach(() => {
-    vi.clearAllTimers();
-  });
-
-  // This test fails because the hook improperly handles this case.
-  it.skip("should handle empty strings array", () => {
-    const { result } = renderHook(() => useTypingEffect([]));
-
-    // Immediately check the result since there's nothing to type
-    expect(result.current).toBe("\u00A0"); // Non-breaking space
-  });
-
-  it("should type out a string correctly", () => {
-    const message = "Hello, world! This is a test message.";
-
-    const { result } = renderHook(() => useTypingEffect([message]));
-
-    // msg.length - 2 because the first two characters are typed immediately
-    // 100ms per character, 0.1 playbackRate
-    const msToRun = (message.length - 2) * 100 * 0.1;
-
-    // Fast-forward time by to simulate typing message
-    act(() => {
-      vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
-    });
-
-    expect(result.current).toBe(message.slice(0, -1));
-
-    act(() => {
-      vi.advanceTimersByTime(1); // include the last character
-    });
-
-    expect(result.current).toBe(message);
-  });
-
-  it("should type of a string correctly with a different playback rate", () => {
-    const message = "Hello, world! This is a test message.";
-    const playbackRate = 0.5;
-
-    const { result } = renderHook(() =>
-      useTypingEffect([message], { playbackRate }),
-    );
-
-    const msToRun = (message.length - 2) * 100 * playbackRate;
-
-    act(() => {
-      vi.advanceTimersByTime(msToRun - 1); // exclude the last character for testing
-    });
-
-    expect(result.current).toBe(message.slice(0, -1));
-
-    act(() => {
-      vi.advanceTimersByTime(1); // include the last character
-    });
-
-    expect(result.current).toBe(message);
-  });
-
-  it("should loop through strings when multiple are provided", () => {
-    const messages = ["Hello", "World"];
-
-    const { result } = renderHook(() => useTypingEffect(messages));
-
-    const msToRunFirstString = messages[0].length * 100 * 0.1;
-
-    // Fast-forward to end of first string
-    act(() => {
-      vi.advanceTimersByTime(msToRunFirstString);
-    });
-
-    expect(result.current).toBe(messages[0]); // Hello
-
-    // Fast-forward through the delay and through the second string
-    act(() => {
-      // TODO: Improve to clarify the expected timing
-      vi.runAllTimers();
-    });
-
-    expect(result.current).toBe(messages[1]); // World
-  });
-
-  it("should call setTypingActive with false when typing completes without loop", () => {
-    const setTypingActiveMock = vi.fn();
-
-    renderHook(() =>
-      useTypingEffect(["Hello, world!", "This is a test message."], {
-        loop: false,
-        setTypingActive: setTypingActiveMock,
-      }),
-    );
-
-    expect(setTypingActiveMock).not.toHaveBeenCalled();
-
-    act(() => {
-      vi.runAllTimers();
-    });
-
-    expect(setTypingActiveMock).toHaveBeenCalledWith(false);
-    expect(setTypingActiveMock).toHaveBeenCalledTimes(1);
-  });
-
-  it("should call addAssistantMessageToChat with the typeThis argument when typing completes without loop", () => {
-    const addAssistantMessageToChatMock = vi.fn();
-
-    renderHook(() =>
-      useTypingEffect(["Hello, world!", "This is a test message."], {
-        loop: false,
-        // Note that only "Hello, world!" is typed out (the first string in the array)
-        typeThis: { content: "Hello, world!", sender: "assistant" },
-        addAssistantMessageToChat: addAssistantMessageToChatMock,
-      }),
-    );
-
-    expect(addAssistantMessageToChatMock).not.toHaveBeenCalled();
-
-    act(() => {
-      vi.runAllTimers();
-    });
-
-    expect(addAssistantMessageToChatMock).toHaveBeenCalledTimes(1);
-    expect(addAssistantMessageToChatMock).toHaveBeenCalledWith({
-      content: "Hello, world!",
-      sender: "assistant",
-    });
-  });
-
-  it("should call takeOneAndType when typing completes without loop", () => {
-    const takeOneAndTypeMock = vi.fn();
-
-    renderHook(() =>
-      useTypingEffect(["Hello, world!", "This is a test message."], {
-        loop: false,
-        takeOneAndType: takeOneAndTypeMock,
-      }),
-    );
-
-    expect(takeOneAndTypeMock).not.toHaveBeenCalled();
-
-    act(() => {
-      vi.runAllTimers();
-    });
-
-    expect(takeOneAndTypeMock).toHaveBeenCalledTimes(1);
-  });
-
-  // Implementation is not clear on how to handle this case
-  it.todo("should handle typing with loop");
-});

+ 0 - 79
frontend/src/hooks/useTypingEffect.ts

@@ -1,79 +0,0 @@
-import { useEffect, useState } from "react";
-import { Message } from "#/state/chatSlice";
-/**
- * hook to be used for typing chat effect
- */
-export const useTypingEffect = (
-  strings: string[] = [""],
-  {
-    loop = false,
-    playbackRate = 0.1,
-    setTypingActive = () => {},
-    addAssistantMessageToChat = () => {},
-    takeOneAndType = () => {},
-    typeThis = { content: "", sender: "assistant" },
-  }: {
-    loop?: boolean;
-    playbackRate?: number;
-    setTypingActive?: (bool: boolean) => void;
-    addAssistantMessageToChat?: (msg: Message) => void;
-    takeOneAndType?: () => void;
-    typeThis?: Message;
-  } = {
-    loop: false,
-    playbackRate: 0.1,
-    setTypingActive: () => {},
-    addAssistantMessageToChat: () => {},
-    takeOneAndType: () => {},
-    typeThis: { content: "", sender: "assistant" },
-  },
-) => {
-  // eslint-disable-next-line prefer-const
-  let [{ stringIndex, characterIndex }, setState] = useState<{
-    stringIndex: number;
-    characterIndex: number;
-  }>({
-    stringIndex: 0,
-    characterIndex: 0,
-  });
-
-  let timeoutId: number;
-  const emulateKeyStroke = () => {
-    // eslint-disable-next-line no-plusplus
-    characterIndex++;
-    if (characterIndex === strings[stringIndex].length) {
-      characterIndex = 0;
-      // eslint-disable-next-line no-plusplus
-      stringIndex++;
-      if (stringIndex === strings.length) {
-        if (!loop) {
-          setTypingActive(false);
-          addAssistantMessageToChat(typeThis);
-          takeOneAndType();
-          return;
-        }
-        stringIndex = 0;
-      }
-      timeoutId = window.setTimeout(emulateKeyStroke, 100 * playbackRate);
-    } else if (characterIndex === strings[stringIndex].length - 1) {
-      timeoutId = window.setTimeout(emulateKeyStroke, 2000 * playbackRate);
-    } else {
-      timeoutId = window.setTimeout(emulateKeyStroke, 100 * playbackRate);
-    }
-    setState({
-      characterIndex,
-      stringIndex,
-    });
-  };
-
-  useEffect(() => {
-    emulateKeyStroke();
-    return () => {
-      window.clearTimeout(timeoutId);
-    };
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
-
-  const nonBreakingSpace = "\u00A0";
-  return strings[stringIndex].slice(0, characterIndex + 1) || nonBreakingSpace;
-};

+ 6 - 6
frontend/src/services/actions.ts

@@ -1,6 +1,6 @@
 import { changeTaskState } from "#/state/agentSlice";
 import { setScreenshotSrc, setUrl } from "#/state/browserSlice";
-import { appendAssistantMessage } from "#/state/chatSlice";
+import { addAssistantMessage } from "#/state/chatSlice";
 import { setCode, updatePath } from "#/state/codeSlice";
 import { appendInput } from "#/state/commandSlice";
 import { appendJupyterInput } from "#/state/jupyterSlice";
@@ -28,23 +28,23 @@ const messageActions = {
     store.dispatch(setCode(content));
   },
   [ActionType.THINK]: (message: ActionMessage) => {
-    store.dispatch(appendAssistantMessage(message.args.thought));
+    store.dispatch(addAssistantMessage(message.args.thought));
   },
   [ActionType.TALK]: (message: ActionMessage) => {
-    store.dispatch(appendAssistantMessage(message.args.content));
+    store.dispatch(addAssistantMessage(message.args.content));
   },
   [ActionType.FINISH]: (message: ActionMessage) => {
-    store.dispatch(appendAssistantMessage(message.message));
+    store.dispatch(addAssistantMessage(message.message));
   },
   [ActionType.RUN]: (message: ActionMessage) => {
     if (message.args.thought) {
-      store.dispatch(appendAssistantMessage(message.args.thought));
+      store.dispatch(addAssistantMessage(message.args.thought));
     }
     store.dispatch(appendInput(message.args.command));
   },
   [ActionType.RUN_IPYTHON]: (message: ActionMessage) => {
     if (message.args.thought) {
-      store.dispatch(appendAssistantMessage(message.args.thought));
+      store.dispatch(addAssistantMessage(message.args.thought));
     }
     store.dispatch(appendJupyterInput(message.args.code));
   },

+ 3 - 19
frontend/src/services/chatService.ts

@@ -1,18 +1,12 @@
-import {
-  Message,
-  appendToNewChatSequence,
-  appendUserMessage,
-  takeOneTypeIt,
-  toggleTypingActive,
-} from "#/state/chatSlice";
 import store from "#/store";
 import ActionType from "#/types/ActionType";
 import { SocketMessage } from "#/types/ResponseType";
 import { ActionMessage } from "#/types/Message";
 import Socket from "./socket";
+import { addUserMessage } from "#/state/chatSlice";
 
 export function sendChatMessage(message: string, isTask: boolean = true): void {
-  store.dispatch(appendUserMessage(message));
+  store.dispatch(addUserMessage(message));
   let event;
   if (isTask) {
     event = { action: ActionType.START, args: { task: message } };
@@ -32,19 +26,9 @@ export function addChatMessageFromEvent(event: string | SocketMessage): void {
       data = event as ActionMessage;
     }
     if (data && data.args && data.args.task) {
-      store.dispatch(appendUserMessage(data.args.task));
+      store.dispatch(addUserMessage(data.args.task));
     }
   } catch (error) {
     //
   }
 }
-
-export function setTypingActive(bool: boolean): void {
-  store.dispatch(toggleTypingActive(bool));
-}
-export function addAssistantMessageToChat(msg: Message): void {
-  store.dispatch(appendToNewChatSequence(msg));
-}
-export function takeOneAndType(): void {
-  store.dispatch(takeOneTypeIt());
-}

+ 2 - 2
frontend/src/services/observations.ts

@@ -1,10 +1,10 @@
-import { appendAssistantMessage } from "#/state/chatSlice";
 import { setUrl, setScreenshotSrc } from "#/state/browserSlice";
 import store from "#/store";
 import { ObservationMessage } from "#/types/Message";
 import { appendOutput } from "#/state/commandSlice";
 import { appendJupyterOutput } from "#/state/jupyterSlice";
 import ObservationType from "#/types/ObservationType";
+import { addAssistantMessage } from "#/state/chatSlice";
 
 export function handleObservationMessage(message: ObservationMessage) {
   switch (message.observation) {
@@ -24,7 +24,7 @@ export function handleObservationMessage(message: ObservationMessage) {
       }
       break;
     default:
-      store.dispatch(appendAssistantMessage(message.message));
+      store.dispatch(addAssistantMessage(message.message));
       break;
   }
 }

+ 28 - 69
frontend/src/state/chatSlice.ts

@@ -1,86 +1,45 @@
-import { createSlice } from "@reduxjs/toolkit";
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 
-export type Message = {
-  content: string;
-  sender: "user" | "assistant";
+type SliceState = { messages: Message[] };
+
+const initialState: SliceState = {
+  messages: [
+    {
+      content:
+        "Hi! I'm OpenDevin, an AI Software Engineer. What would you like to build with me today?",
+      sender: "assistant",
+    },
+  ],
 };
 
-const initialMessages: Message[] = [
-  {
-    content:
-      "Hi! I'm OpenDevin, an AI Software Engineer. What would you like to build with me today?",
-    sender: "assistant",
-  },
-];
 export const chatSlice = createSlice({
   name: "chat",
-  initialState: {
-    messages: initialMessages,
-    typingActive: false,
-    userMessages: initialMessages,
-    assistantMessages: initialMessages,
-    assistantMessagesTypingQueue: [] as Message[],
-    newChatSequence: initialMessages,
-    typeThis: { content: "", sender: "assistant" } as Message,
-  },
+  initialState,
   reducers: {
-    appendUserMessage: (state, action) => {
-      state.messages.push({ content: action.payload, sender: "user" });
-      state.userMessages.push({ content: action.payload, sender: "user" });
-      state.newChatSequence.push({ content: action.payload, sender: "user" });
-    },
-    appendAssistantMessage: (state, action) => {
-      state.messages.push({ content: action.payload, sender: "assistant" });
+    addUserMessage(state, action: PayloadAction<string>) {
+      const message: Message = {
+        sender: "user",
+        content: action.payload,
+      };
 
-      if (state.assistantMessagesTypingQueue.length > 0 || state.typingActive) {
-        state.assistantMessagesTypingQueue.push({
-          content: action.payload,
-          sender: "assistant",
-        });
-      } else if (
-        state.assistantMessagesTypingQueue.length === 0 &&
-        !state.typingActive
-      ) {
-        state.typeThis = {
-          content: action.payload,
-          sender: "assistant",
-        };
-        state.typingActive = true;
-      }
+      state.messages.push(message);
     },
 
-    toggleTypingActive: (state, action) => {
-      state.typingActive = action.payload;
-    },
+    addAssistantMessage(state, action: PayloadAction<string>) {
+      const message: Message = {
+        sender: "assistant",
+        content: action.payload,
+      };
 
-    appendToNewChatSequence: (state, action) => {
-      state.newChatSequence.push(action.payload);
+      state.messages.push(message);
     },
 
-    takeOneTypeIt: (state) => {
-      if (state.assistantMessagesTypingQueue.length > 0) {
-        state.typeThis = state.assistantMessagesTypingQueue.shift() as Message;
-      }
-    },
-    clearMessages: (state) => {
-      state.messages = initialMessages;
-      state.userMessages = initialMessages;
-      state.assistantMessages = initialMessages;
-      state.newChatSequence = initialMessages;
-      state.assistantMessagesTypingQueue = [];
-      state.typingActive = false;
-      state.typeThis = { content: "", sender: "assistant" };
+    clearMessages(state) {
+      state.messages = [];
     },
   },
 });
 
-export const {
-  appendUserMessage,
-  appendAssistantMessage,
-  toggleTypingActive,
-  appendToNewChatSequence,
-  takeOneTypeIt,
-  clearMessages,
-} = chatSlice.actions;
-
+export const { addUserMessage, addAssistantMessage, clearMessages } =
+  chatSlice.actions;
 export default chatSlice.reducer;