refine inline message actions: fork icon, sine-wave read-aloud, shared age util

- Use the fork glyph for branch and a sine wave for read aloud (all one lib now)
- Extract compact "2h ago" into formatAgo() in lib/time.ts (+ ageDays locale string)
- Cover formatAgo with a unit test
This commit is contained in:
Brooklyn Nicholson 2026-07-22 12:18:59 -05:00
parent e4877ba96e
commit ca244e2168
9 changed files with 77 additions and 16 deletions

View file

@ -23,9 +23,9 @@ import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { relativeTime } from '@/lib/time'
import { formatAgo } from '@/lib/time'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
@ -162,13 +162,13 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
}}
tooltip={copy.branchNewChat}
>
<GitBranchIcon className="size-3.5" />
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
<RefreshCwIcon className="size-3.5" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
@ -187,7 +187,7 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
const isPreparing = readAloudStatus === 'preparing'
const isSpeaking = readAloudStatus === 'speaking'
const anyPlaybackActive = voicePlayback.status !== 'idle'
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : AudioLines
const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud
const read = useCallback(async () => {
@ -221,22 +221,19 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
const MessageAge: FC = () => {
const { t } = useI18n()
const createdAt = useAuiState(s => s.message.createdAt)
const date = createdAt ? new Date(createdAt) : null
if (!createdAt) {
if (!date || Number.isNaN(date.getTime())) {
return null
}
const date = createdAt instanceof Date ? createdAt : new Date(createdAt)
if (Number.isNaN(date.getTime())) {
return null
}
const absolute = formatMessageTimestamp(date, t.assistant.thread)
// Compact "2h ago" (shared util) with the absolute time on hover.
return (
<span className="px-0.5 text-[0.6875rem] tabular-nums text-muted-foreground" title={absolute || undefined}>
{relativeTime(date.getTime())}
<span
className="px-0.5 text-[0.6875rem] tabular-nums text-muted-foreground"
title={formatMessageTimestamp(date, t.assistant.thread) || undefined}
>
{formatAgo(date.getTime(), t.agents)}
</span>
)
}

View file

@ -1077,6 +1077,7 @@ export const en: Translations = {
ageSeconds: seconds => `${seconds}s ago`,
ageMinutes: minutes => `${minutes}m ago`,
ageHours: hours => `${hours}h ago`,
ageDays: days => `${days}d ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokens: value => `${value} tok`

View file

@ -1057,6 +1057,7 @@ export const ja = defineLocale({
ageSeconds: seconds => `${seconds}秒前`,
ageMinutes: minutes => `${minutes}分前`,
ageHours: hours => `${hours}時間前`,
ageDays: days => `${days}日前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokens: value => `${value} トーク`

View file

@ -949,6 +949,7 @@ export interface Translations {
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
ageDays: (days: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokens: (value: number | string) => string

View file

@ -1025,6 +1025,7 @@ export const zhHant = defineLocale({
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分鐘前`,
ageHours: hours => `${hours} 小時前`,
ageDays: days => `${days} 天前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokens: value => `${value} 詞元`

View file

@ -1272,6 +1272,7 @@ export const zh: Translations = {
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分钟前`,
ageHours: hours => `${hours} 小时前`,
ageDays: days => `${days} 天前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokens: value => `${value} 词元`

View file

@ -44,6 +44,8 @@ import {
IconFolderOpen as FolderOpen,
IconGitBranch as GitBranch,
IconGitBranch as GitBranchIcon,
IconGitFork as GitFork,
IconGitFork as GitForkIcon,
IconGlobe as Globe,
IconHash as Hash,
IconHelpCircle as HelpCircle,
@ -163,6 +165,8 @@ export {
FolderOpen,
GitBranch,
GitBranchIcon,
GitFork,
GitForkIcon,
Globe,
Hash,
HelpCircle,

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { DAY, HOUR, MINUTE, SECOND, formatAgo } from './time'
const labels = {
ageNow: 'now',
ageSeconds: (s: number) => `${s}s ago`,
ageMinutes: (m: number) => `${m}m ago`,
ageHours: (h: number) => `${h}h ago`,
ageDays: (d: number) => `${d}d ago`
}
const now = 1_000 * DAY
const ago = (delta: number) => formatAgo(now - delta, labels, now)
describe('formatAgo', () => {
it('reads "now" under two seconds, then seconds', () => {
expect(ago(0)).toBe('now')
expect(ago(1.5 * SECOND)).toBe('now')
expect(ago(5 * SECOND)).toBe('5s ago')
})
it('buckets to the coarsest unit, floored', () => {
expect(ago(3 * MINUTE)).toBe('3m ago')
expect(ago(2 * HOUR + 59 * MINUTE)).toBe('2h ago')
expect(ago(5 * DAY)).toBe('5d ago')
})
it('clamps future timestamps to "now"', () => {
expect(ago(-HOUR)).toBe('now')
})
})

View file

@ -72,3 +72,26 @@ export function coarseElapsed(deltaMs: number): { unit: ElapsedUnit; value: numb
return { unit: 'second', value: Math.floor(ms / SECOND) }
}
// Localized strings for `formatAgo`; shaped to accept `t.agents` directly.
export interface AgoLabels {
ageNow: string
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
ageDays: (days: number) => string
}
// Compact localized "2h ago" / "3m ago" / "now" for a past timestamp, bucketed
// via `coarseElapsed` so every age label reads consistently.
export function formatAgo(fromMs: number, labels: AgoLabels, nowMs = Date.now()): string {
const { unit, value } = coarseElapsed(nowMs - fromMs)
if (unit === 'second') {
return value < 2 ? labels.ageNow : labels.ageSeconds(value)
}
const by = { day: labels.ageDays, hour: labels.ageHours, minute: labels.ageMinutes }
return by[unit](value)
}