diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index b5e644be..1d6d9d68 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -17,11 +17,12 @@ type Props = { isStreaming?: boolean branchAction?: MessageBranchAction sessionId?: string + timestamp?: number } const MAX_CARDS = 3 -export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId }: Props) { +export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId, timestamp }: Props) { const t = useTranslation() const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined)) @@ -107,6 +108,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre copyLabel="Copy reply" branchAction={branchAction} align="start" + timestamp={timestamp} /> diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 993e0243..47647272 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -1,4 +1,7 @@ -import { GitFork } from 'lucide-react' +import { Check, Copy, GitFork } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../stores/settingsStore' +import { formatExactMessageTimestamp, formatMessageTimestamp } from '../../lib/formatMessageTimestamp' import { CopyButton } from '../shared/CopyButton' export type MessageBranchAction = { @@ -12,6 +15,7 @@ type Props = { copyLabel: string branchAction?: MessageBranchAction align?: 'start' | 'end' + timestamp?: number } export function MessageActionBar({ @@ -19,8 +23,17 @@ export function MessageActionBar({ copyLabel, branchAction, align = 'start', + timestamp, }: Props) { + const t = useTranslation() + const locale = useSettingsStore((state) => state.locale) const hasCopy = Boolean(copyText?.trim()) + const timeLabel = typeof timestamp === 'number' + ? formatMessageTimestamp(timestamp, t, locale) + : '' + const exactTimeLabel = typeof timestamp === 'number' + ? formatExactMessageTimestamp(timestamp, locale) + : '' if (!hasCopy && !branchAction) return null @@ -28,18 +41,18 @@ export function MessageActionBar({
-
+
{hasCopy ? (
) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 6ed8a784..a67f1404 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -2665,6 +2665,9 @@ describe('MessageList nested tool calls', () => { }) it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { + const now = new Date('2026-05-29T16:00:00+08:00').getTime() + vi.spyOn(Date, 'now').mockReturnValue(now) + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ @@ -2673,13 +2676,13 @@ describe('MessageList nested tool calls', () => { id: 'user-1', type: 'user_text', content: '请把这条 prompt 放在右侧', - timestamp: 1, + timestamp: now - 5 * 60_000, }, { id: 'assistant-1', type: 'assistant_text', content: '这条回复应该停在左侧。', - timestamp: 2, + timestamp: now - 2 * 60 * 60_000, }, ], }), @@ -2700,6 +2703,10 @@ describe('MessageList nested tool calls', () => { expect(assistantShell?.className).not.toContain('ml-10') expect(userActions?.getAttribute('data-align')).toBe('end') expect(assistantActions?.getAttribute('data-align')).toBe('start') + expect(userActions?.className).toContain('invisible') + expect(userActions?.className).toContain('group-hover:visible') + expect(within(userActions as HTMLElement).getByText('5m ago')).toBeTruthy() + expect(within(assistantActions as HTMLElement).getByText('2h ago')).toBeTruthy() }) it('uses the document column for markdown-heavy assistant replies', () => { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index d42670d3..e18c915d 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1948,6 +1948,7 @@ export const MessageBlock = memo(function MessageBlock({ content={message.content} attachments={message.attachments} branchAction={branchAction} + timestamp={message.timestamp} /> ) @@ -1959,7 +1960,12 @@ export const MessageBlock = memo(function MessageBlock({ role="assistant" content={message.content} > - + ) case 'thinking': diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 026ee92d..3dbb5d1b 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -7,9 +7,10 @@ type Props = { content: string attachments?: UIAttachment[] branchAction?: MessageBranchAction + timestamp?: number } -export const UserMessage = memo(function UserMessage({ content, attachments, branchAction }: Props) { +export const UserMessage = memo(function UserMessage({ content, attachments, branchAction, timestamp }: Props) { const hasText = content.trim().length > 0 return ( @@ -41,6 +42,7 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra copyLabel="Copy prompt" branchAction={branchAction} align="end" + timestamp={timestamp} /> )}
diff --git a/desktop/src/components/shared/CopyButton.tsx b/desktop/src/components/shared/CopyButton.tsx index d829c684..a5ae0972 100644 --- a/desktop/src/components/shared/CopyButton.tsx +++ b/desktop/src/components/shared/CopyButton.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { copyTextToClipboard } from '../chat/clipboard' type Props = { text: string label?: string copiedLabel?: string - displayLabel?: string - displayCopiedLabel?: string + displayLabel?: ReactNode + displayCopiedLabel?: ReactNode className?: string } diff --git a/desktop/src/lib/formatMessageTimestamp.test.ts b/desktop/src/lib/formatMessageTimestamp.test.ts new file mode 100644 index 00000000..f7e83d9b --- /dev/null +++ b/desktop/src/lib/formatMessageTimestamp.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { translate, type Locale } from '../i18n' +import { formatMessageTimestamp } from './formatMessageTimestamp' + +const t = (locale: Locale) => ( + key: Parameters[1], + params?: Parameters[2], +) => translate(locale, key, params) + +describe('formatMessageTimestamp', () => { + const now = new Date(2026, 4, 29, 16, 0).getTime() + + it('uses relative labels for recent messages', () => { + expect(formatMessageTimestamp(now - 5 * 60_000, t('zh'), 'zh', now)).toBe('5分钟前') + expect(formatMessageTimestamp(now - 2 * 60 * 60_000, t('en'), 'en', now)).toBe('2h ago') + }) + + it('uses weekday and clock time for recent history', () => { + const value = new Date(2026, 4, 24, 15, 50).getTime() + + expect(formatMessageTimestamp(value, t('zh'), 'zh', now)).toBe('星期日15:50') + }) + + it('includes the calendar date for older messages', () => { + const value = new Date(2026, 3, 20, 9, 30).getTime() + + expect(formatMessageTimestamp(value, t('zh'), 'zh', now)).toBe('4月20日 09:30') + }) +}) diff --git a/desktop/src/lib/formatMessageTimestamp.ts b/desktop/src/lib/formatMessageTimestamp.ts new file mode 100644 index 00000000..455de31b --- /dev/null +++ b/desktop/src/lib/formatMessageTimestamp.ts @@ -0,0 +1,100 @@ +import type { Locale, TranslationKey } from '../i18n' + +type Translator = (key: TranslationKey, params?: Record) => string + +const MINUTE_MS = 60_000 +const HOUR_MS = 60 * MINUTE_MS +const DAY_MS = 24 * HOUR_MS + +export function formatMessageTimestamp( + value: number | string | Date, + t: Translator, + locale: Locale, + now = Date.now(), +): string { + const date = coerceDate(value) + if (!date) return '' + + const timestamp = date.getTime() + const diff = now - timestamp + + if (diff >= 0 && diff < MINUTE_MS) return t('session.timeJustNow') + if (diff >= MINUTE_MS && diff < HOUR_MS) { + return t('session.timeMinutes', { n: Math.floor(diff / MINUTE_MS) }) + } + if (diff >= HOUR_MS && diff < DAY_MS) { + return t('session.timeHours', { n: Math.floor(diff / HOUR_MS) }) + } + if (diff >= DAY_MS && diff < 7 * DAY_MS) { + return formatWeekdayTime(date, locale) + } + + return isSameLocalYear(date, new Date(now)) + ? formatMonthDayTime(date, locale) + : formatYearMonthDayTime(date, locale) +} + +export function formatExactMessageTimestamp(value: number | string | Date, locale: Locale): string { + const date = coerceDate(value) + if (!date) return '' + return new Intl.DateTimeFormat(localeToIntl(locale), { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +function coerceDate(value: number | string | Date): Date | null { + const date = value instanceof Date ? value : new Date(value) + return Number.isFinite(date.getTime()) ? date : null +} + +function localeToIntl(locale: Locale): string { + return locale === 'zh' ? 'zh-CN' : 'en-US' +} + +function formatWeekdayTime(date: Date, locale: Locale): string { + const intlLocale = localeToIntl(locale) + const weekday = new Intl.DateTimeFormat(intlLocale, { weekday: locale === 'zh' ? 'long' : 'short' }).format(date) + const time = formatClockTime(date, intlLocale) + return locale === 'zh' ? `${weekday}${time}` : `${weekday} ${time}` +} + +function formatMonthDayTime(date: Date, locale: Locale): string { + const intlLocale = localeToIntl(locale) + if (locale === 'zh') { + return `${date.getMonth() + 1}月${date.getDate()}日 ${formatClockTime(date, intlLocale)}` + } + const day = new Intl.DateTimeFormat(intlLocale, { + month: 'short', + day: 'numeric', + }).format(date) + return `${day} ${formatClockTime(date, intlLocale)}` +} + +function formatYearMonthDayTime(date: Date, locale: Locale): string { + const intlLocale = localeToIntl(locale) + if (locale === 'zh') { + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${formatClockTime(date, intlLocale)}` + } + const day = new Intl.DateTimeFormat(intlLocale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date) + return `${day} ${formatClockTime(date, intlLocale)}` +} + +function formatClockTime(date: Date, locale: string): string { + return new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date) +} + +function isSameLocalYear(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() +}