mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-12 17:37:24 +00:00
feat: add copy as text or markdown feature for assistant messages (#519)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { memo, useMemo } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
@@ -9,10 +9,10 @@ import type {
|
|||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
type DiffLine = {
|
type DiffLine = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -20,7 +20,7 @@ type DiffLine = {
|
|||||||
lineNum: number;
|
lineNum: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MessageComponentProps {
|
type MessageComponentProps = {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
prevMessage: ChatMessage | null;
|
prevMessage: ChatMessage | null;
|
||||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
@@ -32,7 +32,7 @@ interface MessageComponentProps {
|
|||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type InteractiveOption = {
|
type InteractiveOption = {
|
||||||
number: string;
|
number: string;
|
||||||
@@ -41,6 +41,7 @@ type InteractiveOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
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 MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
@@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
(prevMessage.type === 'user') ||
|
(prevMessage.type === 'user') ||
|
||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
|
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||||
const [messageCopied, setMessageCopied] = React.useState(false);
|
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');
|
setPermissionGrantState('idle');
|
||||||
}, [permissionSuggestion?.entry, message.toolId]);
|
}, [permissionSuggestion?.entry, message.toolId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
|
|
||||||
@@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
||||||
<button
|
{shouldShowUserCopyControl && (
|
||||||
type="button"
|
<MessageCopyControl content={userCopyContent} messageType="user" />
|
||||||
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="h-3.5 w-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="h-3.5 w-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>
|
<span>{formattedTime}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 [)
|
// Detect if content is pure JSON (starts with { or [)
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
@@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isGrouped && (
|
{(shouldShowAssistantCopyControl || !isGrouped) && (
|
||||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
{formattedTime}
|
{shouldShowAssistantCopyControl && (
|
||||||
|
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
|
||||||
|
)}
|
||||||
|
{!isGrouped && <span>{formattedTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
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');
|
||||||
|
const codeBlocks: string[] = [];
|
||||||
|
plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => {
|
||||||
|
const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;
|
||||||
|
codeBlocks.push(code.replace(/\n$/, ''));
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');
|
||||||
|
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<CopyFormat>(defaultFormat);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div ref={dropdownRef} className={rootClassName}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyClick}
|
||||||
|
title={copyTitle}
|
||||||
|
aria-label={copyTitle}
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="h-3.5 w-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="h-3.5 w-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" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide">{selectedFormatTag}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canSelectCopyFormat && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||||
|
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
|
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
|
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
{copyFormatOptions.map((option) => {
|
||||||
|
const isSelected = option.format === selectedFormat;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.format}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFormatChange(option.format)}
|
||||||
|
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||||
|
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageCopyControl;
|
||||||
Reference in New Issue
Block a user