fix: reveal message actions with timestamps on hover (#642)

Message rows should stay visually quiet until the user needs copy or fork controls. The action row now appears on message hover/focus, uses icon-sized controls, and shows the message timestamp beside the actions with locale-aware recent and historical formats.

Constraint: Desktop chat actions already own copy and fork affordances, so the change keeps that surface instead of adding a separate always-visible timestamp row.
Rejected: Keep text-labeled Copy buttons | the row remained too visually heavy for every message.
Confidence: high
Scope-risk: narrow
Directive: Keep copy, fork, and timestamp as one hover/focus metadata row unless the message layout is redesigned as a whole.
Tested: bun run check:desktop
Not-tested: Live Tauri native packaging
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 12:52:50 +08:00
parent 1ca29d8a4a
commit b483a9929d
8 changed files with 182 additions and 15 deletions

View File

@ -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}
/>
</div>
</div>

View File

@ -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({
<div
data-message-actions
data-align={align}
className={`flex w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
className={`invisible flex w-full opacity-0 transition-opacity duration-150 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 ${
align === 'end' ? 'justify-end' : 'justify-start'
}`}
>
<div className="flex items-center gap-1.5">
<div className="flex min-h-7 items-center gap-1.5">
{hasCopy ? (
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium 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"
displayLabel={<Copy size={13} strokeWidth={2.2} aria-hidden="true" />}
displayCopiedLabel={<Check size={13} strokeWidth={2.4} aria-hidden="true" />}
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"
>
<GitFork size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
) : null}
{timeLabel ? (
<span
className="ml-1 inline-flex items-center text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
title={exactTimeLabel || timeLabel}
>
{timeLabel}
</span>
) : null}
</div>
</div>
)

View File

@ -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', () => {

View File

@ -1948,6 +1948,7 @@ export const MessageBlock = memo(function MessageBlock({
content={message.content}
attachments={message.attachments}
branchAction={branchAction}
timestamp={message.timestamp}
/>
</SelectableChatMessage>
)
@ -1959,7 +1960,12 @@ export const MessageBlock = memo(function MessageBlock({
role="assistant"
content={message.content}
>
<AssistantMessage content={message.content} branchAction={branchAction} sessionId={sessionId ?? undefined} />
<AssistantMessage
content={message.content}
branchAction={branchAction}
sessionId={sessionId ?? undefined}
timestamp={message.timestamp}
/>
</SelectableChatMessage>
)
case 'thinking':

View File

@ -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}
/>
)}
</div>

View File

@ -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
}

View File

@ -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<typeof translate>[1],
params?: Parameters<typeof translate>[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')
})
})

View File

@ -0,0 +1,100 @@
import type { Locale, TranslationKey } from '../i18n'
type Translator = (key: TranslationKey, params?: Record<string, string | number>) => 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()
}