From 93c62a2bdcecfbff84057eddb4e42f1212e3823f Mon Sep 17 00:00:00 2001 From: Ali Muthanna Date: Mon, 26 Jan 2026 19:59:20 +0300 Subject: [PATCH] Fix/issue 17070 (#17242) Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com> --- packages/cli/src/ui/AppContainer.tsx | 5 +- packages/cli/src/ui/components/Composer.tsx | 8 +- .../src/ui/components/ConfigInitDisplay.tsx | 22 +++-- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/useSessionBrowser.ts | 4 +- .../cli/src/ui/hooks/useSessionResume.test.ts | 94 ++++++++++--------- packages/cli/src/ui/hooks/useSessionResume.ts | 46 ++++++--- 7 files changed, 112 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0e337b7c1f..4f10e10645 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -560,7 +560,7 @@ export const AppContainer = (props: AppContainerProps) => { // Session browser and resume functionality const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); - const { loadHistoryForResume } = useSessionResume({ + const { loadHistoryForResume, isResuming } = useSessionResume({ config, historyManager, refreshStatic, @@ -1018,6 +1018,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isConfigInitialized && !initError && !isProcessing && + !isResuming && !!slashCommands && (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding) && @@ -1670,6 +1671,7 @@ Logging in with Google... Restarting Gemini CLI to continue. inputWidth, suggestionsWidth, isInputActive, + isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, isTrustedFolder, @@ -1766,6 +1768,7 @@ Logging in with Google... Restarting Gemini CLI to continue. inputWidth, suggestionsWidth, isInputActive, + isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, isTrustedFolder, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 9a550a323e..de3ecebd19 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -71,8 +71,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { /> )} - {(!uiState.slashCommands || !uiState.isConfigInitialized) && ( - + {(!uiState.slashCommands || + !uiState.isConfigInitialized || + uiState.isResuming) && ( + )} diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx index b1dc71ff74..a47e16daff 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -15,13 +15,17 @@ import { import { GeminiSpinner } from './GeminiRespondingSpinner.js'; import { theme } from '../semantic-colors.js'; -export const ConfigInitDisplay = () => { - const [message, setMessage] = useState('Initializing...'); +export const ConfigInitDisplay = ({ + message: initialMessage = 'Initializing...', +}: { + message?: string; +}) => { + const [message, setMessage] = useState(initialMessage); useEffect(() => { const onChange = (clients?: Map) => { if (!clients || clients.size === 0) { - setMessage(`Initializing...`); + setMessage(initialMessage); return; } let connected = 0; @@ -39,12 +43,18 @@ export const ConfigInitDisplay = () => { const displayedServers = connecting.slice(0, maxDisplay).join(', '); const remaining = connecting.length - maxDisplay; const suffix = remaining > 0 ? `, +${remaining} more` : ''; + const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`; setMessage( - `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`, + initialMessage && initialMessage !== 'Initializing...' + ? `${initialMessage} (${mcpMessage})` + : mcpMessage, ); } else { + const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`; setMessage( - `Connecting to MCP servers... (${connected}/${clients.size})`, + initialMessage && initialMessage !== 'Initializing...' + ? `${initialMessage} (${mcpMessage})` + : mcpMessage, ); } }; @@ -53,7 +63,7 @@ export const ConfigInitDisplay = () => { return () => { coreEvents.off(CoreEvent.McpClientUpdate, onChange); }; - }, []); + }, [initialMessage]); return ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 893ee80c07..fea13285b1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -94,6 +94,7 @@ export interface UIState { inputWidth: number; suggestionsWidth: number; isInputActive: boolean; + isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; isTrustedFolder: boolean | undefined; diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 1dbced887d..3d9619d738 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -24,7 +24,7 @@ export const useSessionBrowser = ( uiHistory: HistoryItemWithoutId[], clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, resumedSessionData: ResumedSessionData, - ) => void, + ) => Promise, ) => { const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false); @@ -73,7 +73,7 @@ export const useSessionBrowser = ( const historyData = convertSessionToHistoryFormats( conversation.messages, ); - onLoadHistory( + await onLoadHistory( historyData.uiHistory, historyData.clientHistory, resumedSessionData, diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 029d23d725..071fe5878b 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -62,7 +62,7 @@ describe('useSessionResume', () => { expect(result.current.loadHistoryForResume).toBeInstanceOf(Function); }); - it('should clear history and add items when loading history', () => { + it('should clear history and add items when loading history', async () => { const { result } = renderHook(() => useSessionResume(getDefaultProps())); const uiHistory: HistoryItemWithoutId[] = [ @@ -86,8 +86,8 @@ describe('useSessionResume', () => { filePath: '/path/to/session.json', }; - act(() => { - result.current.loadHistoryForResume( + await act(async () => { + await result.current.loadHistoryForResume( uiHistory, clientHistory, resumedData, @@ -116,7 +116,7 @@ describe('useSessionResume', () => { ); }); - it('should not load history if Gemini client is not initialized', () => { + it('should not load history if Gemini client is not initialized', async () => { const { result } = renderHook(() => useSessionResume({ ...getDefaultProps(), @@ -141,8 +141,8 @@ describe('useSessionResume', () => { filePath: '/path/to/session.json', }; - act(() => { - result.current.loadHistoryForResume( + await act(async () => { + await result.current.loadHistoryForResume( uiHistory, clientHistory, resumedData, @@ -154,7 +154,7 @@ describe('useSessionResume', () => { expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); - it('should handle empty history arrays', () => { + it('should handle empty history arrays', async () => { const { result } = renderHook(() => useSessionResume(getDefaultProps())); const resumedData: ResumedSessionData = { @@ -168,8 +168,8 @@ describe('useSessionResume', () => { filePath: '/path/to/session.json', }; - act(() => { - result.current.loadHistoryForResume([], [], resumedData); + await act(async () => { + await result.current.loadHistoryForResume([], [], resumedData); }); expect(mockHistoryManager.clearItems).toHaveBeenCalled(); @@ -311,15 +311,17 @@ describe('useSessionResume', () => { ] as MessageRecord[], }; - renderHook(() => - useSessionResume({ - ...getDefaultProps(), - resumedSessionData: { - conversation, - filePath: '/path/to/session.json', - }, - }), - ); + await act(async () => { + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + ); + }); await waitFor(() => { expect(mockHistoryManager.clearItems).toHaveBeenCalled(); @@ -358,20 +360,24 @@ describe('useSessionResume', () => { ] as MessageRecord[], }; - const { rerender } = renderHook( - ({ refreshStatic }: { refreshStatic: () => void }) => - useSessionResume({ - ...getDefaultProps(), - refreshStatic, - resumedSessionData: { - conversation, - filePath: '/path/to/session.json', - }, - }), - { - initialProps: { refreshStatic: mockRefreshStatic }, - }, - ); + let rerenderFunc: (props: { refreshStatic: () => void }) => void; + await act(async () => { + const { rerender } = renderHook( + ({ refreshStatic }: { refreshStatic: () => void }) => + useSessionResume({ + ...getDefaultProps(), + refreshStatic, + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + { + initialProps: { refreshStatic: mockRefreshStatic as () => void }, + }, + ); + rerenderFunc = rerender; + }); await waitFor(() => { expect(mockHistoryManager.clearItems).toHaveBeenCalled(); @@ -383,7 +389,9 @@ describe('useSessionResume', () => { // Rerender with different refreshStatic const newRefreshStatic = vi.fn(); - rerender({ refreshStatic: newRefreshStatic }); + await act(async () => { + rerenderFunc({ refreshStatic: newRefreshStatic }); + }); // Should not resume again expect(mockHistoryManager.clearItems).toHaveBeenCalledTimes( @@ -413,15 +421,17 @@ describe('useSessionResume', () => { ] as MessageRecord[], }; - renderHook(() => - useSessionResume({ - ...getDefaultProps(), - resumedSessionData: { - conversation, - filePath: '/path/to/session.json', - }, - }), - ); + await act(async () => { + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + ); + }); await waitFor(() => { expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 228ca6ac2c..21b9d0884f 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -4,8 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useRef } from 'react'; -import type { Config, ResumedSessionData } from '@google/gemini-cli-core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + coreEvents, + type Config, + type ResumedSessionData, +} from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import type { HistoryItemWithoutId } from '../types.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -35,6 +39,8 @@ export function useSessionResume({ resumedSessionData, isAuthenticating, }: UseSessionResumeParams) { + const [isResuming, setIsResuming] = useState(false); + // Use refs to avoid dependency chain that causes infinite loop const historyManagerRef = useRef(historyManager); const refreshStaticRef = useRef(refreshStatic); @@ -45,7 +51,7 @@ export function useSessionResume({ }); const loadHistoryForResume = useCallback( - ( + async ( uiHistory: HistoryItemWithoutId[], clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, resumedData: ResumedSessionData, @@ -55,17 +61,27 @@ export function useSessionResume({ return; } - // Now that we have the client, load the history into the UI and the client. - setQuittingMessages(null); - historyManagerRef.current.clearItems(); - uiHistory.forEach((item, index) => { - historyManagerRef.current.addItem(item, index, true); - }); - refreshStaticRef.current(); // Force Static component to re-render with the updated history. + setIsResuming(true); + try { + // Now that we have the client, load the history into the UI and the client. + setQuittingMessages(null); + historyManagerRef.current.clearItems(); + uiHistory.forEach((item, index) => { + historyManagerRef.current.addItem(item, index, true); + }); + refreshStaticRef.current(); // Force Static component to re-render with the updated history. - // Give the history to the Gemini client. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - config.getGeminiClient()?.resumeChat(clientHistory, resumedData); + // Give the history to the Gemini client. + await config.getGeminiClient()?.resumeChat(clientHistory, resumedData); + } catch (error) { + coreEvents.emitFeedback( + 'error', + 'Failed to resume session. Please try again.', + error, + ); + } finally { + setIsResuming(false); + } }, [config, isGeminiClientInitialized, setQuittingMessages], ); @@ -84,7 +100,7 @@ export function useSessionResume({ const historyData = convertSessionToHistoryFormats( resumedSessionData.conversation.messages, ); - loadHistoryForResume( + void loadHistoryForResume( historyData.uiHistory, historyData.clientHistory, resumedSessionData, @@ -97,5 +113,5 @@ export function useSessionResume({ loadHistoryForResume, ]); - return { loadHistoryForResume }; + return { loadHistoryForResume, isResuming }; }