From e4877ba96e85f75d007e073b1a59326c3641b7f8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 11:24:58 -0500 Subject: [PATCH 1/2] flatten assistant message actions into an inline icon row Drop the kebab overflow so age, branch, copy, read aloud, and refresh are always one hover away. --- .../assistant-ui/thread/assistant-message.tsx | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index e9ac8ad28a87..4c01d550f454 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -7,7 +7,7 @@ import { useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, useCallback, useMemo, useState } from 'react' +import { type FC, useCallback, useMemo } from 'react' import { contentHasVisibleText, @@ -21,17 +21,11 @@ import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' +import { relativeTime } from '@/lib/time' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' @@ -144,12 +138,11 @@ export const AssistantMessage: FC<{ const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { const { t } = useI18n() const copy = t.assistant.thread - const [menuOpen, setMenuOpen] = useState(false) return (
= ({ messageId, getMessageText, // invisible by default (opacity-0 + pointer-events-none, reveals on // hover), so keeping it mounted reserves stable layout height with // no visual change during streaming. - 'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100', - menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100' - )} + 'relative flex flex-row items-center justify-end gap-1.5 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100' + } data-slot="aui_msg-actions" > + + { + triggerHaptic('selection') + onBranchInNewChat?.(messageId) + }} + tooltip={copy.branchNewChat} + > + + + triggerHaptic('submit')} tooltip={copy.refresh}> - - - - - - - e.preventDefault()} sideOffset={6}> - - onBranchInNewChat?.(messageId)}> - - {copy.branchNewChat} - - - -
) } -const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { +const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { const { t } = useI18n() const copy = t.assistant.thread const voicePlayback = useStore($voicePlayback) @@ -200,6 +188,7 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe const isSpeaking = readAloudStatus === 'speaking' const anyPlaybackActive = voicePlayback.status !== 'idle' const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon + const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud const read = useCallback(async () => { const text = getText() @@ -216,29 +205,40 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe }, [copy.readAloudFailed, getText, messageId]) return ( - { - e.preventDefault() + onClick={() => { + triggerHaptic('selection') void (isSpeaking ? stopVoicePlayback() : read()) }} + tooltip={tooltip} > - - {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud} - + + ) } -const MessageTimestamp: FC = () => { +const MessageAge: FC = () => { const { t } = useI18n() const createdAt = useAuiState(s => s.message.createdAt) - const label = formatMessageTimestamp(createdAt, t.assistant.thread) - if (!label) { + if (!createdAt) { return null } - return {label} + const date = createdAt instanceof Date ? createdAt : new Date(createdAt) + + if (Number.isNaN(date.getTime())) { + return null + } + + const absolute = formatMessageTimestamp(date, t.assistant.thread) + + return ( + + {relativeTime(date.getTime())} + + ) } const AssistantFooter: FC = props => ( From ca244e2168e0fa141127f7d0da9317b1145f3e0c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 12:18:59 -0500 Subject: [PATCH 2/2] 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 --- .../assistant-ui/thread/assistant-message.tsx | 29 ++++++++--------- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/icons.ts | 4 +++ apps/desktop/src/lib/time.test.ts | 32 +++++++++++++++++++ apps/desktop/src/lib/time.ts | 23 +++++++++++++ 9 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/lib/time.test.ts diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 4c01d550f454..abf5e085e0ff 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -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 = ({ messageId, getMessageText, }} tooltip={copy.branchNewChat} > - + triggerHaptic('submit')} tooltip={copy.refresh}> - + @@ -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 ( - - {relativeTime(date.getTime())} + + {formatAgo(date.getTime(), t.agents)} ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 532a52a26b71..30d6015d00b2 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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` diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ae7863f1d640..552bcd2ebd9d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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} トーク` diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d0ead27cfe20..53f40cf60c01 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 10cac8c42080..e30b1e1a0472 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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} 詞元` diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 62cf3450267b..9785d5bdbd65 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 词元` diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index fae9e8d76c09..e66a433b30ec 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/lib/time.test.ts b/apps/desktop/src/lib/time.test.ts new file mode 100644 index 000000000000..6253f0644265 --- /dev/null +++ b/apps/desktop/src/lib/time.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/lib/time.ts b/apps/desktop/src/lib/time.ts index e26666c15db0..da1d29151921 100644 --- a/apps/desktop/src/lib/time.ts +++ b/apps/desktop/src/lib/time.ts @@ -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) +}