diff --git a/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx new file mode 100644 index 00000000..10c7c493 --- /dev/null +++ b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx @@ -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 . + +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 ( + 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') + }) +}) diff --git a/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx b/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx new file mode 100644 index 00000000..9efe107e --- /dev/null +++ b/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx @@ -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 . + +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(Hello) + + expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan( + 0 + ) + expect(screen.getByText('Hello')).toBeTruthy() + + rerender(Hello world) + + 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(Hello world) + + 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( + + {['Use `code` and:', '', '```', 'block', '```'].join('\n')} + + ) + + 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(**fin) + expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan( + 0 + ) + + vi.spyOn(performance, 'now').mockReturnValue( + 1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1 + ) + rerender(**final**) + + 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({hydrated}) + + expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0) + }) + + test('drops all fade wrappers once the stream settles', () => { + const { rerender } = render( + Streaming text + ) + expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan( + 0 + ) + + rerender(Streaming text) + expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0) + }) +}) diff --git a/web/src/components/ai-elements/__tests__/response-fade.test.ts b/web/src/components/ai-elements/__tests__/response-fade.test.ts new file mode 100644 index 00000000..d569b027 --- /dev/null +++ b/web/src/components/ai-elements/__tests__/response-fade.test.ts @@ -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 . + +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) + }) +}) diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx index df70915f..77a73409 100644 --- a/web/src/components/ai-elements/code-block.tsx +++ b/web/src/components/ai-elements/code-block.tsx @@ -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(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 diff --git a/web/src/components/ai-elements/reasoning.tsx b/web/src/components/ai-elements/reasoning.tsx index b6f7ba7c..31c415bc 100644 --- a/web/src/components/ai-elements/reasoning.tsx +++ b/web/src/components/ai-elements/reasoning.tsx @@ -191,22 +191,30 @@ export type ReasoningContentProps = ComponentProps< } export const ReasoningContent = memo( - ({ className, children, ...props }: ReasoningContentProps) => ( - -
- - {children} - -
-
- ) + ({ className, children, ...props }: ReasoningContentProps) => { + const { isStreaming } = useReasoning() + + return ( + +
+ + {children} + +
+
+ ) + } ) Reasoning.displayName = 'Reasoning' diff --git a/web/src/components/ai-elements/response-fade.ts b/web/src/components/ai-elements/response-fade.ts new file mode 100644 index 00000000..4fad5638 --- /dev/null +++ b/web/src/components/ai-elements/response-fade.ts @@ -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 . + +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 + 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 + /** 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 +} + +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 {