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:
RedwindA
2026-08-18 18:20:53 +08:00
committed by GitHub
parent 4add708ebe
commit 137d1171f2
13 changed files with 993 additions and 90 deletions
@@ -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)
})
})
+20 -15
View File
@@ -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
+10 -2
View File
@@ -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,11 +24,43 @@ 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(
@@ -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
}
+68 -9
View File
@@ -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
@@ -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')}
/>
</>
)
}
+22
View File
@@ -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 {