diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/useGeminiStream.ts | 441 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useLoadingIndicator.test.ts | 185 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useLoadingIndicator.ts | 124 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/usePhraseCycler.test.ts | 111 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/usePhraseCycler.ts | 84 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useTimer.test.ts | 120 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useTimer.ts | 65 |
7 files changed, 733 insertions, 397 deletions
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 45415f39..9dcb005b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -42,7 +42,6 @@ import { useToolScheduler, mapToDisplay } from './useToolScheduler.js'; enum StreamProcessingStatus { Completed, - PausedForConfirmation, UserCancelled, Error, } @@ -103,6 +102,21 @@ export const useGeminiStream = ( config, ); + const streamingState = useMemo(() => { + if (toolCalls.some((t) => t.status === 'awaiting_approval')) { + return StreamingState.WaitingForConfirmation; + } + if ( + isResponding || + toolCalls.some( + (t) => t.status === 'executing' || t.status === 'scheduled', + ) + ) { + return StreamingState.Responding; + } + return StreamingState.Idle; + }, [isResponding, toolCalls]); + useEffect(() => { setInitError(null); if (!geminiClientRef.current) { @@ -123,85 +137,100 @@ export const useGeminiStream = ( } }); - const prepareQueryForGemini = async ( - query: PartListUnion, - userMessageTimestamp: number, - signal: AbortSignal, - ): Promise<{ queryToSend: PartListUnion | null; shouldProceed: boolean }> => { - if (typeof query === 'string' && query.trim().length === 0) { - return { queryToSend: null, shouldProceed: false }; - } + const prepareQueryForGemini = useCallback( + async ( + query: PartListUnion, + userMessageTimestamp: number, + signal: AbortSignal, + ): Promise<{ + queryToSend: PartListUnion | null; + shouldProceed: boolean; + }> => { + if (typeof query === 'string' && query.trim().length === 0) { + return { queryToSend: null, shouldProceed: false }; + } - let localQueryToSendToGemini: PartListUnion | null = null; + let localQueryToSendToGemini: PartListUnion | null = null; - if (typeof query === 'string') { - const trimmedQuery = query.trim(); - onDebugMessage(`User query: '${trimmedQuery}'`); - await logger?.logMessage(MessageSenderType.USER, trimmedQuery); + if (typeof query === 'string') { + const trimmedQuery = query.trim(); + onDebugMessage(`User query: '${trimmedQuery}'`); + await logger?.logMessage(MessageSenderType.USER, trimmedQuery); - // Handle UI-only commands first - const slashCommandResult = handleSlashCommand(trimmedQuery); - if (typeof slashCommandResult === 'boolean' && slashCommandResult) { - // Command was handled, and it doesn't require a tool call from here - return { queryToSend: null, shouldProceed: false }; - } else if ( - typeof slashCommandResult === 'object' && - slashCommandResult.shouldScheduleTool - ) { - // Slash command wants to schedule a tool call (e.g., /memory add) - const { toolName, toolArgs } = slashCommandResult; - if (toolName && toolArgs) { - const toolCallRequest: ToolCallRequestInfo = { - callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, - name: toolName, - args: toolArgs, - }; - schedule([toolCallRequest]); // schedule expects an array or single object + // Handle UI-only commands first + const slashCommandResult = handleSlashCommand(trimmedQuery); + if (typeof slashCommandResult === 'boolean' && slashCommandResult) { + // Command was handled, and it doesn't require a tool call from here + return { queryToSend: null, shouldProceed: false }; + } else if ( + typeof slashCommandResult === 'object' && + slashCommandResult.shouldScheduleTool + ) { + // Slash command wants to schedule a tool call (e.g., /memory add) + const { toolName, toolArgs } = slashCommandResult; + if (toolName && toolArgs) { + const toolCallRequest: ToolCallRequestInfo = { + callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name: toolName, + args: toolArgs, + }; + schedule([toolCallRequest]); // schedule expects an array or single object + } + return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool } - return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool - } - if (shellModeActive && handleShellCommand(trimmedQuery)) { - return { queryToSend: null, shouldProceed: false }; - } - - // Handle @-commands (which might involve tool calls) - if (isAtCommand(trimmedQuery)) { - const atCommandResult = await handleAtCommand({ - query: trimmedQuery, - config, - addItem, - onDebugMessage, - messageId: userMessageTimestamp, - signal, - }); - if (!atCommandResult.shouldProceed) { + if (shellModeActive && handleShellCommand(trimmedQuery)) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = atCommandResult.processedQuery; + + // Handle @-commands (which might involve tool calls) + if (isAtCommand(trimmedQuery)) { + const atCommandResult = await handleAtCommand({ + query: trimmedQuery, + config, + addItem, + onDebugMessage, + messageId: userMessageTimestamp, + signal, + }); + if (!atCommandResult.shouldProceed) { + return { queryToSend: null, shouldProceed: false }; + } + localQueryToSendToGemini = atCommandResult.processedQuery; + } else { + // Normal query for Gemini + addItem( + { type: MessageType.USER, text: trimmedQuery }, + userMessageTimestamp, + ); + localQueryToSendToGemini = trimmedQuery; + } } else { - // Normal query for Gemini - addItem( - { type: MessageType.USER, text: trimmedQuery }, - userMessageTimestamp, - ); - localQueryToSendToGemini = trimmedQuery; + // It's a function response (PartListUnion that isn't a string) + localQueryToSendToGemini = query; } - } else { - // It's a function response (PartListUnion that isn't a string) - localQueryToSendToGemini = query; - } - if (localQueryToSendToGemini === null) { - onDebugMessage( - 'Query processing resulted in null, not sending to Gemini.', - ); - return { queryToSend: null, shouldProceed: false }; - } - return { queryToSend: localQueryToSendToGemini, shouldProceed: true }; - }; + if (localQueryToSendToGemini === null) { + onDebugMessage( + 'Query processing resulted in null, not sending to Gemini.', + ); + return { queryToSend: null, shouldProceed: false }; + } + return { queryToSend: localQueryToSendToGemini, shouldProceed: true }; + }, + [ + config, + addItem, + onDebugMessage, + handleShellCommand, + handleSlashCommand, + logger, + shellModeActive, + schedule, + ], + ); - const ensureChatSession = async (): Promise<{ + const ensureChatSession = useCallback(async (): Promise<{ client: GeminiClient | null; chat: Chat | null; }> => { @@ -224,7 +253,7 @@ export const useGeminiStream = ( } } return { client: currentClient, chat: chatSessionRef.current }; - }; + }, [addItem]); // --- UI Helper Functions (used by event handlers) --- const updateFunctionResponseUI = ( @@ -285,6 +314,7 @@ export const useGeminiStream = ( history.push({ role: 'model', parts: [functionResponse] }); } updateFunctionResponseUI(responseInfo, status); + if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, Date.now()); setPendingHistoryItem(null); @@ -293,138 +323,151 @@ export const useGeminiStream = ( } // --- Stream Event Handlers --- - const handleContentEvent = ( - eventValue: ContentEvent['value'], - currentGeminiMessageBuffer: string, - userMessageTimestamp: number, - ): string => { - let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; - if ( - pendingHistoryItemRef.current?.type !== 'gemini' && - pendingHistoryItemRef.current?.type !== 'gemini_content' - ) { + + const handleContentEvent = useCallback( + ( + eventValue: ContentEvent['value'], + currentGeminiMessageBuffer: string, + userMessageTimestamp: number, + ): string => { + let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; + if ( + pendingHistoryItemRef.current?.type !== 'gemini' && + pendingHistoryItemRef.current?.type !== 'gemini_content' + ) { + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem({ type: 'gemini', text: '' }); + newGeminiMessageBuffer = eventValue; + } + // Split large messages for better rendering performance. Ideally, + // we should maximize the amount of output sent to <Static />. + const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); + if (splitPoint === newGeminiMessageBuffer.length) { + // Update the existing message with accumulated content + setPendingHistoryItem((item) => ({ + type: item?.type as 'gemini' | 'gemini_content', + text: newGeminiMessageBuffer, + })); + } else { + // This indicates that we need to split up this Gemini Message. + // Splitting a message is primarily a performance consideration. There is a + // <Static> component at the root of App.tsx which takes care of rendering + // content statically or dynamically. Everything but the last message is + // treated as static in order to prevent re-rendering an entire message history + // multiple times per-second (as streaming occurs). Prior to this change you'd + // see heavy flickering of the terminal. This ensures that larger messages get + // broken up so that there are more "statically" rendered. + const beforeText = newGeminiMessageBuffer.substring(0, splitPoint); + const afterText = newGeminiMessageBuffer.substring(splitPoint); + addItem( + { + type: pendingHistoryItemRef.current?.type as + | 'gemini' + | 'gemini_content', + text: beforeText, + }, + userMessageTimestamp, + ); + setPendingHistoryItem({ type: 'gemini_content', text: afterText }); + newGeminiMessageBuffer = afterText; + } + return newGeminiMessageBuffer; + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem], + ); + + const handleUserCancelledEvent = useCallback( + (userMessageTimestamp: number) => { if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + if (pendingHistoryItemRef.current.type === 'tool_group') { + const updatedTools = pendingHistoryItemRef.current.tools.map( + (tool) => + tool.status === ToolCallStatus.Pending || + tool.status === ToolCallStatus.Confirming || + tool.status === ToolCallStatus.Executing + ? { ...tool, status: ToolCallStatus.Canceled } + : tool, + ); + const pendingItem: HistoryItemToolGroup = { + ...pendingHistoryItemRef.current, + tools: updatedTools, + }; + addItem(pendingItem, userMessageTimestamp); + } else { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem(null); } - setPendingHistoryItem({ type: 'gemini', text: '' }); - newGeminiMessageBuffer = eventValue; - } - // Split large messages for better rendering performance. Ideally, - // we should maximize the amount of output sent to <Static />. - const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); - if (splitPoint === newGeminiMessageBuffer.length) { - // Update the existing message with accumulated content - setPendingHistoryItem((item) => ({ - type: item?.type as 'gemini' | 'gemini_content', - text: newGeminiMessageBuffer, - })); - } else { - // This indicates that we need to split up this Gemini Message. - // Splitting a message is primarily a performance consideration. There is a - // <Static> component at the root of App.tsx which takes care of rendering - // content statically or dynamically. Everything but the last message is - // treated as static in order to prevent re-rendering an entire message history - // multiple times per-second (as streaming occurs). Prior to this change you'd - // see heavy flickering of the terminal. This ensures that larger messages get - // broken up so that there are more "statically" rendered. - const beforeText = newGeminiMessageBuffer.substring(0, splitPoint); - const afterText = newGeminiMessageBuffer.substring(splitPoint); addItem( - { - type: pendingHistoryItemRef.current?.type as - | 'gemini' - | 'gemini_content', - text: beforeText, - }, + { type: MessageType.INFO, text: 'User cancelled the request.' }, userMessageTimestamp, ); - setPendingHistoryItem({ type: 'gemini_content', text: afterText }); - newGeminiMessageBuffer = afterText; - } - return newGeminiMessageBuffer; - }; + setIsResponding(false); + cancel(); + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem, cancel], + ); - const handleUserCancelledEvent = (userMessageTimestamp: number) => { - if (pendingHistoryItemRef.current) { - if (pendingHistoryItemRef.current.type === 'tool_group') { - const updatedTools = pendingHistoryItemRef.current.tools.map((tool) => - tool.status === ToolCallStatus.Pending || - tool.status === ToolCallStatus.Confirming || - tool.status === ToolCallStatus.Executing - ? { ...tool, status: ToolCallStatus.Canceled } - : tool, - ); - const pendingItem: HistoryItemToolGroup = { - ...pendingHistoryItemRef.current, - tools: updatedTools, - }; - addItem(pendingItem, userMessageTimestamp); - } else { + const handleErrorEvent = useCallback( + (eventValue: ErrorEvent['value'], userMessageTimestamp: number) => { + if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); + setPendingHistoryItem(null); } - setPendingHistoryItem(null); - } - addItem( - { type: MessageType.INFO, text: 'User cancelled the request.' }, - userMessageTimestamp, - ); - setIsResponding(false); - cancel(); - }; - - const handleErrorEvent = ( - eventValue: ErrorEvent['value'], - userMessageTimestamp: number, - ) => { - if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); - setPendingHistoryItem(null); - } - addItem( - { type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` }, - userMessageTimestamp, - ); - }; + addItem( + { type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` }, + userMessageTimestamp, + ); + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem], + ); - const processGeminiStreamEvents = async ( - stream: AsyncIterable<GeminiEvent>, - userMessageTimestamp: number, - ): Promise<StreamProcessingStatus> => { - let geminiMessageBuffer = ''; - const toolCallRequests: ToolCallRequestInfo[] = []; - for await (const event of stream) { - if (event.type === ServerGeminiEventType.Content) { - geminiMessageBuffer = handleContentEvent( - event.value, - geminiMessageBuffer, - userMessageTimestamp, - ); - } else if (event.type === ServerGeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); - } else if (event.type === ServerGeminiEventType.UserCancelled) { - handleUserCancelledEvent(userMessageTimestamp); - cancel(); - return StreamProcessingStatus.UserCancelled; - } else if (event.type === ServerGeminiEventType.Error) { - handleErrorEvent(event.value, userMessageTimestamp); - return StreamProcessingStatus.Error; + const processGeminiStreamEvents = useCallback( + async ( + stream: AsyncIterable<GeminiEvent>, + userMessageTimestamp: number, + ): Promise<StreamProcessingStatus> => { + let geminiMessageBuffer = ''; + const toolCallRequests: ToolCallRequestInfo[] = []; + for await (const event of stream) { + if (event.type === ServerGeminiEventType.Content) { + geminiMessageBuffer = handleContentEvent( + event.value, + geminiMessageBuffer, + userMessageTimestamp, + ); + } else if (event.type === ServerGeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } else if (event.type === ServerGeminiEventType.UserCancelled) { + handleUserCancelledEvent(userMessageTimestamp); + cancel(); + return StreamProcessingStatus.UserCancelled; + } else if (event.type === ServerGeminiEventType.Error) { + handleErrorEvent(event.value, userMessageTimestamp); + return StreamProcessingStatus.Error; + } } - } - schedule(toolCallRequests); - return StreamProcessingStatus.Completed; - }; - - const streamingState: StreamingState = - isResponding || - toolCalls.some( - (t) => t.status === 'awaiting_approval' || t.status === 'executing', - ) - ? StreamingState.Responding - : StreamingState.Idle; + schedule(toolCallRequests); + return StreamProcessingStatus.Completed; + }, + [ + handleContentEvent, + handleUserCancelledEvent, + cancel, + handleErrorEvent, + schedule, + ], + ); const submitQuery = useCallback( async (query: PartListUnion) => { - if (isResponding) return; + if ( + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation + ) + return; const userMessageTimestamp = Date.now(); setShowHelp(false); @@ -458,10 +501,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); - if ( - processingStatus === StreamProcessingStatus.PausedForConfirmation || - processingStatus === StreamProcessingStatus.UserCancelled - ) { + if (processingStatus === StreamProcessingStatus.UserCancelled) { return; } @@ -484,19 +524,16 @@ export const useGeminiStream = ( setIsResponding(false); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [ - isResponding, setShowHelp, - handleSlashCommand, - shellModeActive, - handleShellCommand, - config, addItem, - onDebugMessage, - refreshStatic, setInitError, - logger, + ensureChatSession, + prepareQueryForGemini, + processGeminiStreamEvents, + setPendingHistoryItem, + pendingHistoryItemRef, + streamingState, ], ); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts index 496e13d3..c20ded88 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts @@ -4,161 +4,124 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS, -} from '../constants.js'; +} from './usePhraseCycler.js'; describe('useLoadingIndicator', () => { beforeEach(() => { vi.useFakeTimers(); + vi.clearAllMocks(); }); afterEach(() => { - vi.restoreAllMocks(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); // Restore real timers after each test }); - it('should initialize with default values when not responding', () => { + it('should initialize with default values when Idle', () => { const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Idle, false), + useLoadingIndicator(StreamingState.Idle), ); expect(result.current.elapsedTime).toBe(0); expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - expect(result.current.shouldShowSpinner).toBe(true); }); - describe('when streamingState is Responding', () => { - it('should increment elapsedTime and cycle phrases when not paused', () => { - const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Responding, false), - ); - expect(result.current.shouldShowSpinner).toBe(true); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[0], - ); + it('should reflect values when Responding', () => { + const { result } = renderHook(() => + useLoadingIndicator(StreamingState.Responding), + ); - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(result.current.elapsedTime).toBe(1); + // Initial state before timers advance + expect(result.current.elapsedTime).toBe(0); + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[1], - ); - expect(result.current.elapsedTime).toBe( - 1 + PHRASE_CHANGE_INTERVAL_MS / 1000, - ); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); }); + // Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed + // This depends on the actual implementation of usePhraseCycler + // For simplicity, we'll check it's one of the witty phrases + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[1]); + }); - it('should pause elapsedTime, show specific phrase, and hide spinner when paused', () => { - const { result, rerender } = renderHook( - ({ isPaused }) => - useLoadingIndicator(StreamingState.Responding, isPaused), - { initialProps: { isPaused: false } }, - ); + it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', () => { + const { result, rerender } = renderHook( + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, + ); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(result.current.elapsedTime).toBe(2); - expect(result.current.shouldShowSpinner).toBe(true); + act(() => { + vi.advanceTimersByTime(60000); + }); + expect(result.current.elapsedTime).toBe(60); - rerender({ isPaused: true }); + rerender({ streamingState: StreamingState.WaitingForConfirmation }); - expect(result.current.currentLoadingPhrase).toBe( - 'Waiting for user confirmation...', - ); - expect(result.current.shouldShowSpinner).toBe(false); + expect(result.current.currentLoadingPhrase).toBe( + 'Waiting for user confirmation...', + ); + expect(result.current.elapsedTime).toBe(60); // Elapsed time should be retained - // Time should not advance while paused - const timeBeforePauseAdv = result.current.elapsedTime; - act(() => { - vi.advanceTimersByTime(3000); - }); - expect(result.current.elapsedTime).toBe(timeBeforePauseAdv); + // Timer should not advance further + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.elapsedTime).toBe(60); + }); - // Unpause - rerender({ isPaused: false }); - expect(result.current.shouldShowSpinner).toBe(true); - // Phrase should reset to the beginning of witty phrases - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[0], - ); + it('should reset elapsedTime and use initial phrase when transitioning from WaitingForConfirmation to Responding', () => { + const { result, rerender } = renderHook( + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, + ); - act(() => { - vi.advanceTimersByTime(1000); - }); - // Elapsed time should resume from where it left off - expect(result.current.elapsedTime).toBe(timeBeforePauseAdv + 1); + act(() => { + vi.advanceTimersByTime(5000); // 5s }); + expect(result.current.elapsedTime).toBe(5); - it('should reset timer and phrase when streamingState changes from Responding to Idle', () => { - const { result, rerender } = renderHook( - ({ streamingState }) => useLoadingIndicator(streamingState, false), - { initialProps: { streamingState: StreamingState.Responding } }, - ); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS + 1000); - }); - expect(result.current.elapsedTime).toBe( - PHRASE_CHANGE_INTERVAL_MS / 1000 + 1, - ); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[1], - ); + rerender({ streamingState: StreamingState.WaitingForConfirmation }); + expect(result.current.elapsedTime).toBe(5); + expect(result.current.currentLoadingPhrase).toBe( + 'Waiting for user confirmation...', + ); - rerender({ streamingState: StreamingState.Idle }); + rerender({ streamingState: StreamingState.Responding }); + expect(result.current.elapsedTime).toBe(0); // Should reset + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - expect(result.current.elapsedTime).toBe(0); - // When idle, the phrase interval should be cleared, but the last phrase might persist - // until the next "Responding" state. The important part is that the timer is reset. - // Depending on exact timing, it might be the last witty phrase or the first. - // For this test, we'll ensure it's one of them. - expect(WITTY_LOADING_PHRASES).toContain( - result.current.currentLoadingPhrase, - ); + act(() => { + vi.advanceTimersByTime(1000); }); + expect(result.current.elapsedTime).toBe(1); }); - it('should clear intervals on unmount', () => { - const { unmount } = renderHook(() => - useLoadingIndicator(StreamingState.Responding, false), - ); - const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); - unmount(); - // Expecting two intervals (elapsedTime and phraseInterval) to be cleared. - expect(clearIntervalSpy).toHaveBeenCalledTimes(2); - }); - - it('should reset to initial witty phrase when unpaused', () => { + it('should reset timer and phrase when streamingState changes from Responding to Idle', () => { const { result, rerender } = renderHook( - ({ isPaused }) => - useLoadingIndicator(StreamingState.Responding, isPaused), - { initialProps: { isPaused: false } }, + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, ); - // Advance to the second witty phrase act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + vi.advanceTimersByTime(10000); // 10s }); - expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[1]); + expect(result.current.elapsedTime).toBe(10); - // Pause - rerender({ isPaused: true }); - expect(result.current.currentLoadingPhrase).toBe( - 'Waiting for user confirmation...', - ); + rerender({ streamingState: StreamingState.Idle }); - // Unpause - rerender({ isPaused: false }); + expect(result.current.elapsedTime).toBe(0); expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); + + // Timer should not advance + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.elapsedTime).toBe(0); }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index ac75986a..46754ac1 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -4,98 +4,54 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useRef } from 'react'; -import { - WITTY_LOADING_PHRASES, - PHRASE_CHANGE_INTERVAL_MS, -} from '../constants.js'; import { StreamingState } from '../types.js'; +import { useTimer } from './useTimer.js'; +import { usePhraseCycler } from './usePhraseCycler.js'; +import { useState, useEffect, useRef } from 'react'; // Added useRef -export const useLoadingIndicator = ( - streamingState: StreamingState, - isPaused: boolean, -) => { - const [elapsedTime, setElapsedTime] = useState(0); - const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( - WITTY_LOADING_PHRASES[0], - ); - const timerRef = useRef<NodeJS.Timeout | null>(null); - const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); - const currentPhraseIndexRef = useRef<number>(0); +export const useLoadingIndicator = (streamingState: StreamingState) => { + const [timerResetKey, setTimerResetKey] = useState(0); + const isTimerActive = streamingState === StreamingState.Responding; - const [shouldShowSpinner, setShouldShowSpinner] = useState(true); + const elapsedTimeFromTimer = useTimer(isTimerActive, timerResetKey); - useEffect(() => { - if (streamingState === StreamingState.Responding) { - if (!isPaused) { - if (!timerRef.current) { - // No specific action needed here if timer wasn't running and we are not paused. - // Elapsed time continues from where it left off or starts from 0 if it's a fresh start. - } - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setElapsedTime((prevTime) => prevTime + 1); - }, 1000); - } else { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - } - } else { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setElapsedTime(0); - } + const isPhraseCyclingActive = streamingState === StreamingState.Responding; + const isWaiting = streamingState === StreamingState.WaitingForConfirmation; + const currentLoadingPhrase = usePhraseCycler( + isPhraseCyclingActive, + isWaiting, + ); - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [streamingState, isPaused]); + const [retainedElapsedTime, setRetainedElapsedTime] = useState(0); + const prevStreamingStateRef = useRef<StreamingState | null>(null); useEffect(() => { - if (streamingState === StreamingState.Responding) { - if (!isPaused) { - setShouldShowSpinner(true); - if (!phraseIntervalRef.current) { - currentPhraseIndexRef.current = 0; - setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); - phraseIntervalRef.current = setInterval(() => { - currentPhraseIndexRef.current = - (currentPhraseIndexRef.current + 1) % - WITTY_LOADING_PHRASES.length; - setCurrentLoadingPhrase( - WITTY_LOADING_PHRASES[currentPhraseIndexRef.current], - ); - }, PHRASE_CHANGE_INTERVAL_MS); - } - } else { - setShouldShowSpinner(false); - setCurrentLoadingPhrase('Waiting for user confirmation...'); - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - } - } else { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } + if ( + prevStreamingStateRef.current === StreamingState.WaitingForConfirmation && + streamingState === StreamingState.Responding + ) { + setTimerResetKey((prevKey) => prevKey + 1); + setRetainedElapsedTime(0); // Clear retained time when going back to responding + } else if ( + streamingState === StreamingState.Idle && + prevStreamingStateRef.current === StreamingState.Responding + ) { + setTimerResetKey((prevKey) => prevKey + 1); // Reset timer when becoming idle from responding + setRetainedElapsedTime(0); + } else if (streamingState === StreamingState.WaitingForConfirmation) { + // Capture the time when entering WaitingForConfirmation + // elapsedTimeFromTimer will hold the last value from when isTimerActive was true. + setRetainedElapsedTime(elapsedTimeFromTimer); } - return () => { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - }; - }, [streamingState, isPaused]); + prevStreamingStateRef.current = streamingState; + }, [streamingState, elapsedTimeFromTimer]); - return { elapsedTime, currentLoadingPhrase, shouldShowSpinner }; + return { + elapsedTime: + streamingState === StreamingState.WaitingForConfirmation + ? retainedElapsedTime + : elapsedTimeFromTimer, + currentLoadingPhrase, + }; }; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts new file mode 100644 index 00000000..f5be12e9 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + usePhraseCycler, + WITTY_LOADING_PHRASES, + PHRASE_CHANGE_INTERVAL_MS, +} from './usePhraseCycler.js'; + +describe('usePhraseCycler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with the first witty phrase when not active and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(false, false)); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); + + it('should show "Waiting for user confirmation..." when isWaiting is true', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: true, isWaiting: false } }, + ); + rerender({ isActive: true, isWaiting: true }); + expect(result.current).toBe('Waiting for user confirmation...'); + }); + + it('should not cycle phrases if isActive is false and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(false, false)); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS * 2); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); + + it('should cycle through witty phrases when isActive is true and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(true, false)); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); + + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe( + WITTY_LOADING_PHRASES[2 % WITTY_LOADING_PHRASES.length], + ); + }); + + it('should reset to the first phrase when isActive becomes true after being false (and not waiting)', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: false, isWaiting: false } }, + ); + // Cycle to a different phrase + rerender({ isActive: true, isWaiting: false }); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).not.toBe(WITTY_LOADING_PHRASES[0]); + + // Set to inactive + rerender({ isActive: false, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); // Should reset to first phrase + + // Set back to active + rerender({ isActive: true, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); // Should start with the first phrase + }); + + it('should clear phrase interval on unmount when active', () => { + const { unmount } = renderHook(() => usePhraseCycler(true, false)); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should reset to the first witty phrase when transitioning from waiting to active', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: true, isWaiting: false } }, + ); + + // Cycle to a different phrase + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); + + // Go to waiting state + rerender({ isActive: false, isWaiting: true }); + expect(result.current).toBe('Waiting for user confirmation...'); + + // Go back to active cycling + rerender({ isActive: true, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); +}); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts new file mode 100644 index 00000000..96bad676 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; + +export const WITTY_LOADING_PHRASES = [ + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', +]; + +export const PHRASE_CHANGE_INTERVAL_MS = 15000; + +/** + * Custom hook to manage cycling through loading phrases. + * @param isActive Whether the phrase cycling should be active. + * @param isWaiting Whether to show a specific waiting phrase. + * @returns The current loading phrase. + */ +export const usePhraseCycler = (isActive: boolean, isWaiting: boolean) => { + const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( + WITTY_LOADING_PHRASES[0], + ); + const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); + const currentPhraseIndexRef = useRef<number>(0); + + useEffect(() => { + if (isWaiting) { + setCurrentLoadingPhrase('Waiting for user confirmation...'); + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + } else if (isActive) { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + } + // Reset to the first witty phrase when starting to respond + currentPhraseIndexRef.current = 0; + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + + phraseIntervalRef.current = setInterval(() => { + currentPhraseIndexRef.current = + (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; + setCurrentLoadingPhrase( + WITTY_LOADING_PHRASES[currentPhraseIndexRef.current], + ); + }, PHRASE_CHANGE_INTERVAL_MS); + } else { + // Idle or other states, clear the phrase interval + // and reset to the first phrase for next active state. + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + currentPhraseIndexRef.current = 0; + } + + return () => { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + }; + }, [isActive, isWaiting]); + + return currentLoadingPhrase; +}; diff --git a/packages/cli/src/ui/hooks/useTimer.test.ts b/packages/cli/src/ui/hooks/useTimer.test.ts new file mode 100644 index 00000000..20d44d17 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimer.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTimer } from './useTimer.js'; + +describe('useTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with 0', () => { + const { result } = renderHook(() => useTimer(false, 0)); + expect(result.current).toBe(0); + }); + + it('should not increment time if isActive is false', () => { + const { result } = renderHook(() => useTimer(false, 0)); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(0); + }); + + it('should increment time every second if isActive is true', () => { + const { result } = renderHook(() => useTimer(true, 0)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBe(3); + }); + + it('should reset to 0 and start incrementing when isActive becomes true from false', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: false, resetKey: 0 } }, + ); + expect(result.current).toBe(0); + + rerender({ isActive: true, resetKey: 0 }); + expect(result.current).toBe(0); // Should reset to 0 upon becoming active + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + }); + + it('should reset to 0 when resetKey changes while active', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: true, resetKey: 0 } }, + ); + act(() => { + vi.advanceTimersByTime(3000); // 3s + }); + expect(result.current).toBe(3); + + rerender({ isActive: true, resetKey: 1 }); // Change resetKey + expect(result.current).toBe(0); // Should reset to 0 + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); // Starts incrementing from 0 + }); + + it('should be 0 if isActive is false, regardless of resetKey changes', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: false, resetKey: 0 } }, + ); + expect(result.current).toBe(0); + + rerender({ isActive: false, resetKey: 1 }); + expect(result.current).toBe(0); + }); + + it('should clear timer on unmount', () => { + const { unmount } = renderHook(() => useTimer(true, 0)); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should preserve elapsedTime when isActive becomes false, and reset to 0 when it becomes active again', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: true, resetKey: 0 } }, + ); + + act(() => { + vi.advanceTimersByTime(3000); // Advance to 3 seconds + }); + expect(result.current).toBe(3); + + rerender({ isActive: false, resetKey: 0 }); + expect(result.current).toBe(3); // Time should be preserved when timer becomes inactive + + // Now make it active again, it should reset to 0 + rerender({ isActive: true, resetKey: 0 }); + expect(result.current).toBe(0); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTimer.ts b/packages/cli/src/ui/hooks/useTimer.ts new file mode 100644 index 00000000..53c266dd --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimer.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; + +/** + * Custom hook to manage a timer that increments every second. + * @param isActive Whether the timer should be running. + * @param resetKey A key that, when changed, will reset the timer to 0 and restart the interval. + * @returns The elapsed time in seconds. + */ +export const useTimer = (isActive: boolean, resetKey: unknown) => { + const [elapsedTime, setElapsedTime] = useState(0); + const timerRef = useRef<NodeJS.Timeout | null>(null); + const prevResetKeyRef = useRef(resetKey); + const prevIsActiveRef = useRef(isActive); + + useEffect(() => { + let shouldResetTime = false; + + if (prevResetKeyRef.current !== resetKey) { + shouldResetTime = true; + prevResetKeyRef.current = resetKey; + } + + if (prevIsActiveRef.current === false && isActive) { + // Transitioned from inactive to active + shouldResetTime = true; + } + + if (shouldResetTime) { + setElapsedTime(0); + } + prevIsActiveRef.current = isActive; + + // Manage interval + if (isActive) { + // Clear previous interval unconditionally before starting a new one + // This handles resetKey changes while active, ensuring a fresh interval start. + if (timerRef.current) { + clearInterval(timerRef.current); + } + timerRef.current = setInterval(() => { + setElapsedTime((prev) => prev + 1); + }, 1000); + } else { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [isActive, resetKey]); + + return elapsedTime; +}; |
