+}
+
+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
+}
diff --git a/web/src/components/ai-elements/response-renderer-inline.tsx b/web/src/components/ai-elements/response-renderer-inline.tsx
index 0f2a0443..218945f1 100644
--- a/web/src/components/ai-elements/response-renderer-inline.tsx
+++ b/web/src/components/ai-elements/response-renderer-inline.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
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 {
- return node.content
+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 {segment.value}
+ }
+
+ const style =
+ segment.delay > 0
+ ? ({
+ [STREAM_FADE_DELAY_VAR]: `${segment.delay}ms`,
+ } as CSSProperties)
+ : undefined
+
+ return (
+
+ {segment.value}
+
+ )
+ })
}
export function renderLink(
diff --git a/web/src/components/ai-elements/response-renderer.tsx b/web/src/components/ai-elements/response-renderer.tsx
index 97230841..c524c8cf 100644
--- a/web/src/components/ai-elements/response-renderer.tsx
+++ b/web/src/components/ai-elements/response-renderer.tsx
@@ -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 (
- {renderChildren(node.children)}
+ {options.renderChildren(node.children)}
)
}
if (node.type === 'inline' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
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 (
- {renderChildren(node.children)}
+ {options.renderChildren(node.children)}
)
}
if (node.type === 'emphasis' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'highlight' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'insert' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'subscript' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if (node.type === 'superscript' && hasParsedChildren(node)) {
- return {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
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 {renderChildren(node.children)}
+ return {options.renderChildren(node.children)}
}
if ('content' in node && typeof node.content === 'string') {
diff --git a/web/src/components/ai-elements/response-types.ts b/web/src/components/ai-elements/response-types.ts
index fb4b8e79..994b884b 100644
--- a/web/src/components/ai-elements/response-types.ts
+++ b/web/src/components/ai-elements/response-types.ts
@@ -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
}
diff --git a/web/src/components/ai-elements/response.tsx b/web/src/components/ai-elements/response.tsx
index 66e267d0..bff21e24 100644
--- a/web/src/components/ai-elements/response.tsx
+++ b/web/src/components/ai-elements/response.tsx
@@ -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
+
+const markdownByParserId = new Map()
+
+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(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 (
.
+
+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(
+ 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(
+ undefined}
+ originalText='original'
+ />
+ )
+
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+
+ expect(event.defaultPrevented).toBe(false)
+ })
+})
diff --git a/web/src/features/playground/components/message/playground-message-editor.tsx b/web/src/features/playground/components/message/playground-message-editor.tsx
index 3d826159..c5962700 100644
--- a/web/src/features/playground/components/message/playground-message-editor.tsx
+++ b/web/src/features/playground/components/message/playground-message-editor.tsx
@@ -17,9 +17,11 @@ along with this program. If not, see .
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 (
-
- {t('Edit')}
-
- {hasChanged ? t('Unsaved changes') : t('No changes')}
+ <>
+
+ {t('Edit')}
+
+ {hasChanged ? t('Unsaved changes') : t('No changes')}
+
-
- }
- value={editText}
- />
+ }
+ value={editText}
+ />
+ {
+ if (!open) setShowLeaveDialog(false)
+ }}
+ open={showLeaveDialog}
+ title={t('Unsaved changes')}
+ />
+ >
)
}
diff --git a/web/src/styles/index.css b/web/src/styles/index.css
index 940618c0..a9eadb5c 100644
--- a/web/src/styles/index.css
+++ b/web/src/styles/index.css
@@ -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 {