feat: add copy button for tool call blocks (#463)

* feat: add copy button for tool call blocks

* refactor: simplify copy state updates with helper function

---------

Co-authored-by: binge_c-admin <totchinaa@gmail.com>
This commit is contained in:
broBinChen
2025-12-30 22:45:50 +08:00
committed by GitHub
parent 1d19127855
commit 24afa0b58a

View File

@@ -230,6 +230,12 @@ export function ChatMessageDisplay({
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}, {},
) )
const [copiedToolCallId, setCopiedToolCallId] = useState<string | null>(
null,
)
const [copyFailedToolCallId, setCopyFailedToolCallId] = useState<
string | null
>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null) const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState< const [copyFailedMessageId, setCopyFailedMessageId] = useState<
string | null string | null
@@ -245,12 +251,38 @@ export function ChatMessageDisplay({
Record<string, boolean> Record<string, boolean>
>({}) >({})
const copyMessageToClipboard = async (messageId: string, text: string) => { const setCopyState = (
try { messageId: string,
await navigator.clipboard.writeText(text) isToolCall: boolean,
isSuccess: boolean,
) => {
if (isSuccess) {
if (isToolCall) {
setCopiedToolCallId(messageId)
setTimeout(() => setCopiedToolCallId(null), 2000)
} else {
setCopiedMessageId(messageId) setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000) setTimeout(() => setCopiedMessageId(null), 2000)
}
} else {
if (isToolCall) {
setCopyFailedToolCallId(messageId)
setTimeout(() => setCopyFailedToolCallId(null), 2000)
} else {
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
}
}
const copyMessageToClipboard = async (
messageId: string,
text: string,
isToolCall = false,
) => {
try {
await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true)
} catch (err) { } catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied // Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
@@ -266,13 +298,11 @@ export function ChatMessageDisplay({
if (!success) { if (!success) {
throw new Error("Copy command failed") throw new Error("Copy command failed")
} }
setCopiedMessageId(messageId) setCopyState(messageId, isToolCall, true)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (fallbackErr) { } catch (fallbackErr) {
console.error("Failed to copy message:", fallbackErr) console.error("Failed to copy message:", fallbackErr)
toast.error(dict.chat.failedToCopyDetail) toast.error(dict.chat.failedToCopyDetail)
setCopyFailedMessageId(messageId) setCopyState(messageId, isToolCall, false)
setTimeout(() => setCopyFailedMessageId(null), 2000)
} finally { } finally {
document.body.removeChild(textarea) document.body.removeChild(textarea)
} }
@@ -641,6 +671,7 @@ export function ChatMessageDisplay({
const { state, input, output } = part const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "") const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => { const toggleExpanded = () => {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
@@ -662,6 +693,35 @@ export function ChatMessageDisplay({
} }
} }
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (
input.operations &&
Array.isArray(input.operations)
) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
copyMessageToClipboard(callId, textToCopy, true)
}
}
return ( return (
<div <div
key={callId} key={callId}
@@ -681,9 +741,32 @@ export function ChatMessageDisplay({
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)} )}
{state === "output-available" && ( {state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full"> <span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
Complete {dict.tools.complete}
</span> </span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId ===
callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)} )}
{state === "output-error" && {state === "output-error" &&
(() => { (() => {