diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index a1a3c0db9c..e8bb0789c8 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -6,7 +6,6 @@ import type { WebviewMessage } from "@roo-code/types" import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js" import { arePathsEqual } from "../utils/pathUtils.js" import { getContextWindow } from "../utils/getContextWindow.js" - import type { AppProps } from "./types.js" import * as theme from "./theme.js" @@ -140,10 +139,9 @@ function AppInner({ } = useUIStateStore() // Compute context window from router models and API configuration - const contextWindow = useMemo( - () => getContextWindow(routerModels, apiConfiguration), - [routerModels, apiConfiguration], - ) + const contextWindow = useMemo(() => { + return getContextWindow(routerModels, apiConfiguration) + }, [routerModels, apiConfiguration]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteRef = useRef>(null) @@ -175,6 +173,10 @@ function AppInner({ const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true }) const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom() + // RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick) + const rafIdRef = useRef(null) + const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null) + // Toast notifications for ephemeral messages (e.g., mode changes) const { currentToast, showInfo } = useToast() @@ -275,9 +277,31 @@ function AppInner({ prevMessageCount.current = messages.length }, [messages.length, scrollState.isAtBottom, scrollToBottom]) - // Handle scroll state changes from ScrollArea + // Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates) const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => { - setScrollState({ scrollTop, maxScroll, isAtBottom }) + // Store the latest scroll values in ref + pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom } + + // Only schedule one update per event loop tick + if (rafIdRef.current === null) { + rafIdRef.current = setImmediate(() => { + rafIdRef.current = null + const pending = pendingScrollRef.current + if (pending) { + setScrollState(pending) + pendingScrollRef.current = null + } + }) + } + }, []) + + // Cleanup RAF-style timer on unmount + useEffect(() => { + return () => { + if (rafIdRef.current !== null) { + clearImmediate(rafIdRef.current) + } + } }, []) // File search handler for the file trigger @@ -350,17 +374,15 @@ function AppInner({ if (fileSearchResults === prevFileSearchResultsRef.current) { return } + + const currentPickerState = pickerStateRef.current + const willRefresh = + currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0 + prevFileSearchResultsRef.current = fileSearchResults - // Read pickerState from ref to avoid dependency - const currentPickerState = pickerStateRef.current - // Only refresh when file picker is open and we have new results - if ( - currentPickerState.isOpen && - currentPickerState.activeTrigger?.id === "file" && - fileSearchResults.length > 0 - ) { + if (willRefresh) { autocompleteRef.current?.refreshSearch() followupAutocompleteRef.current?.refreshSearch() } diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 7317181068..2b2c1ba99a 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from "react" +import { useEffect, useRef, useCallback, useMemo } from "react" import { useApp } from "ink" import { randomUUID } from "crypto" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" @@ -179,27 +179,28 @@ export function useExtensionHost({ } }, []) // Run once on mount - // Expose sendToExtension method - const sendToExtension = hostRef.current - ? (msg: WebviewMessage) => { - hostRef.current?.sendToExtension(msg) - } - : null + // Stable sendToExtension - uses ref to always access current host + // This function reference never changes, preventing downstream useCallback/useMemo invalidations + const sendToExtension = useCallback((msg: WebviewMessage) => { + hostRef.current?.sendToExtension(msg) + }, []) - // Expose runTask method - const runTask = hostRef.current - ? (prompt: string) => { - if (!hostRef.current) { - return Promise.reject(new Error("Extension host not ready")) - } - return hostRef.current.runTask(prompt) - } - : null + // Stable runTask - uses ref to always access current host + const runTask = useCallback((prompt: string): Promise => { + if (!hostRef.current) { + return Promise.reject(new Error("Extension host not ready")) + } + return hostRef.current.runTask(prompt) + }, []) - return { - isReady: isReadyRef.current, - sendToExtension, - runTask, - cleanup, - } + // Memoized return object to prevent unnecessary re-renders in consumers + return useMemo( + () => ({ + isReady: isReadyRef.current, + sendToExtension, + runTask, + cleanup, + }), + [sendToExtension, runTask, cleanup], + ) } diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index c89e3060d3..6c9566a006 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -5,6 +5,38 @@ import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js" import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js" +/** + * Shallow array equality check - compares array length and element references. + * Used to prevent unnecessary state updates when array content hasn't changed. + */ +function shallowArrayEqual(a: T[], b: T[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +/** + * Streaming message debounce configuration. + * Batches rapid partial message updates to reduce re-renders during streaming. + * Higher values = fewer renders but text appears more "chunky" + * Lower values = smoother text but more renders + */ +const STREAMING_DEBOUNCE_MS = 150 // 150ms debounce for aggressive batching + +// Pending streaming updates - batched and flushed after debounce interval +interface PendingStreamUpdate { + id: string + content: string + partial: boolean + timestamp: number +} + +const pendingStreamUpdates: Map = new Map() +let streamingDebounceTimer: ReturnType | null = null + /** * RouterModels type for context window lookup. * Simplified version - we only need contextWindow from ModelInfo. @@ -120,24 +152,74 @@ const initialState: CLIState = { previousTodos: [], } -export const useCLIStore = create((set) => ({ +export const useCLIStore = create((set, get) => ({ ...initialState, - addMessage: (msg) => - set((state) => { - // Check if message already exists (by ID). - const existingIndex = state.messages.findIndex((m) => m.id === msg.id) + addMessage: (msg) => { + const state = get() + // Check if message already exists (by ID). + const existingIndex = state.messages.findIndex((m) => m.id === msg.id) - if (existingIndex !== -1) { - // Update existing message in place. - const updated = [...state.messages] - updated[existingIndex] = msg - return { messages: updated } + // For NEW messages (not updates) - always apply immediately + if (existingIndex === -1) { + set({ messages: [...state.messages, msg] }) + return + } + + // For UPDATES to existing messages: + // If partial (streaming) and message exists, debounce the update + if (msg.partial) { + // Queue the update + pendingStreamUpdates.set(msg.id, { + id: msg.id, + content: msg.content, + partial: true, + timestamp: Date.now(), + }) + + // Schedule flush if not already scheduled + if (!streamingDebounceTimer) { + streamingDebounceTimer = setTimeout(() => { + // Flush all pending updates as a single batch + const currentState = get() + const updates = Array.from(pendingStreamUpdates.values()) + pendingStreamUpdates.clear() + streamingDebounceTimer = null + + if (updates.length === 0) return + + // Apply all pending updates in one state change + const newMessages = [...currentState.messages] + let hasChanges = false + + for (const update of updates) { + const idx = newMessages.findIndex((m) => m.id === update.id) + if (idx !== -1 && newMessages[idx]) { + newMessages[idx] = { + ...newMessages[idx], + content: update.content, + partial: update.partial, + } + hasChanges = true + } + } + + if (hasChanges) { + set({ messages: newMessages }) + } + }, STREAMING_DEBOUNCE_MS) } + return + } - // Add new message. - return { messages: [...state.messages, msg] } - }), + // Non-partial update (final message) - apply immediately and clear any pending + // This ensures the final complete message is always shown + pendingStreamUpdates.delete(msg.id) + + const updated = [...state.messages] + updated[existingIndex] = msg + set({ messages: updated }) + }, updateMessage: (id, content, partial) => set((state) => { @@ -195,10 +277,15 @@ export const useCLIStore = create((set) => ({ apiConfiguration: state.apiConfiguration, })), setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }), - setFileSearchResults: (results) => set({ fileSearchResults: results }), - setAllSlashCommands: (commands) => set({ allSlashCommands: commands }), - setAvailableModes: (modes) => set({ availableModes: modes }), - setTaskHistory: (history) => set({ taskHistory: history }), + // Use shallow equality to prevent unnecessary re-renders when array content is the same + setFileSearchResults: (results) => + set((state) => (shallowArrayEqual(state.fileSearchResults, results) ? state : { fileSearchResults: results })), + setAllSlashCommands: (commands) => + set((state) => (shallowArrayEqual(state.allSlashCommands, commands) ? state : { allSlashCommands: commands })), + setAvailableModes: (modes) => + set((state) => (shallowArrayEqual(state.availableModes, modes) ? state : { availableModes: modes })), + setTaskHistory: (history) => + set((state) => (shallowArrayEqual(state.taskHistory, history) ? state : { taskHistory: history })), setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }), setCurrentMode: (mode) => set({ currentMode: mode }), setTokenUsage: (usage) => set({ tokenUsage: usage }),