From cb17bcfdb5dc42b9511c85878815f15f7c0e8fa5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 22:53:49 +0300 Subject: [PATCH] fix(team): show provider loading while models sync --- .../team/dialogs/TeamModelSelector.tsx | 29 ++++++++++++--- .../hooks/useEffectiveCliProviderStatus.ts | 8 ++++ src/renderer/utils/teamModelAvailability.ts | 4 ++ .../ProviderActivityStatusStrip.test.ts | 37 +++++++++++++++++++ .../TeamModelSelectorDisabledState.test.ts | 5 +++ 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 52014274..3d5fea23 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; +import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -814,8 +815,14 @@ export const TeamModelSelector: React.FC = ({ const previousSelectedProviderIdRef = useRef(selectedProviderId); const effectiveProviderId = inspectedProviderId ?? selectedProviderId; const isInspectingInactiveProvider = inspectedProviderId !== null; - const { cliStatus: effectiveCliStatus, providerStatus: runtimeProviderStatus } = - useEffectiveCliProviderStatus(effectiveProviderId); + const { + cliStatus: effectiveCliStatus, + sourceCliStatus, + providerStatus: runtimeProviderStatus, + codexSnapshotPending, + } = useEffectiveCliProviderStatus(effectiveProviderId); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading ?? {}); const multimodelAvailable = multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; const runtimeProviderStatusById = useMemo( @@ -1777,9 +1784,21 @@ export const TeamModelSelector: React.FC = ({ ) : null} {shouldAwaitRuntimeModelList ? ( -

- {t('modelSelector.runtimeModelsSyncing')} -

+
+

+ {t('modelSelector.runtimeModelsSyncing')} +

+ +
) : null} {showAnthropicCompatibleCustomModelInput ? (
diff --git a/src/renderer/hooks/useEffectiveCliProviderStatus.ts b/src/renderer/hooks/useEffectiveCliProviderStatus.ts index de6dff01..3bc66869 100644 --- a/src/renderer/hooks/useEffectiveCliProviderStatus.ts +++ b/src/renderer/hooks/useEffectiveCliProviderStatus.ts @@ -11,8 +11,10 @@ import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@s export interface EffectiveCliProviderStatusSnapshot { cliStatus: CliInstallationStatus | null; + sourceCliStatus: CliInstallationStatus | null; providerStatus: CliProviderStatus | null; loading: boolean; + codexSnapshotPending: boolean; } export function useEffectiveCliProviderStatus( @@ -42,6 +44,10 @@ export function useEffectiveCliProviderStatus( () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), [codexAccount.snapshot, loadingCliStatus] ); + const codexSnapshotPending = + codexAccount.loading && + Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && + !codexAccount.snapshot; const providerStatus = useMemo( () => providerId @@ -53,7 +59,9 @@ export function useEffectiveCliProviderStatus( return { cliStatus: effectiveCliStatus, + sourceCliStatus: loadingCliStatus, providerStatus, loading: cliStatusLoading && effectiveCliStatus === null, + codexSnapshotPending, }; } diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 9b11f080..3fedefa5 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -166,6 +166,10 @@ export function isTeamProviderModelVerificationPending( return true; } + if (providerStatus.verificationState === 'error') { + return false; + } + const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? ''; const statusMessagePending = statusMessage === 'checking...' || diff --git a/test/renderer/components/common/ProviderActivityStatusStrip.test.ts b/test/renderer/components/common/ProviderActivityStatusStrip.test.ts index 8d273609..5754322e 100644 --- a/test/renderer/components/common/ProviderActivityStatusStrip.test.ts +++ b/test/renderer/components/common/ProviderActivityStatusStrip.test.ts @@ -292,6 +292,43 @@ describe('ProviderActivityStatusStrip', () => { }); }); + it('does not mask finished Codex native provider errors as model loading', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + + let root!: ReturnType; + await act(async () => { + root = renderStrip(host, { + cliStatus: createMultimodelStatus([ + createProvider({ + providerId: 'codex', + displayName: 'Codex', + supported: true, + verificationState: 'error', + statusMessage: 'Failed to refresh Codex status', + backend: { + kind: 'codex-native', + label: 'Codex native', + endpointLabel: 'codex exec --json', + }, + models: [], + modelAvailability: [], + }), + ]), + }); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('Failed to refresh Codex status'); + expect(host.textContent).not.toContain('Checking...'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('masks a negative Codex bootstrap state while source placeholder loading is still active', async () => { const sourceCliStatus = createMultimodelStatus([ createProvider({ diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index b1d12dc0..953bda58 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -60,6 +60,7 @@ vi.mock('@renderer/components/ui/tabs', () => { const storeState = { cliStatus: null as unknown, cliStatusLoading: false, + cliProviderStatusLoading: {} as Record, appConfig: { general: { multimodelEnabled: true } }, fetchCliProviderStatus: vi.fn().mockResolvedValue(undefined), }; @@ -109,8 +110,10 @@ import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSe describe('TeamModelSelector disabled Codex models', () => { afterEach(() => { document.body.innerHTML = ''; + Reflect.deleteProperty(window, 'electronAPI'); storeState.cliStatus = null; storeState.cliStatusLoading = false; + storeState.cliProviderStatusLoading = {}; storeState.fetchCliProviderStatus.mockClear(); codexAccountHookState.snapshot = null; codexAccountHookState.loading = false; @@ -124,6 +127,7 @@ describe('TeamModelSelector disabled Codex models', () => { it('shows only Default while Codex runtime models are still loading', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + Object.defineProperty(window, 'electronAPI', { value: {}, configurable: true }); storeState.cliStatusLoading = true; const host = document.createElement('div'); document.body.appendChild(host); @@ -142,6 +146,7 @@ describe('TeamModelSelector disabled Codex models', () => { }); expect(host.textContent).toContain('Default'); + expect(host.querySelector('[data-testid="provider-activity-status-codex"]')).not.toBeNull(); expect(host.textContent).not.toContain('5.1 Codex Mini'); expect(host.textContent).not.toContain('5.3 Codex Spark'); const defaultButton = Array.from(host.querySelectorAll('button')).find((button) =>