diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx index 0ff984d7cbe..fc1dc6e45a6 100644 --- a/packages/cli/src/ui/components/messages/ToolShared.tsx +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -80,20 +80,24 @@ export function useFocusHint( isThisShellFocused: boolean, resultDisplay: ToolResultDisplay | undefined, ) { - const [lastUpdateTime, setLastUpdateTime] = useState(null); const [userHasFocused, setUserHasFocused] = useState(false); + + // Derive a stable reset key for the inactivity timer. For strings and arrays + // (shell output), we use the length to capture updates without referential + // identity issues or expensive deep comparisons. + const resetKey = + typeof resultDisplay === 'string' + ? resultDisplay.length + : Array.isArray(resultDisplay) + ? resultDisplay.length + : !!resultDisplay; + const showFocusHint = useInactivityTimer( isThisShellFocusable, - lastUpdateTime ? lastUpdateTime.getTime() : 0, + resetKey, SHELL_FOCUS_HINT_DELAY_MS, ); - useEffect(() => { - if (resultDisplay) { - setLastUpdateTime(new Date()); - } - }, [resultDisplay]); - useEffect(() => { if (isThisShellFocused) { setUserHasFocused(true); diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx index 466c5966844..8761ef7167f 100644 --- a/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx +++ b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx @@ -96,7 +96,7 @@ describe('useConsoleMessages', () => { }); await act(async () => { - await vi.advanceTimersByTimeAsync(20); + await vi.advanceTimersByTimeAsync(60); }); expect(result.current.consoleMessages).toEqual([ @@ -114,7 +114,7 @@ describe('useConsoleMessages', () => { }); await act(async () => { - await vi.advanceTimersByTimeAsync(20); + await vi.advanceTimersByTimeAsync(60); }); expect(result.current.consoleMessages).toEqual([ @@ -131,7 +131,7 @@ describe('useConsoleMessages', () => { }); await act(async () => { - await vi.advanceTimersByTimeAsync(20); + await vi.advanceTimersByTimeAsync(60); }); expect(result.current.consoleMessages).toEqual([ @@ -148,7 +148,7 @@ describe('useConsoleMessages', () => { }); await act(async () => { - await vi.advanceTimersByTimeAsync(20); + await vi.advanceTimersByTimeAsync(60); }); expect(result.current.consoleMessages).toHaveLength(1); diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.ts b/packages/cli/src/ui/hooks/useConsoleMessages.ts index 69fa7fe6e4f..8dfa4814cd3 100644 --- a/packages/cli/src/ui/hooks/useConsoleMessages.ts +++ b/packages/cli/src/ui/hooks/useConsoleMessages.ts @@ -9,7 +9,7 @@ import { useEffect, useReducer, useRef, - useTransition, + startTransition, } from 'react'; import type { ConsoleMessageItem } from '../types.js'; import { @@ -71,10 +71,11 @@ export function useConsoleMessages(): UseConsoleMessagesReturn { const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []); const messageQueueRef = useRef([]); const timeoutRef = useRef(null); - const [, startTransition] = useTransition(); + const isProcessingRef = useRef(false); const processQueue = useCallback(() => { if (messageQueueRef.current.length > 0) { + isProcessingRef.current = true; const messagesToProcess = messageQueueRef.current; messageQueueRef.current = []; startTransition(() => { @@ -87,15 +88,26 @@ export function useConsoleMessages(): UseConsoleMessagesReturn { const handleNewMessage = useCallback( (message: ConsoleMessageItem) => { messageQueueRef.current.push(message); - if (!timeoutRef.current) { - // Batch updates using a timeout. 16ms is a reasonable delay to batch - // rapid-fire messages without noticeable lag. - timeoutRef.current = setTimeout(processQueue, 16); + if (!isProcessingRef.current && !timeoutRef.current) { + // Batch updates using a timeout. 50ms is a reasonable delay to batch + // rapid-fire messages without noticeable lag while avoiding React update + // queue flooding. + timeoutRef.current = setTimeout(processQueue, 50); } }, [processQueue], ); + // Once the updated consoleMessages have been committed to the screen, + // we can safely process the next batch of queued messages if any exist. + // This completely eliminates overlapping concurrent updates to this state. + useEffect(() => { + isProcessingRef.current = false; + if (messageQueueRef.current.length > 0 && !timeoutRef.current) { + timeoutRef.current = setTimeout(processQueue, 50); + } + }, [consoleMessages, processQueue]); + useEffect(() => { const handleConsoleLog = (payload: ConsoleLogPayload) => { let content = payload.content; @@ -149,6 +161,7 @@ export function useConsoleMessages(): UseConsoleMessagesReturn { timeoutRef.current = null; } messageQueueRef.current = []; + isProcessingRef.current = true; startTransition(() => { dispatch({ type: 'CLEAR' }); });