diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 339268e92d1..bcd035f8e2d 100644
--- a/frontend/src/App.tsx
+++ b/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";
diff --git a/frontend/src/components/AgentControlBar.tsx b/frontend/src/components/AgentControlBar.tsx
index d73850803dd..331e14c4d9d 100644
--- a/frontend/src/components/AgentControlBar.tsx
+++ b/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,
diff --git a/frontend/src/components/ChatInterface.tsx b/frontend/src/components/ChatInterface.tsx
deleted file mode 100644
index ee08c01d2fb..00000000000
--- a/frontend/src/components/ChatInterface.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-function ChatBubble({ msg }: IChatBubbleProps): JSX.Element {
- return (
-
- );
-}
-
-function MessageList(): JSX.Element {
- const messagesEndRef = useRef(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 (
-
-
- {newChatSequence.map((msg, index) => (
-
- ))}
- {typingActive &&
}
-
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
-export default ChatInterface;
diff --git a/frontend/src/components/chat/Chat.test.tsx b/frontend/src/components/chat/Chat.test.tsx
new file mode 100644
index 00000000000..dcf4bf412eb
--- /dev/null
+++ b/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();
+
+ const messages = screen.getAllByTestId("message");
+
+ expect(messages).toHaveLength(MESSAGES.length);
+ });
+
+ it("should scroll to the newest message", () => {
+ const { rerender } = render();
+
+ const newMessages: Message[] = [
+ ...MESSAGES,
+ { sender: "user", content: "Create a spaceship" },
+ ];
+
+ act(() => {
+ rerender();
+ });
+
+ expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx
new file mode 100644
index 00000000000..426fa95a503
--- /dev/null
+++ b/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(null);
+
+ React.useEffect(() => {
+ endOfMessagesRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
+
+ return (
+
+ {messages.map((message, index) => (
+
+ ))}
+
+
+ );
+}
+
+export default Chat;
diff --git a/frontend/src/components/ChatInput.test.tsx b/frontend/src/components/chat/ChatInput.test.tsx
similarity index 100%
rename from frontend/src/components/ChatInput.test.tsx
rename to frontend/src/components/chat/ChatInput.test.tsx
diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx
similarity index 98%
rename from frontend/src/components/ChatInput.tsx
rename to frontend/src/components/chat/ChatInput.tsx
index b0e8ee6bfe5..568e43ef5ee 100644
--- a/frontend/src/components/ChatInput.tsx
+++ b/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",
diff --git a/frontend/src/components/chat/ChatInterface.test.tsx b/frontend/src/components/chat/ChatInterface.test.tsx
new file mode 100644
index 00000000000..37290af5e37
--- /dev/null
+++ b/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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ preloadedState: {
+ task: {
+ initialized: false,
+ completed: false,
+ },
+ },
+ });
+
+ const submitButton = screen.getByLabelText(/send message/i);
+
+ expect(submitButton).toBeDisabled();
+ });
+});
diff --git a/frontend/src/components/chat/ChatInterface.tsx b/frontend/src/components/chat/ChatInterface.tsx
new file mode 100644
index 00000000000..36be99b1bbc
--- /dev/null
+++ b/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 (
+
+
+
+ Chat
+
+
+
+
+
+ {/* Fade between messages and input */}
+
+
+
+
+ );
+}
+
+export default ChatInterface;
diff --git a/frontend/src/components/chat/ChatMessage.test.tsx b/frontend/src/components/chat/ChatMessage.test.tsx
new file mode 100644
index 00000000000..e385386cc9d
--- /dev/null
+++ b/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();
+
+ 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();
+
+ 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(
+ ,
+ );
+
+ // SyntaxHighlighter breaks the code blocks into "tokens"
+ expect(screen.getByText("console")).toBeInTheDocument();
+ expect(screen.getByText("log")).toBeInTheDocument();
+ expect(screen.getByText("'Hello'")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx
new file mode 100644
index 00000000000..5490fb230e0
--- /dev/null
+++ b/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 (
+
+ {text}
+
+ );
+}
+
+export default ChatMessage;
diff --git a/frontend/src/components/chat/message.d.ts b/frontend/src/components/chat/message.d.ts
new file mode 100644
index 00000000000..6a3aa491678
--- /dev/null
+++ b/frontend/src/components/chat/message.d.ts
@@ -0,0 +1,4 @@
+type Message = {
+ sender: "user" | "assistant";
+ content: string;
+};
diff --git a/frontend/src/hooks/useTyping.test.ts b/frontend/src/hooks/useTyping.test.ts
new file mode 100644
index 00000000000..dfe0719fb5a
--- /dev/null
+++ b/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!");
+ });
+});
diff --git a/frontend/src/hooks/useTyping.ts b/frontend/src/hooks/useTyping.ts
new file mode 100644
index 00000000000..fc4e2f7191c
--- /dev/null
+++ b/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;
+};
diff --git a/frontend/src/hooks/useTypingEffect.test.ts b/frontend/src/hooks/useTypingEffect.test.ts
deleted file mode 100644
index 18942d0b0a4..00000000000
--- a/frontend/src/hooks/useTypingEffect.test.ts
+++ /dev/null
@@ -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");
-});
diff --git a/frontend/src/hooks/useTypingEffect.ts b/frontend/src/hooks/useTypingEffect.ts
deleted file mode 100644
index af80eb098a0..00000000000
--- a/frontend/src/hooks/useTypingEffect.ts
+++ /dev/null
@@ -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;
-};
diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts
index 121ff0a678c..d4e8165c812 100644
--- a/frontend/src/services/actions.ts
+++ b/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));
},
diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts
index 8c01b0079f0..2ac75cf1bb4 100644
--- a/frontend/src/services/chatService.ts
+++ b/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());
-}
diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts
index 956fc906697..2225693e2d5 100644
--- a/frontend/src/services/observations.ts
+++ b/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;
}
}
diff --git a/frontend/src/state/chatSlice.ts b/frontend/src/state/chatSlice.ts
index 292d606b7fe..1438103f912 100644
--- a/frontend/src/state/chatSlice.ts
+++ b/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) {
+ 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) {
+ 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;