KaustubPM commited on
Commit
6bcc076
·
1 Parent(s): 1f330c3

feat: add copy capability to LLM responses

Browse files
frontend/src/components/chat/MessageBubble.tsx CHANGED
@@ -1,9 +1,11 @@
1
  "use client";
2
 
 
3
  import ReactMarkdown from "react-markdown";
4
  import remarkGfm from "remark-gfm";
5
  import type { ChatMsg } from "./ChatPanel";
6
- import { Brain, User } from "lucide-react";
 
7
 
8
  interface Props {
9
  message: ChatMsg;
@@ -11,6 +13,20 @@ interface Props {
11
 
12
  export default function MessageBubble({ message }: Props) {
13
  const isUser = message.role === "user";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  return (
16
  <div
@@ -23,31 +39,53 @@ export default function MessageBubble({ message }: Props) {
23
  )}
24
 
25
  <div
26
- className={`max-w-[80%] rounded-xl px-4 py-3 ${
27
  isUser
28
  ? "bg-primary text-primary-foreground rounded-br-sm"
29
- : "bg-card border border-border/50 rounded-bl-sm"
30
  }`}
31
  >
32
  {isUser ? (
33
  <p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
34
  ) : (
35
- <div className="prose-chat text-sm">
36
- {message.content ? (
37
- <ReactMarkdown remarkPlugins={[remarkGfm]}>
38
- {message.content}
39
- </ReactMarkdown>
40
- ) : message.isStreaming ? (
41
- <div className="flex items-center gap-1.5">
42
- <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0ms]" />
43
- <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:150ms]" />
44
- <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:300ms]" />
45
- </div>
46
- ) : null}
47
- {message.isStreaming && message.content && (
48
- <span className="inline-block w-0.5 h-4 bg-primary/60 animate-pulse ml-0.5 align-text-bottom" />
 
 
 
 
 
 
49
  )}
50
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  )}
52
  </div>
53
 
 
1
  "use client";
2
 
3
+ import { useState, useRef } from "react";
4
  import ReactMarkdown from "react-markdown";
5
  import remarkGfm from "remark-gfm";
6
  import type { ChatMsg } from "./ChatPanel";
7
+ import { Brain, User, Copy, Check } from "lucide-react";
8
+ import { Button } from "@/components/ui/button";
9
 
10
  interface Props {
11
  message: ChatMsg;
 
13
 
14
  export default function MessageBubble({ message }: Props) {
15
  const isUser = message.role === "user";
16
+ const [copied, setCopied] = useState(false);
17
+ const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
18
+
19
+ const handleCopy = async () => {
20
+ if (!message.content) return;
21
+ try {
22
+ await navigator.clipboard.writeText(message.content);
23
+ setCopied(true);
24
+ if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current);
25
+ copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
26
+ } catch {
27
+ setCopied(false);
28
+ }
29
+ };
30
 
31
  return (
32
  <div
 
39
  )}
40
 
41
  <div
42
+ className={`relative max-w-[80%] rounded-xl px-4 py-3 ${
43
  isUser
44
  ? "bg-primary text-primary-foreground rounded-br-sm"
45
+ : "group bg-card border border-border/50 rounded-bl-sm"
46
  }`}
47
  >
48
  {isUser ? (
49
  <p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
50
  ) : (
51
+ <>
52
+ {message.content && (
53
+ <Button
54
+ type="button"
55
+ variant="ghost"
56
+ size="icon-xs"
57
+ className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
58
+ copied
59
+ ? "opacity-100"
60
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
61
+ }`}
62
+ onClick={handleCopy}
63
+ aria-label={copied ? "Copied" : "Copy response"}
64
+ >
65
+ {copied ? (
66
+ <Check className="w-3.5 h-3.5 text-emerald-400" />
67
+ ) : (
68
+ <Copy className="w-3.5 h-3.5" />
69
+ )}
70
+ </Button>
71
  )}
72
+ <div className={`prose-chat text-sm ${message.content ? "pr-7" : ""}`}>
73
+ {message.content ? (
74
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>
75
+ {message.content}
76
+ </ReactMarkdown>
77
+ ) : message.isStreaming ? (
78
+ <div className="flex items-center gap-1.5">
79
+ <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0ms]" />
80
+ <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:150ms]" />
81
+ <span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:300ms]" />
82
+ </div>
83
+ ) : null}
84
+ {message.isStreaming && message.content && (
85
+ <span className="inline-block w-0.5 h-4 bg-primary/60 animate-pulse ml-0.5 align-text-bottom" />
86
+ )}
87
+ </div>
88
+ </>
89
  )}
90
  </div>
91