summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts441
-rw-r--r--packages/cli/src/ui/hooks/useLoadingIndicator.test.ts185
-rw-r--r--packages/cli/src/ui/hooks/useLoadingIndicator.ts124
-rw-r--r--packages/cli/src/ui/hooks/usePhraseCycler.test.ts111
-rw-r--r--packages/cli/src/ui/hooks/usePhraseCycler.ts84
-rw-r--r--packages/cli/src/ui/hooks/useTimer.test.ts120
-rw-r--r--packages/cli/src/ui/hooks/useTimer.ts65
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;
+};