Message.Actions.tsx213 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
3import { useEffect, useState, type PropsWithChildren } from 'react'
4import { useForm } from 'react-hook-form'
5import {
6 Button,
7 cn,
8 Form,
9 FormControl,
10 FormField,
11 Popover,
12 PopoverContent,
13 PopoverTrigger,
14 TextArea,
15} from 'ui'
16import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
17import * as z from 'zod'
18
19import { ButtonTooltip } from '../ButtonTooltip'
20
21export function MessageActions({
22 children,
23 alwaysShow = false,
24}: PropsWithChildren<{ alwaysShow?: boolean }>) {
25 return (
26 <div className="flex items-center gap-4 mt-2 mb-1">
27 <span className="h-0.5 w-5 bg-muted" />
28 <div className={cn('group-hover:opacity-100 transition-opacity', !alwaysShow && 'opacity-0')}>
29 {children}
30 </div>
31 </div>
32 )
33}
34function MessageActionsEdit({ onClick, tooltip }: { onClick: () => void; tooltip: string }) {
35 return (
36 <ButtonTooltip
37 type="text"
38 icon={<Pencil size={14} strokeWidth={1.5} />}
39 onClick={onClick}
40 className="text-foreground-light hover:text-foreground p-1 rounded-sm"
41 aria-label={tooltip}
42 tooltip={{
43 content: {
44 side: 'bottom',
45 text: tooltip,
46 },
47 }}
48 />
49 )
50}
51MessageActions.Edit = MessageActionsEdit
52
53function MessageActionsDelete({ onClick }: { onClick: () => void }) {
54 return (
55 <ButtonTooltip
56 type="text"
57 icon={<Trash2 size={14} strokeWidth={1.5} />}
58 tooltip={{ content: { side: 'bottom', text: 'Delete message' } }}
59 onClick={onClick}
60 className="text-foreground-light hover:text-foreground p-1 rounded-sm"
61 title="Delete message"
62 aria-label="Delete message"
63 />
64 )
65}
66MessageActions.Delete = MessageActionsDelete
67
68function MessageActionsThumbsUp({
69 onClick,
70 isActive,
71 disabled,
72}: {
73 onClick: () => void
74 isActive?: boolean
75 disabled?: boolean
76}) {
77 return (
78 <Button
79 type="text"
80 disabled={disabled}
81 icon={
82 <ThumbsUp
83 size={14}
84 strokeWidth={1.5}
85 className={cn(
86 isActive
87 ? 'text-brand hover:text-brand-700'
88 : 'text-foreground-light hover:text-foreground'
89 )}
90 />
91 }
92 onClick={onClick}
93 className={cn(
94 'p-1 rounded-sm transition-colors',
95 disabled && 'opacity-50 pointer-events-none'
96 )}
97 title="Good response"
98 aria-label="Good response"
99 />
100 )
101}
102MessageActions.ThumbsUp = MessageActionsThumbsUp
103
104const feedbackSchema = z.object({
105 reason: z.string().optional(),
106})
107
108type FeedbackFormValues = z.infer<typeof feedbackSchema>
109
110function MessageActionsThumbsDown({
111 onClick,
112 isActive,
113 disabled,
114}: {
115 onClick: (reason?: string) => void
116 isActive?: boolean
117 disabled?: boolean
118}) {
119 const [open, setOpen] = useState(false)
120
121 const form = useForm<FeedbackFormValues>({
122 resolver: zodResolver(feedbackSchema as any),
123 defaultValues: { reason: '' },
124 mode: 'onSubmit',
125 })
126
127 const handleOpenChange = (newOpen: boolean) => {
128 if (disabled) return
129 // When popover closes, submit the rating if not already submitted
130 if (!newOpen && open && !form.formState.isSubmitSuccessful) {
131 onClick()
132 }
133 setOpen(newOpen)
134 if (!newOpen) {
135 form.reset()
136 }
137 }
138
139 const onSubmit = (values: FeedbackFormValues) => {
140 onClick(values.reason || undefined)
141 }
142
143 // Auto-close popover after showing thank you message
144 useEffect(() => {
145 if (form.formState.isSubmitSuccessful) {
146 const timer = setTimeout(() => {
147 setOpen(false)
148 }, 2000)
149 return () => clearTimeout(timer)
150 }
151 }, [form.formState.isSubmitSuccessful])
152
153 return (
154 <Popover open={open} onOpenChange={handleOpenChange}>
155 <PopoverTrigger asChild>
156 <Button
157 type="text"
158 disabled={disabled}
159 onClick={() => !disabled && setOpen(true)}
160 className={cn(
161 'p-1 rounded-sm transition-colors',
162 disabled && 'opacity-50 pointer-events-none'
163 )}
164 title="Bad response"
165 aria-label="Bad response"
166 >
167 <ThumbsDown
168 size={14}
169 strokeWidth={1.5}
170 className={cn(
171 isActive
172 ? 'text-warning hover:text-warning-700'
173 : 'text-foreground-light hover:text-foreground'
174 )}
175 />
176 </Button>
177 </PopoverTrigger>
178 <PopoverContent className="w-80" align="start">
179 {form.formState.isSubmitSuccessful ? (
180 <p className="text-sm">We appreciate your feedback!</p>
181 ) : (
182 <Form {...form}>
183 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
184 <FormField
185 control={form.control}
186 name="reason"
187 render={({ field }) => (
188 <FormItemLayout label="What went wrong?" labelOptional="optional">
189 <FormControl>
190 <TextArea
191 placeholder="Describe why the response was not helpful..."
192 autoComplete="off"
193 rows={4}
194 autoFocus
195 {...field}
196 />
197 </FormControl>
198 </FormItemLayout>
199 )}
200 />
201 <div className="flex justify-end">
202 <Button type="primary" htmlType="submit" size="tiny">
203 Submit feedback
204 </Button>
205 </div>
206 </form>
207 </Form>
208 )}
209 </PopoverContent>
210 </Popover>
211 )
212}
213MessageActions.ThumbsDown = MessageActionsThumbsDown