diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 591ef21e..976c09c0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -33,6 +33,7 @@ import { } from '@/components/ai-elements/prompt-input'; import { Shimmer } from '@/components/ai-elements/shimmer'; +import { useSmoothedText } from './hooks/useSmoothedText'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; @@ -93,6 +94,11 @@ interface TreeNode extends DirEntry { const streamdownComponents = { pre: MarkdownPreOverride } +function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) { + const smoothText = useSmoothedText(text) + return {smoothText} +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -4237,7 +4243,7 @@ function App() { {tabState.currentAssistantMessage && ( - {tabState.currentAssistantMessage.replace(/<\/?voice>/g, '')} + /g, '')} components={streamdownComponents} /> )} diff --git a/apps/x/apps/renderer/src/hooks/useSmoothedText.ts b/apps/x/apps/renderer/src/hooks/useSmoothedText.ts new file mode 100644 index 00000000..2328e72c --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSmoothedText.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Smoothly reveals streamed text by buffering incoming chunks and releasing + * them gradually via requestAnimationFrame, producing the fluid typing effect + * seen in apps like Claude and ChatGPT. + */ +export function useSmoothedText(targetText: string): string { + const [displayText, setDisplayText] = useState('') + const targetRef = useRef('') + const displayLenRef = useRef(0) + const rafRef = useRef(0) + + targetRef.current = targetText + + useEffect(() => { + // Target cleared → immediately clear display + if (!targetText) { + displayLenRef.current = 0 + setDisplayText('') + cancelAnimationFrame(rafRef.current) + return + } + + const tick = () => { + const target = targetRef.current + if (!target) return + + const currentLen = displayLenRef.current + if (currentLen < target.length) { + const remaining = target.length - currentLen + // Adaptive speed: reveal faster when buffer is large, slower when small + const step = Math.max(2, Math.ceil(remaining * 0.18)) + displayLenRef.current = Math.min(currentLen + step, target.length) + setDisplayText(target.slice(0, displayLenRef.current)) + rafRef.current = requestAnimationFrame(tick) + } + // When caught up, stop. New useEffect call restarts when more text arrives. + } + + cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(tick) + + return () => cancelAnimationFrame(rafRef.current) + }, [targetText]) + + return displayText +}