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:
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
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 { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { CodeBlockEditor } from '../code-block'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function editorTree(value: string) {
|
||||
// A fresh inline onKeyDown per call mirrors PlaygroundMessageEditor, which
|
||||
// recreates its handler on every keystroke-driven render.
|
||||
return (
|
||||
<CodeBlockEditor
|
||||
ariaLabel='Edit message'
|
||||
language='markdown'
|
||||
onChange={() => undefined}
|
||||
onKeyDown={() => undefined}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CodeBlockEditor', () => {
|
||||
test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
|
||||
const { rerender } = render(editorTree('h'))
|
||||
|
||||
const contentBefore = document.querySelector('.cm-content')
|
||||
expect(contentBefore).not.toBeNull()
|
||||
|
||||
rerender(editorTree('hi'))
|
||||
|
||||
const contentAfter = document.querySelector('.cm-content')
|
||||
// If the EditorView were torn down and rebuilt, the content node would be
|
||||
// replaced and the cursor would reset to the document start, making typed
|
||||
// characters pile up at the beginning (text appears right-to-left).
|
||||
expect(contentAfter).toBe(contentBefore)
|
||||
expect(contentAfter?.textContent).toContain('hi')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { Response } from '../response'
|
||||
import {
|
||||
FADE_DURATION_MS,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
FADE_STAGGER_MAX_MS,
|
||||
} from '../response-fade'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Response streaming fade', () => {
|
||||
test('wraps newly streamed words when final is false', () => {
|
||||
const { rerender } = render(<Response final={false}>Hello</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(screen.getByText('Hello')).toBeTruthy()
|
||||
|
||||
rerender(<Response final={false}>Hello world</Response>)
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
expect(fades.some((node) => node.textContent === 'world')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders settled content with zero fade wrappers when final is true', () => {
|
||||
render(<Response final>Hello world</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
expect(screen.getByText(/Hello world/)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('does not fade inline code or fenced code blocks', () => {
|
||||
render(
|
||||
<Response final={false}>
|
||||
{['Use `code` and:', '', '```', 'block', '```'].join('\n')}
|
||||
</Response>
|
||||
)
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
const fadedText = fades.map((node) => node.textContent ?? '').join('')
|
||||
expect(fadedText.includes('block')).toBe(false)
|
||||
expect(
|
||||
fades.every((node) => {
|
||||
const text = node.textContent ?? ''
|
||||
return text.trim() !== 'code' && text.trim() !== 'block'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('does not re-animate words after markdown restructuring around strong', () => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
const { rerender } = render(<Response final={false}>**fin</Response>)
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1
|
||||
)
|
||||
rerender(<Response final={false}>**final**</Response>)
|
||||
|
||||
const strong = document.querySelector('strong')
|
||||
expect(strong?.textContent).toContain('final')
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
expect(
|
||||
fades.every((node) => !(node.textContent ?? '').includes('final'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('suppresses fades on the first streaming render of hydrated content', () => {
|
||||
const hydrated = 'word '.repeat(FADE_HYDRATION_THRESHOLD)
|
||||
|
||||
render(<Response final={false}>{hydrated}</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('drops all fade wrappers once the stream settles', () => {
|
||||
const { rerender } = render(
|
||||
<Response final={false}>Streaming text</Response>
|
||||
)
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
|
||||
rerender(<Response final>Streaming text</Response>)
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
beginRun,
|
||||
classifyValue,
|
||||
createFadeState,
|
||||
endRun,
|
||||
FADE_DURATION_MS,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
FADE_STAGGER_MAX_MS,
|
||||
FADE_STAGGER_MS,
|
||||
splitWords,
|
||||
stageRun,
|
||||
} from '../response-fade'
|
||||
|
||||
describe('splitWords', () => {
|
||||
test('round-trips ASCII words with trailing whitespace', () => {
|
||||
const value = 'Hello world, stream.\n'
|
||||
expect(splitWords(value).join('')).toBe(value)
|
||||
})
|
||||
|
||||
test('keeps leading whitespace as its own part', () => {
|
||||
expect(splitWords(' hi')).toEqual([' ', 'hi'])
|
||||
})
|
||||
|
||||
test('segments CJK without spaces via Intl.Segmenter', () => {
|
||||
const value = '你好世界'
|
||||
const parts = splitWords(value)
|
||||
expect(parts.join('')).toBe(value)
|
||||
expect(parts.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyValue', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('animates only newly appended words with capped stagger', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
const firstSegments = classifyValue(first, 'one two ')
|
||||
endRun(first)
|
||||
|
||||
expect(firstSegments.filter((s) => s.animated)).toHaveLength(2)
|
||||
expect(firstSegments[0]?.delay).toBe(0)
|
||||
expect(firstSegments[1]?.delay).toBe(FADE_STAGGER_MS)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + FADE_STAGGER_MS + 1
|
||||
)
|
||||
const second = beginRun(state)
|
||||
const secondSegments = classifyValue(second, 'one two three four')
|
||||
endRun(second)
|
||||
|
||||
const animated = secondSegments.filter((s) => s.animated)
|
||||
expect(animated.map((s) => s.value.trim())).toEqual(['three', 'four'])
|
||||
expect(animated[0]?.delay).toBe(0)
|
||||
expect(animated[1]?.delay).toBe(FADE_STAGGER_MS)
|
||||
})
|
||||
|
||||
test('caps stagger delay at FADE_STAGGER_MAX_MS', () => {
|
||||
const state = createFadeState()
|
||||
const run = beginRun(state)
|
||||
const words = Array.from({ length: 20 }, (_, i) => `w${i}`).join(' ')
|
||||
const segments = classifyValue(run, words)
|
||||
endRun(run)
|
||||
|
||||
const delays = segments.filter((s) => s.animated).map((s) => s.delay)
|
||||
expect(Math.max(...delays)).toBe(FADE_STAGGER_MAX_MS)
|
||||
})
|
||||
|
||||
test('replays identical delay while still inside the animation window', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'hello ')
|
||||
endRun(first)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000 + FADE_DURATION_MS / 2)
|
||||
const second = beginRun(state)
|
||||
const segments = classifyValue(second, 'hello world')
|
||||
endRun(second)
|
||||
|
||||
expect(segments[0]).toMatchObject({
|
||||
animated: true,
|
||||
delay: 0,
|
||||
start: 0,
|
||||
value: 'hello ',
|
||||
})
|
||||
expect(segments[1]).toMatchObject({
|
||||
animated: true,
|
||||
start: 6,
|
||||
value: 'world',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps the same start offset when the head word grows', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
const head = classifyValue(first, 'hel')
|
||||
endRun(first)
|
||||
expect(head[0]?.start).toBe(0)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1050)
|
||||
const second = beginRun(state)
|
||||
const grown = classifyValue(second, 'hello')
|
||||
endRun(second)
|
||||
|
||||
expect(grown[0]?.start).toBe(0)
|
||||
expect(grown[0]?.animated).toBe(true)
|
||||
expect(grown[0]?.value).toBe('hello')
|
||||
})
|
||||
|
||||
test('does not animate whitespace-only parts', () => {
|
||||
const state = createFadeState()
|
||||
const run = beginRun(state)
|
||||
const segments = classifyValue(run, ' \n')
|
||||
endRun(run)
|
||||
|
||||
expect(segments.every((s) => !s.animated)).toBe(true)
|
||||
})
|
||||
|
||||
test('suppresses animation on the hydration baseline', () => {
|
||||
const state = createFadeState()
|
||||
const longText = 'a'.repeat(FADE_HYDRATION_THRESHOLD + 1)
|
||||
const run = beginRun(state, true)
|
||||
const segments = classifyValue(run, longText)
|
||||
endRun(run)
|
||||
|
||||
expect(segments.every((s) => !s.animated)).toBe(true)
|
||||
expect(state.prevCount).toBe(longText.length)
|
||||
})
|
||||
|
||||
test('stops replaying animation after the window expires', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'done ')
|
||||
endRun(first)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + 1
|
||||
)
|
||||
const second = beginRun(state)
|
||||
const segments = classifyValue(second, 'done next')
|
||||
endRun(second)
|
||||
|
||||
expect(segments[0]).toMatchObject({ animated: false, value: 'done ' })
|
||||
expect(segments[1]).toMatchObject({ animated: true, value: 'next' })
|
||||
expect(state.active.has(0)).toBe(false)
|
||||
})
|
||||
|
||||
test('abandoned staged runs leave committed state untouched', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'keep ')
|
||||
endRun(first)
|
||||
expect(state.prevCount).toBe(5)
|
||||
|
||||
const abandoned = beginRun(state)
|
||||
classifyValue(abandoned, 'keep extra')
|
||||
stageRun(abandoned)
|
||||
// Never commit — simulate React discarding the render
|
||||
state.pending = null
|
||||
|
||||
expect(state.prevCount).toBe(5)
|
||||
expect(state.active.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -265,7 +265,7 @@ function getCodeBlockMaxHeight(
|
||||
|
||||
function getCodeMirrorExtensions(options: {
|
||||
language: BundledLanguage | string
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
onKeyDown: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly: boolean
|
||||
showLineNumbers: boolean
|
||||
}): Extension[] {
|
||||
@@ -276,23 +276,18 @@ function getCodeMirrorExtensions(options: {
|
||||
EditorState.tabSize.of(2),
|
||||
EditorState.readOnly.of(options.readOnly),
|
||||
EditorView.editable.of(!options.readOnly),
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
if (options.showLineNumbers) {
|
||||
extensions.unshift(lineNumbers())
|
||||
}
|
||||
|
||||
if (options.onKeyDown) {
|
||||
extensions.push(
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown?.(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
@@ -311,21 +306,27 @@ function CodeMirrorCodeView({
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const initialValueRef = useRef(value)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onKeyDownRef = useRef(onKeyDown)
|
||||
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
|
||||
// onKeyDown is delivered through a ref so a new handler identity from the
|
||||
// parent (recreated on every keystroke-driven render) does not invalidate
|
||||
// the extensions and tear down the EditorView, which would reset the cursor
|
||||
// to the document start and make typing appear right-to-left.
|
||||
const editorExtensions = useMemo(
|
||||
() =>
|
||||
getCodeMirrorExtensions({
|
||||
language,
|
||||
onKeyDown,
|
||||
onKeyDown: (event) => onKeyDownRef.current?.(event),
|
||||
readOnly,
|
||||
showLineNumbers,
|
||||
}),
|
||||
[language, onKeyDown, readOnly, showLineNumbers]
|
||||
[language, readOnly, showLineNumbers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
onKeyDownRef.current = onKeyDown
|
||||
}, [onChange, onKeyDown])
|
||||
|
||||
useEffect(() => {
|
||||
const editorHost = editorHostRef.current
|
||||
@@ -357,6 +358,10 @@ function CodeMirrorCodeView({
|
||||
}, [autoFocus, editorExtensions])
|
||||
|
||||
useEffect(() => {
|
||||
// Track the latest value so a future editor rebuild (e.g. language change)
|
||||
// starts from the current document instead of the mount-time snapshot.
|
||||
initialValueRef.current = value
|
||||
|
||||
const editorView = editorViewRef.current
|
||||
if (!editorView) {
|
||||
return
|
||||
|
||||
@@ -191,7 +191,10 @@ export type ReasoningContentProps = ComponentProps<
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
({ className, children, ...props }: ReasoningContentProps) => {
|
||||
const { isStreaming } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
|
||||
@@ -201,12 +204,17 @@ export const ReasoningContent = memo(
|
||||
{...props}
|
||||
>
|
||||
<div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
|
||||
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
|
||||
<Response
|
||||
className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'
|
||||
final={!isStreaming}
|
||||
parserId='new-api-reasoning'
|
||||
>
|
||||
{children}
|
||||
</Response>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Reasoning.displayName = 'Reasoning'
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
/** Must match the animation duration on `[data-stream-fade]` in styles/index.css */
|
||||
export const FADE_DURATION_MS = 250
|
||||
export const FADE_STAGGER_MS = 25
|
||||
export const FADE_STAGGER_MAX_MS = 250
|
||||
/**
|
||||
* Text already longer than this when animation starts is hydrated/resumed
|
||||
* content (reconnected stream, conversation switch), not a fresh delta —
|
||||
* that content becomes the baseline instead of re-fading.
|
||||
*/
|
||||
export const FADE_HYDRATION_THRESHOLD = 120
|
||||
|
||||
type FadeEntry = { at: number; delay: number }
|
||||
|
||||
type PendingRun = {
|
||||
prevCount: number
|
||||
additions: Map<number, FadeEntry>
|
||||
now: number
|
||||
}
|
||||
|
||||
export type FadeState = {
|
||||
/** Total characters classified during the last committed run */
|
||||
prevCount: number
|
||||
/** Parts still mid-animation, keyed by start offset */
|
||||
active: Map<number, FadeEntry>
|
||||
/** True until the first run commits */
|
||||
firstRun: boolean
|
||||
/** Staged result of the latest render; published on commit */
|
||||
pending: PendingRun | null
|
||||
}
|
||||
|
||||
export type FadeRun = {
|
||||
state: FadeState
|
||||
now: number
|
||||
count: number
|
||||
newIndex: number
|
||||
/** Baseline mode: classify everything as already seen */
|
||||
suppress: boolean
|
||||
additions: Map<number, FadeEntry>
|
||||
}
|
||||
|
||||
export type FadeSegment = {
|
||||
start: number
|
||||
value: string
|
||||
animated: boolean
|
||||
delay: number
|
||||
}
|
||||
|
||||
const WORD_REGEX = /\S+\s*/g
|
||||
const NON_WHITESPACE_REGEX = /\S/
|
||||
/**
|
||||
* Scripts without word-delimiting spaces: Thai, Lao, Myanmar, Khmer,
|
||||
* Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs.
|
||||
*/
|
||||
const SPACELESS_REGEX =
|
||||
/[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/
|
||||
|
||||
let wordSegmenter: Intl.Segmenter | null | undefined
|
||||
|
||||
function getWordSegmenter(): Intl.Segmenter | null {
|
||||
if (wordSegmenter === undefined) {
|
||||
wordSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null
|
||||
}
|
||||
return wordSegmenter
|
||||
}
|
||||
|
||||
function pushSegmentedParts(parts: string[], token: string): void {
|
||||
const segmenter = getWordSegmenter()
|
||||
if (segmenter == null) {
|
||||
parts.push(token)
|
||||
return
|
||||
}
|
||||
const trailing = /\s+$/.exec(token)
|
||||
const word = trailing == null ? token : token.slice(0, trailing.index)
|
||||
for (const segment of segmenter.segment(word)) {
|
||||
parts.push(segment.segment)
|
||||
}
|
||||
if (trailing != null) {
|
||||
parts.push(trailing[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into word parts (non-whitespace run plus trailing whitespace).
|
||||
* Spaceless scripts are further split via Intl.Segmenter.
|
||||
* Concatenating the result always reproduces the input exactly.
|
||||
*/
|
||||
export function splitWords(value: string): string[] {
|
||||
const parts: string[] = []
|
||||
WORD_REGEX.lastIndex = 0
|
||||
let index = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = WORD_REGEX.exec(value)) !== null) {
|
||||
if (match.index > index) {
|
||||
parts.push(value.slice(index, match.index))
|
||||
}
|
||||
const token = match[0]
|
||||
if (SPACELESS_REGEX.test(token)) {
|
||||
pushSegmentedParts(parts, token)
|
||||
} else {
|
||||
parts.push(token)
|
||||
}
|
||||
index = match.index + token.length
|
||||
}
|
||||
if (index < value.length) {
|
||||
parts.push(value.slice(index))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
export function createFadeState(): FadeState {
|
||||
return { prevCount: 0, active: new Map(), firstRun: true, pending: null }
|
||||
}
|
||||
|
||||
export function beginRun(state: FadeState, suppress = false): FadeRun {
|
||||
return {
|
||||
state,
|
||||
now: performance.now(),
|
||||
count: 0,
|
||||
newIndex: 0,
|
||||
suppress,
|
||||
additions: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages the run's result without publishing. Classification never mutates
|
||||
* committed state during render, so abandoned renders leave no trace.
|
||||
*/
|
||||
export function stageRun(run: FadeRun): void {
|
||||
run.state.pending = {
|
||||
prevCount: run.count,
|
||||
additions: run.additions,
|
||||
now: run.now,
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes the staged run: baseline offset, new animations, pruned entries. */
|
||||
export function commitRun(state: FadeState): void {
|
||||
const pending = state.pending
|
||||
if (pending == null) {
|
||||
return
|
||||
}
|
||||
state.pending = null
|
||||
state.firstRun = false
|
||||
state.prevCount = pending.prevCount
|
||||
for (const [start, entry] of pending.additions) {
|
||||
state.active.set(start, entry)
|
||||
}
|
||||
for (const [start, entry] of state.active) {
|
||||
if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) {
|
||||
state.active.delete(start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages and immediately commits — for callers without a commit phase. */
|
||||
export function endRun(run: FadeRun): void {
|
||||
stageRun(run)
|
||||
commitRun(run.state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies one text value into fade segments, advancing document-order
|
||||
* character offset. New parts (start >= prevCount) animate; parts still
|
||||
* inside their animation window replay identical props.
|
||||
*/
|
||||
export function classifyValue(run: FadeRun, value: string): FadeSegment[] {
|
||||
const { state, now } = run
|
||||
const segments: FadeSegment[] = []
|
||||
for (const part of splitWords(value)) {
|
||||
const start = run.count
|
||||
run.count += part.length
|
||||
if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) {
|
||||
segments.push({ start, value: part, animated: false, delay: 0 })
|
||||
continue
|
||||
}
|
||||
if (start >= state.prevCount) {
|
||||
const staged = run.additions.get(start)
|
||||
const delay =
|
||||
staged?.delay ??
|
||||
Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS)
|
||||
run.newIndex += 1
|
||||
run.additions.set(start, staged ?? { at: now, delay })
|
||||
segments.push({ start, value: part, animated: true, delay })
|
||||
continue
|
||||
}
|
||||
const entry = state.active.get(start)
|
||||
if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) {
|
||||
segments.push({
|
||||
start,
|
||||
value: part,
|
||||
animated: true,
|
||||
delay: entry.delay,
|
||||
})
|
||||
continue
|
||||
}
|
||||
segments.push({ start, value: part, animated: false, delay: 0 })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, type CSSProperties, type ReactNode } from 'react'
|
||||
import {
|
||||
shouldOpenLinkInNewTab,
|
||||
type ImageNode,
|
||||
@@ -24,13 +24,45 @@ import {
|
||||
type TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import { classifyValue, type FadeRun } from './response-fade'
|
||||
import { ResponseImage } from './response-renderer-image'
|
||||
import type { RenderChildren } from './response-types'
|
||||
|
||||
export function renderTextNode(node: TextNode): ReactNode {
|
||||
const STREAM_FADE_DELAY_VAR = '--stream-fade-delay'
|
||||
|
||||
export function renderTextNode(
|
||||
node: TextNode,
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
if (!fadeRun) {
|
||||
return node.content
|
||||
}
|
||||
|
||||
const segments = classifyValue(fadeRun, node.content)
|
||||
if (segments.every((segment) => !segment.animated)) {
|
||||
return node.content
|
||||
}
|
||||
|
||||
return segments.map((segment) => {
|
||||
if (!segment.animated) {
|
||||
return <Fragment key={segment.start}>{segment.value}</Fragment>
|
||||
}
|
||||
|
||||
const style =
|
||||
segment.delay > 0
|
||||
? ({
|
||||
[STREAM_FADE_DELAY_VAR]: `${segment.delay}ms`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<span data-stream-fade='' key={segment.start} style={style}>
|
||||
{segment.value}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function renderLink(
|
||||
node: LinkNode,
|
||||
key: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { FadeRun } from './response-fade'
|
||||
import {
|
||||
hasParsedChildren,
|
||||
isBlockquoteNode,
|
||||
@@ -52,40 +53,68 @@ import {
|
||||
renderTextNode,
|
||||
} from './response-renderer-inline'
|
||||
import { renderTable } from './response-renderer-table'
|
||||
import type { BlockRendererOptions, RenderChildren } from './response-types'
|
||||
|
||||
export function renderChildren(nodes: ParsedNode[]): ReactNode {
|
||||
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
|
||||
function createRenderChildren(fadeRun?: FadeRun): RenderChildren {
|
||||
return (nodes) => renderChildren(nodes, fadeRun)
|
||||
}
|
||||
|
||||
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, { renderChildren })
|
||||
export function renderChildren(
|
||||
nodes: ParsedNode[],
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
const options: BlockRendererOptions = {
|
||||
fadeRun,
|
||||
renderChildren: createRenderChildren(fadeRun),
|
||||
}
|
||||
return nodes.map((node, index) =>
|
||||
renderNode(node, getNodeKey(node, index), options)
|
||||
)
|
||||
}
|
||||
|
||||
function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
export function renderFootnotes(
|
||||
footnotes: FootnoteNode[],
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, {
|
||||
fadeRun,
|
||||
renderChildren: createRenderChildren(fadeRun),
|
||||
})
|
||||
}
|
||||
|
||||
/** Settled (non-animated) renderChildren for skipped subtrees */
|
||||
const settledRenderChildren = createRenderChildren()
|
||||
|
||||
function renderNode(
|
||||
node: ParsedNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (isTextNode(node)) {
|
||||
return renderTextNode(node)
|
||||
return renderTextNode(node, options.fadeRun)
|
||||
}
|
||||
|
||||
if (isHeadingNode(node)) {
|
||||
return renderHeading(node, key, { renderChildren })
|
||||
return renderHeading(node, key, options)
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<p className='my-3 leading-7' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
{options.renderChildren(node.children)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'inline' && hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
return <span key={key}>{options.renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if (isListNode(node)) {
|
||||
return renderList(node, key, { renderChildren })
|
||||
return renderList(node, key, options)
|
||||
}
|
||||
|
||||
// Skip list: code / math / html / image — no fade wrapping, offset not advanced
|
||||
if (isCodeBlockNode(node)) {
|
||||
return renderCodeBlock(node, key)
|
||||
}
|
||||
@@ -102,7 +131,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isLinkNode(node)) {
|
||||
return renderLink(node, key, renderChildren)
|
||||
return renderLink(node, key, options.renderChildren)
|
||||
}
|
||||
|
||||
if (isImageNode(node)) {
|
||||
@@ -110,47 +139,47 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isBlockquoteNode(node)) {
|
||||
return renderBlockquote(node, key, { renderChildren })
|
||||
return renderBlockquote(node, key, options)
|
||||
}
|
||||
|
||||
if (isTableNode(node)) {
|
||||
return renderTable(node, key, { renderChildren })
|
||||
return renderTable(node, key, options)
|
||||
}
|
||||
|
||||
if (isDefinitionListNode(node)) {
|
||||
return renderDefinitionList(node, key, { renderChildren })
|
||||
return renderDefinitionList(node, key, options)
|
||||
}
|
||||
|
||||
if (node.type === 'strong' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<strong className='text-foreground font-semibold' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
{options.renderChildren(node.children)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'emphasis' && hasParsedChildren(node)) {
|
||||
return <em key={key}>{renderChildren(node.children)}</em>
|
||||
return <em key={key}>{options.renderChildren(node.children)}</em>
|
||||
}
|
||||
|
||||
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
|
||||
return <del key={key}>{renderChildren(node.children)}</del>
|
||||
return <del key={key}>{options.renderChildren(node.children)}</del>
|
||||
}
|
||||
|
||||
if (node.type === 'highlight' && hasParsedChildren(node)) {
|
||||
return <mark key={key}>{renderChildren(node.children)}</mark>
|
||||
return <mark key={key}>{options.renderChildren(node.children)}</mark>
|
||||
}
|
||||
|
||||
if (node.type === 'insert' && hasParsedChildren(node)) {
|
||||
return <ins key={key}>{renderChildren(node.children)}</ins>
|
||||
return <ins key={key}>{options.renderChildren(node.children)}</ins>
|
||||
}
|
||||
|
||||
if (node.type === 'subscript' && hasParsedChildren(node)) {
|
||||
return <sub key={key}>{renderChildren(node.children)}</sub>
|
||||
return <sub key={key}>{options.renderChildren(node.children)}</sub>
|
||||
}
|
||||
|
||||
if (node.type === 'superscript' && hasParsedChildren(node)) {
|
||||
return <sup key={key}>{renderChildren(node.children)}</sup>
|
||||
return <sup key={key}>{options.renderChildren(node.children)}</sup>
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -204,7 +233,9 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isHtmlBlockNode(node) && node.tag === 'details') {
|
||||
return renderDetails(node, key, { renderChildren })
|
||||
return renderDetails(node, key, {
|
||||
renderChildren: settledRenderChildren,
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'html_block' && 'content' in node) {
|
||||
@@ -216,7 +247,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
return <span key={key}>{options.renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if ('content' in node && typeof node.content === 'string') {
|
||||
|
||||
@@ -19,10 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import type { FadeRun } from './response-fade'
|
||||
|
||||
export type ResponseProps = {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
final?: boolean
|
||||
/** Distinct stream-markdown-parser cache id when multiple Responses stream concurrently */
|
||||
parserId?: string
|
||||
}
|
||||
|
||||
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
|
||||
@@ -42,4 +46,5 @@ export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
|
||||
|
||||
export type BlockRendererOptions = {
|
||||
renderChildren: RenderChildren
|
||||
fadeRun?: FadeRun
|
||||
}
|
||||
|
||||
@@ -18,37 +18,96 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getMarkdownContent, parseResponseContent } from './response-content'
|
||||
import {
|
||||
beginRun,
|
||||
commitRun,
|
||||
createFadeState,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
stageRun,
|
||||
type FadeRun,
|
||||
type FadeState,
|
||||
} from './response-fade'
|
||||
import { renderChildren, renderFootnotes } from './response-renderer'
|
||||
import type { ResponseProps } from './response-types'
|
||||
|
||||
const markdown = getMarkdown('new-api-response')
|
||||
const DEFAULT_PARSER_ID = 'new-api-response'
|
||||
const MAX_PARSED_MARKDOWN_CHARS = 20_000
|
||||
type MarkdownInstance = ReturnType<typeof getMarkdown>
|
||||
|
||||
const markdownByParserId = new Map<string, MarkdownInstance>()
|
||||
|
||||
function getCachedMarkdown(parserId: string): MarkdownInstance {
|
||||
const cached = markdownByParserId.get(parserId)
|
||||
if (cached != null) {
|
||||
return cached
|
||||
}
|
||||
const markdown = getMarkdown(parserId)
|
||||
markdownByParserId.set(parserId, markdown)
|
||||
return markdown
|
||||
}
|
||||
|
||||
export const Response = memo((props: ResponseProps) => {
|
||||
const content = getMarkdownContent(props.children)
|
||||
const isFinal = props.final ?? true
|
||||
const shouldAnimate = !isFinal
|
||||
const parserId = props.parserId ?? DEFAULT_PARSER_ID
|
||||
const markdown = getCachedMarkdown(parserId)
|
||||
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
|
||||
const fadeStateRef = useRef<FadeState | null>(null)
|
||||
if (fadeStateRef.current == null) {
|
||||
fadeStateRef.current = createFadeState()
|
||||
}
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
if (!shouldParseMarkdown) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseMarkdownToStructure(content, markdown, {
|
||||
final: props.final ?? true,
|
||||
final: isFinal,
|
||||
validateLink: markdown.options.validateLink,
|
||||
})
|
||||
}, [content, props.final, shouldParseMarkdown])
|
||||
}, [content, isFinal, markdown, shouldParseMarkdown])
|
||||
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
|
||||
const renderedContent =
|
||||
parsedContent.bodyNodes.length > 0
|
||||
? renderChildren(parsedContent.bodyNodes)
|
||||
: content
|
||||
const footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
|
||||
let fadeRun: FadeRun | undefined
|
||||
let renderedContent
|
||||
let footnotes
|
||||
|
||||
if (parsedContent.bodyNodes.length > 0) {
|
||||
if (shouldAnimate) {
|
||||
const fadeState = fadeStateRef.current
|
||||
const suppress =
|
||||
fadeState.firstRun && content.length > FADE_HYDRATION_THRESHOLD
|
||||
fadeRun = beginRun(fadeState, suppress)
|
||||
renderedContent = renderChildren(parsedContent.bodyNodes, fadeRun)
|
||||
footnotes = renderFootnotes(parsedContent.footnotes, fadeRun)
|
||||
stageRun(fadeRun)
|
||||
} else {
|
||||
renderedContent = renderChildren(parsedContent.bodyNodes)
|
||||
footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
}
|
||||
} else {
|
||||
renderedContent = content
|
||||
footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
return
|
||||
}
|
||||
const fadeState = fadeStateRef.current
|
||||
if (fadeState == null) {
|
||||
return
|
||||
}
|
||||
commitRun(fadeState)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
+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,6 +151,7 @@ export function PlaygroundMessageEditor({
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeBlockEditor
|
||||
actions={editorActions}
|
||||
ariaLabel={t('Edit')}
|
||||
@@ -151,5 +170,18 @@ export function PlaygroundMessageEditor({
|
||||
}
|
||||
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')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,28 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth streaming: one-shot fade-in on newly streamed words.
|
||||
* Duration must match FADE_DURATION_MS in response-fade.ts */
|
||||
@keyframes stream-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-stream-fade] {
|
||||
animation: stream-fade-in 250ms ease-out both;
|
||||
animation-delay: var(--stream-fade-delay, 0ms);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-stream-fade] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Landing page scroll-triggered animations ── */
|
||||
@keyframes landing-fade-up {
|
||||
from {
|
||||
|
||||
Reference in New Issue
Block a user