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.
This commit is contained in:
777genius 2026-05-24 15:58:22 +03:00
parent 7aa87f2278
commit 57931c0abd
8 changed files with 228 additions and 297 deletions

View file

@ -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 (
<ProviderActivityStatusStrip
cliStatus={effectiveCliStatus}
sourceCliStatus={loadingCliStatus}
cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading}
multimodelEnabled={multimodelEnabled}
codexSnapshotPending={codexSnapshotPending}
className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-4 py-2"
/>
);
};

View file

@ -4,7 +4,7 @@ import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi'; import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; 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 { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { ProviderBrandLogo } from './ProviderBrandLogo'; import { ProviderBrandLogo } from './ProviderBrandLogo';
@ -27,17 +27,13 @@ interface ProviderActivityStatusStripProps {
readonly providerIds?: readonly CliProviderId[]; readonly providerIds?: readonly CliProviderId[];
readonly className?: string; readonly className?: string;
readonly label?: string | null; readonly label?: string | null;
readonly layout?: 'inline' | 'stacked';
readonly showReadyProviders?: boolean;
readonly readyStatusText?: string;
} }
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean { function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
return ( return providerLoading || isTeamProviderModelVerificationPending(provider.providerId, provider);
providerLoading ||
(!provider.authenticated &&
(provider.statusMessage === 'Checking...' ||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
provider.models.length === 0 &&
provider.backend == null)
);
} }
function shouldMaskCodexNegativeBootstrapState( function shouldMaskCodexNegativeBootstrapState(
@ -97,6 +93,7 @@ function useProviderActivityDisplay({
multimodelEnabled, multimodelEnabled,
codexSnapshotPending = false, codexSnapshotPending = false,
providerIds, providerIds,
showReadyProviders,
}: Pick< }: Pick<
ProviderActivityStatusStripProps, ProviderActivityStatusStripProps,
| 'cliStatus' | 'cliStatus'
@ -106,6 +103,7 @@ function useProviderActivityDisplay({
| 'multimodelEnabled' | 'multimodelEnabled'
| 'codexSnapshotPending' | 'codexSnapshotPending'
| 'providerIds' | 'providerIds'
| 'showReadyProviders'
>): { >): {
displayProviderIds: CliProviderId[]; displayProviderIds: CliProviderId[];
providerStateMap: Map<CliProviderId, ProviderActivityState>; providerStateMap: Map<CliProviderId, ProviderActivityState>;
@ -201,6 +199,10 @@ function useProviderActivityDisplay({
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]); }, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
const displayProviderIds = useMemo(() => { const displayProviderIds = useMemo(() => {
if (showReadyProviders) {
return visibleProviderIds;
}
if (loadingProviderIds.length > 0) { if (loadingProviderIds.length > 0) {
const activeCycleIds = ( const activeCycleIds = (
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
@ -213,7 +215,14 @@ function useProviderActivityDisplay({
} }
return []; return [];
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]); }, [
cycleProviderIds,
errorProviderIds,
loadingProviderIds,
providerStateMap,
showReadyProviders,
visibleProviderIds,
]);
return { return {
displayProviderIds, displayProviderIds,
@ -237,6 +246,9 @@ export const ProviderActivityStatusStrip = ({
providerIds, providerIds,
className = '', className = '',
label = 'Provider Activity', label = 'Provider Activity',
layout = 'inline',
showReadyProviders = false,
readyStatusText = 'Checked',
}: ProviderActivityStatusStripProps): React.JSX.Element | null => { }: ProviderActivityStatusStripProps): React.JSX.Element | null => {
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({ const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
cliStatus, cliStatus,
@ -246,14 +258,24 @@ export const ProviderActivityStatusStrip = ({
multimodelEnabled, multimodelEnabled,
codexSnapshotPending, codexSnapshotPending,
providerIds, providerIds,
showReadyProviders,
}); });
if (!shouldRender) { if (!shouldRender) {
return null; 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 ( return (
<div className={`flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim()}> <div className={rootClassName}>
{label ? ( {label ? (
<span <span
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]" className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
@ -262,7 +284,7 @@ export const ProviderActivityStatusStrip = ({
{label} {label}
</span> </span>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className={itemsClassName}>
{displayProviderIds.map((providerId) => { {displayProviderIds.map((providerId) => {
const providerState = providerStateMap.get(providerId); const providerState = providerStateMap.get(providerId);
if (!providerState) { if (!providerState) {
@ -280,13 +302,13 @@ export const ProviderActivityStatusStrip = ({
? 'Checking...' ? 'Checking...'
: tone === 'error' : tone === 'error'
? formatProviderStatusText(providerState.provider) ? formatProviderStatusText(providerState.provider)
: 'Checked'; : readyStatusText;
return ( return (
<div <div
key={providerId} key={providerId}
data-testid={`provider-activity-status-${providerId}`} data-testid={`provider-activity-status-${providerId}`}
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]" className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
style={{ style={{
borderColor: styles.borderColor, borderColor: styles.borderColor,
backgroundColor: styles.backgroundColor, backgroundColor: styles.backgroundColor,

View file

@ -29,7 +29,6 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner'; import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
import { GlobalProviderStatusHeader } from '../common/GlobalProviderStatusHeader';
import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog'; import { UpdateDialog } from '../common/UpdateDialog';
import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
@ -164,7 +163,6 @@ export const TabbedLayout = (): React.JSX.Element => {
> >
<TabBarRow /> <TabBarRow />
<CliInstallWarningBanner /> <CliInstallWarningBanner />
<GlobalProviderStatusHeader />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */} {/* Command Palette (Cmd+K) */}
<CommandPalette /> <CommandPalette />

View file

@ -77,6 +77,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus
import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions'; import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
import { import {
getTeamModelSelectionError, getTeamModelSelectionError,
isTeamProviderRuntimeStatusLoading,
normalizeExplicitTeamModelForUi, normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability'; } from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
@ -699,6 +700,29 @@ export const CreateTeamDialog = ({
), ),
[effectiveCliStatus?.providers] [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( const selectedProviderBackendId = useMemo(
() => () =>
resolveUiOwnedProviderBackendId( 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({ const providerPlans = buildProviderPreparePlans({
cwd: effectiveCwd, cwd: effectiveCwd,
providerIds: selectedMemberProviders, providerIds: readyProviderIds,
selectedModelChecksByProvider, selectedModelChecksByProvider,
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
limitContext: effectiveAnthropicRuntimeLimitContext, limitContext: effectiveAnthropicRuntimeLimitContext,
@ -1048,7 +1078,7 @@ export const CreateTeamDialog = ({
return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature; return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature;
}); });
const loadingMessage = getProvisioningProviderProgressMessage( const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId), [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)],
selectedMemberProviders.length selectedMemberProviders.length
); );
const getSelectedWarnings = (): string[] => const getSelectedWarnings = (): string[] =>
@ -1089,6 +1119,20 @@ export const CreateTeamDialog = ({
}; };
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); 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) { for (const plan of changedPlans) {
checks = updateProviderCheck(checks, plan.providerId, { checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
@ -1229,6 +1273,7 @@ export const CreateTeamDialog = ({
effectiveAnthropicRuntimeLimitContext, effectiveAnthropicRuntimeLimitContext,
prepareProviderInvalidationEpochById, prepareProviderInvalidationEpochById,
runtimeProviderStatusById, runtimeProviderStatusById,
runtimeProviderLoadingById,
selectedModel, selectedModel,
selectedModelChecksByProvider, selectedModelChecksByProvider,
selectedModelChecksByProviderSignature, selectedModelChecksByProviderSignature,
@ -1724,13 +1769,15 @@ export const CreateTeamDialog = ({
} }
} }
const leadError = getTeamModelSelectionError( if (!runtimeProviderLoadingById.get(selectedProviderId)) {
selectedProviderId, const leadError = getTeamModelSelectionError(
selectedModel, selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId) selectedModel,
); runtimeProviderStatusById.get(selectedProviderId)
if (leadError) { );
return leadError; if (leadError) {
return leadError;
}
} }
for (const member of effectiveMemberDrafts) { for (const member of effectiveMemberDrafts) {
@ -1739,6 +1786,9 @@ export const CreateTeamDialog = ({
} }
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
if (runtimeProviderLoadingById.get(providerId)) {
continue;
}
const memberError = getTeamModelSelectionError( const memberError = getTeamModelSelectionError(
providerId, providerId,
member.model, member.model,
@ -1756,6 +1806,7 @@ export const CreateTeamDialog = ({
}, [ }, [
effectiveMemberDrafts, effectiveMemberDrafts,
runtimeProviderStatusById, runtimeProviderStatusById,
runtimeProviderLoadingById,
selectedModel, selectedModel,
selectedProviderId, selectedProviderId,
soloTeam, soloTeam,
@ -2488,6 +2539,12 @@ export const CreateTeamDialog = ({
codexSnapshotPending={codexSnapshotPending} codexSnapshotPending={codexSnapshotPending}
providerIds={selectedMemberProviders} providerIds={selectedMemberProviders}
className="mb-2" className="mb-2"
label="Selected providers"
layout="stacked"
showReadyProviders={
effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading'
}
readyStatusText="Ready"
/> />
) : null} ) : null}
{canCreate && {canCreate &&

View file

@ -68,6 +68,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus
import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions'; import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
import { import {
getTeamModelSelectionError, getTeamModelSelectionError,
isTeamProviderRuntimeStatusLoading,
normalizeExplicitTeamModelForUi, normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability'; } from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
@ -609,6 +610,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
), ),
[effectiveCliStatus?.providers] [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(() => { useEffect(() => {
if (!open) { 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({ const providerPlans = buildProviderPreparePlans({
cwd: effectiveCwd, cwd: effectiveCwd,
providerIds: selectedMemberProviders, providerIds: readyProviderIds,
selectedModelChecksByProvider, selectedModelChecksByProvider,
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current, backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
limitContext: effectiveAnthropicRuntimeLimitContext, limitContext: effectiveAnthropicRuntimeLimitContext,
@ -1634,7 +1664,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature
); );
const loadingMessage = getProvisioningProviderProgressMessage( const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId), [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)],
selectedMemberProviders.length selectedMemberProviders.length
); );
const getSelectedWarnings = (): string[] => const getSelectedWarnings = (): string[] =>
@ -1675,6 +1705,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}; };
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders); 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) { for (const plan of changedPlans) {
checks = updateProviderCheck(checks, plan.providerId, { checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
@ -1789,6 +1832,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
effectiveCwd, effectiveCwd,
effectiveAnthropicRuntimeLimitContext, effectiveAnthropicRuntimeLimitContext,
prepareProviderInvalidationEpochById, prepareProviderInvalidationEpochById,
runtimeProviderLoadingById,
runtimeProviderStatusById, runtimeProviderStatusById,
selectedMemberProviders, selectedMemberProviders,
selectedModelChecksByProvider, selectedModelChecksByProvider,
@ -2056,13 +2100,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
} }
const leadError = getTeamModelSelectionError( if (!runtimeProviderLoadingById.get(selectedProviderId)) {
selectedProviderId, const leadError = getTeamModelSelectionError(
selectedModel, selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId) selectedModel,
); runtimeProviderStatusById.get(selectedProviderId)
if (leadError) { );
return leadError; if (leadError) {
return leadError;
}
} }
if (!isLaunchMode) { if (!isLaunchMode) {
@ -2075,6 +2121,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
if (runtimeProviderLoadingById.get(providerId)) {
continue;
}
const memberError = getTeamModelSelectionError( const memberError = getTeamModelSelectionError(
providerId, providerId,
member.model, member.model,
@ -2092,6 +2141,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [ }, [
effectiveMemberDrafts, effectiveMemberDrafts,
isLaunchMode, isLaunchMode,
runtimeProviderLoadingById,
runtimeProviderStatusById, runtimeProviderStatusById,
selectedModel, selectedModel,
selectedProviderId, selectedProviderId,
@ -3054,6 +3104,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
codexSnapshotPending={codexSnapshotPending} codexSnapshotPending={codexSnapshotPending}
providerIds={selectedMemberProviders} providerIds={selectedMemberProviders}
label="Selected providers"
layout="stacked"
showReadyProviders={
effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading'
}
readyStatusText="Ready"
className="mb-2" className="mb-2"
/> />
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? ( {effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (

View file

@ -5,6 +5,7 @@ import {
getAvailableTeamProviderModels, getAvailableTeamProviderModels,
getTeamModelSelectionError, getTeamModelSelectionError,
isTeamModelAvailableForUi, isTeamModelAvailableForUi,
isTeamProviderModelVerificationPending,
normalizeTeamModelForUi, normalizeTeamModelForUi,
} from '../teamModelAvailability'; } from '../teamModelAvailability';
@ -204,6 +205,32 @@ describe('team model availability Codex catalog integration', () => {
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); 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', () => { it('orders GPT-5.5 first after the virtual default option', () => {
const providerStatus = createCodexProviderStatus([ const providerStatus = createCodexProviderStatus([
{ {

View file

@ -1,3 +1,5 @@
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import { import {
getProviderScopedTeamModelLabel, getProviderScopedTeamModelLabel,
getRuntimeAwareProviderScopedTeamModelLabel, getRuntimeAwareProviderScopedTeamModelLabel,
@ -43,6 +45,7 @@ export type TeamModelRuntimeProviderStatus = Pick<
| 'providerId' | 'providerId'
| 'models' | 'models'
| 'modelCatalog' | 'modelCatalog'
| 'modelCatalogRefreshState'
| 'modelAvailability' | 'modelAvailability'
| 'modelVerificationState' | 'modelVerificationState'
| 'runtimeCapabilities' | 'runtimeCapabilities'
@ -163,6 +166,21 @@ export function isTeamProviderModelVerificationPending(
return true; 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 = const hasRuntimeModelTruth =
providerStatus.models.length > 0 || providerStatus.models.length > 0 ||
(providerStatus.modelCatalog?.models.length ?? 0) > 0 || (providerStatus.modelCatalog?.models.length ?? 0) > 0 ||
@ -193,10 +211,25 @@ export function isTeamProviderModelVerificationPending(
return false; return false;
} }
const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? '';
return statusMessage.length === 0 || statusMessage === 'checking...'; 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[] { function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] {
return getVisibleTeamProviderModels( return getVisibleTeamProviderModels(
providerId, providerId,

View file

@ -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<string, unknown> | null;
cliStatusLoading: boolean;
cliProviderStatusLoading: Record<string, boolean>;
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<typeof import('@features/codex-account/renderer')>();
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<string, unknown>): Record<string, unknown> {
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<string, unknown>[]): Record<string, unknown> {
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();
});
});
});