mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): show timestamps on chat messages (#664)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user