@@ -2,8 +2,15 @@ import {
22 ChartBarIcon ,
33 ChartLineIcon ,
44 ChartPieIcon ,
5+ CopyIcon ,
56 HashIcon ,
6- } from '@phosphor-icons/react/ssr' ;
7+ PaperPlaneRightIcon ,
8+ ThumbsDownIcon ,
9+ ThumbsUpIcon ,
10+ XIcon ,
11+ } from '@phosphor-icons/react' ;
12+ import { useState } from 'react' ;
13+ import { Action , Actions } from '@/components/ai-elements/actions' ;
714import { Loader } from '@/components/ai-elements/loader' ;
815import {
916 Message as AIMessage ,
@@ -16,10 +23,16 @@ import {
1623 ReasoningTrigger ,
1724} from '@/components/ai-elements/reasoning' ;
1825import { Response } from '@/components/ai-elements/response' ;
26+ import { Button } from '@/components/ui/button' ;
27+ import { Textarea } from '@/components/ui/textarea' ;
28+ import type { useChat , Vote } from '../hooks/use-chat' ;
1929import type { Message } from '../types/message' ;
2030
2131interface MessageBubbleProps {
2232 message : Message ;
33+ handleVote : ReturnType < typeof useChat > [ 'handleVote' ] ;
34+ handleFeedbackComment : ReturnType < typeof useChat > [ 'handleFeedbackComment' ] ;
35+ isLastMessage : boolean ;
2336}
2437
2538const getChartIcon = ( chartType : string ) => {
@@ -88,75 +101,222 @@ function InProgressMessage({ message }: { message: Message }) {
88101 ) ;
89102}
90103
91- function CompletedMessage ( {
92- message ,
93- isUser ,
104+ function FeedbackInput ( {
105+ onSubmit ,
106+ onCancel ,
94107} : {
108+ onSubmit : ( feedbackText : string ) => void ;
109+ onCancel : ( ) => void ;
110+ } ) {
111+ const [ feedbackText , setFeedbackText ] = useState ( '' ) ;
112+
113+ const handleSubmit = ( ) => {
114+ if ( ! feedbackText . trim ( ) ) {
115+ return ;
116+ }
117+
118+ onSubmit ( feedbackText . trim ( ) ) ;
119+ onCancel ( ) ;
120+ } ;
121+
122+ const handleCancel = ( ) => {
123+ setFeedbackText ( '' ) ;
124+ onCancel ( ) ;
125+ } ;
126+
127+ return (
128+ < div className = "slide-in-from-top-2 fade-in-0 mt-3 animate-in space-y-3 rounded-md border border-border/50 bg-muted/30 p-3 duration-200" >
129+ < div className = "text-muted-foreground text-xs" >
130+ Help us improve by sharing what went wrong:
131+ </ div >
132+ < Textarea
133+ className = "resize-none"
134+ onChange = { ( e ) => setFeedbackText ( e . target . value ) }
135+ onKeyDown = { ( e ) => {
136+ if ( e . key === 'Enter' && ( e . metaKey || e . ctrlKey ) ) {
137+ e . preventDefault ( ) ;
138+ handleSubmit ( ) ;
139+ }
140+ } }
141+ placeholder = "Please describe what went wrong..."
142+ rows = { 4 }
143+ value = { feedbackText }
144+ />
145+ < div className = "flex justify-end gap-2" >
146+ < Button onClick = { handleCancel } size = "sm" variant = "ghost" >
147+ < XIcon />
148+ Cancel
149+ </ Button >
150+ < Button
151+ disabled = { ! feedbackText . trim ( ) }
152+ onClick = { handleSubmit }
153+ size = "sm"
154+ >
155+ < PaperPlaneRightIcon />
156+ Submit Feedback
157+ </ Button >
158+ </ div >
159+ </ div >
160+ ) ;
161+ }
162+
163+ interface CompletedMessageProps {
95164 message : Message ;
96165 isUser : boolean ;
97- } ) {
98- const hasThinkingSteps =
99- message . thinkingSteps && message . thinkingSteps . length > 0 ;
166+ handleVote : ReturnType < typeof useChat > [ 'handleVote' ] ;
167+ handleFeedbackComment : ReturnType < typeof useChat > [ 'handleFeedbackComment' ] ;
168+ isLastMessage : boolean ;
169+ }
170+
171+ function CompletedMessage ( {
172+ message,
173+ isUser,
174+ handleVote,
175+ handleFeedbackComment,
176+ isLastMessage,
177+ } : CompletedMessageProps ) {
178+ const [ voteType , setVoteType ] = useState < Vote | null > ( null ) ;
179+ const [ showFeedbackInput , setShowFeedbackInput ] = useState ( false ) ;
180+ const hasThinkingSteps = Boolean ( message . thinkingSteps ?. length ) ;
181+
182+ const resetFeedback = ( ) => {
183+ setShowFeedbackInput ( false ) ;
184+ } ;
185+
186+ const handleSubmitFeedback = ( feedbackText : string ) => {
187+ handleFeedbackComment ( message . id , feedbackText ) ;
188+ setShowFeedbackInput ( false ) ;
189+ } ;
190+
191+ const handleFeedbackButtonClick = ( type : Vote ) => {
192+ if ( type === 'downvote' ) {
193+ setShowFeedbackInput ( true ) ;
194+ }
195+
196+ setVoteType ( type ) ;
197+ handleVote ( message . id , type ) ;
198+ } ;
199+
200+ const showUpVoteButton = voteType !== 'downvote' ;
201+ const showDownVoteButton = voteType !== 'upvote' ;
202+ const isVoteButtonClicked = voteType !== null ;
100203
101204 return (
102205 < AIMessage from = { isUser ? 'user' : 'assistant' } >
103- { ! isUser && (
104- < MessageAvatar
105- name = { isUser ? 'You' : 'Databunny' }
106- src = { '/databunny.webp' }
107- />
108- ) }
109- < MessageContent >
110- < Response > { message . content } </ Response >
206+ < div className = "space-y-2" >
207+ < MessageContent >
208+ < Response > { message . content } </ Response >
111209
112- { hasThinkingSteps && ! isUser && message . content && (
113- < div className = "mt-2" >
114- < ThinkingStepsReasoning steps = { message . thinkingSteps || [ ] } />
115- </ div >
116- ) }
210+ { hasThinkingSteps && ! isUser && message . content && (
211+ < div className = "mt-2" >
212+ < ThinkingStepsReasoning steps = { message . thinkingSteps || [ ] } />
213+ </ div >
214+ ) }
117215
118- { message . responseType === 'metric' &&
119- message . metricValue !== undefined &&
120- ! isUser && (
121- < div className = "mt-4 rounded border border-primary/20 bg-primary/5 p-4" >
122- < div className = "flex min-w-0 items-center gap-3" >
123- < div className = "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-primary/10" >
124- < HashIcon className = "h-4 w-4 text-primary" />
125- </ div >
126- < div className = "min-w-0 flex-1" >
127- < div className = "truncate font-medium text-muted-foreground text-xs uppercase tracking-wide" >
128- { message . metricLabel || 'Result' }
216+ { message . responseType === 'metric' &&
217+ message . metricValue !== undefined &&
218+ ! isUser && (
219+ < div className = "mt-4 rounded border border-primary/20 bg-primary/5 p-4" >
220+ < div className = "flex min-w-0 items-center gap-3" >
221+ < div className = "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-primary/10" >
222+ < HashIcon className = "h-4 w-4 text-primary" />
129223 </ div >
130- < div className = "mt-2 break-words font-bold text-foreground text-lg" >
131- { typeof message . metricValue === 'number'
132- ? message . metricValue . toLocaleString ( )
133- : message . metricValue }
224+ < div className = "min-w-0 flex-1" >
225+ < div className = "truncate font-medium text-muted-foreground text-xs uppercase tracking-wide" >
226+ { message . metricLabel || 'Result' }
227+ </ div >
228+ < div className = "mt-2 break-words font-bold text-foreground text-lg" >
229+ { typeof message . metricValue === 'number'
230+ ? message . metricValue . toLocaleString ( )
231+ : message . metricValue }
232+ </ div >
134233 </ div >
135234 </ div >
136235 </ div >
236+ ) }
237+
238+ { message . hasVisualization && ! isUser && (
239+ < div className = "mt-3 border-border/30 border-t pt-3" >
240+ < div className = "flex items-center gap-2 text-muted-foreground text-xs" >
241+ { getChartIcon ( message . chartType || 'bar' ) }
242+ < span > Visualization generated in the data panel.</ span >
243+ </ div >
137244 </ div >
138245 ) }
246+ </ MessageContent >
247+ { ! isUser && isLastMessage && (
248+ < >
249+ < Actions >
250+ < Action
251+ className = "cursor-pointer transition-colors hover:bg-blue-50 hover:text-blue-500 active:bg-blue-100"
252+ onClick = { ( ) => navigator . clipboard . writeText ( message . content ) }
253+ tooltip = "Copy message"
254+ >
255+ < CopyIcon className = "h-4 w-4" />
256+ </ Action >
257+ { showUpVoteButton && (
258+ < Action
259+ className = { `cursor-pointer transition-colors ${
260+ isVoteButtonClicked
261+ ? 'bg-green-100 text-green-600'
262+ : 'hover:bg-green-50 hover:text-green-500 active:bg-green-100'
263+ } `}
264+ disabled = { isVoteButtonClicked }
265+ onClick = { ( ) => handleFeedbackButtonClick ( 'upvote' ) }
266+ tooltip = "Upvote"
267+ >
268+ < ThumbsUpIcon className = "h-4 w-4" />
269+ </ Action >
270+ ) }
271+ { showDownVoteButton && (
272+ < Action
273+ className = { `cursor-pointer transition-colors ${
274+ isVoteButtonClicked
275+ ? 'bg-red-100 text-red-600'
276+ : 'hover:bg-red-50 hover:text-red-500 active:bg-red-100'
277+ } `}
278+ disabled = { isVoteButtonClicked }
279+ onClick = { ( ) => handleFeedbackButtonClick ( 'downvote' ) }
280+ tooltip = "Downvote"
281+ >
282+ < ThumbsDownIcon className = "h-4 w-4" />
283+ </ Action >
284+ ) }
285+ </ Actions >
139286
140- { message . hasVisualization && ! isUser && (
141- < div className = "mt-3 border-border/30 border-t pt-3" >
142- < div className = "flex items-center gap-2 text-muted-foreground text-xs" >
143- { getChartIcon ( message . chartType || 'bar' ) }
144- < span > Visualization generated in the data panel. </ span >
145- </ div >
146- </ div >
287+ { showFeedbackInput && (
288+ < FeedbackInput
289+ onCancel = { resetFeedback }
290+ onSubmit = { handleSubmitFeedback }
291+ / >
292+ ) }
293+ </ >
147294 ) }
148- </ MessageContent >
295+ </ div >
149296 </ AIMessage >
150297 ) ;
151298}
152299
153- export function MessageBubble ( { message } : MessageBubbleProps ) {
300+ export function MessageBubble ( {
301+ message,
302+ handleVote,
303+ handleFeedbackComment,
304+ isLastMessage,
305+ } : MessageBubbleProps ) {
154306 const isUser = message . type === 'user' ;
155307 const isInProgress = message . type === 'assistant' && ! message . content ;
156308
157309 if ( isInProgress ) {
158310 return < InProgressMessage message = { message } /> ;
159311 }
160312
161- return < CompletedMessage isUser = { isUser } message = { message } /> ;
313+ return (
314+ < CompletedMessage
315+ handleFeedbackComment = { handleFeedbackComment }
316+ handleVote = { handleVote }
317+ isLastMessage = { isLastMessage }
318+ isUser = { isUser }
319+ message = { message }
320+ />
321+ ) ;
162322}
0 commit comments