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:
parent
7aa87f2278
commit
57931c0abd
8 changed files with 228 additions and 297 deletions
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<CliProviderId, ProviderActivityState>;
|
||||
|
|
@ -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 (
|
||||
<div className={`flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
<div className={rootClassName}>
|
||||
{label ? (
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
|
|
@ -262,7 +284,7 @@ export const ProviderActivityStatusStrip = ({
|
|||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<div className={itemsClassName}>
|
||||
{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 (
|
||||
<div
|
||||
key={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={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import { useStore } from '@renderer/store';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CliInstallWarningBanner } from '../common/CliInstallWarningBanner';
|
||||
import { GlobalProviderStatusHeader } from '../common/GlobalProviderStatusHeader';
|
||||
import { UpdateBanner } from '../common/UpdateBanner';
|
||||
import { UpdateDialog } from '../common/UpdateDialog';
|
||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||
|
|
@ -164,7 +163,6 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
>
|
||||
<TabBarRow />
|
||||
<CliInstallWarningBanner />
|
||||
<GlobalProviderStatusHeader />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue