Ver Fonte

feat: chat interface autoscroll (#1761)

Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Shimada666 há 1 ano atrás
pai
commit
e4460a974d

+ 2 - 17
frontend/src/components/chat/Chat.test.tsx

@@ -1,5 +1,5 @@
 import React from "react";
-import { act, render, screen } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
 import { describe, expect, it } from "vitest";
 import Chat from "./Chat";
 
@@ -9,7 +9,7 @@ const MESSAGES: Message[] = [
   { sender: "assistant", content: "How can I help you today?" },
 ];
 
-HTMLElement.prototype.scrollIntoView = vi.fn();
+HTMLElement.prototype.scrollTo = vi.fn(() => {});
 
 describe("Chat", () => {
   it("should render chat messages", () => {
@@ -19,19 +19,4 @@ describe("Chat", () => {
 
     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();
-  });
 });

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

@@ -6,18 +6,11 @@ interface ChatProps {
 }
 
 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>
   );
 }

+ 1 - 1
frontend/src/components/chat/ChatInterface.test.tsx

@@ -19,7 +19,7 @@ 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();
+HTMLElement.prototype.scrollTo = vi.fn(() => {});
 
 describe("ChatInterface", () => {
   it("should render the messages and input", () => {

+ 55 - 19
frontend/src/components/chat/ChatInterface.tsx

@@ -1,9 +1,10 @@
-import React from "react";
+import React, { useRef } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { IoMdChatbubbles } from "react-icons/io";
 import { RiArrowRightDoubleLine } from "react-icons/ri";
 import { useTranslation } from "react-i18next";
 import { twMerge } from "tailwind-merge";
+import { VscArrowDown } from "react-icons/vsc";
 import ChatInput from "./ChatInput";
 import Chat from "./Chat";
 import { RootState } from "#/store";
@@ -11,6 +12,31 @@ import AgentState from "#/types/AgentState";
 import { sendChatMessage } from "#/services/chatService";
 import { addUserMessage } from "#/state/chatSlice";
 import { I18nKey } from "#/i18n/declaration";
+import { useScrollToBottom } from "#/hooks/useScrollToBottom";
+
+interface ScrollButtonProps {
+  onClick: () => void;
+  icon: JSX.Element;
+  label: string;
+}
+
+function ScrollButton({
+  onClick,
+  icon,
+  label,
+}: ScrollButtonProps): JSX.Element {
+  return (
+    <button
+      type="button"
+      className="relative border-1 text-xs rounded px-2 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
+      onClick={onClick}
+    >
+      <div className="flex items-center">
+        {icon} <span className="inline-block">{label}</span>
+      </div>
+    </button>
+  );
+}
 
 function ChatInterface() {
   const dispatch = useDispatch();
@@ -27,6 +53,11 @@ function ChatInterface() {
     handleSendMessage(t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE));
   };
 
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
+    useScrollToBottom(scrollRef);
+
   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">
@@ -34,7 +65,11 @@ function ChatInterface() {
         Chat
       </div>
       <div className="flex-1 flex flex-col relative min-h-0">
-        <div className="overflow-x-auto p-3">
+        <div
+          ref={scrollRef}
+          className="overflow-y-auto p-3"
+          onScroll={(e) => onChatBodyScroll(e.currentTarget)}
+        >
           <Chat messages={messages} />
         </div>
         {/* Fade between messages and input */}
@@ -46,24 +81,25 @@ function ChatInterface() {
           )}
         />
       </div>
-      {curAgentState === AgentState.AWAITING_USER_INPUT && (
-        <div className="relative">
-          <div className="absolute bottom-2 left-0 right-0 flex items-center justify-center">
-            <button
-              type="button"
-              className="relative border-1 text-xs rounded px-2 py-1 border-neutral-600 bg-neutral-700 cursor-pointer select-none"
-              onClick={handleSendContinueMsg}
-            >
-              <div className="flex items-center">
-                <RiArrowRightDoubleLine className="inline mr-2 w-3 h-3" />
-                <span className="inline-block">
-                  {t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE)}
-                </span>
-              </div>
-            </button>
-          </div>
+
+      <div className="relative">
+        <div className="absolute bottom-2 left-0 right-0 flex items-center justify-center">
+          {!hitBottom &&
+            ScrollButton({
+              onClick: scrollDomToBottom,
+              icon: <VscArrowDown className="inline mr-2 w-3 h-3" />,
+              label: t(I18nKey.CHAT_INTERFACE$TO_BOTTOM),
+            })}
+          {curAgentState === AgentState.AWAITING_USER_INPUT &&
+            hitBottom &&
+            ScrollButton({
+              onClick: handleSendContinueMsg,
+              icon: <RiArrowRightDoubleLine className="inline mr-2 w-3 h-3" />,
+              label: t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE),
+            })}
         </div>
-      )}
+      </div>
+
       <ChatInput
         disabled={curAgentState === AgentState.LOADING}
         onSendMessage={handleSendMessage}

+ 2 - 3
frontend/src/components/chat/ChatMessage.tsx

@@ -2,14 +2,13 @@ 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 text = useTyping(message.content);
 
   const className = twMerge(
     "p-3 text-white max-w-[90%] overflow-y-auto rounded-lg",
@@ -18,7 +17,7 @@ function ChatMessage({ message }: MessageProps) {
 
   return (
     <div data-testid="message" className={className}>
-      <Markdown components={{ code }}>{text}</Markdown>
+      <Markdown components={{ code }}>{message.content}</Markdown>
     </div>
   );
 }

+ 14 - 0
frontend/src/hooks/useScrollToBottom.ts

@@ -4,6 +4,17 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement>) {
   // for auto-scroll
 
   const [autoScroll, setAutoScroll] = useState(true);
+  const [hitBottom, setHitBottom] = useState(true);
+
+  const onChatBodyScroll = (e: HTMLElement) => {
+    const bottomHeight = e.scrollTop + e.clientHeight;
+
+    const isHitBottom = bottomHeight >= e.scrollHeight - 10;
+
+    setHitBottom(isHitBottom);
+    setAutoScroll(isHitBottom);
+  };
+
   function scrollDomToBottom() {
     const dom = scrollRef.current;
     if (dom) {
@@ -26,5 +37,8 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement>) {
     autoScroll,
     setAutoScroll,
     scrollDomToBottom,
+    hitBottom,
+    setHitBottom,
+    onChatBodyScroll,
   };
 }