From 56dc49966d663b6d17c71e7e531e808bac415ca2 Mon Sep 17 00:00:00 2001
From: Sean Hatfield <seanhatfield5@gmail.com>
Date: Wed, 17 Jan 2024 16:22:06 -0800
Subject: [PATCH] add copy feature to assistant chat message (#611)

* add copy feature to assistant chat message

* fix tooltip not hiding on mobile

* fix: add tooltips
chore: breakout actions to extendable component + memoize
add CopyText to hook we can reuse
fix: Copy on code snippets broken, moved to event listener
fix: highlightjs patch for new API support
feat: add copy response support

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 frontend/package.json                         |  1 +
 .../HistoricalMessage/Actions/index.jsx       | 43 ++++++++++++++++
 .../ChatHistory/HistoricalMessage/index.jsx   | 11 ++++-
 .../ChatContainer/ChatHistory/index.jsx       |  8 +--
 .../src/components/WorkspaceChat/index.jsx    | 36 ++++++++++++++
 frontend/src/hooks/useCopyText.js             | 15 ++++++
 frontend/src/index.css                        |  4 ++
 frontend/src/utils/chat/markdown.js           | 49 +++++++++----------
 frontend/yarn.lock                            | 33 +++++++++++++
 9 files changed, 169 insertions(+), 31 deletions(-)
 create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx
 create mode 100644 frontend/src/hooks/useCopyText.js

diff --git a/frontend/package.json b/frontend/package.json
index 86e552ab7..17d9af913 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -31,6 +31,7 @@
     "react-router-dom": "^6.3.0",
     "react-tag-input-component": "^2.0.2",
     "react-toastify": "^9.1.3",
+    "react-tooltip": "^5.25.2",
     "text-case": "^1.0.9",
     "truncate": "^3.0.0",
     "uuid": "^9.0.0"
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx
new file mode 100644
index 000000000..12fa7dc73
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx
@@ -0,0 +1,43 @@
+import useCopyText from "@/hooks/useCopyText";
+import { Check, ClipboardText } from "@phosphor-icons/react";
+import { memo } from "react";
+import { Tooltip } from "react-tooltip";
+
+const Actions = ({ message }) => {
+  return (
+    <div className="flex justify-start items-center gap-x-4">
+      <CopyMessage message={message} />
+      {/* Other actions to go here later. */}
+    </div>
+  );
+};
+
+function CopyMessage({ message }) {
+  const { copied, copyText } = useCopyText();
+  return (
+    <>
+      <div className="mt-3 relative">
+        <button
+          data-tooltip-id="copy-assistant-text"
+          data-tooltip-content="Copy"
+          className="text-zinc-300"
+          onClick={() => copyText(message)}
+        >
+          {copied ? (
+            <Check size={18} className="mb-1" />
+          ) : (
+            <ClipboardText size={18} className="mb-1" />
+          )}
+        </button>
+      </div>
+      <Tooltip
+        id="copy-assistant-text"
+        place="bottom"
+        delayShow={300}
+        className="tooltip !text-xs"
+      />
+    </>
+  );
+}
+
+export default memo(Actions);
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
index 4637b1cd7..c39220f37 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx
@@ -1,14 +1,15 @@
-import { memo, forwardRef } from "react";
+import React, { memo, forwardRef } from "react";
 import { Warning } from "@phosphor-icons/react";
 import Jazzicon from "../../../../UserIcon";
+import Actions from "./Actions";
 import renderMarkdown from "@/utils/chat/markdown";
 import { userFromStorage } from "@/utils/request";
 import Citations from "../Citation";
 import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants";
 import { v4 } from "uuid";
 import createDOMPurify from "dompurify";
-const DOMPurify = createDOMPurify(window);
 
+const DOMPurify = createDOMPurify(window);
 const HistoricalMessage = forwardRef(
   (
     { uuid = v4(), message, role, workspace, sources = [], error = false },
@@ -53,6 +54,12 @@ const HistoricalMessage = forwardRef(
               />
             )}
           </div>
+          {role === "assistant" && (
+            <div className="flex gap-x-5">
+              <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" />
+              <Actions message={DOMPurify.sanitize(message)} />
+            </div>
+          )}
           {role === "assistant" && <Citations sources={sources} />}
         </div>
       </div>
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
index 4a7cd4827..358e520a1 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
@@ -17,9 +17,12 @@ export default function ChatHistory({ history = [], workspace }) {
   }, [history]);
 
   const handleScroll = () => {
-    const isBottom =
-      chatHistoryRef.current.scrollHeight - chatHistoryRef.current.scrollTop ===
+    const diff =
+      chatHistoryRef.current.scrollHeight -
+      chatHistoryRef.current.scrollTop -
       chatHistoryRef.current.clientHeight;
+    // Fuzzy margin for what qualifies as "bottom". Stronger than straight comparison since that may change over time.
+    const isBottom = diff <= 10;
     setIsAtBottom(isBottom);
   };
 
@@ -112,7 +115,6 @@ export default function ChatHistory({ history = [], workspace }) {
           />
         );
       })}
