Merge pull request #69505 from NousResearch/bb/inline-msg-actions

Flatten assistant message actions into an inline icon row
This commit is contained in:
brooklyn! 2026-07-22 12:24:20 -05:00 committed by GitHub
commit 59614ef9af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 103 additions and 42 deletions

View file

@ -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 { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { formatAgo } 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<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="relative flex w-full shrink-0 justify-end">
<ActionBarPrimitive.Root
className={cn(
className={
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
// bar while the thread streams, which collapses every completed
// assistant message's footer by this bar's height and shifts the
@ -157,38 +150,33 @@ const AssistantActionBar: FC<MessageActionProps> = ({ 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"
>
<MessageAge />
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat?.(messageId)
}}
tooltip={copy.branchNewChat}
>
<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>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip={copy.moreActions}>
<Codicon name="ellipsis" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem getText={getMessageText} messageId={messageId} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
</div>
)
}
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)
@ -199,7 +187,8 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
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 () => {
const text = getText()
@ -216,29 +205,37 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
}, [copy.readAloudFailed, getText, messageId])
return (
<DropdownMenuItem
<TooltipIconButton
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onSelect={e => {
e.preventDefault()
onClick={() => {
triggerHaptic('selection')
void (isSpeaking ? stopVoicePlayback() : read())
}}
tooltip={tooltip}
>
<Icon className={isPreparing ? 'animate-spin' : undefined} />
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
</DropdownMenuItem>
<Icon className={cn('size-3.5', isPreparing && 'animate-spin')} />
</TooltipIconButton>
)
}
const MessageTimestamp: FC = () => {
const MessageAge: FC = () => {
const { t } = useI18n()
const createdAt = useAuiState(s => s.message.createdAt)
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
const date = createdAt ? new Date(createdAt) : null
if (!label) {
if (!date || Number.isNaN(date.getTime())) {
return null
}
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
// 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={formatMessageTimestamp(date, t.assistant.thread) || undefined}
>
{formatAgo(date.getTime(), t.agents)}
</span>
)
}
const AssistantFooter: FC<MessageActionProps> = props => (

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