fix(web): hide unavailable history actions and reorder message actions (#1494)

* fix(web): reorder and hide history actions

* test(web): cover locked history actions
This commit is contained in:
Ananovo
2026-08-11 10:03:09 +08:00
committed by GitHub
parent b572c35e34
commit db46b4e08f
2 changed files with 115 additions and 29 deletions
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
@@ -13,6 +13,7 @@ const auiState = {
message: { id: 'msg-1', createdAt: new Date(2026, 6, 12, 10, 30) },
thread: {
isRunning: false,
messages: [],
extras: {
shareHiddenByMessageId: new Set<string>(),
},
@@ -50,6 +51,10 @@ vi.mock('@/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copied: false, copy })
}))
vi.mock('@/components/AssistantChat/context', () => ({
useOptionalHappyChatContext: () => ({ onShareTurn: vi.fn() })
}))
function renderActions(props: ComponentProps<typeof MessageActions>) {
return render(
<I18nProvider>
@@ -211,6 +216,85 @@ describe('MessageActions', () => {
}
})
it('hides Fork and Rewind while the thread is running', () => {
auiState.thread.isRunning = true
renderActions({
align: 'end',
copyText: 'body',
showFork: true,
showRewind: true,
onFork: async () => {},
onRewind: async () => {}
})
expect(screen.queryByRole('button', { name: 'Fork' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull()
})
it('hides Fork and Rewind while a history action is pending', () => {
renderActions({
align: 'end',
copyText: 'body',
showFork: true,
showRewind: true,
historyActionPending: true,
onFork: async () => {},
onRewind: async () => {}
})
expect(screen.queryByRole('button', { name: 'Fork' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull()
})
it('hides all history actions while a confirmation is pending', async () => {
let resolveFork: (() => void) | undefined
const onFork = vi.fn(() => new Promise<void>((resolve) => {
resolveFork = resolve
}))
renderActions({
align: 'end',
copyText: 'body',
showFork: true,
showRewind: true,
onFork,
onRewind: async () => {}
})
fireEvent.click(screen.getByRole('button', { name: 'Fork' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Fork' }).at(-1)!)
await waitFor(() => {
expect(document.querySelector('.happy-message-actions')?.querySelectorAll('button')).toHaveLength(1)
})
expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull()
resolveFork?.()
await waitFor(() => expect(onFork).toHaveBeenCalledTimes(1))
})
it('orders user actions as Share, Rewind, Fork, Copy', () => {
renderActions({
align: 'end',
copyText: 'body',
messageElementId: 'message-1',
showFork: true,
showRewind: true,
onFork: async () => {},
onRewind: async () => {}
})
const row = document.querySelector('.happy-message-actions')
expect(row).not.toBeNull()
expect(Array.from(row!.querySelectorAll('button')).map((button) => button.getAttribute('aria-label'))).toEqual([
'Share turn as image',
'Rewind',
'Fork',
'Copy'
])
})
it('shows Fork confirm dialog and calls onFork only after confirm', async () => {
const onFork = vi.fn(async () => {})
renderActions({ align: 'start', copyText: 'body', showFork: true, onFork })
@@ -88,34 +88,44 @@ export function MessageActions({
/>
) : null
const historyButtons = (
const historyButtons = !actionsLocked ? (
<>
{showFork && onFork ? (
<button
type="button"
title={t('message.fork')}
aria-label={t('message.fork')}
disabled={actionsLocked}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:opacity-40"
onClick={() => setForkOpen(true)}
>
<ForkIcon className="h-3.5 w-3.5" />
</button>
) : null}
{showRewind && onRewind ? (
<button
type="button"
title={t('message.rewind')}
aria-label={t('message.rewind')}
disabled={actionsLocked}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] disabled:opacity-40"
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
onClick={() => setRewindOpen(true)}
>
<RewindIcon className="h-3.5 w-3.5" />
</button>
) : null}
{showFork && onFork ? (
<button
type="button"
title={t('message.fork')}
aria-label={t('message.fork')}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
onClick={() => setForkOpen(true)}
>
<ForkIcon className="h-3.5 w-3.5" />
</button>
) : null}
</>
)
) : null
const copyButton = canCopy ? (
<button
type="button"
title={copied ? t('message.copied') : t('message.copy')}
aria-label={copied ? t('message.copied') : t('message.copy')}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
onClick={() => copy(copyText!)}
>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null
return (
<>
@@ -128,18 +138,10 @@ export function MessageActions({
{align === 'end' ? <DesktopTimestamp /> : null}
{align === 'end' && hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{align === 'end' ? shareButton : null}
{canCopy ? (
<button
type="button"
title={copied ? t('message.copied') : t('message.copy')}
aria-label={copied ? t('message.copied') : t('message.copy')}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
onClick={() => copy(copyText!)}
>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
{historyButtons}
{align === 'end' ? historyButtons : null}
{align === 'end' ? copyButton : null}
{align === 'start' ? copyButton : null}
{align === 'start' ? historyButtons : null}
{align === 'start' ? shareButton : null}
{align === 'start' && hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{align === 'start' ? <DesktopTimestamp /> : null}