diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx index 940ae6fc..17416c00 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.test.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -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(), }, @@ -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) { return render( @@ -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((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 }) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index eb6c7492..3645db57 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -88,34 +88,44 @@ export function MessageActions({ /> ) : null - const historyButtons = ( + const historyButtons = !actionsLocked ? ( <> - {showFork && onFork ? ( - - ) : null} {showRewind && onRewind ? ( ) : null} + {showFork && onFork ? ( + + ) : null} - ) + ) : null + + const copyButton = canCopy ? ( + + ) : null return ( <> @@ -128,18 +138,10 @@ export function MessageActions({ {align === 'end' ? : null} {align === 'end' && hasMetadata && metadata ? : null} {align === 'end' ? shareButton : null} - {canCopy ? ( - - ) : 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 ? : null} {align === 'start' ? : null}