-
       {showing && (
         <ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} />
       )}
diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx
index 3e129c2a6..30bd494f3 100644
--- a/frontend/src/components/WorkspaceChat/index.jsx
+++ b/frontend/src/components/WorkspaceChat/index.jsx
@@ -59,5 +59,41 @@ export default function WorkspaceChat({ loading, workspace }) {
     );
   }
 
+  setEventDelegatorForCodeSnippets();
   return <ChatContainer workspace={workspace} knownHistory={history} />;
 }
+
+// Enables us to safely markdown and sanitize all responses without risk of injection
+// but still be able to attach a handler to copy code snippets on all elements
+// that are code snippets.
+function copyCodeSnippet(uuid) {
+  const target = document.querySelector(`[data-code="${uuid}"]`);
+  if (!target) return false;
+  const markdown =
+    target.parentElement?.parentElement?.querySelector(
+      "pre:first-of-type"
+    )?.innerText;
+  if (!markdown) return false;
+
+  window.navigator.clipboard.writeText(markdown);
+  target.classList.add("text-green-500");
+  const originalText = target.innerHTML;
+  target.innerText = "Copied!";
+  target.setAttribute("disabled", true);
+
+  setTimeout(() => {
+    target.classList.remove("text-green-500");
+    target.innerHTML = originalText;
+    target.removeAttribute("disabled");
+  }, 2500);
+}
+
+// Listens and hunts for all data-code-snippet clicks.
+function setEventDelegatorForCodeSnippets() {
+  document?.addEventListener("click", function (e) {
+    const target = e.target.closest("[data-code-snippet]");
+    const uuidCode = target?.dataset?.code;
+    if (!uuidCode) return false;
+    copyCodeSnippet(uuidCode);
+  });
+}
diff --git a/frontend/src/hooks/useCopyText.js b/frontend/src/hooks/useCopyText.js
new file mode 100644
index 000000000..04519b2ef
--- /dev/null
+++ b/frontend/src/hooks/useCopyText.js
@@ -0,0 +1,15 @@
+import { useState } from "react";
+
+export default function useCopyText(delay = 2500) {
+  const [copied, setCopied] = useState(false);
+  const copyText = async (content) => {
+    if (!content) return;
+    navigator?.clipboard?.writeText(content);
+    setCopied(content);
+    setTimeout(() => {
+      setCopied(false);
+    }, delay);
+  };
+
+  return { copyText, copied };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1d1b2da85..e8d7e2d8c 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -399,3 +399,7 @@ dialog::backdrop {
 .rti--container {
   @apply !bg-zinc-900 !text-white !placeholder-white !placeholder-opacity-60 !text-sm !rounded-lg !p-2.5;
 }
+
+.tooltip {
+  @apply !bg-black !text-white !py-2 !px-3 !rounded-md;
+}
diff --git a/frontend/src/utils/chat/markdown.js b/frontend/src/utils/chat/markdown.js
index 53b6804fe..ff4af77bc 100644
--- a/frontend/src/utils/chat/markdown.js
+++ b/frontend/src/utils/chat/markdown.js
@@ -7,47 +7,44 @@ import { v4 } from "uuid";
 const markdown = markdownIt({
   html: true,
   typographer: true,
-  highlight: function (str, lang) {
+  highlight: function (code, lang) {
     const uuid = v4();
     if (lang && hljs.getLanguage(lang)) {
       try {
         return (
-          `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pt-10 pb-4 relative font-mono font-normal text-sm text-slate-200"><div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"><button id="code-${uuid}" onclick='window.copySnippet("${uuid}");' class="flex ml-auto gap-2"><svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>Copy code</button></div><pre class="whitespace-pre-wrap">` +
-          hljs.highlight(lang, str, true).value +
+          `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pb-4 relative font-mono font-normal text-sm text-slate-200">
+            <div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md">
+              <div class="flex gap-2">
+                <code class="text-xs">${lang || ""}</code>
+              </div>
+              <button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
+                <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
+                <p>Copy code</p>
+              </button>
+            </div>
+          <pre class="whitespace-pre-wrap">` +
+          hljs.highlight(code, { language: lang, ignoreIllegals: true }).value +
           "</pre></div>"
         );
       } catch (__) {}
     }
 
     return (
-      `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pt-10 pb-4 relative font-mono font-normal text-sm text-slate-200"><div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"><button id="code-${uuid}" onclick='window.copySnippet("${uuid}");' class="flex ml-auto gap-2"><svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>Copy code</button></div><pre class="whitespace-pre-wrap">` +
-      HTMLEncode(str) +
+      `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pb-4 relative font-mono font-normal text-sm text-slate-200">
+        <div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md">
+          <div class="flex gap-2"><code class="text-xs"></code></div>
+          <button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2">
+            <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
+            <p>Copy code</p>
+          </button>
+        </div>
+      <pre class="whitespace-pre-wrap">` +
+      HTMLEncode(code) +
       "</pre></div>"
     );
   },
 });
 
-window.copySnippet = function (uuid = "") {
-  const target = document.getElementById(`code-${uuid}`);
-  const markdown =
-    target.parentElement?.parentElement?.querySelector(
-      "pre:first-of-type"
-    )?.innerText;
-  if (!markdown) return false;
-
-  window.navigator.clipboard.writeText(markdown);
-  target.classList.add("text-green-500");
-  const originalText = target.innerHTML;
-  target.innerText = "Copied!";
-  target.setAttribute("disabled", true);
-
-  setTimeout(() => {
-    target.classList.remove("text-green-500");
-    target.innerHTML = originalText;
-    target.removeAttribute("disabled");
-  }, 5000);
-};
-
 export default function renderMarkdown(text = "") {
   return markdown.render(text);
 }
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index c9181f15f..fa1e71331 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -365,6 +365,26 @@
   resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d"
   integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==
 
+"@floating-ui/core@^1.5.3":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.3.tgz#b6aa0827708d70971c8679a16cf680a515b8a52a"
+  integrity sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==
+  dependencies:
+    "@floating-ui/utils" "^0.2.0"
+
+"@floating-ui/dom@^1.0.0":
+  version "1.5.4"
+  resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.4.tgz#28df1e1cb373884224a463235c218dcbd81a16bb"
+  integrity sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==
+  dependencies:
+    "@floating-ui/core" "^1.5.3"
+    "@floating-ui/utils" "^0.2.0"
+
+"@floating-ui/utils@^0.2.0":
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
+  integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+
 "@humanwhocodes/config-array@^0.11.13":
   version "0.11.13"
   resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
@@ -846,6 +866,11 @@ chokidar@^3.5.3:
   optionalDependencies:
     fsevents "~2.3.2"
 
+classnames@^2.3.0:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
+  integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
+
 cliui@^8.0.1:
   version "8.0.1"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
@@ -2543,6 +2568,14 @@ react-toastify@^9.1.3:
   dependencies:
     clsx "^1.1.1"
 
+react-tooltip@^5.25.2:
+  version "5.25.2"
+  resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.25.2.tgz#efb51845ec2e863045812ad1dc1927573922d629"
+  integrity sha512-MwZ3S9xcHpojZaKqjr5mTs0yp/YBPpKFcayY7MaaIIBr2QskkeeyelpY2YdGLxIMyEj4sxl0rGoK6dQIKvNLlw==
+  dependencies:
+    "@floating-ui/dom" "^1.0.0"
+    classnames "^2.3.0"
+
 react@^18.2.0:
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"