Fix/issue 17070 (#17242)

Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
Ali Muthanna
2026-01-26 19:59:20 +03:00
committed by GitHub
parent 39e91ad633
commit 93c62a2bdc
7 changed files with 112 additions and 68 deletions

View File

@@ -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,

View File

@@ -71,8 +71,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
/>
)}
{(!uiState.slashCommands || !uiState.isConfigInitialized) && (
<ConfigInitDisplay />
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
)}
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />

View File

@@ -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<string, McpClient>) => {
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 (
<Box marginTop={1}>

View File

@@ -94,6 +94,7 @@ export interface UIState {
inputWidth: number;
suggestionsWidth: number;
isInputActive: boolean;
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isTrustedFolder: boolean | undefined;

View File

@@ -24,7 +24,7 @@ export const useSessionBrowser = (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
resumedSessionData: ResumedSessionData,
) => void,
) => Promise<void>,
) => {
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,

View File

@@ -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();

View File

@@ -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 };
}