this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add copy button to chat messages (#6)

### TL;DR

Adds a copy-to-clipboard button that appears on hover/focus for each chat message.

### What changed?

- Added a `CopyButton` component that writes text to the clipboard using the Clipboard API. After copying, it briefly shows a checkmark icon for 1.5 seconds before reverting to the copy icon.
- Added a `ChatMessageActions` component that renders the `CopyButton` and is hidden by default, becoming visible when the message is hovered or focused.
- Updated `ChatMessage` to wrap the message bubble in a `Stack` with a `group` class, enabling the hover/focus visibility behavior for `ChatMessageActions`. Actions are only rendered when the message has content and is not in the typing state.

### How to test?

1. Open a chat and send or receive a message.
2. Hover over a message — a copy button should appear below it.
3. Click the copy button and verify the message text is copied to the clipboard.
4. Confirm the icon switches to a checkmark briefly before reverting back to the copy icon.
5. Verify the button does not appear while the assistant typing indicator is active.

### Why make this change?

Users need a convenient way to copy message content without manually selecting text, improving the overall usability of the chat interface.

authored by

James Blair and committed by
GitHub
0a4ab65b f068cf43

+78 -7
+17
apps/web/src/components/chat-message-actions.tsx
··· 1 + import { CopyButton } from "./copy-button"; 2 + import { Row } from "./layout"; 3 + 4 + interface ChatMessageActionsProps { 5 + text: string; 6 + } 7 + 8 + export function ChatMessageActions({ text }: ChatMessageActionsProps) { 9 + return ( 10 + <Row 11 + gap="xs" 12 + className="opacity-0 transition-opacity duration-150 group-focus-within:opacity-100 group-hover:opacity-100" 13 + > 14 + <CopyButton text={text} /> 15 + </Row> 16 + ); 17 + }
+19 -7
apps/web/src/components/chat-message.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 2 import Markdown from "react-markdown"; 3 3 import { cn } from "#/lib/utils"; 4 + import { ChatMessageActions } from "./chat-message-actions"; 5 + import { Stack } from "./layout"; 4 6 import { TypingIndicator } from "./typing-indicator"; 5 7 6 8 const chatMessage = cva( ··· 27 29 const isTyping = author === "assistant" && text.length === 0; 28 30 29 31 return ( 30 - <div 32 + <Stack 33 + gap="xs" 31 34 className={cn( 32 - chatMessage({ author }), 33 - !isTyping && "markdown", 34 - author === "assistant" ? "self-start" : "self-end", 35 - className, 35 + "group w-full", 36 + author === "assistant" 37 + ? "items-start self-start" 38 + : "items-end self-end", 36 39 )} 37 40 > 38 - {isTyping ? <TypingIndicator /> : <Markdown>{text}</Markdown>} 39 - </div> 41 + <div 42 + className={cn( 43 + chatMessage({ author }), 44 + !isTyping && "markdown", 45 + className, 46 + )} 47 + > 48 + {isTyping ? <TypingIndicator /> : <Markdown>{text}</Markdown>} 49 + </div> 50 + {!isTyping && text && <ChatMessageActions text={text} />} 51 + </Stack> 40 52 ); 41 53 }
+42
apps/web/src/components/copy-button.tsx
··· 1 + import { CheckIcon, CopyIcon } from "lucide-react"; 2 + import { useEffect, useState } from "react"; 3 + import { Button } from "./ui/button"; 4 + 5 + interface CopyButtonProps { 6 + text: string; 7 + } 8 + 9 + export function CopyButton({ text }: CopyButtonProps) { 10 + const [isCopied, setIsCopied] = useState(false); 11 + 12 + useEffect(() => { 13 + if (!isCopied) return; 14 + 15 + const timeoutId = window.setTimeout(() => { 16 + setIsCopied(false); 17 + }, 1500); 18 + 19 + return () => window.clearTimeout(timeoutId); 20 + }, [isCopied]); 21 + 22 + async function handleCopy() { 23 + if (!text) return; 24 + 25 + await navigator.clipboard.writeText(text); 26 + setIsCopied(true); 27 + } 28 + 29 + return ( 30 + <Button 31 + type="button" 32 + size="icon" 33 + variant="ghost" 34 + onClick={handleCopy} 35 + className="text-muted-foreground hover:text-foreground" 36 + aria-label={isCopied ? "Copied message" : "Copy message"} 37 + title={isCopied ? "Copied" : "Copy message"} 38 + > 39 + {isCopied ? <CheckIcon /> : <CopyIcon />} 40 + </Button> 41 + ); 42 + }