feat(web): show timestamps in conversation outline (#1111)

This commit is contained in:
Ananovo
2026-07-27 19:52:33 +08:00
committed by GitHub
parent 5e8515c1a5
commit b28088bfe3
6 changed files with 94 additions and 13 deletions
+40 -1
View File
@@ -1,5 +1,44 @@
import { describe, expect, it } from 'vitest'
import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation'
import { getEventPresentation, formatMessageTimestamp, formatOutlineTimestamp, formatResetTime } from './presentation'
describe('formatOutlineTimestamp', () => {
it('shows only the time for same-day messages', () => {
const date = new Date(2026, 6, 21, 9, 55)
const now = new Date(2026, 6, 21, 12, 0)
expect(formatOutlineTimestamp(date, 'en', now)).toBe(
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
)
})
it('zero-pads Chinese dates within the current year', () => {
const date = new Date(2026, 8, 9, 10, 31)
const now = new Date(2026, 6, 21, 12, 0)
expect(formatOutlineTimestamp(date, 'zh-CN', now)).toBe(
`09月09日 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })}`
)
})
it('uses a zero-padded numeric date in English', () => {
const date = new Date(2026, 9, 1, 10, 31)
const now = new Date(2026, 6, 21, 12, 0)
expect(formatOutlineTimestamp(date, 'en', now)).toBe(
`10/01 ${date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })}`
)
})
it('includes the year for older years in both locales', () => {
const date = new Date(2025, 8, 9, 10, 31)
const now = new Date(2026, 6, 21, 12, 0)
const zhTime = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
const enTime = date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
expect(formatOutlineTimestamp(date, 'zh-CN', now)).toBe(`2025年09月09日 ${zhTime}`)
expect(formatOutlineTimestamp(date, 'en', now)).toBe(`2025/09/09 ${enTime}`)
})
})
describe('getEventPresentation — agent errors', () => {
it('formats error events with warning icon and message text', () => {
+28
View File
@@ -43,6 +43,34 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
}
export function formatOutlineTimestamp(
date: Date,
locale: 'en' | 'zh-CN',
now: Date = new Date()
): string {
const sameDay = date.getFullYear() === now.getFullYear()
&& date.getMonth() === now.getMonth()
&& date.getDate() === now.getDate()
const time = date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
if (sameDay) {
return time
}
const year = String(date.getFullYear())
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const sameYear = date.getFullYear() === now.getFullYear()
if (locale === 'zh-CN') {
const dateLabel = sameYear ? `${month}${day}` : `${year}${month}${day}`
return `${dateLabel} ${time}`
}
const dateLabel = sameYear ? `${month}/${day}` : `${year}/${month}/${day}`
return `${dateLabel} ${time}`
}
export function formatMessageTimestampTitle(date: Date): string {
return date.toLocaleString(undefined, {
year: 'numeric',
@@ -69,6 +69,16 @@ describe('ConversationOutlinePanel', () => {
expect(onSelect).toHaveBeenCalledWith(outlineItems[0])
})
it('shows each message time instead of a redundant user label', () => {
const { container } = renderPanel()
const timestamps = container.querySelectorAll('time')
expect(timestamps).toHaveLength(outlineItems.length)
expect(timestamps[0]).toHaveAttribute('dateTime', new Date(outlineItems[0].createdAt).toISOString())
expect(timestamps[0]).toHaveAttribute('title')
expect(screen.queryByText('User')).not.toBeInTheDocument()
})
it('shows load earlier when older messages exist', () => {
const onLoadMore = vi.fn()
renderPanel({ hasMoreMessages: true, onLoadMore })
@@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import type { ConversationOutlineItem } from '@/chat/outline'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { formatMessageTimestampTitle, formatOutlineTimestamp } from '@/chat/presentation'
import { HappyChatProvider } from '@/components/AssistantChat/context'
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
@@ -208,7 +209,7 @@ export function ConversationOutlinePanel(props: {
onSelect: (item: ConversationOutlineItem) => void
onClose: () => void
}) {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase()
const filteredItems = useMemo(() => {
@@ -302,21 +303,26 @@ export function ConversationOutlinePanel(props: {
) : (
<div className="space-y-1">
{filteredItems.map((item) => {
const createdAt = new Date(item.createdAt)
return (
<button
key={item.id}
type="button"
onClick={() => props.onSelect(item)}
className="group flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
className="group block w-full min-w-0 rounded-md px-2 py-2 text-left transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--app-button)]" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium uppercase text-[var(--app-hint)]">
{t('session.outline.kind.user')}
</span>
<span className="line-clamp-2 text-sm leading-snug text-[var(--app-fg)]">
{item.label}
</span>
<span className="flex min-w-0 items-center gap-2 text-[11px] font-medium tabular-nums text-[var(--app-hint)]">
<span className="h-2 w-2 shrink-0 rounded-full bg-[var(--app-button)]" aria-hidden="true" />
<time
dateTime={createdAt.toISOString()}
title={formatMessageTimestampTitle(createdAt)}
className="truncate"
>
{formatOutlineTimestamp(createdAt, locale)}
</time>
</span>
<span className="mt-0.5 line-clamp-2 pl-4 text-sm leading-snug text-[var(--app-fg)]">
{item.label}
</span>
</button>
)
-1
View File
@@ -180,7 +180,6 @@ export default {
'session.outline.searchLabel': 'Search outline items',
'session.outline.searchResults': '{matched} of {total} items',
'session.outline.noSearchResults': 'No matching outline items',
'session.outline.kind.user': 'User',
// Session actions
'session.action.rename': 'Rename',
-1
View File
@@ -180,7 +180,6 @@ export default {
'session.outline.searchLabel': '搜索大纲条目',
'session.outline.searchResults': '{matched}/{total} 个条目',
'session.outline.noSearchResults': '没有匹配的大纲条目',
'session.outline.kind.user': '用户',
// Session actions
'session.action.rename': '重命名',