diff --git a/web/app/components/base/chat/chat/question.spec.tsx b/web/app/components/base/chat/chat/question.spec.tsx index 2f8714ef77..99c25f5659 100644 --- a/web/app/components/base/chat/chat/question.spec.tsx +++ b/web/app/components/base/chat/chat/question.spec.tsx @@ -1,7 +1,7 @@ import type { Theme } from '../embedded-chatbot/theme/theme-context' import type { ChatConfig, ChatItem, OnRegenerate } from '../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import copy from 'copy-to-clipboard' import * as React from 'react' @@ -180,7 +180,7 @@ describe('Question component', () => { await user.clear(textbox) await user.type(textbox, 'Edited question') - const resendBtn = screen.getByRole('button', { name: /chat.resend/i }) + const resendBtn = screen.getByRole('button', { name: /operation.save/i }) await user.click(resendBtn) await waitFor(() => { @@ -209,6 +209,91 @@ describe('Question component', () => { }) }) + it('should confirm editing when Enter is pressed', async () => { + const user = userEvent.setup() + const onRegenerate = vi.fn() as unknown as OnRegenerate + + renderWithProvider(makeItem(), onRegenerate) + + await user.click(screen.getByTestId('edit-btn')) + const textbox = await screen.findByRole('textbox') + + await user.clear(textbox) + await user.type(textbox, 'Edited with Enter') + + fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) + + await waitFor(() => { + expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited with Enter', files: [] }) + }) + }) + + it('should insert a new line when Shift+Enter is pressed', async () => { + const user = userEvent.setup() + const onRegenerate = vi.fn() as unknown as OnRegenerate + + renderWithProvider(makeItem(), onRegenerate) + + await user.click(screen.getByTestId('edit-btn')) + const textbox = await screen.findByRole('textbox') + + await user.clear(textbox) + await user.type(textbox, 'Line 1') + await user.type(textbox, '{Shift>}{Enter}{/Shift}') + + expect(textbox).toHaveValue('Line 1\n') + expect(onRegenerate).not.toHaveBeenCalled() + }) + + it('should not confirm editing when Enter is pressed during IME composition', () => { + const onRegenerate = vi.fn() as unknown as OnRegenerate + + renderWithProvider(makeItem(), onRegenerate) + + fireEvent.click(screen.getByTestId('edit-btn')) + const textbox = screen.getByRole('textbox') + + fireEvent.compositionStart(textbox) + fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) + + expect(onRegenerate).not.toHaveBeenCalled() + expect(textbox).toHaveValue('This is the question content') + }) + + it('should keep text unchanged and suppress Enter if a new composition starts before previous composition-end timer finishes', async () => { + vi.useFakeTimers() + + try { + const onRegenerate = vi.fn() as unknown as OnRegenerate + renderWithProvider(makeItem(), onRegenerate) + + fireEvent.click(screen.getByTestId('edit-btn')) + const textbox = screen.getByRole('textbox') + fireEvent.change(textbox, { target: { value: 'IME guard text' } }) + + fireEvent.compositionStart(textbox) + fireEvent.compositionEnd(textbox) + fireEvent.compositionStart(textbox) + + vi.advanceTimersByTime(50) + + const blockedEnterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }) + textbox.dispatchEvent(blockedEnterEvent) + expect(onRegenerate).not.toHaveBeenCalled() + expect(blockedEnterEvent.defaultPrevented).toBe(true) + expect(textbox).toHaveValue('IME guard text') + + fireEvent.compositionEnd(textbox) + vi.advanceTimersByTime(50) + + fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' }) + expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'IME guard text', files: [] }) + } + finally { + vi.useRealTimers() + } + }) + it('should switch siblings when prev/next buttons are clicked', async () => { const user = userEvent.setup() const switchSibling = vi.fn() diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 4c8c7f262d..6eceadf6ea 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -56,6 +56,8 @@ const Question: FC = ({ const [editedContent, setEditedContent] = useState(content) const [contentWidth, setContentWidth] = useState(0) const contentRef = useRef(null) + const isComposingRef = useRef(false) + const compositionEndTimerRef = useRef | null>(null) const handleEdit = useCallback(() => { setIsEditing(true) @@ -63,15 +65,62 @@ const Question: FC = ({ }, [content]) const handleResend = useCallback(() => { + if (compositionEndTimerRef.current) { + clearTimeout(compositionEndTimerRef.current) + compositionEndTimerRef.current = null + } + isComposingRef.current = false setIsEditing(false) onRegenerate?.(item, { message: editedContent, files: message_files }) }, [editedContent, message_files, item, onRegenerate]) const handleCancelEditing = useCallback(() => { + if (compositionEndTimerRef.current) { + clearTimeout(compositionEndTimerRef.current) + compositionEndTimerRef.current = null + } + isComposingRef.current = false setIsEditing(false) setEditedContent(content) }, [content]) + const handleEditInputKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || e.shiftKey) + return + + if (e.nativeEvent.isComposing) + return + + if (isComposingRef.current) { + e.preventDefault() + return + } + + e.preventDefault() + handleResend() + }, [handleResend]) + + const clearCompositionEndTimer = useCallback(() => { + if (!compositionEndTimerRef.current) + return + + clearTimeout(compositionEndTimerRef.current) + compositionEndTimerRef.current = null + }, []) + + const handleCompositionStart = useCallback(() => { + clearCompositionEndTimer() + isComposingRef.current = true + }, [clearCompositionEndTimer]) + + const handleCompositionEnd = useCallback(() => { + clearCompositionEndTimer() + compositionEndTimerRef.current = setTimeout(() => { + isComposingRef.current = false + compositionEndTimerRef.current = null + }, 50) + }, [clearCompositionEndTimer]) + const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => { if (direction === 'prev') { if (item.prevSibling) @@ -100,6 +149,12 @@ const Question: FC = ({ } }, []) + useEffect(() => { + return () => { + clearCompositionEndTimer() + } + }, [clearCompositionEndTimer]) + return (
@@ -128,13 +183,17 @@ const Question: FC = ({
{ !!message_files?.length && ( = ({ {!isEditing ? : ( -
-
+
+