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 ? (
}
+ displayCopiedLabel={}
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/30 hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
) : null}
{branchAction ? (
@@ -49,11 +62,19 @@ export function MessageActionBar({
disabled={branchAction.loading}
aria-label={branchAction.label}
title={branchAction.label}
- className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/30 hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
) : null}
+ {timeLabel ? (
+
+ {timeLabel}
+
+ ) : null}
)
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()
+}