Skip to content

Commit 7478516

Browse files
author
JooHyung Park
committed
[ai-assisted] fix: add copy and feedback actions to assistant messages
1 parent cd45e23 commit 7478516

2 files changed

Lines changed: 140 additions & 8 deletions

File tree

src/components/prompt-kit/message.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react'
22

33
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
4+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
45
import { cn } from '@/lib/utils'
56
import { Markdown } from '@/components/prompt-kit/markdown'
67

@@ -70,3 +71,23 @@ export function MessageContent({
7071
</div>
7172
)
7273
}
74+
75+
type MessageActionsProps = React.HTMLAttributes<HTMLDivElement>
76+
77+
export function MessageActions({ className, ...props }: MessageActionsProps) {
78+
return <div className={cn('flex items-center gap-0.5 text-muted-foreground', className)} {...props} />
79+
}
80+
81+
interface MessageActionProps {
82+
tooltip: React.ReactNode
83+
children: React.ReactElement
84+
}
85+
86+
export function MessageAction({ tooltip, children }: MessageActionProps) {
87+
return (
88+
<Tooltip>
89+
<TooltipTrigger asChild>{children}</TooltipTrigger>
90+
<TooltipContent sideOffset={6}>{tooltip}</TooltipContent>
91+
</Tooltip>
92+
)
93+
}

src/pages/Assistant.tsx

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { 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

44
import { Button } from '@/components/ui/button'
55
import { 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([
7274
type AppState = 'ready' | 'needs-setup' | 'needs-model-selection' | 'waiting-approval'
7375
type ToolPartState = ToolPart['state']
7476
type PermissionMode = 'run-everything' | 'ask-every-time'
77+
type AssistantFeedback = 'up' | 'down'
7578
const 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

Comments
 (0)