mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Code review comment fixes and some refactors. (#525)
No intentional different behavior aside for tweaks suggested from the code review of #506 Refactor: Extract console message logic to custom hook This commit refactors the console message handling from App.tsx into a new custom hook useConsoleMessages. This change improves the testability of the console message logic and declutters the main App component. Created useConsoleMessages.ts to encapsulate console message state and update logic. Updated App.tsx to utilize the new useConsoleMessages hook. Added unit tests for useConsoleMessages.ts to ensure its functionality. I deleted and started over on LoadingIndicator.test.tsx as I spent way too much time trying to fix it before just regenerating the tests as the code was easier to write tests for from scratch and the existing tests were not that good (I added them in the previous pull request).
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
let localQueryToSendToGemini: PartListUnion | null = null;
|
||||
|
||||
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
|
||||
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 };
|
||||
} 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
|
||||
}
|
||||
|
||||
let localQueryToSendToGemini: PartListUnion | null = null;
|
||||
|
||||
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
|
||||
}
|
||||
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'
|
||||
) {
|
||||
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;
|
||||
};
|
||||
|
||||
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);
|
||||
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 {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
}
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
// 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,
|
||||
);
|
||||
} 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;
|
||||
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
|
||||
newGeminiMessageBuffer = afterText;
|
||||
}
|
||||
}
|
||||
schedule(toolCallRequests);
|
||||
return StreamProcessingStatus.Completed;
|
||||
};
|
||||
return newGeminiMessageBuffer;
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const streamingState: StreamingState =
|
||||
isResponding ||
|
||||
toolCalls.some(
|
||||
(t) => t.status === 'awaiting_approval' || t.status === 'executing',
|
||||
)
|
||||
? StreamingState.Responding
|
||||
: StreamingState.Idle;
|
||||
const handleUserCancelledEvent = useCallback(
|
||||
(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 {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
}
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
addItem(
|
||||
{ type: MessageType.INFO, text: 'User cancelled the request.' },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
setIsResponding(false);
|
||||
cancel();
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, cancel],
|
||||
);
|
||||
|
||||
const handleErrorEvent = useCallback(
|
||||
(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, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
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;
|
||||
},
|
||||
[
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(1);
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pause elapsedTime, show specific phrase, and hide spinner when paused', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isPaused }) =>
|
||||
useLoadingIndicator(StreamingState.Responding, isPaused),
|
||||
{ initialProps: { isPaused: false } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(2);
|
||||
expect(result.current.shouldShowSpinner).toBe(true);
|
||||
|
||||
rerender({ isPaused: true });
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
'Waiting for user confirmation...',
|
||||
);
|
||||
expect(result.current.shouldShowSpinner).toBe(false);
|
||||
|
||||
// Time should not advance while paused
|
||||
const timeBeforePauseAdv = result.current.elapsedTime;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(timeBeforePauseAdv);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
// Elapsed time should resume from where it left off
|
||||
expect(result.current.elapsedTime).toBe(timeBeforePauseAdv + 1);
|
||||
});
|
||||
|
||||
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.Idle });
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isPaused }) =>
|
||||
useLoadingIndicator(StreamingState.Responding, isPaused),
|
||||
{ initialProps: { isPaused: false } },
|
||||
it('should reflect values when Responding', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLoadingIndicator(StreamingState.Responding),
|
||||
);
|
||||
|
||||
// Advance to the second witty phrase
|
||||
// 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);
|
||||
});
|
||||
// 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]);
|
||||
});
|
||||
|
||||
// Pause
|
||||
rerender({ isPaused: true });
|
||||
it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) => useLoadingIndicator(streamingState),
|
||||
{ initialProps: { streamingState: StreamingState.Responding } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(60);
|
||||
|
||||
rerender({ streamingState: StreamingState.WaitingForConfirmation });
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
'Waiting for user confirmation...',
|
||||
);
|
||||
expect(result.current.elapsedTime).toBe(60); // Elapsed time should be retained
|
||||
|
||||
// Timer should not advance further
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(60);
|
||||
});
|
||||
|
||||
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(5000); // 5s
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(5);
|
||||
|
||||
rerender({ streamingState: StreamingState.WaitingForConfirmation });
|
||||
expect(result.current.elapsedTime).toBe(5);
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
'Waiting for user confirmation...',
|
||||
);
|
||||
|
||||
// Unpause
|
||||
rerender({ isPaused: false });
|
||||
rerender({ streamingState: StreamingState.Responding });
|
||||
expect(result.current.elapsedTime).toBe(0); // Should reset
|
||||
expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset timer and phrase when streamingState changes from Responding to Idle', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) => useLoadingIndicator(streamingState),
|
||||
{ initialProps: { streamingState: StreamingState.Responding } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10000); // 10s
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(10);
|
||||
|
||||
rerender({ streamingState: StreamingState.Idle });
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
export const useLoadingIndicator = (streamingState: StreamingState) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
|
||||
const elapsedTimeFromTimer = useTimer(isTimerActive, timerResetKey);
|
||||
|
||||
const isPhraseCyclingActive = streamingState === StreamingState.Responding;
|
||||
const isWaiting = streamingState === StreamingState.WaitingForConfirmation;
|
||||
const currentLoadingPhrase = usePhraseCycler(
|
||||
isPhraseCyclingActive,
|
||||
isWaiting,
|
||||
);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentPhraseIndexRef = useRef<number>(0);
|
||||
|
||||
const [shouldShowSpinner, setShouldShowSpinner] = useState(true);
|
||||
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
|
||||
const prevStreamingStateRef = useRef<StreamingState | null>(null);
|
||||
|
||||
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);
|
||||
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 (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [streamingState, isPaused]);
|
||||
prevStreamingStateRef.current = streamingState;
|
||||
}, [streamingState, elapsedTimeFromTimer]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (phraseIntervalRef.current) {
|
||||
clearInterval(phraseIntervalRef.current);
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [streamingState, isPaused]);
|
||||
|
||||
return { elapsedTime, currentLoadingPhrase, shouldShowSpinner };
|
||||
return {
|
||||
elapsedTime:
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
? retainedElapsedTime
|
||||
: elapsedTimeFromTimer,
|
||||
currentLoadingPhrase,
|
||||
};
|
||||
};
|
||||
|
||||
111
packages/cli/src/ui/hooks/usePhraseCycler.test.ts
Normal file
111
packages/cli/src/ui/hooks/usePhraseCycler.test.ts
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
84
packages/cli/src/ui/hooks/usePhraseCycler.ts
Normal file
84
packages/cli/src/ui/hooks/usePhraseCycler.ts
Normal file
@@ -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;
|
||||
};
|
||||
120
packages/cli/src/ui/hooks/useTimer.test.ts
Normal file
120
packages/cli/src/ui/hooks/useTimer.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
65
packages/cli/src/ui/hooks/useTimer.ts
Normal file
65
packages/cli/src/ui/hooks/useTimer.ts
Normal file
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user