feat(web): fade in streamed response words and harden playground editor (#6895)
* feat(web): fade in newly streamed response words Animate only new word-level deltas while markdown is still streaming, and cache markdown-it instances per parser id so concurrent Response trees do not rebuild or reparse on every render. * fix(web): keep CodeMirror editor alive across keystroke re-renders Deliver onKeyDown through a ref instead of the extensions memo so a new handler identity no longer tears down the EditorView, which reset the cursor to the document start and made typing appear right-to-left. * feat(web): add unsaved changes confirmation dialog in PlaygroundMessageEditor Implement a confirmation dialog to warn users about unsaved changes when attempting to leave the editor. This includes handling the beforeunload event to prevent accidental navigation away from the editor. Additionally, add tests to verify the dialog's behavior under various scenarios. * test(web): cover beforeunload guard and fade hydration suppression Address review feedback: add regression tests for the unsaved-changes beforeunload guard and the first-render fade suppression of hydrated content, and annotate getCachedMarkdown's return type.
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import i18next from 'i18next'
|
||||
import { beforeAll, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { Message } from '../../../types'
|
||||
import { PlaygroundMessageEditor } from '../playground-message-editor'
|
||||
|
||||
const leavePrompt = 'You have unsaved changes. Are you sure you want to leave?'
|
||||
|
||||
const userMessage: Message = {
|
||||
key: 'msg-1',
|
||||
from: 'user',
|
||||
versions: [{ id: 'v1', content: 'original' }],
|
||||
}
|
||||
|
||||
function renderEditor(options: {
|
||||
editText: string
|
||||
onCancelEdit?: (open: boolean) => void
|
||||
}) {
|
||||
return render(
|
||||
<PlaygroundMessageEditor
|
||||
editText={options.editText}
|
||||
message={userMessage}
|
||||
onCancelEdit={options.onCancelEdit}
|
||||
onEditTextChange={() => undefined}
|
||||
originalText='original'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PlaygroundMessageEditor leave warning', () => {
|
||||
beforeAll(() => {
|
||||
i18next.addResourceBundle('en', 'translation', {
|
||||
Cancel: 'Cancel',
|
||||
Leave: 'Leave',
|
||||
Stay: 'Stay',
|
||||
[leavePrompt]: leavePrompt,
|
||||
})
|
||||
})
|
||||
|
||||
test('cancels immediately when the edit has no unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'original', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(onCancelEdit).toHaveBeenCalledWith(false)
|
||||
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('leaves the editor after confirming unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'changed', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Leave' }))
|
||||
|
||||
expect(onCancelEdit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
test('keeps the editor open after staying with unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'changed', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Stay' }))
|
||||
|
||||
expect(onCancelEdit).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaygroundMessageEditor beforeunload guard', () => {
|
||||
test('blocks page unload while the edit has unsaved changes', () => {
|
||||
renderEditor({ editText: 'changed' })
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
test('stops blocking page unload after the edit reverts to the original text', () => {
|
||||
const { rerender } = renderEditor({ editText: 'changed' })
|
||||
|
||||
rerender(
|
||||
<PlaygroundMessageEditor
|
||||
editText='original'
|
||||
message={userMessage}
|
||||
onEditTextChange={() => undefined}
|
||||
originalText='original'
|
||||
/>
|
||||
)
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -17,9 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, RotateCcw, Send, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { getMessageEditorState } from '../../lib'
|
||||
@@ -45,28 +47,44 @@ export function PlaygroundMessageEditor({
|
||||
originalText,
|
||||
}: PlaygroundMessageEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false)
|
||||
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanged) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
return ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasChanged])
|
||||
|
||||
const leaveEdit = () => {
|
||||
setShowLeaveDialog(false)
|
||||
onCancelEdit?.(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (
|
||||
hasChanged &&
|
||||
!window.confirm(
|
||||
t('You have unsaved changes. Are you sure you want to leave?')
|
||||
)
|
||||
) {
|
||||
if (hasChanged) {
|
||||
setShowLeaveDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
onCancelEdit?.(false)
|
||||
leaveEdit()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (showLeaveDialog) return
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
@@ -133,23 +151,37 @@ export function PlaygroundMessageEditor({
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeBlockEditor
|
||||
actions={editorActions}
|
||||
ariaLabel={t('Edit')}
|
||||
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch] group-[.is-user]:max-w-[85%] sm:group-[.is-user]:max-w-[62ch] md:group-[.is-user]:max-w-[68ch] lg:group-[.is-user]:max-w-[72ch]'
|
||||
language='markdown'
|
||||
onChange={onEditTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={8}
|
||||
title={
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<span>{t('Edit')}</span>
|
||||
<span className='text-muted-foreground/80 normal-case'>
|
||||
{hasChanged ? t('Unsaved changes') : t('No changes')}
|
||||
<>
|
||||
<CodeBlockEditor
|
||||
actions={editorActions}
|
||||
ariaLabel={t('Edit')}
|
||||
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch] group-[.is-user]:max-w-[85%] sm:group-[.is-user]:max-w-[62ch] md:group-[.is-user]:max-w-[68ch] lg:group-[.is-user]:max-w-[72ch]'
|
||||
language='markdown'
|
||||
onChange={onEditTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={8}
|
||||
title={
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<span>{t('Edit')}</span>
|
||||
<span className='text-muted-foreground/80 normal-case'>
|
||||
{hasChanged ? t('Unsaved changes') : t('No changes')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
value={editText}
|
||||
/>
|
||||
}
|
||||
value={editText}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
cancelBtnText={t('Stay')}
|
||||
confirmText={t('Leave')}
|
||||
desc={t('You have unsaved changes. Are you sure you want to leave?')}
|
||||
destructive
|
||||
handleConfirm={leaveEdit}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setShowLeaveDialog(false)
|
||||
}}
|
||||
open={showLeaveDialog}
|
||||
title={t('Unsaved changes')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user