11import { useState , useRef , useLayoutEffect , memo } from 'react' ;
2+ import { Copy , Check } from 'lucide-react' ;
23import type { Item } from '@shared/types' ;
34import ToolCallCard from './ToolCallCard' ;
45import DiffBlock from './DiffBlock' ;
56import ToolSummary from './ToolSummary' ;
67import MarkdownRenderer from './MarkdownRenderer' ;
8+ import { useCopyToClipboard } from '../hooks/useCopyToClipboard' ;
79
810interface MessageItemProps {
911 item : Item ;
@@ -34,10 +36,13 @@ const MessageItem = memo(function MessageItem({
3436 const rollbackBtnRef = useRef < HTMLButtonElement > ( null ) ;
3537 const rollbackMenuRef = useRef < HTMLDivElement > ( null ) ;
3638 const [ menuFlip , setMenuFlip ] = useState < { vertical ?: boolean ; horizontal ?: boolean } > ( { } ) ;
39+ const { copiedId, copy } = useCopyToClipboard ( ) ;
3740
3841 const messageContent = item . type === 'message' ? item . content : null ;
3942 const isAssistant = item . type === 'message' && item . role === 'assistant' ;
4043
44+ const isCopied = copiedId === `msg-${ item . id } ` ;
45+
4146 // Dynamically flip menu if it would overflow the viewport
4247 useLayoutEffect ( ( ) => {
4348 if ( ! rollbackMenuOpen || ! rollbackMenuRef . current || ! rollbackBtnRef . current ) return ;
@@ -62,8 +67,8 @@ const MessageItem = memo(function MessageItem({
6267
6368 if ( isUser ) {
6469 return (
65- < div className = "flex justify- end mb-4 mt-4" >
66- < div className = "relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words group " >
70+ < div className = "flex flex-col items- end mb-4 mt-4 group " >
71+ < div className = "relative max-w-[78%] px-4 py-3 rounded-2xl rounded-br-sm bg-[var(--border-card)] text-[var(--text-title)] text-[15px] leading-relaxed whitespace-pre-wrap break-words" >
6772 { content }
6873 { hasRollback && (
6974 < div className = "absolute -right-1 -bottom-1" >
@@ -114,12 +119,31 @@ const MessageItem = memo(function MessageItem({
114119 </ div >
115120 ) }
116121 </ div >
122+ < div className = "mt-1.5 mr-1 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity" >
123+ < button
124+ type = "button"
125+ onClick = { ( e ) => {
126+ e . stopPropagation ( ) ;
127+ copy ( content , `msg-${ item . id } ` ) ;
128+ } }
129+ aria-label = "复制消息"
130+ title = "复制"
131+ className = { `flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
132+ isCopied
133+ ? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
134+ : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
135+ } `}
136+ >
137+ { isCopied ? < Check size = { 12 } /> : < Copy size = { 12 } /> }
138+ { isCopied ? '已复制' : '复制' }
139+ </ button >
140+ </ div >
117141 </ div >
118142 ) ;
119143 }
120144
121145 return (
122- < div className = "flex justify- start mb-1 pl-8" >
146+ < div className = "flex flex-col items- start mb-1 pl-8 group " >
123147 < div className = "max-w-[80%] text-[15px] text-[var(--text-primary)] leading-relaxed" >
124148 { isAssistant && messageContent != null && (
125149 < MarkdownRenderer content = { messageContent } />
@@ -128,6 +152,27 @@ const MessageItem = memo(function MessageItem({
128152 < span className = "inline-block w-1.5 h-[1.1em] bg-[var(--accent-primary)] animate-pulse ml-0.5 align-middle" />
129153 ) }
130154 </ div >
155+ { isAssistant && ! item . partial && messageContent != null && (
156+ < div className = "max-w-[80%] mt-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity" >
157+ < button
158+ type = "button"
159+ onClick = { ( e ) => {
160+ e . stopPropagation ( ) ;
161+ copy ( messageContent || '' , `msg-${ item . id } ` ) ;
162+ } }
163+ aria-label = "复制消息"
164+ title = "复制"
165+ className = { `flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${
166+ isCopied
167+ ? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
168+ : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
169+ } `}
170+ >
171+ { isCopied ? < Check size = { 12 } /> : < Copy size = { 12 } /> }
172+ { isCopied ? '已复制' : '复制' }
173+ </ button >
174+ </ div >
175+ ) }
131176 </ div >
132177 ) ;
133178 }
0 commit comments