From 57931c0abd747c5b8886909f5edffb58d2af813d Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 24 May 2026 15:58:22 +0300 Subject: [PATCH] fix(renderer): defer model validation while providers load - Treat checking, deferred and loading provider model catalog states as pending instead of unavailable. - Show selected provider activity inside create and launch dialogs while keeping ready providers visible during checks. - Remove the global provider status header so provider activity is scoped to launch flows. --- .../common/GlobalProviderStatusHeader.tsx | 85 --------- .../common/ProviderActivityStatusStrip.tsx | 50 +++-- .../components/layout/TabbedLayout.tsx | 2 - .../team/dialogs/CreateTeamDialog.tsx | 75 +++++++- .../team/dialogs/LaunchTeamDialog.tsx | 74 +++++++- ...teamModelAvailability.codexCatalog.test.ts | 27 +++ src/renderer/utils/teamModelAvailability.ts | 35 +++- .../common/GlobalProviderStatusHeader.test.ts | 177 ------------------ 8 files changed, 228 insertions(+), 297 deletions(-) delete mode 100644 src/renderer/components/common/GlobalProviderStatusHeader.tsx delete mode 100644 test/renderer/components/common/GlobalProviderStatusHeader.test.ts diff --git a/src/renderer/components/common/GlobalProviderStatusHeader.tsx b/src/renderer/components/common/GlobalProviderStatusHeader.tsx deleted file mode 100644 index 8daa8480..00000000 --- a/src/renderer/components/common/GlobalProviderStatusHeader.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useMemo } from 'react'; - -import { - CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS, - CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS, - mergeCodexCliStatusWithSnapshot, - useCodexAccountSnapshot, -} from '@features/codex-account/renderer'; -import { isElectronMode } from '@renderer/api'; -import { useStore } from '@renderer/store'; -import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; -import { useShallow } from 'zustand/react/shallow'; - -import { ProviderActivityStatusStrip } from './ProviderActivityStatusStrip'; - -export const GlobalProviderStatusHeader = (): React.JSX.Element | null => { - const isElectron = useMemo(() => isElectronMode(), []); - const { - cliStatus, - cliStatusLoading, - cliProviderStatusLoading, - multimodelEnabled, - isDashboardFocused, - } = useStore( - useShallow((state) => { - const focusedPane = state.paneLayout.panes.find( - (pane) => pane.id === state.paneLayout.focusedPaneId - ); - const activeTab = focusedPane?.tabs.find((tab) => tab.id === focusedPane.activeTabId) ?? null; - - return { - cliStatus: state.cliStatus, - cliStatusLoading: state.cliStatusLoading, - cliProviderStatusLoading: state.cliProviderStatusLoading, - multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true, - isDashboardFocused: - !focusedPane || focusedPane.tabs.length === 0 || activeTab?.type === 'dashboard', - }; - }) - ); - - const loadingCliStatus = useMemo( - () => - !cliStatus && cliStatusLoading && multimodelEnabled - ? createLoadingMultimodelCliStatus() - : cliStatus, - [cliStatus, cliStatusLoading, multimodelEnabled] - ); - - const codexAccount = useCodexAccountSnapshot({ - enabled: - isElectron && - multimodelEnabled && - loadingCliStatus?.flavor === 'agent_teams_orchestrator' && - Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), - includeRateLimits: false, - initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS, - initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS, - }); - - const effectiveCliStatus = useMemo( - () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), - [codexAccount.snapshot, loadingCliStatus] - ); - const codexSnapshotPending = - codexAccount.loading && - Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && - !codexAccount.snapshot; - - if (isDashboardFocused) { - return null; - } - - return ( - - ); -}; diff --git a/src/renderer/components/common/ProviderActivityStatusStrip.tsx b/src/renderer/components/common/ProviderActivityStatusStrip.tsx index 0d6965b3..cee21915 100644 --- a/src/renderer/components/common/ProviderActivityStatusStrip.tsx +++ b/src/renderer/components/common/ProviderActivityStatusStrip.tsx @@ -4,7 +4,7 @@ import { isElectronMode } from '@renderer/api'; import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; -import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; +import { isTeamProviderModelVerificationPending } from '@renderer/utils/teamModelAvailability'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import { ProviderBrandLogo } from './ProviderBrandLogo'; @@ -27,17 +27,13 @@ interface ProviderActivityStatusStripProps { readonly providerIds?: readonly CliProviderId[]; readonly className?: string; readonly label?: string | null; + readonly layout?: 'inline' | 'stacked'; + readonly showReadyProviders?: boolean; + readonly readyStatusText?: string; } function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { - return ( - providerLoading || - (!provider.authenticated && - (provider.statusMessage === 'Checking...' || - provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) && - provider.models.length === 0 && - provider.backend == null) - ); + return providerLoading || isTeamProviderModelVerificationPending(provider.providerId, provider); } function shouldMaskCodexNegativeBootstrapState( @@ -97,6 +93,7 @@ function useProviderActivityDisplay({ multimodelEnabled, codexSnapshotPending = false, providerIds, + showReadyProviders, }: Pick< ProviderActivityStatusStripProps, | 'cliStatus' @@ -106,6 +103,7 @@ function useProviderActivityDisplay({ | 'multimodelEnabled' | 'codexSnapshotPending' | 'providerIds' + | 'showReadyProviders' >): { displayProviderIds: CliProviderId[]; providerStateMap: Map; @@ -201,6 +199,10 @@ function useProviderActivityDisplay({ }, [errorProviderIds, loadingProviderIds, visibleProviderIds]); const displayProviderIds = useMemo(() => { + if (showReadyProviders) { + return visibleProviderIds; + } + if (loadingProviderIds.length > 0) { const activeCycleIds = ( cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds @@ -213,7 +215,14 @@ function useProviderActivityDisplay({ } return []; - }, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]); + }, [ + cycleProviderIds, + errorProviderIds, + loadingProviderIds, + providerStateMap, + showReadyProviders, + visibleProviderIds, + ]); return { displayProviderIds, @@ -237,6 +246,9 @@ export const ProviderActivityStatusStrip = ({ providerIds, className = '', label = 'Provider Activity', + layout = 'inline', + showReadyProviders = false, + readyStatusText = 'Checked', }: ProviderActivityStatusStripProps): React.JSX.Element | null => { const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({ cliStatus, @@ -246,14 +258,24 @@ export const ProviderActivityStatusStrip = ({ multimodelEnabled, codexSnapshotPending, providerIds, + showReadyProviders, }); if (!shouldRender) { return null; } + const rootClassName = + layout === 'stacked' + ? `flex min-w-0 flex-col items-start gap-1.5 ${className}`.trim() + : `flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim(); + const itemsClassName = + layout === 'stacked' + ? 'flex min-w-0 w-full flex-wrap items-center gap-1.5' + : 'flex min-w-0 flex-1 flex-wrap items-center gap-2'; + return ( -
+
{label ? ( ) : null} -
+
{displayProviderIds.map((providerId) => { const providerState = providerStateMap.get(providerId); if (!providerState) { @@ -280,13 +302,13 @@ export const ProviderActivityStatusStrip = ({ ? 'Checking...' : tone === 'error' ? formatProviderStatusText(providerState.provider) - : 'Checked'; + : readyStatusText; return (
{ > -
{/* Command Palette (Cmd+K) */} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 05dcfe38..d57f838a 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -77,6 +77,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions'; import { getTeamModelSelectionError, + isTeamProviderRuntimeStatusLoading, normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; @@ -699,6 +700,29 @@ export const CreateTeamDialog = ({ ), [effectiveCliStatus?.providers] ); + const runtimeProviderLoadingById = useMemo( + () => + new Map( + selectedMemberProviders.map( + (providerId) => + [ + providerId, + isTeamProviderRuntimeStatusLoading( + providerId, + runtimeProviderStatusById.get(providerId), + cliProviderStatusLoading[providerId] === true || + (providerId === 'codex' && codexSnapshotPending) + ), + ] as const + ) + ), + [ + cliProviderStatusLoading, + codexSnapshotPending, + runtimeProviderStatusById, + selectedMemberProviders, + ] + ); const selectedProviderBackendId = useMemo( () => resolveUiOwnedProviderBackendId( @@ -1033,9 +1057,15 @@ export const CreateTeamDialog = ({ } } + const loadingProviderIds = selectedMemberProviders.filter((providerId) => + runtimeProviderLoadingById.get(providerId) + ); + const readyProviderIds = selectedMemberProviders.filter( + (providerId) => !runtimeProviderLoadingById.get(providerId) + ); const providerPlans = buildProviderPreparePlans({ cwd: effectiveCwd, - providerIds: selectedMemberProviders, + providerIds: readyProviderIds, selectedModelChecksByProvider, backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, limitContext: effectiveAnthropicRuntimeLimitContext, @@ -1048,7 +1078,7 @@ export const CreateTeamDialog = ({ return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature; }); const loadingMessage = getProvisioningProviderProgressMessage( - changedPlans.map((plan) => plan.providerId), + [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)], selectedMemberProviders.length ); const getSelectedWarnings = (): string[] => @@ -1089,6 +1119,20 @@ export const CreateTeamDialog = ({ }; let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); + for (const providerId of loadingProviderIds) { + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + pendingPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.delete(providerId); + prepareWarningsByProviderIdRef.current.delete(providerId); + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary: runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null, + details: [ + `${getProviderLabel(providerId)} provider status is still loading. Model checks will start automatically.`, + ], + supportDiagnostics: undefined, + }); + } for (const plan of changedPlans) { checks = updateProviderCheck(checks, plan.providerId, { status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', @@ -1229,6 +1273,7 @@ export const CreateTeamDialog = ({ effectiveAnthropicRuntimeLimitContext, prepareProviderInvalidationEpochById, runtimeProviderStatusById, + runtimeProviderLoadingById, selectedModel, selectedModelChecksByProvider, selectedModelChecksByProviderSignature, @@ -1724,13 +1769,15 @@ export const CreateTeamDialog = ({ } } - const leadError = getTeamModelSelectionError( - selectedProviderId, - selectedModel, - runtimeProviderStatusById.get(selectedProviderId) - ); - if (leadError) { - return leadError; + if (!runtimeProviderLoadingById.get(selectedProviderId)) { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } } for (const member of effectiveMemberDrafts) { @@ -1739,6 +1786,9 @@ export const CreateTeamDialog = ({ } const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (runtimeProviderLoadingById.get(providerId)) { + continue; + } const memberError = getTeamModelSelectionError( providerId, member.model, @@ -1756,6 +1806,7 @@ export const CreateTeamDialog = ({ }, [ effectiveMemberDrafts, runtimeProviderStatusById, + runtimeProviderLoadingById, selectedModel, selectedProviderId, soloTeam, @@ -2488,6 +2539,12 @@ export const CreateTeamDialog = ({ codexSnapshotPending={codexSnapshotPending} providerIds={selectedMemberProviders} className="mb-2" + label="Selected providers" + layout="stacked" + showReadyProviders={ + effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' + } + readyStatusText="Ready" /> ) : null} {canCreate && diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7d4fff47..a25dfb7c 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -68,6 +68,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions'; import { getTeamModelSelectionError, + isTeamProviderRuntimeStatusLoading, normalizeExplicitTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; @@ -609,6 +610,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ), [effectiveCliStatus?.providers] ); + const runtimeProviderLoadingById = useMemo( + () => + new Map( + selectedMemberProviders.map( + (providerId) => + [ + providerId, + isTeamProviderRuntimeStatusLoading( + providerId, + runtimeProviderStatusById.get(providerId), + cliProviderStatusLoading[providerId] === true || + (providerId === 'codex' && codexSnapshotPending) + ), + ] as const + ) + ), + [ + cliProviderStatusLoading, + codexSnapshotPending, + runtimeProviderStatusById, + selectedMemberProviders, + ] + ); useEffect(() => { if (!open) { @@ -1620,9 +1644,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } } + const loadingProviderIds = selectedMemberProviders.filter((providerId) => + runtimeProviderLoadingById.get(providerId) + ); + const readyProviderIds = selectedMemberProviders.filter( + (providerId) => !runtimeProviderLoadingById.get(providerId) + ); const providerPlans = buildProviderPreparePlans({ cwd: effectiveCwd, - providerIds: selectedMemberProviders, + providerIds: readyProviderIds, selectedModelChecksByProvider, backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, limitContext: effectiveAnthropicRuntimeLimitContext, @@ -1634,7 +1664,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature ); const loadingMessage = getProvisioningProviderProgressMessage( - changedPlans.map((plan) => plan.providerId), + [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)], selectedMemberProviders.length ); const getSelectedWarnings = (): string[] => @@ -1675,6 +1705,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); + for (const providerId of loadingProviderIds) { + lastPrepareProviderSignatureByIdRef.current.delete(providerId); + prepareProviderRequestSeqByIdRef.current.delete(providerId); + prepareWarningsByProviderIdRef.current.delete(providerId); + checks = updateProviderCheck(checks, providerId, { + status: 'checking', + backendSummary: runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null, + details: [ + `${getProviderLabel(providerId)} provider status is still loading. Model checks will start automatically.`, + ], + supportDiagnostics: undefined, + }); + } for (const plan of changedPlans) { checks = updateProviderCheck(checks, plan.providerId, { status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', @@ -1789,6 +1832,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen effectiveCwd, effectiveAnthropicRuntimeLimitContext, prepareProviderInvalidationEpochById, + runtimeProviderLoadingById, runtimeProviderStatusById, selectedMemberProviders, selectedModelChecksByProvider, @@ -2056,13 +2100,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } } - const leadError = getTeamModelSelectionError( - selectedProviderId, - selectedModel, - runtimeProviderStatusById.get(selectedProviderId) - ); - if (leadError) { - return leadError; + if (!runtimeProviderLoadingById.get(selectedProviderId)) { + const leadError = getTeamModelSelectionError( + selectedProviderId, + selectedModel, + runtimeProviderStatusById.get(selectedProviderId) + ); + if (leadError) { + return leadError; + } } if (!isLaunchMode) { @@ -2075,6 +2121,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (runtimeProviderLoadingById.get(providerId)) { + continue; + } const memberError = getTeamModelSelectionError( providerId, member.model, @@ -2092,6 +2141,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [ effectiveMemberDrafts, isLaunchMode, + runtimeProviderLoadingById, runtimeProviderStatusById, selectedModel, selectedProviderId, @@ -3054,6 +3104,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen multimodelEnabled={multimodelEnabled} codexSnapshotPending={codexSnapshotPending} providerIds={selectedMemberProviders} + label="Selected providers" + layout="stacked" + showReadyProviders={ + effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' + } + readyStatusText="Ready" className="mb-2" /> {effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? ( diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index 2d4073f1..a46a06b5 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -5,6 +5,7 @@ import { getAvailableTeamProviderModels, getTeamModelSelectionError, isTeamModelAvailableForUi, + isTeamProviderModelVerificationPending, normalizeTeamModelForUi, } from '../teamModelAvailability'; @@ -204,6 +205,32 @@ describe('team model availability Codex catalog integration', () => { expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); }); + it('does not reject a selected Codex model while provider status is still loading', () => { + const providerStatus = { + ...createCodexProviderStatus([ + { + id: 'gpt-5.1-codex', + launchModel: 'gpt-5.1-codex', + displayName: 'GPT-5.1 Codex', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'static-fallback', + }, + ]), + verificationState: 'unknown' as const, + statusMessage: 'Checking...', + }; + + expect(isTeamProviderModelVerificationPending('codex', providerStatus)).toBe(true); + expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); + expect(normalizeTeamModelForUi('codex', 'gpt-5.5', providerStatus)).toBe('gpt-5.5'); + }); + it('orders GPT-5.5 first after the virtual default option', () => { const providerStatus = createCodexProviderStatus([ { diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 68d5af04..9b11f080 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,3 +1,5 @@ +import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller'; + import { getProviderScopedTeamModelLabel, getRuntimeAwareProviderScopedTeamModelLabel, @@ -43,6 +45,7 @@ export type TeamModelRuntimeProviderStatus = Pick< | 'providerId' | 'models' | 'modelCatalog' + | 'modelCatalogRefreshState' | 'modelAvailability' | 'modelVerificationState' | 'runtimeCapabilities' @@ -163,6 +166,21 @@ export function isTeamProviderModelVerificationPending( return true; } + const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? ''; + const statusMessagePending = + statusMessage === 'checking...' || + statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE.toLowerCase(); + if (providerStatus.verificationState !== 'error' && statusMessagePending) { + return true; + } + + if ( + providerStatus.verificationState !== 'error' && + providerStatus.modelCatalogRefreshState === 'loading' + ) { + return true; + } + const hasRuntimeModelTruth = providerStatus.models.length > 0 || (providerStatus.modelCatalog?.models.length ?? 0) > 0 || @@ -193,10 +211,25 @@ export function isTeamProviderModelVerificationPending( return false; } - const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? ''; return statusMessage.length === 0 || statusMessage === 'checking...'; } +export function isTeamProviderRuntimeStatusLoading( + providerId: SupportedProviderId | undefined, + providerStatus?: TeamModelRuntimeProviderStatus | null, + providerLoading = false +): boolean { + if (!providerId) { + return false; + } + + if (providerLoading) { + return true; + } + + return isTeamProviderModelVerificationPending(providerId, providerStatus); +} + function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] { return getVisibleTeamProviderModels( providerId, diff --git a/test/renderer/components/common/GlobalProviderStatusHeader.test.ts b/test/renderer/components/common/GlobalProviderStatusHeader.test.ts deleted file mode 100644 index c28c352a..00000000 --- a/test/renderer/components/common/GlobalProviderStatusHeader.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import React, { act } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -interface StoreState { - cliStatus: Record | null; - cliStatusLoading: boolean; - cliProviderStatusLoading: Record; - appConfig: { - general: { - multimodelEnabled: boolean; - }; - }; - paneLayout: { - focusedPaneId: string; - panes: Array<{ - id: string; - activeTabId: string | null; - tabs: Array<{ - id: string; - type: string; - }>; - }>; - }; -} - -const storeState = {} as StoreState; -const codexAccountHookState = { - snapshot: null, - loading: false, - error: null, - refresh: vi.fn(() => Promise.resolve(undefined)), - startChatgptLogin: vi.fn(() => Promise.resolve(true)), - cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), - logout: vi.fn(() => Promise.resolve(true)), -}; - -vi.mock('@renderer/api', () => ({ - isElectronMode: () => true, -})); - -vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({ - ProviderBrandLogo: ({ providerId }: { providerId: string }) => - React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId), -})); - -vi.mock('@features/codex-account/renderer', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useCodexAccountSnapshot: () => codexAccountHookState, - }; -}); - -vi.mock('@renderer/store', () => ({ - useStore: (selector: (state: StoreState) => unknown) => selector(storeState), -})); - -import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader'; - -function createProvider(overrides: Record): Record { - return { - providerId: 'anthropic', - displayName: 'Anthropic', - supported: true, - authenticated: false, - authMethod: null, - verificationState: 'unknown', - statusMessage: 'Checking...', - detailMessage: null, - models: [], - modelVerificationState: 'idle', - modelAvailability: [], - canLoginFromUi: true, - capabilities: { - teamLaunch: true, - oneShot: true, - extensions: {}, - }, - backend: null, - availableBackends: [], - connection: null, - ...overrides, - }; -} - -function createMultimodelStatus(providers: Record[]): Record { - return { - flavor: 'agent_teams_orchestrator', - displayName: 'Multimodel runtime', - supportsSelfUpdate: false, - showVersionDetails: false, - showBinaryPath: false, - installed: true, - installedVersion: '0.0.3', - binaryPath: '/tmp/claude-multimodel', - latestVersion: null, - updateAvailable: false, - authLoggedIn: providers.some((provider) => provider.authenticated === true), - authStatusChecking: false, - authMethod: null, - providers, - }; -} - -function setFocusedTab(type: string): void { - storeState.paneLayout = { - focusedPaneId: 'pane-1', - panes: [ - { - id: 'pane-1', - activeTabId: type === 'empty' ? null : 'tab-1', - tabs: type === 'empty' ? [] : [{ id: 'tab-1', type }], - }, - ], - }; -} - -describe('GlobalProviderStatusHeader', () => { - beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - storeState.cliStatus = createMultimodelStatus([createProvider({})]); - storeState.cliStatusLoading = false; - storeState.cliProviderStatusLoading = { anthropic: true }; - storeState.appConfig = { - general: { - multimodelEnabled: true, - }, - }; - setFocusedTab('team'); - }); - - afterEach(() => { - document.body.innerHTML = ''; - vi.unstubAllGlobals(); - }); - - it('shows provider activity on non-dashboard screens', async () => { - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render(React.createElement(GlobalProviderStatusHeader)); - await Promise.resolve(); - }); - - expect(host.textContent).toContain('Provider Activity'); - expect(host.textContent).toContain('Anthropic'); - expect(host.textContent).toContain('Checking...'); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - - it('hides on dashboard screens', async () => { - setFocusedTab('dashboard'); - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render(React.createElement(GlobalProviderStatusHeader)); - await Promise.resolve(); - }); - - expect(host.textContent).toBe(''); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); -});