feat: add copy icon for user messages (#449)

* feat: add copy icon for user messages

Expose a copy control on user chat bubbles so previous content can be reused quickly.

* fix: Copy control is effectively hidden on touch devices

* fix: copyTextToClipboard doesn't need timer

---------

Co-authored-by: dev <dev@host.local>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
This commit is contained in:
Xì Gà
2026-02-27 22:28:10 +07:00
committed by GitHub
parent a367edd515
commit b359c51527
5 changed files with 58 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ import type {
import { Markdown } from './Markdown';
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';
@@ -53,6 +54,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
const [messageCopied, setMessageCopied] = React.useState(false);
React.useEffect(() => {
@@ -100,7 +102,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{message.type === 'user' ? (
/* User message bubble on the right */
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group">
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
@@ -117,8 +119,45 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{formattedTime}
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100">
<button
type="button"
onClick={() => {
const text = String(message.content || '');
if (!text) return;
copyTextToClipboard(text).then((success) => {
if (!success) return;
setMessageCopied(true);
});
}}
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
>
{messageCopied ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
)}
</button>
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (

View File

@@ -4,6 +4,10 @@
"copied": "Copied",
"copyCode": "Copy code"
},
"copyMessage": {
"copy": "Copy message",
"copied": "Message copied"
},
"messageTypes": {
"user": "U",
"error": "Error",

View File

@@ -4,6 +4,10 @@
"copied": "コピーしました",
"copyCode": "コードをコピー"
},
"copyMessage": {
"copy": "メッセージをコピー",
"copied": "メッセージをコピーしました"
},
"messageTypes": {
"user": "U",
"error": "エラー",

View File

@@ -4,6 +4,10 @@
"copied": "복사됨",
"copyCode": "코드 복사"
},
"copyMessage": {
"copy": "메시지 복사",
"copied": "메시지 복사됨"
},
"messageTypes": {
"user": "U",
"error": "오류",

View File

@@ -4,6 +4,10 @@
"copied": "已复制",
"copyCode": "复制代码"
},
"copyMessage": {
"copy": "复制消息",
"copied": "消息已复制"
},
"messageTypes": {
"user": "U",
"error": "错误",