From b28088bfe3db585dabfc6b3f9471738eef53dfdf Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 27 Jul 2026 19:52:33 +0800 Subject: [PATCH] feat(web): show timestamps in conversation outline (#1111) --- web/src/chat/presentation.test.ts | 41 ++++++++++++++++++- web/src/chat/presentation.ts | 28 +++++++++++++ .../AssistantChat/HappyThread.test.tsx | 10 +++++ .../components/AssistantChat/HappyThread.tsx | 26 +++++++----- web/src/lib/locales/en.ts | 1 - web/src/lib/locales/zh-CN.ts | 1 - 6 files changed, 94 insertions(+), 13 deletions(-) diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index efa45320..7bb55335 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -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', () => { diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 823c0595..a57a8d9d 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -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', diff --git a/web/src/components/AssistantChat/HappyThread.test.tsx b/web/src/components/AssistantChat/HappyThread.test.tsx index 98ac0986..271e9278 100644 --- a/web/src/components/AssistantChat/HappyThread.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.test.tsx @@ -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 }) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index d961ba0d..88cc23bd 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -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: { ) : (
{filteredItems.map((item) => { + const createdAt = new Date(item.createdAt) return ( ) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f7a44e40..bc33d8db 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 94ec4521..4a8aabd0 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '重命名',