11import { useCallback , useEffect , useMemo , useRef , useState , type ChangeEvent } from 'react'
2- import { Check , ChevronDown , Loader2 , Paperclip , Plus , SendHorizontal , Shield , Trash2 , X } from 'lucide-react'
2+ import { Check , ChevronDown , Copy , Loader2 , Paperclip , Plus , SendHorizontal , Shield , ThumbsDown , ThumbsUp , Trash2 , X } from 'lucide-react'
33
44import { Button } from '@/components/ui/button'
55import { Dialog , DialogContent , DialogDescription , DialogFooter , DialogHeader , DialogTitle } from '@/components/ui/dialog'
@@ -10,6 +10,8 @@ import {
1010 ChatContainerRoot ,
1111 ChatContainerScrollAnchor ,
1212 Message ,
13+ MessageAction ,
14+ MessageActions ,
1315 MessageContent ,
1416 PromptInput ,
1517 PromptInputAction ,
@@ -72,6 +74,7 @@ const TEXT_ATTACHMENT_EXTENSIONS = new Set([
7274type AppState = 'ready' | 'needs-setup' | 'needs-model-selection' | 'waiting-approval'
7375type ToolPartState = ToolPart [ 'state' ]
7476type PermissionMode = 'run-everything' | 'ask-every-time'
77+ type AssistantFeedback = 'up' | 'down'
7578const VALID_TOOL_PART_STATES : ToolPartState [ ] = [
7679 'input-streaming' ,
7780 'input-available' ,
@@ -487,11 +490,14 @@ export function AssistantPage() {
487490 const [ reasoningOpen , setReasoningOpen ] = useState ( false )
488491 const [ permissionMode , setPermissionMode ] = useState < PermissionMode > ( 'ask-every-time' )
489492 const [ deletingThreadId , setDeletingThreadId ] = useState < string | null > ( null )
493+ const [ copiedMessageId , setCopiedMessageId ] = useState < string | null > ( null )
494+ const [ assistantFeedbackByMessageId , setAssistantFeedbackByMessageId ] = useState < Record < string , AssistantFeedback > > ( { } )
490495
491496 const sendLockRef = useRef ( false )
492497 const setupDialogPinnedRef = useRef ( false )
493498 const shimmerVisibleSinceRef = useRef < number | null > ( null )
494499 const shimmerHideTimerRef = useRef < number | null > ( null )
500+ const copyFeedbackTimerRef = useRef < number | null > ( null )
495501 const fileInputRef = useRef < HTMLInputElement | null > ( null )
496502
497503 const activeThread = useMemo (
@@ -505,6 +511,15 @@ export function AssistantPage() {
505511 ( ) => toRenderableItems ( messages , liveToolEvents , streamingText ) ,
506512 [ messages , liveToolEvents , streamingText ] ,
507513 )
514+ const lastAssistantMessageId = useMemo ( ( ) => {
515+ for ( let index = renderableItems . length - 1 ; index >= 0 ; index -= 1 ) {
516+ const item = renderableItems [ index ]
517+ if ( item . kind === 'text' && item . role === 'assistant' && item . text . trim ( ) . length > 0 ) {
518+ return item . id
519+ }
520+ }
521+ return null
522+ } , [ renderableItems ] )
508523
509524 const isComposerDisabled = ! activeThreadId || ! ! runId || isSending || appState === 'needs-setup'
510525 const isSendDisabled = isComposerDisabled || appState === 'needs-model-selection'
@@ -569,6 +584,16 @@ export function AssistantPage() {
569584 }
570585 } , [ shouldShowPreResponseShimmer , showPreResponseShimmer ] )
571586
587+ useEffect (
588+ ( ) => ( ) => {
589+ if ( copyFeedbackTimerRef . current !== null ) {
590+ window . clearTimeout ( copyFeedbackTimerRef . current )
591+ copyFeedbackTimerRef . current = null
592+ }
593+ } ,
594+ [ ] ,
595+ )
596+
572597 const openSetupDialog = useCallback ( ( pinned = false ) => {
573598 setupDialogPinnedRef . current = pinned
574599 setSetupDialogOpen ( true )
@@ -1060,6 +1085,37 @@ export function AssistantPage() {
10601085 await window . electron . assistant . cancelRun ( runId )
10611086 } , [ runId ] )
10621087
1088+ const handleCopyAssistantMessage = useCallback ( async ( messageId : string , content : string ) => {
1089+ try {
1090+ await navigator . clipboard . writeText ( content )
1091+ setCopiedMessageId ( messageId )
1092+ if ( copyFeedbackTimerRef . current !== null ) {
1093+ window . clearTimeout ( copyFeedbackTimerRef . current )
1094+ }
1095+ copyFeedbackTimerRef . current = window . setTimeout ( ( ) => {
1096+ setCopiedMessageId ( ( current ) => ( current === messageId ? null : current ) )
1097+ copyFeedbackTimerRef . current = null
1098+ } , 1500 )
1099+ } catch ( copyError ) {
1100+ console . error ( 'Failed to copy assistant message' , copyError )
1101+ }
1102+ } , [ ] )
1103+
1104+ const handleAssistantFeedback = useCallback ( ( messageId : string , feedback : AssistantFeedback ) => {
1105+ setAssistantFeedbackByMessageId ( ( current ) => {
1106+ const existing = current [ messageId ]
1107+ if ( existing === feedback ) {
1108+ const next = { ...current }
1109+ delete next [ messageId ]
1110+ return next
1111+ }
1112+ return {
1113+ ...current ,
1114+ [ messageId ] : feedback ,
1115+ }
1116+ } )
1117+ } , [ ] )
1118+
10631119 return (
10641120 < >
10651121 < div className = "flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden bg-background" >
@@ -1089,13 +1145,68 @@ export function AssistantPage() {
10891145 >
10901146 { hasText ? (
10911147 isAssistant ? (
1092- < MessageContent
1093- from = { from }
1094- markdown
1095- className = "text-foreground prose w-full flex-1 rounded-lg border-transparent bg-transparent p-2 shadow-none"
1096- >
1097- { item . text }
1098- </ MessageContent >
1148+ < div className = "group flex w-full flex-col gap-0.5" >
1149+ < MessageContent
1150+ from = { from }
1151+ markdown
1152+ className = "text-foreground prose w-full flex-1 rounded-lg border-transparent bg-transparent p-2 leading-relaxed shadow-none"
1153+ >
1154+ { item . text }
1155+ </ MessageContent >
1156+ < MessageActions
1157+ className = { cn (
1158+ 'ml-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100' ,
1159+ item . id === lastAssistantMessageId && 'opacity-100' ,
1160+ ) }
1161+ >
1162+ < MessageAction tooltip = { copiedMessageId === item . id ? 'Copied' : 'Copy' } >
1163+ < Button
1164+ variant = "ghost"
1165+ size = "icon-sm"
1166+ className = "rounded-full"
1167+ aria-label = "Copy assistant message"
1168+ onClick = { ( ) => void handleCopyAssistantMessage ( item . id , item . text ) }
1169+ >
1170+ { copiedMessageId === item . id ? (
1171+ < Check className = "size-3.5" />
1172+ ) : (
1173+ < Copy className = "size-3.5" />
1174+ ) }
1175+ </ Button >
1176+ </ MessageAction >
1177+ < MessageAction tooltip = "Helpful" >
1178+ < Button
1179+ variant = "ghost"
1180+ size = "icon-sm"
1181+ className = { cn (
1182+ 'rounded-full' ,
1183+ assistantFeedbackByMessageId [ item . id ] === 'up' && 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20' ,
1184+ ) }
1185+ aria-label = "Mark assistant response as helpful"
1186+ aria-pressed = { assistantFeedbackByMessageId [ item . id ] === 'up' }
1187+ onClick = { ( ) => handleAssistantFeedback ( item . id , 'up' ) }
1188+ >
1189+ < ThumbsUp className = "size-3.5" />
1190+ </ Button >
1191+ </ MessageAction >
1192+ < MessageAction tooltip = "Not helpful" >
1193+ < Button
1194+ variant = "ghost"
1195+ size = "icon-sm"
1196+ className = { cn (
1197+ 'rounded-full' ,
1198+ assistantFeedbackByMessageId [ item . id ] === 'down'
1199+ && 'bg-rose-500/10 text-rose-700 hover:bg-rose-500/20' ,
1200+ ) }
1201+ aria-label = "Mark assistant response as not helpful"
1202+ aria-pressed = { assistantFeedbackByMessageId [ item . id ] === 'down' }
1203+ onClick = { ( ) => handleAssistantFeedback ( item . id , 'down' ) }
1204+ >
1205+ < ThumbsDown className = "size-3.5" />
1206+ </ Button >
1207+ </ MessageAction >
1208+ </ MessageActions >
1209+ </ div >
10991210 ) : (
11001211 < MessageContent from = "user" className = "bg-primary text-primary-foreground max-w-[85%] sm:max-w-[75%]" >
11011212 { item . text }
0 commit comments