From fb05929ca5dace4ab4edc3a4bb4dd9a0eace3a32 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 4 Mar 2025 17:20:39 -0500 Subject: [PATCH] feat: copy message content (#1511) Co-authored-by: Nahiyan Khan --- ui/desktop/openapi.json | 2 +- ui/desktop/src/components/GooseMessage.tsx | 23 +++++-- ui/desktop/src/components/MessageCopyLink.tsx | 60 +++++++++++++++++++ ui/desktop/src/components/UserMessage.tsx | 16 ++++- 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 ui/desktop/src/components/MessageCopyLink.tsx diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 97b460f2..ebf4fdb0 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.0.11" + "version": "1.0.12" }, "paths": { "/config": { diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index f41159ad..dfe1b9b6 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; import GooseResponseForm from './GooseResponseForm'; import { extractUrls } from '../utils/urlUtils'; @@ -12,6 +12,7 @@ import { getToolConfirmationContent, } from '../types/message'; import ToolCallConfirmation from './ToolCallConfirmation'; +import MessageCopyLink from './MessageCopyLink'; interface GooseMessageProps { message: Message; @@ -21,6 +22,8 @@ interface GooseMessageProps { } export default function GooseMessage({ message, metadata, messages, append }: GooseMessageProps) { + const contentRef = useRef(null); + // Extract text content from the message let textContent = getTextContent(message); @@ -66,10 +69,20 @@ export default function GooseMessage({ message, metadata, messages, append }: Go
{/* Always show the top content area if there are tool calls, even if textContent is empty */} {(textContent || toolRequests.length > 0) && ( -
0 ? 'rounded-b-none' : ''}`} - > - {textContent ? : null} +
+
0 ? 'rounded-b-none' : ''}`} + > +
+ {textContent ? : null} +
+
+ {/* Only show MessageCopyLink if there's text content and no tool requests/responses */} + {textContent && message.content.every((content) => content.type === 'text') && ( +
+ +
+ )}
)} diff --git a/ui/desktop/src/components/MessageCopyLink.tsx b/ui/desktop/src/components/MessageCopyLink.tsx new file mode 100644 index 00000000..f19994f1 --- /dev/null +++ b/ui/desktop/src/components/MessageCopyLink.tsx @@ -0,0 +1,60 @@ +/* global Blob, ClipboardItem */ + +import React, { useState } from 'react'; +import { Copy } from './icons'; + +interface MessageCopyLinkProps { + text: string; + contentRef: React.RefObject; +} + +export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + if (contentRef?.current) { + // Create a temporary container to handle HTML content + const container = document.createElement('div'); + container.innerHTML = contentRef.current.innerHTML; + + // Clean up any copy buttons from the content + const copyButtons = container.querySelectorAll('button'); + copyButtons.forEach((button) => button.remove()); + + // Create the clipboard data + const clipboardData = new ClipboardItem({ + 'text/plain': new Blob([text], { type: 'text/plain' }), + 'text/html': new Blob([container.innerHTML], { type: 'text/html' }), + }); + + await navigator.clipboard.write([clipboardData]); + } else { + await navigator.clipboard.writeText(text); + } + + setCopied(true); + setTimeout(() => setCopied(false), 2000); // Reset after 2 seconds + } catch (err) { + console.error('Failed to copy text: ', err); + // Fallback to plain text if HTML copy fails + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (fallbackErr) { + console.error('Failed to copy text (fallback): ', fallbackErr); + } + } + }; + + return ( + + ); +} diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 28d49cb0..fd287b8c 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, { useRef } from 'react'; import LinkPreview from './LinkPreview'; import { extractUrls } from '../utils/urlUtils'; import MarkdownContent from './MarkdownContent'; import { Message, getTextContent } from '../types/message'; +import MessageCopyLink from './MessageCopyLink'; interface UserMessageProps { message: Message; } export default function UserMessage({ message }: UserMessageProps) { + const contentRef = useRef(null); + // Extract text content from the message const textContent = getTextContent(message); @@ -18,8 +21,15 @@ export default function UserMessage({ message }: UserMessageProps) { return (
-
- +
+
+
+ +
+
+
+ +
{/* TODO(alexhancock): Re-enable link previews once styled well again */}