@@ -2,13 +2,19 @@ import ReactMarkdown from "react-markdown"
22import remarkGfm from "remark-gfm"
33import "@/utils/plain-text-markdown.css"
44import type { MessageType } from "./message"
5- import { IconFile } from "@tabler/icons-react"
5+ import { IconCopy , IconFile } from "@tabler/icons-react"
66import { isTaskImageAttachment , type TaskUserInputAttachment } from "./task-shared"
77import React from "react"
88import { TaskAttachmentPreviewDialog , type TaskAttachmentPreviewFile } from "./task-attachment-preview-dialog"
9+ import { Button } from "@/components/ui/button"
10+ import { Tooltip , TooltipContent , TooltipTrigger } from "@/components/ui/tooltip"
11+ import { toast } from "sonner"
12+ import { useTranslation } from "react-i18next"
913
1014export const UserInputMessageItem = ( { message } : { message : MessageType } ) => {
15+ const { t } = useTranslation ( )
1116 const [ previewFile , setPreviewFile ] = React . useState < TaskAttachmentPreviewFile | null > ( null )
17+ const content = typeof message . data . content === "string" ? message . data . content : ""
1218 const attachments = Array . isArray ( message . data . attachments )
1319 ? message . data . attachments . filter ( ( attachment ) : attachment is TaskUserInputAttachment => (
1420 ! ! attachment
@@ -19,47 +25,80 @@ export const UserInputMessageItem = ({ message }: { message: MessageType }) => {
1925 ) )
2026 : [ ]
2127
28+ const handleCopy = async ( ) => {
29+ if ( ! content ) return
30+
31+ try {
32+ await navigator . clipboard . writeText ( content )
33+ toast . success ( t ( "taskDetail.messages.copyUserInputSuccess" ) )
34+ } catch ( error ) {
35+ toast . error ( t ( "taskDetail.messages.copyUserInputFailed" ) )
36+ console . error ( "Copy user input failed:" , error )
37+ }
38+ }
39+
2240 return (
23- < div className = "flex flex-col w-fit rounded-md bg-accent/50 px-4 py-3 max-w-[80%] break-all" >
24- { message . data . content && (
25- < div className = "user-message-markdown break-all" >
26- < ReactMarkdown
27- remarkPlugins = { [ remarkGfm ] }
28- components = { {
29- p ( { children, ...props } ) {
30- if ( typeof children === 'string' ) {
31- return ( children as string ) . split ( '\n' ) . map ( ( line : string , index : number ) => (
32- < p key = { index } { ...props } > { line } </ p >
33- ) )
34- } else {
35- return < p { ...props } > { children } </ p >
41+ < div className = "group/user-input relative inline-flex w-fit max-w-[80%] flex-col pb-7" >
42+ < div className = "flex flex-col rounded-md bg-accent/50 px-4 py-3 break-all" >
43+ { content && (
44+ < div className = "user-message-markdown break-all" >
45+ < ReactMarkdown
46+ remarkPlugins = { [ remarkGfm ] }
47+ components = { {
48+ p ( { children, ...props } ) {
49+ if ( typeof children === 'string' ) {
50+ return ( children as string ) . split ( '\n' ) . map ( ( line : string , index : number ) => (
51+ < p key = { index } { ...props } > { line } </ p >
52+ ) )
53+ } else {
54+ return < p { ...props } > { children } </ p >
55+ }
3656 }
37- }
38- } } >
39- { message . data . content }
40- </ ReactMarkdown >
41- </ div >
42- ) }
43- { attachments . length > 0 && (
44- < div className = "mt-2 flex flex-col gap-1.5" >
45- { attachments . map ( ( attachment , index ) => (
46- < button
47- type = "button"
48- key = { `${ attachment . url } -${ index } ` }
49- className = "group/attachment flex max-w-full cursor-pointer items-center gap-2 rounded-md border bg-background/70 px-2 py-1.5 text-left text-xs text-foreground hover:bg-background"
50- onClick = { ( ) => setPreviewFile ( {
51- name : attachment . filename ,
52- accessUrl : attachment . url ,
53- } ) }
54- >
55- { isTaskImageAttachment ( attachment . filename ) ? (
56- < img src = { attachment . url } alt = { attachment . filename } className = "size-4 shrink-0 rounded object-cover" />
57- ) : (
58- < IconFile className = "size-4 shrink-0 text-muted-foreground" />
59- ) }
60- < span className = "min-w-0 flex-1 truncate group-hover/attachment:text-primary" > { attachment . filename } </ span >
61- </ button >
62- ) ) }
57+ } } >
58+ { content }
59+ </ ReactMarkdown >
60+ </ div >
61+ ) }
62+ { attachments . length > 0 && (
63+ < div className = "mt-2 flex flex-col gap-1.5" >
64+ { attachments . map ( ( attachment , index ) => (
65+ < button
66+ type = "button"
67+ key = { `${ attachment . url } -${ index } ` }
68+ className = "group/attachment flex max-w-full cursor-pointer items-center gap-2 rounded-md border bg-background/70 px-2 py-1.5 text-left text-xs text-foreground hover:bg-background"
69+ onClick = { ( ) => setPreviewFile ( {
70+ name : attachment . filename ,
71+ accessUrl : attachment . url ,
72+ } ) }
73+ >
74+ { isTaskImageAttachment ( attachment . filename ) ? (
75+ < img src = { attachment . url } alt = { attachment . filename } className = "size-4 shrink-0 rounded object-cover" />
76+ ) : (
77+ < IconFile className = "size-4 shrink-0 text-muted-foreground" />
78+ ) }
79+ < span className = "min-w-0 flex-1 truncate group-hover/attachment:text-primary" > { attachment . filename } </ span >
80+ </ button >
81+ ) ) }
82+ </ div >
83+ ) }
84+ </ div >
85+ { content && (
86+ < div className = "absolute bottom-0 right-0 flex justify-end opacity-0 transition-opacity group-hover/user-input:opacity-100 group-focus-within/user-input:opacity-100" >
87+ < Tooltip >
88+ < TooltipTrigger asChild >
89+ < Button
90+ type = "button"
91+ variant = "ghost"
92+ size = "icon-sm"
93+ className = "size-6 text-muted-foreground hover:text-foreground"
94+ aria-label = { t ( "taskDetail.messages.copyUserInput" ) }
95+ onClick = { handleCopy }
96+ >
97+ < IconCopy className = "size-3.5" />
98+ </ Button >
99+ </ TooltipTrigger >
100+ < TooltipContent > { t ( "taskDetail.messages.copyUserInput" ) } </ TooltipContent >
101+ </ Tooltip >
63102 </ div >
64103 ) }
65104 < TaskAttachmentPreviewDialog
0 commit comments