diff --git a/bun.lock b/bun.lock index 7d0875c9..cda410b4 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", diff --git a/web/package.json b/web/package.json index f6c8904e..c6822aba 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@hapi/protocol": "workspace:*", "@lobehub/icons": "^5.4.0", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 3cb2baf5..4391f9b9 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -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 | undefined @@ -51,187 +45,28 @@ export function HappyAssistantMessage() { return getAssistantCopyText(message.content) }) - const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs) const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.usage) const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.model) const turnCount = useAssistantState(({ message }) => (message.metadata.custom as Partial | 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 ( - - -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- ) - } - - if (codexReview) { - return ( - -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
-
- ) - } - - if (toolOnly) { - return ( - -
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
-
- ) - } - return ( -
-
- -
- - {hasMetadata && ( - - )} -
- {showMetadata && ( - - )} -
- {copyText ? ( -
- -
- ) : null} -
+ {isCliOutput + ? + : codexReview + ? + : } +
) } diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx new file mode 100644 index 00000000..89117c1d --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -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) =>
{children}
+})) + +vi.mock('@/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copied: false, copy }) +})) + +function renderActions(props: ComponentProps) { + return render( + + + + ) +} + +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() + }) +}) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx new file mode 100644 index 00000000..1238a4d4 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -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 +} + +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 ( +
+ {align === 'end' ? : null} + {canCopy ? ( + + ) : null} + {hasMetadata && metadata ? : null} + {align === 'start' ? : null} +
+ ) +} + +function DesktopTimestamp() { + return ( + + + + ) +} + +function MessageInfoPopover({ metadata }: { metadata: Omit }) { + const { t } = useTranslation() + return ( + + + + + + + + + + + ) +} diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts index e06946cd..f44274b5 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.test.ts +++ b/web/src/components/AssistantChat/messages/MessageMetadata.test.ts @@ -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 diff --git a/web/src/components/AssistantChat/messages/MessageMetadata.tsx b/web/src/components/AssistantChat/messages/MessageMetadata.tsx index 31358147..bb73aca3 100644 --- a/web/src/components/AssistantChat/messages/MessageMetadata.tsx +++ b/web/src/components/AssistantChat/messages/MessageMetadata.tsx @@ -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): string[] { +export function buildMessageMetadataLabels({ durationMs, usage, model, turnCount }: Omit): 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 ( -
+
{parts.map((part, i) => ( - {part} + {part} ))}
) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 8ebe3dc9..a8ad631d 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -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 | 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 (
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )} +
) @@ -90,49 +65,22 @@ export function HappyUserMessage() { return ( -
+
{hasText ? : null} {hasAttachments ? : null}
- {(hasText || showStatus) && ( + {showStatus && (
- {hasText && ( - - )} {showStatus ? : null}
)}
-
- - {hasMetadata && ( - - )} -
- {showMetadata && invokedAt != null && ( - - )}
+ ) } diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 5e587144..f55f3c71 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -61,6 +61,18 @@ export function CheckIcon(props: IconProps) { ) } +export function InfoIcon(props: IconProps) { + return createIcon( + <> + + + + , + props, + 2 + ) +} + /** Composer schedule-send clock — circle + hands (matches ComposerButtons). */ export function ScheduleIcon(props: IconProps) { return createIcon( diff --git a/web/src/index.css b/web/src/index.css index 5833f9d7..612b0704 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c606cea4..245be7a8 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 44b73250..a9664838 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -8,6 +8,9 @@ export default { 'loading.files': '加载文件…', 'loading.messages': '加载消息…', 'loading.machines': '加载机器…', + 'message.copy': '复制', + 'message.copied': '已复制', + 'message.info': '消息详情', // Login / Auth 'login.title': 'HAPI',