mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
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:
parent
1ca29d8a4a
commit
b483a9929d
@ -17,11 +17,12 @@ type Props = {
|
|||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
branchAction?: MessageBranchAction
|
branchAction?: MessageBranchAction
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
timestamp?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CARDS = 3
|
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 t = useTranslation()
|
||||||
const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
|
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"
|
copyLabel="Copy reply"
|
||||||
branchAction={branchAction}
|
branchAction={branchAction}
|
||||||
align="start"
|
align="start"
|
||||||
|
timestamp={timestamp}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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'
|
import { CopyButton } from '../shared/CopyButton'
|
||||||
|
|
||||||
export type MessageBranchAction = {
|
export type MessageBranchAction = {
|
||||||
@ -12,6 +15,7 @@ type Props = {
|
|||||||
copyLabel: string
|
copyLabel: string
|
||||||
branchAction?: MessageBranchAction
|
branchAction?: MessageBranchAction
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
|
timestamp?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageActionBar({
|
export function MessageActionBar({
|
||||||
@ -19,8 +23,17 @@ export function MessageActionBar({
|
|||||||
copyLabel,
|
copyLabel,
|
||||||
branchAction,
|
branchAction,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
timestamp,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslation()
|
||||||
|
const locale = useSettingsStore((state) => state.locale)
|
||||||
const hasCopy = Boolean(copyText?.trim())
|
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
|
if (!hasCopy && !branchAction) return null
|
||||||
|
|
||||||
@ -28,18 +41,18 @@ export function MessageActionBar({
|
|||||||
<div
|
<div
|
||||||
data-message-actions
|
data-message-actions
|
||||||
data-align={align}
|
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'
|
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 ? (
|
{hasCopy ? (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={copyText!}
|
text={copyText!}
|
||||||
label={copyLabel}
|
label={copyLabel}
|
||||||
displayLabel="Copy"
|
displayLabel={<Copy size={13} strokeWidth={2.2} aria-hidden="true" />}
|
||||||
displayCopiedLabel="Copied"
|
displayCopiedLabel={<Check size={13} strokeWidth={2.4} aria-hidden="true" />}
|
||||||
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"
|
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}
|
) : null}
|
||||||
{branchAction ? (
|
{branchAction ? (
|
||||||
@ -49,11 +62,19 @@ export function MessageActionBar({
|
|||||||
disabled={branchAction.loading}
|
disabled={branchAction.loading}
|
||||||
aria-label={branchAction.label}
|
aria-label={branchAction.label}
|
||||||
title={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" />
|
<GitFork size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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', () => {
|
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({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
[ACTIVE_TAB]: makeSessionState({
|
[ACTIVE_TAB]: makeSessionState({
|
||||||
@ -2673,13 +2676,13 @@ describe('MessageList nested tool calls', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
type: 'user_text',
|
type: 'user_text',
|
||||||
content: '请把这条 prompt 放在右侧',
|
content: '请把这条 prompt 放在右侧',
|
||||||
timestamp: 1,
|
timestamp: now - 5 * 60_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'assistant-1',
|
id: 'assistant-1',
|
||||||
type: 'assistant_text',
|
type: 'assistant_text',
|
||||||
content: '这条回复应该停在左侧。',
|
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(assistantShell?.className).not.toContain('ml-10')
|
||||||
expect(userActions?.getAttribute('data-align')).toBe('end')
|
expect(userActions?.getAttribute('data-align')).toBe('end')
|
||||||
expect(assistantActions?.getAttribute('data-align')).toBe('start')
|
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', () => {
|
it('uses the document column for markdown-heavy assistant replies', () => {
|
||||||
|
|||||||
@ -1948,6 +1948,7 @@ export const MessageBlock = memo(function MessageBlock({
|
|||||||
content={message.content}
|
content={message.content}
|
||||||
attachments={message.attachments}
|
attachments={message.attachments}
|
||||||
branchAction={branchAction}
|
branchAction={branchAction}
|
||||||
|
timestamp={message.timestamp}
|
||||||
/>
|
/>
|
||||||
</SelectableChatMessage>
|
</SelectableChatMessage>
|
||||||
)
|
)
|
||||||
@ -1959,7 +1960,12 @@ export const MessageBlock = memo(function MessageBlock({
|
|||||||
role="assistant"
|
role="assistant"
|
||||||
content={message.content}
|
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>
|
</SelectableChatMessage>
|
||||||
)
|
)
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
|
|||||||
@ -7,9 +7,10 @@ type Props = {
|
|||||||
content: string
|
content: string
|
||||||
attachments?: UIAttachment[]
|
attachments?: UIAttachment[]
|
||||||
branchAction?: MessageBranchAction
|
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
|
const hasText = content.trim().length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -41,6 +42,7 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra
|
|||||||
copyLabel="Copy prompt"
|
copyLabel="Copy prompt"
|
||||||
branchAction={branchAction}
|
branchAction={branchAction}
|
||||||
align="end"
|
align="end"
|
||||||
|
timestamp={timestamp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import { copyTextToClipboard } from '../chat/clipboard'
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string
|
text: string
|
||||||
label?: string
|
label?: string
|
||||||
copiedLabel?: string
|
copiedLabel?: string
|
||||||
displayLabel?: string
|
displayLabel?: ReactNode
|
||||||
displayCopiedLabel?: string
|
displayCopiedLabel?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
desktop/src/lib/formatMessageTimestamp.test.ts
Normal file
29
desktop/src/lib/formatMessageTimestamp.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
100
desktop/src/lib/formatMessageTimestamp.ts
Normal file
100
desktop/src/lib/formatMessageTimestamp.ts
Normal 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()
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user