mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #69505 from NousResearch/bb/inline-msg-actions
Flatten assistant message actions into an inline icon row
This commit is contained in:
commit
59614ef9af
9 changed files with 103 additions and 42 deletions
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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} トーク`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} 詞元`
|
||||
|
|
|
|||
|
|
@ -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} 词元`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
32
apps/desktop/src/lib/time.test.ts
Normal file
32
apps/desktop/src/lib/time.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue