diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 6542e370..75a6f69c 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { @@ -9,10 +9,10 @@ import type { } from '../../types/types'; import { formatUsageLimitText } from '../../utils/chatFormatting'; import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; -import { copyTextToClipboard } from '../../../../utils/clipboard'; import type { Project } from '../../../../types/app'; import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { Markdown } from './Markdown'; +import MessageCopyControl from './MessageCopyControl'; type DiffLine = { type: string; @@ -20,7 +20,7 @@ type DiffLine = { lineNum: number; }; -interface MessageComponentProps { +type MessageComponentProps = { message: ChatMessage; prevMessage: ChatMessage | null; createDiff: (oldStr: string, newStr: string) => DiffLine[]; @@ -32,7 +32,7 @@ interface MessageComponentProps { showThinking?: boolean; selectedProject?: Project | null; provider: Provider | string; -} +}; type InteractiveOption = { number: string; @@ -41,6 +41,7 @@ type InteractiveOption = { }; type PermissionGrantState = 'idle' | 'granted' | 'error'; +const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']); const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const { t } = useTranslation('chat'); @@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o (prevMessage.type === 'user') || (prevMessage.type === 'tool') || (prevMessage.type === 'error')); - const messageRef = React.useRef(null); - const [isExpanded, setIsExpanded] = React.useState(false); + const messageRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); const permissionSuggestion = getClaudePermissionSuggestion(message, provider); - const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); - const [messageCopied, setMessageCopied] = React.useState(false); + const [permissionGrantState, setPermissionGrantState] = useState('idle'); + const userCopyContent = String(message.content || ''); + const formattedMessageContent = useMemo( + () => formatUsageLimitText(String(message.content || '')), + [message.content] + ); + const assistantCopyContent = message.isToolUse + ? String(message.displayText || message.content || '') + : formattedMessageContent; + const isCommandOrFileEditToolResponse = Boolean( + message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || '')) + ); + const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0; + const shouldShowAssistantCopyControl = message.type === 'assistant' && + assistantCopyContent.trim().length > 0 && + !isCommandOrFileEditToolResponse; - React.useEffect(() => { + useEffect(() => { setPermissionGrantState('idle'); }, [permissionSuggestion?.entry, message.toolId]); - React.useEffect(() => { + useEffect(() => { const node = messageRef.current; if (!autoExpandTools || !node || !message.isToolUse) return; @@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o )}
- + {shouldShowUserCopyControl && ( + + )} {formattedTime}
@@ -430,7 +411,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o )} {(() => { - const content = formatUsageLimitText(String(message.content || '')); + const content = formattedMessageContent; // Detect if content is pure JSON (starts with { or [) const trimmedContent = content.trim(); @@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o )} - {!isGrouped && ( -
- {formattedTime} + {(shouldShowAssistantCopyControl || !isGrouped) && ( +
+ {shouldShowAssistantCopyControl && ( + + )} + {!isGrouped && {formattedTime}}
)}
diff --git a/src/components/chat/view/subcomponents/MessageCopyControl.tsx b/src/components/chat/view/subcomponents/MessageCopyControl.tsx new file mode 100644 index 00000000..dac02b7c --- /dev/null +++ b/src/components/chat/view/subcomponents/MessageCopyControl.tsx @@ -0,0 +1,209 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { copyTextToClipboard } from '../../../../utils/clipboard'; + +const COPY_SUCCESS_TIMEOUT_MS = 2000; + +type CopyFormat = 'text' | 'markdown'; + +type CopyFormatOption = { + format: CopyFormat; + label: string; +}; + +// Converts markdown into readable plain text for "Copy as text". +const convertMarkdownToPlainText = (markdown: string): string => { + let plainText = markdown.replace(/\r\n/g, '\n'); + plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, '$1'); + plainText = plainText.replace(/`([^`]+)`/g, '$1'); + plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + plainText = plainText.replace(/^>\s?/gm, ''); + plainText = plainText.replace(/^#{1,6}\s+/gm, ''); + plainText = plainText.replace(/^[-*+]\s+/gm, ''); + plainText = plainText.replace(/^\d+\.\s+/gm, ''); + plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2'); + plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2'); + plainText = plainText.replace(/~~(.*?)~~/g, '$1'); + plainText = plainText.replace(/<\/?[^>]+(>|$)/g, ''); + plainText = plainText.replace(/\n{3,}/g, '\n\n'); + return plainText.trim(); +}; + +const MessageCopyControl = ({ + content, + messageType, +}: { + content: string; + messageType: 'user' | 'assistant'; +}) => { + const { t } = useTranslation('chat'); + const canSelectCopyFormat = messageType === 'assistant'; + const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text'; + const [selectedFormat, setSelectedFormat] = useState(defaultFormat); + const [copied, setCopied] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + const copyFeedbackTimerRef = useRef | null>(null); + + const copyFormatOptions: CopyFormatOption[] = useMemo( + () => [ + { + format: 'markdown', + label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }), + }, + { + format: 'text', + label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }), + }, + ], + [t] + ); + + const selectedFormatTag = selectedFormat === 'markdown' + ? t('copyMessage.markdownShort', { defaultValue: 'MD' }) + : t('copyMessage.textShort', { defaultValue: 'TXT' }); + + const copyPayload = useMemo(() => { + if (selectedFormat === 'markdown') { + return content; + } + return convertMarkdownToPlainText(content); + }, [content, selectedFormat]); + + useEffect(() => { + setSelectedFormat(defaultFormat); + setIsDropdownOpen(false); + }, [defaultFormat]); + + useEffect(() => { + // Close the dropdown when clicking anywhere outside this control. + const closeOnOutsideClick = (event: MouseEvent) => { + if (!isDropdownOpen) return; + const target = event.target as Node; + if (dropdownRef.current && !dropdownRef.current.contains(target)) { + setIsDropdownOpen(false); + } + }; + + window.addEventListener('mousedown', closeOnOutsideClick); + return () => { + window.removeEventListener('mousedown', closeOnOutsideClick); + }; + }, [isDropdownOpen]); + + useEffect(() => { + return () => { + if (copyFeedbackTimerRef.current) { + clearTimeout(copyFeedbackTimerRef.current); + } + }; + }, []); + + const handleCopyClick = async () => { + if (!copyPayload.trim()) return; + const didCopy = await copyTextToClipboard(copyPayload); + if (!didCopy) return; + + setCopied(true); + if (copyFeedbackTimerRef.current) { + clearTimeout(copyFeedbackTimerRef.current); + } + copyFeedbackTimerRef.current = setTimeout(() => { + setCopied(false); + }, COPY_SUCCESS_TIMEOUT_MS); + }; + + const handleFormatChange = (format: CopyFormat) => { + setSelectedFormat(format); + setIsDropdownOpen(false); + }; + + const toneClass = messageType === 'user' + ? 'text-blue-100 hover:text-white' + : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'; + const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy'); + const rootClassName = canSelectCopyFormat + ? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto' + : 'relative flex items-center gap-0.5'; + + return ( +
+ + + {canSelectCopyFormat && ( + <> + + + {isDropdownOpen && ( +
+ {copyFormatOptions.map((option) => { + const isSelected = option.format === selectedFormat; + return ( + + ); + })} +
+ )} + + )} +
+ ); +}; + +export default MessageCopyControl;