From 34589414146163d803372ee46543a9a55dcabb18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 07:04:05 +0000 Subject: [PATCH 1/3] fix: reduce UI jank during agent streaming with rAF event batching Three root causes of UI slowness during the primary user journey: 1. Every SSE token triggered a full React re-render (~20-50/sec during streaming). Added requestAnimationFrame-based event batching so multiple SSE events within a single frame are processed in one synchronous pass. React 18 batches all setState calls within the same callback, reducing re-renders from ~50/sec to ~60fps max. 2. sendMessage callback was recreated on every state change because it depended on state.threadId, state.runId, and state.status. Replaced with refs so the callback is stable across state changes, preventing cascading re-renders in child components. 3. Three overlapping polling intervals fired simultaneously during session startup: useSession (500-1000ms), workflow queue (2000ms), and message queue (2000ms). The latter two were redundant since useSession already polls aggressively during transitional states. Removed the duplicate intervals. 4. Initial prompt tracking effect had unnecessary deps (aguiState.messages.length, aguiState.status) that caused it to re-evaluate on every streamed message, even though it only updates a ref. Reduced to only depend on session.spec.initialPrompt. https://claude.ai/code/session_01P6sFQgvLjBHd6zuieQuzyz --- .../[name]/sessions/[sessionName]/page.tsx | 52 ++++--------- .../frontend/src/hooks/use-agui-stream.ts | 75 +++++++++++++++++-- 2 files changed, 82 insertions(+), 45 deletions(-) diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index b0b7ad68f..5c142d4cb 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -283,20 +283,23 @@ export default function ProjectSessionDetailPage({ const lastProcessedPromptRef = useRef(""); useEffect(() => { - if (!session || !aguiSendMessage) return; - + if (!session) return; + const initialPrompt = session?.spec?.initialPrompt; - + // NOTE: Initial prompt execution handled by backend auto-trigger (StartSession handler) // Backend waits for subscriber before executing, ensuring events are received // This works for both UI and headless/API usage - + // Track that we've seen this prompt (for workflow changes) if (initialPrompt && lastProcessedPromptRef.current !== initialPrompt) { lastProcessedPromptRef.current = initialPrompt; } + // Only re-run when the initialPrompt value actually changes. + // Previous deps (phase, messages.length, status) caused this to re-evaluate + // on every streamed message, even though it only tracks a ref. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session?.spec?.initialPrompt, session?.status?.phase, aguiState.messages.length, aguiState.status]); + }, [session?.spec?.initialPrompt]); // Workflow management hook const workflowManagement = useWorkflowManagement({ @@ -306,22 +309,9 @@ export default function ProjectSessionDetailPage({ onWorkflowActivated: refetchSession, }); - // Poll session status when workflow is queued - useEffect(() => { - if (!workflowManagement.queuedWorkflow) return; - - const phase = session?.status?.phase; - - // If already running, we'll process workflow in the next effect - if (phase === "Running") return; - - // Poll every 2 seconds to check if session is ready - const pollInterval = setInterval(() => { - refetchSession(); - }, 2000); - - return () => clearInterval(pollInterval); - }, [workflowManagement.queuedWorkflow, session?.status?.phase, refetchSession]); + // NOTE: No separate polling needed for queued workflows - useSession already polls + // at 500-1000ms during transitional states (Pending, Creating, Stopping). + // The previous setInterval(refetchSession, 2000) was redundant and slower. // Process queued workflow when session becomes Running useEffect(() => { @@ -342,23 +332,9 @@ export default function ProjectSessionDetailPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.status?.phase, workflowManagement.queuedWorkflow]); - // Poll session status when messages are queued - useEffect(() => { - const queuedMessages = sessionQueue.messages.filter(m => !m.sentAt); - if (queuedMessages.length === 0) return; - - const phase = session?.status?.phase; - - // If already running, we'll process messages in the next effect - if (phase === "Running") return; - - // Poll every 2 seconds to check if session is ready - const pollInterval = setInterval(() => { - refetchSession(); - }, 2000); - - return () => clearInterval(pollInterval); - }, [sessionQueue.messages, session?.status?.phase, refetchSession]); + // NOTE: No separate polling needed for queued messages - useSession already polls + // at 500-1000ms during transitional states (Pending, Creating, Stopping). + // The previous setInterval(refetchSession, 2000) was redundant and slower. // Process queued messages when session becomes Running useEffect(() => { diff --git a/components/frontend/src/hooks/use-agui-stream.ts b/components/frontend/src/hooks/use-agui-stream.ts index 0cef4a021..f4388b185 100644 --- a/components/frontend/src/hooks/use-agui-stream.ts +++ b/components/frontend/src/hooks/use-agui-stream.ts @@ -45,6 +45,17 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur const reconnectAttemptsRef = useRef(0) const mountedRef = useRef(false) + // Event batching: buffer SSE events and flush via requestAnimationFrame + // This prevents re-rendering on every token during streaming (~20-50 events/sec → 1 render/frame) + const eventBufferRef = useRef([]) + const rafIdRef = useRef(null) + + // Refs for stable sendMessage (avoids recreating callback on every state change) + const stateThreadIdRef = useRef(null) + const stateRunIdRef = useRef(null) + const stateStatusRef = useRef('idle') + + // Exponential backoff config for reconnection const MAX_RECONNECT_DELAY = 30000 // 30 seconds max const BASE_RECONNECT_DELAY = 1000 // 1 second base @@ -54,9 +65,21 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur mountedRef.current = true return () => { mountedRef.current = false + // Cancel any pending rAF flush on unmount + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } } }, []) + // Keep sendMessage refs in sync with state + useEffect(() => { + stateThreadIdRef.current = state.threadId + stateRunIdRef.current = state.runId + stateStatusRef.current = state.status + }, [state.threadId, state.runId, state.status]) + // Process incoming AG-UI events const processEvent = useCallback( (event: PlatformEvent) => { @@ -76,6 +99,33 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur [onEvent, onMessage, onError, onTraceId], ) + // Keep a ref to processEvent for use inside rAF callbacks (avoids stale closures) + const processEventRef = useRef(processEvent) + useEffect(() => { + processEventRef.current = processEvent + }, [processEvent]) + + // Flush all buffered events in a single synchronous pass + // React 18 batches all setState calls within the same synchronous callback, + // so N events → N setState calls → 1 re-render (instead of N re-renders) + const flushEventBuffer = useCallback(() => { + rafIdRef.current = null + const events = eventBufferRef.current + if (events.length === 0) return + eventBufferRef.current = [] + + for (const event of events) { + processEventRef.current(event) + } + }, []) + + // Schedule a flush on the next animation frame (~60fps max) + const scheduleFlush = useCallback(() => { + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushEventBuffer) + } + }, [flushEventBuffer]) + // Connect to the AG-UI event stream const connect = useCallback( (runId?: string) => { @@ -113,7 +163,9 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur eventSource.onmessage = (e) => { try { const event = JSON.parse(e.data) as PlatformEvent - processEvent(event) + // Buffer events and flush via requestAnimationFrame for batched rendering + eventBufferRef.current.push(event) + scheduleFlush() } catch (err) { console.error('Failed to parse AG-UI event:', err) } @@ -164,11 +216,18 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur }, delay) } }, - [projectName, sessionName, processEvent, onConnected, onError, onDisconnected], + [projectName, sessionName, scheduleFlush, onConnected, onError, onDisconnected], ) // Disconnect from the event stream const disconnect = useCallback(() => { + // Cancel any pending rAF flush and clear buffered events + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + eventBufferRef.current = [] + if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null @@ -222,6 +281,7 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur // Send a message to start/continue the conversation // AG-UI server pattern: POST returns SSE stream directly + // Uses refs for threadId/runId/status so the callback is stable across state changes const sendMessage = useCallback( async (content: string) => { // Send to backend via run endpoint - this returns an SSE stream @@ -244,6 +304,7 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur } as PlatformMessage], })) + try { const response = await fetch(runUrl, { method: 'POST', @@ -251,8 +312,8 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur 'Content-Type': 'application/json', }, body: JSON.stringify({ - threadId: state.threadId || sessionName, - parentRunId: state.runId, + threadId: stateThreadIdRef.current || sessionName, + parentRunId: stateRunIdRef.current, messages: [userMessage], }), }) @@ -279,8 +340,8 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur setIsRunActive(true) } - // Ensure we're connected to the thread stream to receive events. - if (!eventSourceRef.current) { + // Ensure we're connected to the thread stream to receive events + if (stateStatusRef.current !== 'connected') { connect() } } catch (error) { @@ -293,7 +354,7 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur throw error } }, - [projectName, sessionName, state.threadId, state.runId, connect], + [projectName, sessionName, connect], ) // Auto-connect on mount if enabled (client-side only) From bfa0e8fc384a5b9551b567e1fe35b39d33be3328 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 07:20:09 +0000 Subject: [PATCH 2/3] refactor: consolidate state refs and add unmount guard in useAGUIStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate three separate state-sync refs (threadId, runId, status) into a single stateSnapshotRef with proper typed status field - Add mountedRef guard to flushEventBuffer to prevent stale setState calls after unmount - Update React version reference in comment (18 → 18+) https://claude.ai/code/session_01P6sFQgvLjBHd6zuieQuzyz --- .../frontend/src/hooks/use-agui-stream.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/components/frontend/src/hooks/use-agui-stream.ts b/components/frontend/src/hooks/use-agui-stream.ts index f4388b185..367e6134a 100644 --- a/components/frontend/src/hooks/use-agui-stream.ts +++ b/components/frontend/src/hooks/use-agui-stream.ts @@ -50,10 +50,12 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur const eventBufferRef = useRef([]) const rafIdRef = useRef(null) - // Refs for stable sendMessage (avoids recreating callback on every state change) - const stateThreadIdRef = useRef(null) - const stateRunIdRef = useRef(null) - const stateStatusRef = useRef('idle') + // Ref snapshot of state fields used by sendMessage (avoids recreating callback on every state change) + const stateSnapshotRef = useRef<{ + threadId: string | null + runId: string | null + status: AGUIClientState['status'] + }>({ threadId: null, runId: null, status: 'idle' }) // Exponential backoff config for reconnection @@ -73,11 +75,13 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur } }, []) - // Keep sendMessage refs in sync with state + // Keep sendMessage snapshot in sync with state useEffect(() => { - stateThreadIdRef.current = state.threadId - stateRunIdRef.current = state.runId - stateStatusRef.current = state.status + stateSnapshotRef.current = { + threadId: state.threadId, + runId: state.runId, + status: state.status, + } }, [state.threadId, state.runId, state.status]) // Process incoming AG-UI events @@ -105,11 +109,12 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur processEventRef.current = processEvent }, [processEvent]) - // Flush all buffered events in a single synchronous pass - // React 18 batches all setState calls within the same synchronous callback, - // so N events → N setState calls → 1 re-render (instead of N re-renders) + // Flush all buffered events in a single synchronous pass. + // React 18+ batches all setState calls within the same synchronous callback, + // so N events → N setState calls → 1 re-render (instead of N re-renders). const flushEventBuffer = useCallback(() => { rafIdRef.current = null + if (!mountedRef.current) return const events = eventBufferRef.current if (events.length === 0) return eventBufferRef.current = [] @@ -312,8 +317,8 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur 'Content-Type': 'application/json', }, body: JSON.stringify({ - threadId: stateThreadIdRef.current || sessionName, - parentRunId: stateRunIdRef.current, + threadId: stateSnapshotRef.current.threadId || sessionName, + parentRunId: stateSnapshotRef.current.runId, messages: [userMessage], }), }) @@ -341,7 +346,7 @@ export function useAGUIStream(options: UseAGUIStreamOptions): UseAGUIStreamRetur } // Ensure we're connected to the thread stream to receive events - if (stateStatusRef.current !== 'connected') { + if (stateSnapshotRef.current.status !== 'connected') { connect() } } catch (error) { From 056cbb487d8fc777d5a57bc1240143f96a95748b Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Tue, 24 Feb 2026 12:47:04 -0500 Subject: [PATCH 3/3] fix: add missing AGUIClientState import in use-agui-stream Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/hooks/use-agui-stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/frontend/src/hooks/use-agui-stream.ts b/components/frontend/src/hooks/use-agui-stream.ts index 367e6134a..a98d10fa7 100644 --- a/components/frontend/src/hooks/use-agui-stream.ts +++ b/components/frontend/src/hooks/use-agui-stream.ts @@ -11,7 +11,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react' -import type { PlatformEvent, PlatformMessage } from '@/types/agui' +import type { AGUIClientState, PlatformEvent, PlatformMessage } from '@/types/agui' import { processAGUIEvent } from './agui/event-handlers' import type { EventHandlerCallbacks } from './agui/event-handlers' import { initialState } from './agui/types'