fix(codex): keep catalog hydration in loading state

This commit is contained in:
777genius 2026-06-01 23:42:07 +03:00
parent dd47dfb8a2
commit 3265920ec6
9 changed files with 362 additions and 51 deletions

View file

@ -2,7 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import {
formatProviderStatusText,
shouldMaskCodexNegativeBootstrapState,
} from '@renderer/components/runtime/providerConnectionUi';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { isTeamProviderModelVerificationPending } from '@renderer/utils/teamModelAvailability';
@ -37,19 +40,6 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
return providerLoading || isTeamProviderModelVerificationPending(provider.providerId, provider);
}
function shouldMaskCodexNegativeBootstrapState(
sourceProvider: CliProviderStatus | null,
mergedProvider: CliProviderStatus
): boolean {
return (
sourceProvider?.providerId === 'codex' &&
sourceProvider.statusMessage === 'Checking...' &&
mergedProvider.providerId === 'codex' &&
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
mergedProvider.connection.codex.login.status === 'idle'
);
}
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
borderColor: string;
backgroundColor: string;
@ -139,7 +129,9 @@ function useProviderActivityDisplay({
const loading =
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
(provider.providerId === 'codex' && codexSnapshotPending) ||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider, {
providerLoading: cliProviderStatusLoading[provider.providerId] === true,
});
return {
provider,

View file

@ -38,6 +38,7 @@ import {
isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating,
isProviderInventoryOnlyFallback,
shouldMaskCodexNegativeBootstrapState,
shouldShowProviderConnectAction,
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi';
@ -478,19 +479,6 @@ function isCodexSnapshotPending(
return provider.providerId === 'codex' && codexSnapshotPending;
}
function shouldMaskCodexNegativeBootstrapState(
sourceProvider: CliProviderStatus | null,
mergedProvider: CliProviderStatus
): boolean {
return (
sourceProvider?.providerId === 'codex' &&
sourceProvider.statusMessage === 'Checking...' &&
mergedProvider.providerId === 'codex' &&
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
mergedProvider.connection.codex.login.status === 'idle'
);
}
function getProviderStatusColor(statusText: string, authenticated: boolean): string {
if (statusText === 'Checking...') {
return 'var(--color-text-secondary)';
@ -991,7 +979,8 @@ const InstalledBanner = ({
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
sourceProvider,
provider
provider,
{ providerLoading }
);
const showSkeleton =
shouldShowProviderStatusSkeleton(provider, providerLoading) ||

View file

@ -240,6 +240,60 @@ export function isOpenCodeCatalogHydrating(
);
}
export function shouldMaskCodexNegativeBootstrapState(
sourceProvider:
| Pick<
CliProviderStatus,
| 'providerId'
| 'authenticated'
| 'supported'
| 'verificationState'
| 'statusMessage'
| 'models'
| 'backend'
| 'modelCatalogRefreshState'
>
| null
| undefined,
mergedProvider: Pick<CliProviderStatus, 'providerId' | 'connection' | 'modelCatalogRefreshState'>,
options: { providerLoading?: boolean } = {}
): boolean {
if (
mergedProvider.providerId !== 'codex' ||
mergedProvider.connection?.codex?.launchReadinessState !== 'missing_auth' ||
mergedProvider.connection.codex.login.status !== 'idle'
) {
return false;
}
if (options.providerLoading || mergedProvider.modelCatalogRefreshState === 'loading') {
return true;
}
if (sourceProvider?.providerId !== 'codex') {
return false;
}
if (sourceProvider.modelCatalogRefreshState === 'loading') {
return true;
}
if (
sourceProvider.statusMessage === 'Checking...' ||
sourceProvider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE
) {
return true;
}
return (
sourceProvider.supported === false &&
sourceProvider.authenticated === false &&
sourceProvider.verificationState === 'unknown' &&
sourceProvider.models.length === 0 &&
sourceProvider.backend == null
);
}
function hasKnownProviderStatus(
provider: Pick<
CliProviderStatus,

View file

@ -25,6 +25,7 @@ import {
getProviderDisconnectAction,
isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating,
shouldMaskCodexNegativeBootstrapState,
shouldShowProviderConnectAction,
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi';
@ -93,19 +94,6 @@ function isCodexSnapshotPending(
return provider.providerId === 'codex' && codexSnapshotPending;
}
function shouldMaskCodexNegativeBootstrapState(
sourceProvider: CliProviderStatus | null,
mergedProvider: CliProviderStatus
): boolean {
return (
sourceProvider?.providerId === 'codex' &&
sourceProvider.statusMessage === 'Checking...' &&
mergedProvider.providerId === 'codex' &&
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
mergedProvider.connection.codex.login.status === 'idle'
);
}
function getProviderStatusColor(statusText: string, authenticated: boolean): string {
if (statusText === 'Checking...') {
return 'var(--color-text-secondary)';
@ -498,7 +486,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
loadingCliProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
sourceProvider,
provider
provider,
{ providerLoading }
);
const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState;
const statusText = effectiveShowSkeleton

View file

@ -29,6 +29,8 @@ const OPENCODE_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
const OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
const CODEX_CATALOG_LOADING_REFRESH_ATTEMPTS = 3;
const CODEX_CATALOG_LOADING_REFRESH_RETRY_DELAY_MS = 2_000;
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen()
? ['anthropic', 'codex', 'opencode']
@ -168,6 +170,15 @@ function getProviderStatus(
return status?.providers.find((provider) => provider.providerId === providerId);
}
function isCodexCatalogLoadingSnapshot(provider: CliProviderStatus | undefined): boolean {
return (
provider?.providerId === 'codex' &&
provider.modelCatalog == null &&
provider.modelCatalogRefreshState === 'loading' &&
provider.runtimeCapabilities?.modelCatalog?.dynamic === true
);
}
function hasOpenCodeModels(provider: CliProviderStatus | undefined): boolean {
return (
provider?.providerId === 'opencode' &&
@ -747,9 +758,60 @@ let cliStatusInFlight: Promise<void> | null = null;
const cliProviderStatusInFlight = new Map<string, Promise<void>>();
let cliStatusEpoch = 0;
const cliProviderStatusSeq = new Map<CliProviderId, number>();
const codexCatalogLoadingRefreshAttempts = new Map<CliProviderId, number>();
const codexCatalogLoadingRefreshTimers = new Map<CliProviderId, ReturnType<typeof setTimeout>>();
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
let codexRuntimeStatusInFlight: Promise<void> | null = null;
function clearCodexCatalogLoadingRefresh(providerId: CliProviderId): void {
const timer = codexCatalogLoadingRefreshTimers.get(providerId);
if (timer) {
clearTimeout(timer);
codexCatalogLoadingRefreshTimers.delete(providerId);
}
codexCatalogLoadingRefreshAttempts.delete(providerId);
}
function scheduleCodexCatalogLoadingRefresh(
get: () => Pick<CliInstallerSlice, 'cliStatus' | 'fetchCliProviderStatus'>,
providerId: CliProviderId
): void {
const provider = getProviderStatus(get().cliStatus, providerId);
if (!isCodexCatalogLoadingSnapshot(provider)) {
clearCodexCatalogLoadingRefresh(providerId);
return;
}
if (codexCatalogLoadingRefreshTimers.has(providerId)) {
return;
}
const attempts = codexCatalogLoadingRefreshAttempts.get(providerId) ?? 0;
if (attempts >= CODEX_CATALOG_LOADING_REFRESH_ATTEMPTS) {
return;
}
codexCatalogLoadingRefreshAttempts.set(providerId, attempts + 1);
const timer = setTimeout(() => {
codexCatalogLoadingRefreshTimers.delete(providerId);
const latestProvider = getProviderStatus(get().cliStatus, providerId);
if (!isCodexCatalogLoadingSnapshot(latestProvider)) {
codexCatalogLoadingRefreshAttempts.delete(providerId);
return;
}
void get().fetchCliProviderStatus(providerId, { silent: true });
}, CODEX_CATALOG_LOADING_REFRESH_RETRY_DELAY_MS);
(timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
codexCatalogLoadingRefreshTimers.set(providerId, timer);
}
function scheduleCodexCatalogLoadingRefreshes(
get: () => Pick<CliInstallerSlice, 'cliStatus' | 'fetchCliProviderStatus'>
): void {
scheduleCodexCatalogLoadingRefresh(get, 'codex');
}
// =============================================================================
// Slice Creator
// =============================================================================
@ -877,6 +939,8 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
};
});
scheduleCodexCatalogLoadingRefreshes(get);
if (!metadata.installed) {
if (epoch === cliStatusEpoch) {
set({
@ -948,6 +1012,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliProviderStatusLoading: {},
};
});
scheduleCodexCatalogLoadingRefreshes(get);
if (status.installed) {
for (const provider of status.providers) {
if (!isActiveMultimodelProviderId(provider.providerId)) {
@ -1085,6 +1150,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliProviderStatusLoading: nextLoading,
};
});
scheduleCodexCatalogLoadingRefresh(get, providerId);
} catch (error) {
const message =
error instanceof Error ? error.message : `Failed to refresh ${providerId} status`;
@ -1174,6 +1240,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
},
};
});
clearCodexCatalogLoadingRefresh(providerId);
} finally {
cliProviderStatusInFlight.delete(requestKey);
}
@ -1184,6 +1251,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
},
invalidateCliStatus: async () => {
clearCodexCatalogLoadingRefresh('codex');
await api.cliInstaller?.invalidateStatus();
},

View file

@ -166,7 +166,13 @@ export function isTeamProviderModelVerificationPending(
return true;
}
if (providerStatus.verificationState === 'error') {
const verificationState = providerStatus.verificationState as
| 'verified'
| 'unknown'
| 'offline'
| 'error'
| undefined;
if (verificationState === 'error' || providerStatus.modelCatalogRefreshState === 'error') {
return false;
}
@ -174,14 +180,11 @@ export function isTeamProviderModelVerificationPending(
const statusMessagePending =
statusMessage === 'checking...' ||
statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE.toLowerCase();
if (providerStatus.verificationState !== 'error' && statusMessagePending) {
if (statusMessagePending) {
return true;
}
if (
providerStatus.verificationState !== 'error' &&
providerStatus.modelCatalogRefreshState === 'loading'
) {
if (providerStatus.modelCatalogRefreshState === 'loading') {
return true;
}

View file

@ -3045,6 +3045,83 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('keeps Codex on checking while its model catalog is loading and the live snapshot is only a negative auth result', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
codexAccountHookState.snapshot = {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: new Date().toISOString(),
};
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: false,
providers: [
createCodexNativeRolloutProvider({
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
models: [],
modelCatalog: null,
modelCatalogRefreshState: 'loading',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
}),
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Checking...');
expect(host.textContent).not.toContain(
'Reconnect ChatGPT to refresh the current Codex subscription session.'
);
expect(host.textContent).not.toContain(
'Models unavailable for this runtime build'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('explains missing Codex limits when ChatGPT mode is selected but Codex is not logged in', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';

View file

@ -6,6 +6,7 @@ import {
isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating,
isProviderInventoryOnlyFallback,
shouldMaskCodexNegativeBootstrapState,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -52,7 +53,8 @@ function createAnthropicProvider(
}
function createCodexProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
overrides?: Partial<CliProviderStatus> &
Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
selectedBackendId?: string | null;
@ -71,7 +73,10 @@ function createCodexProvider(
authMethod: overrides?.authMethod ?? 'api_key',
verificationState: 'verified',
statusMessage: overrides?.statusMessage ?? 'Codex native ready',
models: ['gpt-5-codex'],
models: overrides?.models ?? ['gpt-5-codex'],
modelCatalog: overrides?.modelCatalog,
modelCatalogRefreshState: overrides?.modelCatalogRefreshState,
runtimeCapabilities: overrides?.runtimeCapabilities,
canLoginFromUi: overrides?.canLoginFromUi ?? false,
capabilities: {
teamLaunch: true,
@ -604,6 +609,42 @@ describe('providerConnectionUi', () => {
);
});
it('masks stale Codex reconnect state while provider catalog hydration is still loading', () => {
const provider = createCodexProvider({
authenticated: false,
authMethod: null,
models: [],
modelCatalogRefreshState: 'loading',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
codex: {
preferredAuthMode: 'chatgpt',
effectiveAuthMode: null,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
requiresOpenaiAuth: true,
localAccountArtifactsPresent: true,
localActiveChatgptAccountPresent: true,
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
launchAllowed: false,
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
launchReadinessState: 'missing_auth',
},
});
expect(shouldMaskCodexNegativeBootstrapState(provider, provider)).toBe(true);
});
it('surfaces native auth-required state from the selected backend option', () => {
const provider = createCodexProvider({
authenticated: false,

View file

@ -1773,6 +1773,104 @@ describe('cliInstallerSlice', () => {
expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4');
});
it('retries Codex provider refresh when dynamic catalog hydration remains loading', async () => {
vi.useFakeTimers();
const loadingProvider = createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: false,
authMethod: null,
statusMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
models: [],
modelCatalog: null,
modelCatalogRefreshState: 'loading',
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
backend: { kind: 'codex-native', label: 'Codex native' },
});
const readyProvider = createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
authenticated: true,
authMethod: 'chatgpt',
statusMessage: 'ChatGPT account ready',
models: ['gpt-5.4'],
modelCatalogRefreshState: 'ready',
modelCatalog: {
schemaVersion: 1,
providerId: 'codex',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-17T00:00:00.000Z',
staleAt: '2026-05-17T00:10:00.000Z',
defaultModelId: 'gpt-5.4',
defaultLaunchModel: 'gpt-5.4',
models: [
{
id: 'gpt-5.4',
launchModel: 'gpt-5.4',
displayName: 'GPT-5.4',
hidden: false,
supportedReasoningEfforts: ['medium'],
defaultReasoningEffort: 'medium',
inputModalities: ['text'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
},
],
diagnostics: {
configReadState: 'skipped',
appServerState: 'healthy',
},
},
runtimeCapabilities: {
modelCatalog: {
dynamic: true,
source: 'app-server',
},
},
backend: { kind: 'codex-native', label: 'Codex native' },
});
useStore.setState({
cliStatus: createMultimodelStatus([loadingProvider]),
});
vi.mocked(api.cliInstaller.getProviderStatus)
.mockResolvedValueOnce(loadingProvider)
.mockResolvedValueOnce(readyProvider);
await useStore.getState().fetchCliProviderStatus('codex');
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
expect(
useStore
.getState()
.cliStatus?.providers.find((provider) => provider.providerId === 'codex')
?.modelCatalogRefreshState
).toBe('loading');
await vi.runOnlyPendingTimersAsync();
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(2);
expect(
useStore
.getState()
.cliStatus?.providers.find((provider) => provider.providerId === 'codex')
).toMatchObject({
authenticated: true,
statusMessage: 'ChatGPT account ready',
models: ['gpt-5.4'],
modelCatalogRefreshState: 'ready',
});
});
it('keeps cached OpenCode model list when summary refresh only reports big-pickle', async () => {
const currentProvider = createMultimodelProvider({
providerId: 'opencode',