feat(web): add copy button to user messages (#349)

* feat(web): add copy button to user messages

Add a small copy button to user message bubbles for easy text copying,
especially useful on mobile where selecting text is difficult.

- Mobile: button always visible (opacity-60)
- Desktop: button appears on hover
- Uses existing useCopyToClipboard hook with haptic feedback
- Conditionally rendered to avoid empty container spacing

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): use valid CSS property in copy button transition

transition-[opacity,colors] is invalid because 'colors' is not a CSS
property (only Tailwind's utility class 'transition-colors' expands it).
Use 'background-color' instead so the hover background transition
actually works.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
Haoqing Wang
2026-03-24 14:16:19 +08:00
committed by GitHub
co-authored by HAPI
parent 7da6f7c5c5
commit ad4df369ea
@@ -5,9 +5,12 @@ import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator'
import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments'
import { CliOutputBlock } from '@/components/CliOutputBlock'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
export function HappyUserMessage() {
const ctx = useHappyChatContext()
const { copied, copy } = useCopyToClipboard()
const role = useAssistantState(({ message }) => message.role)
const text = useAssistantState(({ message }) => {
if (message.role !== 'user') return ''
@@ -58,17 +61,29 @@ export function HappyUserMessage() {
const hasAttachments = attachments && attachments.length > 0
return (
<MessagePrimitive.Root className={userBubbleClass}>
<MessagePrimitive.Root className={`${userBubbleClass} group/msg`}>
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
{hasText && <LazyRainbowText text={text} />}
{hasAttachments && <MessageAttachments attachments={attachments} />}
</div>
{status ? (
<div className="shrink-0 self-end pb-0.5">
<MessageStatusIndicator status={status} onRetry={onRetry} />
{(hasText || status) && (
<div className="shrink-0 self-end pb-0.5 flex items-center gap-1">
{hasText && (
<button
type="button"
title="Copy"
className="opacity-60 sm:opacity-0 sm:group-hover/msg:opacity-100 transition-[opacity,background-color] p-0.5 rounded hover:bg-[var(--app-subtle-bg)]"
onClick={() => copy(text)}
>
{copied
? <CheckIcon className="h-3.5 w-3.5 text-green-500" />
: <CopyIcon className="h-3.5 w-3.5 text-[var(--app-hint)]" />}
</button>
)}
{status && <MessageStatusIndicator status={status} onRetry={onRetry} />}
</div>
) : null}
)}
</div>
</MessagePrimitive.Root>
)