mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
refine message actions and metadata
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,9 @@ export default {
|
||||
'loading.files': '加载文件…',
|
||||
'loading.messages': '加载消息…',
|
||||
'loading.machines': '加载机器…',
|
||||
'message.copy': '复制',
|
||||
'message.copied': '已复制',
|
||||
'message.info': '消息详情',
|
||||
|
||||
// Login / Auth
|
||||
'login.title': 'HAPI',
|
||||
|
||||
Reference in New Issue
Block a user