11"use client" ;
22
3- import type React from "react" ;
43import { useRef , useEffect , useState , useCallback } from "react" ;
54import Image from "next/image" ;
65import { ScrollArea } from "@/components/ui/scroll-area" ;
76import ExamplePanel from "./chat-example-panel" ;
87import { UIMessage } from "ai" ;
98import { convertToLegalXml , replaceNodes } from "@/lib/utils" ;
9+ import { Copy , Check , X } from "lucide-react" ;
1010
1111import { useDiagram } from "@/contexts/diagram-context" ;
1212
13+ const getMessageTextContent = ( message : UIMessage ) : string => {
14+ if ( ! message . parts ) return "" ;
15+ return message . parts
16+ . filter ( ( part : any ) => part . type === "text" )
17+ . map ( ( part : any ) => part . text )
18+ . join ( "\n" ) ;
19+ } ;
20+
1321interface ChatMessageDisplayProps {
1422 messages : UIMessage [ ] ;
1523 error ?: Error | null ;
@@ -30,6 +38,21 @@ export function ChatMessageDisplay({
3038 const [ expandedTools , setExpandedTools ] = useState < Record < string , boolean > > (
3139 { }
3240 ) ;
41+ const [ copiedMessageId , setCopiedMessageId ] = useState < string | null > ( null ) ;
42+ const [ copyFailedMessageId , setCopyFailedMessageId ] = useState < string | null > ( null ) ;
43+
44+ const copyMessageToClipboard = async ( messageId : string , text : string ) => {
45+ try {
46+ await navigator . clipboard . writeText ( text ) ;
47+ setCopiedMessageId ( messageId ) ;
48+ setTimeout ( ( ) => setCopiedMessageId ( null ) , 2000 ) ;
49+ } catch ( err ) {
50+ console . error ( "Failed to copy message:" , err ) ;
51+ setCopyFailedMessageId ( messageId ) ;
52+ setTimeout ( ( ) => setCopyFailedMessageId ( null ) , 2000 ) ;
53+ }
54+ } ;
55+
3356 const handleDisplayChart = useCallback (
3457 ( xml : string ) => {
3558 const currentXml = xml || "" ;
@@ -137,16 +160,16 @@ export function ChatMessageDisplay({
137160 { output || ( toolName === "display_diagram"
138161 ? "Diagram generated"
139162 : toolName === "edit_diagram"
140- ? "Diagram edited"
141- : "Tool executed" ) }
163+ ? "Diagram edited"
164+ : "Tool executed" ) }
142165 </ div >
143166 ) : state === "output-error" ? (
144167 < div className = "text-red-600" >
145168 { output || ( toolName === "display_diagram"
146169 ? "Error generating diagram"
147170 : toolName === "edit_diagram"
148- ? "Error editing diagram"
149- : "Tool error" ) }
171+ ? "Error editing diagram"
172+ : "Tool error" ) }
150173 </ div >
151174 ) : null }
152175 </ div >
@@ -160,51 +183,66 @@ export function ChatMessageDisplay({
160183 { messages . length === 0 ? (
161184 < ExamplePanel setInput = { setInput } setFiles = { setFiles } />
162185 ) : (
163- messages . map ( ( message ) => (
164- < div
165- key = { message . id }
166- className = { `mb-4 ${
167- message . role === "user" ? "text-right" : "text-left"
168- } `}
169- >
186+ messages . map ( ( message ) => {
187+ const userMessageText = message . role === "user" ? getMessageTextContent ( message ) : "" ;
188+ return (
170189 < div
171- className = { `inline-block px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${
172- message . role === "user"
173- ? "bg-primary text-primary-foreground"
174- : "bg-muted text-muted-foreground"
175- } `}
190+ key = { message . id }
191+ className = { `mb-4 flex ${ message . role === "user" ? "justify-end" : "justify-start" } ` }
176192 >
177- { message . parts ?. map ( ( part : any , index : number ) => {
178- switch ( part . type ) {
179- case "text" :
180- return (
181- < div key = { index } > { part . text } </ div >
182- ) ;
183- case "file" :
184- return (
185- < div key = { index } className = "mt-2" >
186- < Image
187- src = { part . url }
188- width = { 200 }
189- height = { 200 }
190- alt = { `Uploaded diagram or image for AI analysis` }
191- className = "rounded-md border"
192- style = { {
193- objectFit : "contain" ,
194- } }
195- />
196- </ div >
197- ) ;
198- default :
199- if ( part . type ?. startsWith ( "tool-" ) ) {
200- return renderToolPart ( part ) ;
201- }
202- return null ;
203- }
204- } ) }
193+ { message . role === "user" && userMessageText && (
194+ < button
195+ onClick = { ( ) => copyMessageToClipboard ( message . id , userMessageText ) }
196+ className = "p-1 text-gray-400 hover:text-gray-600 transition-colors self-center mr-1"
197+ title = { copiedMessageId === message . id ? "Copied!" : copyFailedMessageId === message . id ? "Failed to copy" : "Copy message" }
198+ >
199+ { copiedMessageId === message . id ? (
200+ < Check className = "h-3.5 w-3.5 text-green-500" />
201+ ) : copyFailedMessageId === message . id ? (
202+ < X className = "h-3.5 w-3.5 text-red-500" />
203+ ) : (
204+ < Copy className = "h-3.5 w-3.5" />
205+ ) }
206+ </ button >
207+ ) }
208+ < div
209+ className = { `px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${ message . role === "user"
210+ ? "bg-primary text-primary-foreground"
211+ : "bg-muted text-muted-foreground"
212+ } `}
213+ >
214+ { message . parts ?. map ( ( part : any , index : number ) => {
215+ switch ( part . type ) {
216+ case "text" :
217+ return (
218+ < div key = { index } > { part . text } </ div >
219+ ) ;
220+ case "file" :
221+ return (
222+ < div key = { index } className = "mt-2" >
223+ < Image
224+ src = { part . url }
225+ width = { 200 }
226+ height = { 200 }
227+ alt = { `Uploaded diagram or image for AI analysis` }
228+ className = "rounded-md border"
229+ style = { {
230+ objectFit : "contain" ,
231+ } }
232+ />
233+ </ div >
234+ ) ;
235+ default :
236+ if ( part . type ?. startsWith ( "tool-" ) ) {
237+ return renderToolPart ( part ) ;
238+ }
239+ return null ;
240+ }
241+ } ) }
242+ </ div >
205243 </ div >
206- </ div >
207- ) )
244+ ) ;
245+ } )
208246 ) }
209247 { error && (
210248 < div className = "text-red-500 text-sm mt-2" >
0 commit comments