refine message actions and metadata

This commit is contained in:
weishu
2026-07-12 18:36:43 +08:00
parent 8782b8a110
commit 4c76668a6c
12 changed files with 222 additions and 271 deletions
@@ -1,17 +1,13 @@
import { useState } from 'react'
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage'
import { CliOutputBlock } from '@/components/CliOutputBlock'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
import { MessageActions } from '@/components/AssistantChat/messages/MessageActions'
const TOOL_COMPONENTS = {
Fallback: HappyToolMessage
@@ -25,8 +21,6 @@ const MESSAGE_PART_COMPONENTS = {
} as const
export function HappyAssistantMessage() {
const { copied, copy } = useCopyToClipboard()
const [showMetadata, setShowMetadata] = useState(false)
const messageId = useAssistantState(({ message }) => message.id)
const isCliOutput = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
@@ -51,187 +45,28 @@ export function HappyAssistantMessage() {
return getAssistantCopyText(message.content)
})
const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.invokedAt)
const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.durationMs)
const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.usage)
const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.model)
const turnCount = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.turnCount)
const hasMetadata = invokedAt != null
|| (typeof durationMs === 'number' && durationMs >= 0)
|| usage != null
|| (messageModel != null && messageModel !== '')
|| (typeof turnCount === 'number' && turnCount >= 2)
const metadata = { durationMs, usage, model: messageModel ?? null, turnCount }
const rootClass = toolOnly
? 'py-1 min-w-0 max-w-full overflow-x-hidden'
: 'px-1 min-w-0 max-w-full overflow-x-hidden'
if (isCliOutput) {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
>
<CliOutputBlock text={cliText} />
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
/>
)}
</MessagePrimitive.Root>
)
}
if (codexReview) {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<CodexReviewCard review={codexReview} />
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
/>
)}
</div>
{copyText ? (
<div className="happy-message-actions-first-line hidden sm:flex shrink-0 opacity-0 group-hover/msg:opacity-100 transition-opacity">
<button
type="button"
title="Copy"
className="p-0.5 rounded hover:bg-[var(--app-subtle-bg)] transition-colors"
onClick={() => copy(copyText)}
>
{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>
</div>
) : null}
</div>
</MessagePrimitive.Root>
)
}
if (toolOnly) {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
>
<div className="min-w-0">
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
/>
)}
</div>
</MessagePrimitive.Root>
)
}
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`}
className={`happy-message ${rootClass} scroll-mt-4`}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
durationMs={durationMs}
usage={usage}
model={messageModel ?? null}
turnCount={turnCount}
/>
)}
</div>
{copyText ? (
<div className="happy-message-actions-first-line hidden sm:flex shrink-0 opacity-0 group-hover/msg:opacity-100 transition-opacity">
<button
type="button"
title="Copy"
className="p-0.5 rounded hover:bg-[var(--app-subtle-bg)] transition-colors"
onClick={() => copy(copyText)}
>
{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>
</div>
) : null}
</div>
{isCliOutput
? <CliOutputBlock text={cliText} />
: codexReview
? <CodexReviewCard review={codexReview} />
: <MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />}
<MessageActions align="start" copyText={copyText || undefined} metadata={metadata} />
</MessagePrimitive.Root>
)
}
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import { MessageActions } from './MessageActions'
const copy = vi.fn()
vi.mock('@assistant-ui/react', () => ({
useAssistantState: (selector: (state: { message: { createdAt: Date } }) => unknown) => selector({
message: { createdAt: new Date(2026, 6, 12, 10, 30) }
})
}))
vi.mock('@radix-ui/react-popover', () => ({
Root: ({ children }: PropsWithChildren) => <>{children}</>,
Trigger: ({ children }: PropsWithChildren) => <>{children}</>,
Portal: ({ children }: PropsWithChildren) => <>{children}</>,
Content: ({ children }: PropsWithChildren) => <div>{children}</div>
}))
vi.mock('@/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copied: false, copy })
}))
function renderActions(props: ComponentProps<typeof MessageActions>) {
return render(
<I18nProvider>
<MessageActions {...props} />
</I18nProvider>
)
}
describe('MessageActions', () => {
beforeEach(() => {
copy.mockReset()
localStorage.clear()
})
it('copies the supplied message text', () => {
renderActions({ align: 'start', copyText: 'message body' })
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
expect(copy).toHaveBeenCalledWith('message body')
})
it('shows meaningful assistant metadata in a popover without invoke time', () => {
renderActions({
align: 'start',
metadata: {
durationMs: 1250,
model: 'gpt-5.2-codex',
usage: { input_tokens: 100, output_tokens: 25 }
}
})
expect(screen.getByRole('button', { name: 'Message details' })).toBeTruthy()
expect(screen.getByText('Duration: 1.3s')).toBeTruthy()
expect(screen.getByText('Model: gpt-5.2-codex')).toBeTruthy()
expect(screen.getByText('Usage: 125 billable tokens (100 in / 25 out)')).toBeTruthy()
expect(screen.queryByText(/^Invoke:/)).toBeNull()
})
it('omits the info action when no display metadata exists', () => {
renderActions({ align: 'end', copyText: 'message body', metadata: {} })
expect(screen.queryByRole('button', { name: 'Message details' })).toBeNull()
})
})
@@ -0,0 +1,82 @@
import * as Popover from '@radix-ui/react-popover'
import { CheckIcon, CopyIcon, InfoIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { useTranslation } from '@/lib/use-translation'
import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps } from './MessageMetadata'
import { MessageTimestamp } from './MessageTimestamp'
import { cn } from '@/lib/utils'
type MessageActionsProps = {
align: 'start' | 'end'
copyText?: string
metadata?: Omit<MessageMetadataProps, 'className'>
}
export function MessageActions({ align, copyText, metadata }: MessageActionsProps) {
const { copied, copy } = useCopyToClipboard()
const { t } = useTranslation()
const canCopy = Boolean(copyText)
const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false
return (
<div
className={cn(
'happy-message-actions mt-1 flex h-5 items-center gap-1',
align === 'end' ? 'justify-end' : 'justify-start',
!canCopy && 'happy-message-actions-desktop-only-row'
)}
>
{align === 'end' ? <DesktopTimestamp /> : null}
{canCopy ? (
<button
type="button"
title={copied ? t('message.copied') : t('message.copy')}
aria-label={copied ? t('message.copied') : t('message.copy')}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
onClick={() => copy(copyText!)}
>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
{hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{align === 'start' ? <DesktopTimestamp /> : null}
</div>
)
}
function DesktopTimestamp() {
return (
<span className="happy-message-actions-desktop-only ml-1 items-center">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
</span>
)
}
function MessageInfoPopover({ metadata }: { metadata: Omit<MessageMetadataProps, 'className'> }) {
const { t } = useTranslation()
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
title={t('message.info')}
aria-label={t('message.info')}
className="happy-message-actions-desktop-only h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
>
<InfoIcon className="h-3.5 w-3.5" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="bottom"
align="start"
sideOffset={6}
collisionPadding={8}
className="z-50 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 shadow-lg"
>
<MessageMetadata {...metadata} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}
@@ -54,22 +54,11 @@ describe('buildMessageMetadataLabels', () => {
expect(parts).toContain('Duration: 0.0s')
})
it('does not drop an Invoke line when invokedAt is the unix epoch', () => {
const parts = buildMessageMetadataLabels({ invokedAt: 0 })
expect(parts.some(p => p.startsWith('Invoke:'))).toBe(true)
})
it('omits the Invoke line when invokedAt is null or undefined', () => {
expect(buildMessageMetadataLabels({ invokedAt: null }).some(p => p.startsWith('Invoke:'))).toBe(false)
expect(buildMessageMetadataLabels({}).some(p => p.startsWith('Invoke:'))).toBe(false)
})
// Proof of Invariance — single-turn inputs (turnCount omitted, or < 2)
// must produce byte-identical output to the pre-aggregate footer so
// existing single-turn cards do not regress visually.
it('single-turn input is byte-identical with or without turnCount=1', () => {
const base = {
invokedAt: 1700000000000,
durationMs: 1234,
model: 'claude-sonnet-4-6',
usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' }
@@ -84,7 +73,6 @@ describe('buildMessageMetadataLabels', () => {
// formatting would surface visually in single-turn cards.
it('pre-aggregate single-turn call produces the exact label sequence', () => {
const parts = buildMessageMetadataLabels({
invokedAt: 1700000000000,
durationMs: 1234,
model: 'claude-sonnet-4-6',
usage: { input_tokens: 3, output_tokens: 19, service_tier: 'standard' }
@@ -92,9 +80,7 @@ describe('buildMessageMetadataLabels', () => {
// The Invoke value depends on the runner's timezone, so match its
// shape rather than a literal time string. The remaining labels are
// timezone-independent and locked exactly.
expect(parts).toHaveLength(4)
expect(parts[0]).toMatch(/^Invoke: \d{2}:\d{2}:\d{2}$/)
expect(parts.slice(1)).toEqual([
expect(parts).toEqual([
'Duration: 1.2s',
'Model: claude-sonnet-4-6',
'Usage: 22 billable tokens (3 in / 19 out)'
@@ -103,7 +89,6 @@ describe('buildMessageMetadataLabels', () => {
it('switches to Models/Total/N turns labels only when turnCount >= 2', () => {
const parts = buildMessageMetadataLabels({
invokedAt: 1700000000000,
model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001',
usage: { input_tokens: 100, output_tokens: 200, service_tier: 'standard' },
turnCount: 3
@@ -119,7 +104,6 @@ describe('buildMessageMetadataLabels', () => {
// Mid-session model switch is rare; the common multi-turn case is one
// model repeated across N turns. The label must stay singular then.
const parts = buildMessageMetadataLabels({
invokedAt: 1700000000000,
model: 'claude-sonnet-4-6',
usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' },
turnCount: 2
@@ -131,7 +115,6 @@ describe('buildMessageMetadataLabels', () => {
it('omits Duration on aggregated footers when durationMs is undefined', () => {
const parts = buildMessageMetadataLabels({
invokedAt: 1700000000000,
model: 'claude-sonnet-4-6, claude-haiku-4-5-20251001',
usage: { input_tokens: 10, output_tokens: 20, service_tier: 'standard' },
turnCount: 2
@@ -1,20 +1,18 @@
import type { UsageData } from '@/chat/types'
export type MessageMetadataProps = {
invokedAt?: number | null
durationMs?: number
usage?: UsageData
model?: string | null
/**
* Distinct turn count for the surrounding response group. Single-turn
* footers pass `undefined` (or any value < 2) so the existing
* `Invoke · Model · Usage` output is preserved byte-for-byte.
* footers pass `undefined` (or any value < 2).
*/
turnCount?: number
className?: string
}
export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount }: Omit<MessageMetadataProps, 'className'>): string[] {
export function buildMessageMetadataLabels({ durationMs, usage, model, turnCount }: Omit<MessageMetadataProps, 'className'>): string[] {
const parts: string[] = []
// Aggregated footers represent a response group with multiple distinct
// turns. When the caller passes `turnCount >= 2` they have already
@@ -22,18 +20,6 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model
// across turns; we adjust the labels to reflect that.
const isAggregated = typeof turnCount === 'number' && turnCount >= 2
// Explicit nullish checks — `if (invokedAt)` would drop epoch 0, and
// `if (durationMs)` would drop legitimate 0 ms turns.
if (invokedAt != null) {
const time = new Date(invokedAt).toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
parts.push(`Invoke: ${time}`)
}
if (typeof durationMs === 'number' && durationMs >= 0) {
parts.push(`Duration: ${(durationMs / 1000).toFixed(1)}s`)
}
@@ -68,14 +54,14 @@ export function buildMessageMetadataLabels({ invokedAt, durationMs, usage, model
return parts
}
export function MessageMetadata({ invokedAt, durationMs, usage, model, turnCount, className }: MessageMetadataProps) {
const parts = buildMessageMetadataLabels({ invokedAt, durationMs, usage, model, turnCount })
export function MessageMetadata({ durationMs, usage, model, turnCount, className }: MessageMetadataProps) {
const parts = buildMessageMetadataLabels({ durationMs, usage, model, turnCount })
if (parts.length === 0) return null
return (
<div className={`text-[10px] text-[var(--app-hint)] bg-[var(--app-subtle-bg)] rounded px-2 py-1.5 flex flex-wrap gap-x-2 gap-y-0.5 mt-1 leading-tight ${className || ''}`}>
<div className={`flex max-w-[min(22rem,calc(100vw-1rem))] flex-col gap-1 text-xs leading-tight text-[var(--app-fg)] ${className || ''}`}>
{parts.map((part, i) => (
<span key={i} className="whitespace-nowrap">{part}</span>
<span key={i} className="break-words">{part}</span>
))}
</div>
)
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
@@ -6,16 +5,11 @@ import { MessageStatusIndicator } from '@/components/AssistantChat/messages/Mess
import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments'
import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble'
import { CliOutputBlock } from '@/components/CliOutputBlock'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
import { MessageActions } from '@/components/AssistantChat/messages/MessageActions'
export function HappyUserMessage() {
const ctx = useHappyChatContext()
const { copied, copy } = useCopyToClipboard()
const [showMetadata, setShowMetadata] = useState(false)
const role = useAssistantState(({ message }) => message.role)
const messageId = useAssistantState(({ message }) => message.id)
const text = useAssistantState(({ message }) => {
@@ -46,10 +40,6 @@ export function HappyUserMessage() {
if (custom?.kind !== 'cli-output') return ''
return message.content.find((part) => part.type === 'text')?.text ?? ''
})
const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.invokedAt)
const hasMetadata = invokedAt != null
if (role !== 'user') return null
const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage)
const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined
@@ -59,26 +49,11 @@ export function HappyUserMessage() {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
className="happy-message scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
>
<div className="ml-auto w-full max-w-[92%]">
<CliOutputBlock text={cliText} />
<div className="mt-1 flex items-center justify-end gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} />
)}
<MessageActions align="end" copyText={cliText} />
</div>
</MessagePrimitive.Root>
)
@@ -90,49 +65,22 @@ export function HappyUserMessage() {
return (
<MessagePrimitive.Root
id={getConversationMessageAnchorId(messageId)}
className={`${getUserBubbleClassName(status)} group/msg scroll-mt-4`}
className="happy-message flex flex-col items-end scroll-mt-4"
>
<div className="flex flex-col gap-1">
<div className={getUserBubbleClassName(status)}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{hasText ? <UserBubbleContent text={text} /> : null}
{hasAttachments ? <MessageAttachments attachments={attachments} /> : null}
</div>
{(hasText || showStatus) && (
{showStatus && (
<div className="happy-message-actions-first-line flex shrink-0 items-center gap-1">
{hasText && (
<button
type="button"
title="Copy"
className="rounded-md p-0.5 opacity-60 transition-[opacity,background-color] hover:bg-[var(--app-chat-user-chip-bg)] sm:opacity-0 sm:group-hover/msg:opacity-100"
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>
)}
{showStatus ? <MessageStatusIndicator status={status} onRetry={onRetry} /> : null}
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide info' : 'Show info'}
</button>
)}
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} />
)}
</div>
<MessageActions align="end" copyText={hasText ? text : undefined} />
</MessagePrimitive.Root>
)
}
+12
View File
@@ -61,6 +61,18 @@ export function CheckIcon(props: IconProps) {
)
}
export function InfoIcon(props: IconProps) {
return createIcon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5" />
<path d="M12 8h.01" />
</>,
props,
2
)
}
/** Composer schedule-send clock — circle + hands (matches ComposerButtons). */
export function ScheduleIcon(props: IconProps) {
return createIcon(
+27
View File
@@ -433,6 +433,33 @@ body {
margin-top: max(0px, calc((var(--app-chat-line-height) - var(--app-message-action-size)) / 2));
}
.happy-message-actions-desktop-only,
.happy-message-actions-desktop-only-row {
display: none;
}
@media (hover: hover) and (pointer: fine) {
.happy-message-actions {
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
.happy-message:hover .happy-message-actions,
.happy-message:focus-within .happy-message-actions {
opacity: 1;
pointer-events: auto;
}
.happy-message-actions-desktop-only {
display: inline-flex;
}
.happy-message-actions-desktop-only-row {
display: flex;
}
}
.aui-md :where(.contains-task-list) {
list-style: none;
padding-left: 0;
+3
View File
@@ -8,6 +8,9 @@ export default {
'loading.files': 'Loading files…',
'loading.messages': 'Loading messages…',
'loading.machines': 'Loading machines…',
'message.copy': 'Copy',
'message.copied': 'Copied',
'message.info': 'Message details',
// Login / Auth
'login.title': 'HAPI',
+3
View File
@@ -8,6 +8,9 @@ export default {
'loading.files': '加载文件…',
'loading.messages': '加载消息…',
'loading.machines': '加载机器…',
'message.copy': '复制',
'message.copied': '已复制',
'message.info': '消息详情',
// Login / Auth
'login.title': 'HAPI',