feat(web): show timestamps on chat messages (#664)

This commit is contained in:
2026-05-22 13:14:26 +08:00
committed by GitHub
parent 763f45acdd
commit 9e35067aa9
6 changed files with 98 additions and 15 deletions
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getEventPresentation, formatResetTime } from './presentation'
import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation'
describe('getEventPresentation — limit-warning', () => {
it('formats five_hour warning', () => {
@@ -139,3 +139,18 @@ describe('formatResetTime', () => {
expect(result).toBeTruthy()
})
})
describe('formatMessageTimestamp', () => {
it('formats today without requiring a date prefix', () => {
const now = new Date(2026, 4, 22, 14, 30)
const result = formatMessageTimestamp(new Date(2026, 4, 22, 9, 5), now)
expect(result).toBeTruthy()
expect(result).not.toContain('2026')
})
it('includes a year for messages outside the current year', () => {
const now = new Date(2026, 4, 22, 14, 30)
const result = formatMessageTimestamp(new Date(2025, 11, 31, 23, 59), now)
expect(result).toContain('2025')
})
})
+28
View File
@@ -26,6 +26,34 @@ export function formatResetTime(value: number): string {
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const sameDay = date.getFullYear() === now.getFullYear()
&& date.getMonth() === now.getMonth()
&& date.getDate() === now.getDate()
if (sameDay) {
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
}
const sameYear = date.getFullYear() === now.getFullYear()
if (sameYear) {
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
export function formatMessageTimestampTitle(date: Date): string {
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
})
}
// Known types: five_hour → "5-hour", seven_day → "7-day".
// Unknown types use underscore-to-space fallback (e.g. thirty_day → "thirty day").
function formatLimitType(limitType: string | undefined): string {
@@ -12,6 +12,7 @@ import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
const TOOL_COMPONENTS = {
Fallback: HappyToolMessage
@@ -85,16 +86,19 @@ export function HappyAssistantMessage() {
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
>
<CliOutputBlock text={cliText} />
{hasMetadata && (
<button
type="button"
onClick={() => setShowMetadata((open) => !open)}
aria-expanded={showMetadata}
className="mt-1 text-[10px] text-[var(--app-hint)] underline-offset-2 hover:text-[var(--app-fg)] hover:underline"
>
{showMetadata ? 'Hide metadata' : 'Show metadata'}
</button>
)}
<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 metadata' : 'Show metadata'}
</button>
)}
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
@@ -125,6 +129,9 @@ export function HappyAssistantMessage() {
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<CodexReviewCard review={codexReview} />
<div className="mt-1 flex justify-start">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
@@ -169,6 +176,9 @@ export function HappyAssistantMessage() {
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex justify-start">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
@@ -199,6 +209,9 @@ export function HappyAssistantMessage() {
aria-expanded={hasMetadata ? showMetadata : undefined}
>
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
<div className="mt-1 flex justify-start">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
</div>
{showMetadata && (
<MessageMetadata
invokedAt={invokedAt}
@@ -0,0 +1,20 @@
import { useAssistantState } from '@assistant-ui/react'
import { formatMessageTimestamp, formatMessageTimestampTitle } from '@/chat/presentation'
type MessageTimestampProps = {
className?: string
}
export function MessageTimestamp(props: MessageTimestampProps) {
const createdAt = useAssistantState(({ message }) => message.createdAt)
return (
<time
dateTime={createdAt.toISOString()}
title={formatMessageTimestampTitle(createdAt)}
className={props.className}
>
{formatMessageTimestamp(createdAt)}
</time>
)
}
@@ -2,6 +2,7 @@ import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { getEventPresentation } from '@/chat/presentation'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
export function HappySystemMessage() {
const role = useAssistantState(({ message }) => message.role)
@@ -25,6 +26,7 @@ export function HappySystemMessage() {
<span className="inline-flex items-center gap-1">
{icon ? <span aria-hidden="true">{icon}</span> : null}
<span>{text}</span>
<MessageTimestamp className="text-[10px]" />
</span>
</div>
</MessagePrimitive.Root>
@@ -11,6 +11,7 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
export function HappyUserMessage() {
const ctx = useHappyChatContext()
@@ -75,8 +76,9 @@ export function HappyUserMessage() {
>
<div className="ml-auto w-full max-w-[92%]">
<CliOutputBlock text={cliText} />
{hasMetadata && (
<div className="mt-1 flex justify-end">
<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)}
@@ -85,8 +87,8 @@ export function HappyUserMessage() {
>
{showMetadata ? 'Hide metadata' : 'Show metadata'}
</button>
</div>
)}
)}
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} className="mt-1 justify-end" />
)}
@@ -135,6 +137,9 @@ export function HappyUserMessage() {
</div>
)}
</div>
<div className="flex justify-end">
<MessageTimestamp className="text-[10px] leading-none text-[var(--app-hint)]" />
</div>
{showMetadata && invokedAt != null && (
<MessageMetadata invokedAt={invokedAt} className="justify-end opacity-60" />
)}