diff --git a/frontend/src/components/thread/content/StreamingText.tsx b/frontend/src/components/thread/content/StreamingText.tsx new file mode 100644 index 00000000..4f8562e3 --- /dev/null +++ b/frontend/src/components/thread/content/StreamingText.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { ComposioUrlDetector } from './composio-url-detector'; + +interface StreamingTextProps { + content: string; + className?: string; +} + +export const StreamingText: React.FC = ({ + content, + className = "text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere" +}) => { + if (!content) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/components/thread/content/ThreadContent.tsx b/frontend/src/components/thread/content/ThreadContent.tsx index c331106e..f72de615 100644 --- a/frontend/src/components/thread/content/ThreadContent.tsx +++ b/frontend/src/components/thread/content/ThreadContent.tsx @@ -17,6 +17,7 @@ import { AgentAvatar, AgentName } from './agent-avatar'; import { parseXmlToolCalls, isNewXmlFormat } from '@/components/thread/tool-views/xml-parser'; import { ShowToolStream } from './ShowToolStream'; import { ComposioUrlDetector } from './composio-url-detector'; +import { StreamingText } from './StreamingText'; import { HIDE_STREAMING_XML_TAGS } from '@/components/thread/utils'; @@ -910,12 +911,10 @@ export const ThreadContent: React.FC = ({ return ( <> - {textBeforeTag && ( - - )} - {showCursor && ( - - )} + {detectedTag && ( = ({ startTime={Date.now()} /> )} - - ); })()} @@ -970,15 +967,16 @@ export const ThreadContent: React.FC = ({ {debugMode && streamingText ? (
                                                                                         {streamingText}
+                                                                                        {showCursor && (
+                                                                                            
+                                                                                        )}
                                                                                     
) : ( <> - {textBeforeTag && ( - - )} - {showCursor && ( - - )} + {detectedTag && ( = ({ {/* Streaming indicator content */}
-
-
-
+
+
+
diff --git a/frontend/src/hooks/useAgentStream.ts b/frontend/src/hooks/useAgentStream.ts index 482c8c99..cbe1b416 100644 --- a/frontend/src/hooks/useAgentStream.ts +++ b/frontend/src/hooks/useAgentStream.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { streamAgent, @@ -90,6 +90,34 @@ export function useAgentStream( const [textContent, setTextContent] = useState< { content: string; sequence?: number }[] >([]); + + // Add throttled state updates for smoother streaming + const throttleRef = useRef(null); + const pendingContentRef = useRef<{ content: string; sequence?: number }[]>([]); + + // Throttled content update function for smoother streaming + const flushPendingContent = useCallback(() => { + if (pendingContentRef.current.length > 0) { + const newContent = [...pendingContentRef.current]; + pendingContentRef.current = []; + + React.startTransition(() => { + setTextContent((prev) => [...prev, ...newContent]); + }); + } + }, []); + + const addContentThrottled = useCallback((content: { content: string; sequence?: number }) => { + pendingContentRef.current.push(content); + + // Clear existing throttle + if (throttleRef.current) { + clearTimeout(throttleRef.current); + } + + // Set new throttle for smooth updates (16ms ≈ 60fps) + throttleRef.current = setTimeout(flushPendingContent, 16); + }, [flushPendingContent]); const [toolCall, setToolCall] = useState(null); const [error, setError] = useState(null); const [agentRunId, setAgentRunId] = useState(null); @@ -101,9 +129,16 @@ export function useAgentStream( const setMessagesRef = useRef(setMessages); // Ref to hold the setMessages function const orderedTextContent = useMemo(() => { - return textContent - .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) - .reduce((acc, curr) => acc + curr.content, ''); + // Use a more efficient approach for streaming performance + if (textContent.length === 0) return ''; + + // Sort once and concatenate efficiently + const sorted = textContent.slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)); + let result = ''; + for (let i = 0; i < sorted.length; i++) { + result += sorted[i].content; + } + return result; }, [textContent]); // Refs to capture current state for persistence @@ -341,14 +376,16 @@ export function useAgentStream( parsedMetadata.stream_status === 'chunk' && parsedContent.content ) { - setTextContent((prev) => { - return prev.concat({ - sequence: message.sequence, - content: parsedContent.content, - }); + // Use throttled approach for smoother streaming + addContentThrottled({ + sequence: message.sequence, + content: parsedContent.content, }); callbacks.onAssistantChunk?.({ content: parsedContent.content }); } else if (parsedMetadata.stream_status === 'complete') { + // Flush any pending content before completing + flushPendingContent(); + setTextContent([]); setToolCall(null); if (message.message_id) callbacks.onMessage(message); @@ -554,6 +591,15 @@ export function useAgentStream( return () => { isMountedRef.current = false; + // Clean up throttle timeout + if (throttleRef.current) { + clearTimeout(throttleRef.current); + throttleRef.current = null; + } + + // Flush any remaining pending content + flushPendingContent(); + // Don't automatically cleanup streams on navigation // Only set mounted flag to false to prevent new operations // Streams will be cleaned up when they naturally complete or on explicit stop