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
+}