11'use client'
22
33import { useCallback , useEffect , useRef , useState } from 'react'
4- import { Check , Copy , Ellipsis , Hash } from 'lucide-react'
54import {
6- DropdownMenu ,
7- DropdownMenuContent ,
8- DropdownMenuItem ,
9- DropdownMenuTrigger ,
5+ Button ,
6+ Check ,
7+ Copy ,
8+ Modal ,
9+ ModalBody ,
10+ ModalContent ,
11+ ModalFooter ,
12+ ModalHeader ,
13+ Textarea ,
14+ ThumbsDown ,
15+ ThumbsUp ,
1016} from '@/components/emcn'
17+ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
18+
19+ const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
20+
21+ function toPlainText ( raw : string ) : string {
22+ return (
23+ raw
24+ // Strip special tags and their contents
25+ . replace ( new RegExp ( `<\\/?(${ SPECIAL_TAGS } )(?:>[\\s\\S]*?<\\/(${ SPECIAL_TAGS } )>|>)` , 'g' ) , '' )
26+ // Strip markdown
27+ . replace ( / ^ # { 1 , 6 } \s + / gm, '' )
28+ . replace ( / \* \* ( .+ ?) \* \* / g, '$1' )
29+ . replace ( / \* ( .+ ?) \* / g, '$1' )
30+ . replace ( / ` { 3 } [ \s \S ] * ?` { 3 } / g, '' )
31+ . replace ( / ` ( .+ ?) ` / g, '$1' )
32+ . replace ( / \[ ( [ ^ \] ] + ) \] \( [ ^ ) ] + \) / g, '$1' )
33+ . replace ( / ^ [ > \- * ] \s + / gm, '' )
34+ . replace ( / ! \[ [ ^ \] ] * \] \( [ ^ ) ] + \) / g, '' )
35+ // Normalize whitespace
36+ . replace ( / \n { 3 , } / g, '\n\n' )
37+ . trim ( )
38+ )
39+ }
40+
41+ const ICON_CLASS = 'h-[14px] w-[14px]'
42+ const BUTTON_CLASS =
43+ 'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
1144
1245interface MessageActionsProps {
1346 content : string
14- requestId ?: string
47+ chatId ?: string
48+ userQuery ?: string
1549}
1650
17- export function MessageActions ( { content, requestId } : MessageActionsProps ) {
18- const [ copied , setCopied ] = useState < 'message' | 'request' | null > ( null )
51+ export function MessageActions ( { content, chatId, userQuery } : MessageActionsProps ) {
52+ const [ copied , setCopied ] = useState ( false )
53+ const [ pendingFeedback , setPendingFeedback ] = useState < 'up' | 'down' | null > ( null )
54+ const [ feedbackText , setFeedbackText ] = useState ( '' )
1955 const resetTimeoutRef = useRef < number | null > ( null )
56+ const submitFeedback = useSubmitCopilotFeedback ( )
2057
2158 useEffect ( ( ) => {
2259 return ( ) => {
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
2663 }
2764 } , [ ] )
2865
29- const copyToClipboard = useCallback ( async ( text : string , type : 'message' | 'request' ) => {
66+ const copyToClipboard = useCallback ( async ( ) => {
67+ if ( ! content ) return
68+ const text = toPlainText ( content )
69+ if ( ! text ) return
3070 try {
3171 await navigator . clipboard . writeText ( text )
32- setCopied ( type )
72+ setCopied ( true )
3373 if ( resetTimeoutRef . current !== null ) {
3474 window . clearTimeout ( resetTimeoutRef . current )
3575 }
36- resetTimeoutRef . current = window . setTimeout ( ( ) => setCopied ( null ) , 1500 )
76+ resetTimeoutRef . current = window . setTimeout ( ( ) => setCopied ( false ) , 1500 )
3777 } catch {
78+ /* clipboard unavailable */
79+ }
80+ } , [ content ] )
81+
82+ const handleFeedbackClick = useCallback (
83+ ( type : 'up' | 'down' ) => {
84+ if ( chatId && userQuery ) {
85+ setPendingFeedback ( type )
86+ setFeedbackText ( '' )
87+ }
88+ } ,
89+ [ chatId , userQuery ]
90+ )
91+
92+ const handleSubmitFeedback = useCallback ( ( ) => {
93+ if ( ! pendingFeedback || ! chatId || ! userQuery ) return
94+ const text = feedbackText . trim ( )
95+ if ( ! text ) {
96+ setPendingFeedback ( null )
97+ setFeedbackText ( '' )
3898 return
3999 }
100+ submitFeedback . mutate ( {
101+ chatId,
102+ userQuery,
103+ agentResponse : content ,
104+ isPositiveFeedback : pendingFeedback === 'up' ,
105+ feedback : text ,
106+ } )
107+ setPendingFeedback ( null )
108+ setFeedbackText ( '' )
109+ } , [ pendingFeedback , chatId , userQuery , content , feedbackText ] )
110+
111+ const handleModalClose = useCallback ( ( open : boolean ) => {
112+ if ( ! open ) {
113+ setPendingFeedback ( null )
114+ setFeedbackText ( '' )
115+ }
40116 } , [ ] )
41117
42- if ( ! content && ! requestId ) {
43- return null
44- }
118+ if ( ! content ) return null
45119
46120 return (
47- < DropdownMenu modal = { false } >
48- < DropdownMenuTrigger asChild >
121+ < >
122+ < div className = 'flex items-center gap-0.5' >
49123 < button
50124 type = 'button'
51- aria-label = 'More options '
52- className = 'flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
53- onClick = { ( event ) => event . stopPropagation ( ) }
125+ aria-label = 'Copy message '
126+ onClick = { copyToClipboard }
127+ className = { BUTTON_CLASS }
54128 >
55- < Ellipsis className = 'h-3 w-3' strokeWidth = { 2 } />
129+ { copied ? < Check className = { ICON_CLASS } /> : < Copy className = { ICON_CLASS } /> }
56130 </ button >
57- </ DropdownMenuTrigger >
58- < DropdownMenuContent align = 'end' side = 'top' sideOffset = { 4 } >
59- < DropdownMenuItem
60- disabled = { ! content }
61- onSelect = { ( event ) => {
62- event . stopPropagation ( )
63- void copyToClipboard ( content , 'message' )
64- } }
131+ < button
132+ type = 'button'
133+ aria-label = 'Like'
134+ onClick = { ( ) => handleFeedbackClick ( 'up' ) }
135+ className = { BUTTON_CLASS }
65136 >
66- { copied === 'message' ? < Check /> : < Copy /> }
67- < span > Copy Message</ span >
68- </ DropdownMenuItem >
69- < DropdownMenuItem
70- disabled = { ! requestId }
71- onSelect = { ( event ) => {
72- event . stopPropagation ( )
73- if ( requestId ) {
74- void copyToClipboard ( requestId , 'request' )
75- }
76- } }
137+ < ThumbsUp className = { ICON_CLASS } />
138+ </ button >
139+ < button
140+ type = 'button'
141+ aria-label = 'Dislike'
142+ onClick = { ( ) => handleFeedbackClick ( 'down' ) }
143+ className = { BUTTON_CLASS }
77144 >
78- { copied === 'request' ? < Check /> : < Hash /> }
79- < span > Copy Request ID</ span >
80- </ DropdownMenuItem >
81- </ DropdownMenuContent >
82- </ DropdownMenu >
145+ < ThumbsDown className = { ICON_CLASS } />
146+ </ button >
147+ </ div >
148+
149+ < Modal open = { pendingFeedback !== null } onOpenChange = { handleModalClose } >
150+ < ModalContent size = 'sm' >
151+ < ModalHeader > Give feedback</ ModalHeader >
152+ < ModalBody >
153+ < div className = 'flex flex-col gap-2' >
154+ < p className = 'font-medium text-[var(--text-secondary)] text-sm' >
155+ { pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?' }
156+ </ p >
157+ < Textarea
158+ placeholder = {
159+ pendingFeedback === 'up'
160+ ? 'Tell us what was helpful...'
161+ : 'Tell us what went wrong...'
162+ }
163+ value = { feedbackText }
164+ onChange = { ( e ) => setFeedbackText ( e . target . value ) }
165+ rows = { 3 }
166+ />
167+ </ div >
168+ </ ModalBody >
169+ < ModalFooter >
170+ < Button variant = 'default' onClick = { ( ) => handleModalClose ( false ) } >
171+ Cancel
172+ </ Button >
173+ < Button variant = 'primary' onClick = { handleSubmitFeedback } >
174+ Submit
175+ </ Button >
176+ </ ModalFooter >
177+ </ ModalContent >
178+ </ Modal >
179+ </ >
83180 )
84181}
0 commit comments