diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index a9d72a7d..1f9dcb2c 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -28,7 +28,17 @@ import {
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
-import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
+import {
+ AlertTriangle,
+ BookOpen,
+ Info,
+ Key,
+ Loader2,
+ Plus,
+ Puzzle,
+ RefreshCw,
+ Server,
+} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
@@ -38,6 +48,40 @@ import { PluginsPanel } from './plugins/PluginsPanel';
import { SkillsPanel } from './skills/SkillsPanel';
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
+const ProviderCapabilityCardSkeleton = ({
+ providerId,
+ displayName,
+}: {
+ providerId: 'anthropic' | 'codex' | 'gemini';
+ displayName: string;
+}): React.JSX.Element => (
+
+
+
+
+
+ {displayName}
+
+
+
+ Checking provider status...
+
+
+
+ Loading...
+
+
+
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+
+
+);
+
export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional();
const {
@@ -53,6 +97,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
skillsLoading,
cliStatus,
cliStatusLoading,
+ cliProviderStatusLoading,
openDashboard,
sessions,
projects,
@@ -71,6 +116,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
skillsLoading: s.skillsLoading,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
+ cliProviderStatusLoading: s.cliProviderStatusLoading,
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
@@ -186,8 +232,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const providers = cliStatus?.providers ?? [];
const visibleProviders = getVisibleMultimodelProviders(providers);
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
+ const shouldShowMultimodelProviderCards =
+ isMultimodel && visibleProviders.length > 0 && cliStatus !== null;
- if (cliStatusLoading || cliStatus === null) {
+ if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) {
return (
@@ -268,6 +316,17 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{visibleProviders.length > 0 && (
{visibleProviders.map((provider) => {
+ const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
+ if (providerLoading) {
+ return (
+
+ );
+ }
+
const statusTone = provider.authenticated
? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300'
: provider.supported
@@ -337,7 +396,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
);
- }, [cliStatus, cliStatusLoading, openDashboard]);
+ }, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
// Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) {
diff --git a/test/renderer/components/extensions/ExtensionStoreView.test.ts b/test/renderer/components/extensions/ExtensionStoreView.test.ts
new file mode 100644
index 00000000..cf450adb
--- /dev/null
+++ b/test/renderer/components/extensions/ExtensionStoreView.test.ts
@@ -0,0 +1,305 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { CliInstallationStatus } from '@shared/types';
+
+interface StoreState {
+ fetchPluginCatalog: ReturnType;
+ fetchCliStatus: ReturnType;
+ fetchApiKeys: ReturnType;
+ fetchSkillsCatalog: ReturnType;
+ mcpBrowse: ReturnType;
+ mcpFetchInstalled: ReturnType;
+ apiKeysLoading: boolean;
+ pluginCatalogLoading: boolean;
+ mcpBrowseLoading: boolean;
+ skillsLoading: boolean;
+ cliStatus: CliInstallationStatus | null;
+ cliStatusLoading: boolean;
+ cliProviderStatusLoading: Record;
+ openDashboard: ReturnType;
+ sessions: Array<{ isOngoing: boolean }>;
+ projects: unknown[];
+ repositoryGroups: unknown[];
+}
+
+const storeState = {} as StoreState;
+
+vi.mock('@renderer/store', () => ({
+ useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
+}));
+
+vi.mock('zustand/react/shallow', () => ({
+ useShallow: (selector: T) => selector,
+}));
+
+vi.mock('@renderer/api', () => ({
+ api: {
+ plugins: {},
+ mcpRegistry: {},
+ skills: {},
+ },
+}));
+
+vi.mock('@renderer/contexts/useTabUIContext', () => ({
+ useTabIdOptional: () => undefined,
+}));
+
+vi.mock('@renderer/hooks/useExtensionsTabState', () => ({
+ useExtensionsTabState: () => ({
+ activeSubTab: 'plugins',
+ setActiveSubTab: vi.fn(),
+ pluginFilters: {
+ search: '',
+ categories: [],
+ capabilities: [],
+ installedOnly: false,
+ },
+ pluginSort: { field: 'popularity', order: 'desc' },
+ setPluginSort: vi.fn(),
+ selectedPluginId: null,
+ setSelectedPluginId: vi.fn(),
+ updatePluginSearch: vi.fn(),
+ toggleCategory: vi.fn(),
+ toggleCapability: vi.fn(),
+ toggleInstalledOnly: vi.fn(),
+ clearFilters: vi.fn(),
+ hasActiveFilters: false,
+ mcpSearchQuery: '',
+ mcpSearch: vi.fn(),
+ mcpSearchResults: [],
+ mcpSearchLoading: false,
+ mcpSearchWarnings: [],
+ selectedMcpServerId: null,
+ setSelectedMcpServerId: vi.fn(),
+ skillsSearchQuery: '',
+ setSkillsSearchQuery: vi.fn(),
+ skillsInstalledOnly: false,
+ skillsSort: 'name-asc',
+ setSkillsSort: vi.fn(),
+ selectedSkillId: null,
+ setSelectedSkillId: vi.fn(),
+ }),
+}));
+
+vi.mock('@renderer/utils/projectLookup', () => ({
+ resolveProjectPathById: () => null,
+}));
+
+vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
+ ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
+ React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId),
+}));
+
+vi.mock('@renderer/components/ui/badge', () => ({
+ Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
+}));
+
+vi.mock('@renderer/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ }: React.PropsWithChildren<{ onClick?: () => void; disabled?: boolean }>) =>
+ React.createElement(
+ 'button',
+ {
+ type: 'button',
+ disabled,
+ onClick,
+ },
+ children
+ ),
+}));
+
+vi.mock('@renderer/components/ui/tabs', () => ({
+ Tabs: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
+ TabsList: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
+ TabsContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
+}));
+
+vi.mock('@renderer/components/ui/tooltip', () => ({
+ TooltipProvider: ({ children }: React.PropsWithChildren) =>
+ React.createElement(React.Fragment, null, children),
+ Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
+ TooltipTrigger: ({ children }: React.PropsWithChildren) =>
+ React.createElement(React.Fragment, null, children),
+ TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
+}));
+
+vi.mock('@renderer/components/extensions/ExtensionsSubTabTrigger', () => ({
+ ExtensionsSubTabTrigger: ({ label }: { label: string }) =>
+ React.createElement('button', { type: 'button' }, label),
+}));
+
+vi.mock('@renderer/components/extensions/plugins/PluginsPanel', () => ({
+ PluginsPanel: () => React.createElement('div', null, 'plugins-panel'),
+}));
+
+vi.mock('@renderer/components/extensions/mcp/McpServersPanel', () => ({
+ McpServersPanel: () => React.createElement('div', null, 'mcp-panel'),
+}));
+
+vi.mock('@renderer/components/extensions/skills/SkillsPanel', () => ({
+ SkillsPanel: () => React.createElement('div', null, 'skills-panel'),
+}));
+
+vi.mock('@renderer/components/extensions/apikeys/ApiKeysPanel', () => ({
+ ApiKeysPanel: () => React.createElement('div', null, 'apikeys-panel'),
+}));
+
+vi.mock('@renderer/components/extensions/mcp/CustomMcpServerDialog', () => ({
+ CustomMcpServerDialog: () => null,
+}));
+
+vi.mock('lucide-react', () => {
+ const Icon = (props: React.SVGProps) => React.createElement('svg', props);
+ return {
+ AlertTriangle: Icon,
+ BookOpen: Icon,
+ Info: Icon,
+ Key: Icon,
+ Loader2: Icon,
+ Plus: Icon,
+ Puzzle: Icon,
+ RefreshCw: Icon,
+ Server: Icon,
+ };
+});
+
+import { ExtensionStoreView } from '@renderer/components/extensions/ExtensionStoreView';
+
+function createLoadingMultimodelStatus(): CliInstallationStatus {
+ return {
+ flavor: 'agent_teams_orchestrator',
+ displayName: 'Multimodel runtime',
+ supportsSelfUpdate: false,
+ showVersionDetails: false,
+ showBinaryPath: false,
+ installed: true,
+ installedVersion: null,
+ binaryPath: '/usr/local/bin/agent-teams',
+ launchError: null,
+ latestVersion: null,
+ updateAvailable: false,
+ authLoggedIn: false,
+ authStatusChecking: true,
+ authMethod: null,
+ providers: [
+ {
+ providerId: 'anthropic',
+ displayName: 'Anthropic',
+ supported: false,
+ authenticated: false,
+ authMethod: null,
+ verificationState: 'unknown',
+ modelVerificationState: 'idle',
+ statusMessage: 'Checking...',
+ models: [],
+ modelAvailability: [],
+ canLoginFromUi: true,
+ capabilities: {
+ teamLaunch: false,
+ oneShot: false,
+ extensions: {
+ plugins: { status: 'supported', ownership: 'shared', reason: null },
+ mcp: { status: 'supported', ownership: 'shared', reason: null },
+ skills: { status: 'supported', ownership: 'shared', reason: null },
+ apiKeys: { status: 'supported', ownership: 'shared', reason: null },
+ },
+ },
+ selectedBackendId: null,
+ resolvedBackendId: null,
+ availableBackends: [],
+ externalRuntimeDiagnostics: [],
+ backend: null,
+ connection: null,
+ },
+ {
+ providerId: 'codex',
+ displayName: 'Codex',
+ supported: false,
+ authenticated: false,
+ authMethod: null,
+ verificationState: 'unknown',
+ modelVerificationState: 'idle',
+ statusMessage: 'Checking...',
+ models: [],
+ modelAvailability: [],
+ canLoginFromUi: true,
+ capabilities: {
+ teamLaunch: false,
+ oneShot: false,
+ extensions: {
+ plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null },
+ mcp: { status: 'supported', ownership: 'shared', reason: null },
+ skills: { status: 'supported', ownership: 'shared', reason: null },
+ apiKeys: { status: 'supported', ownership: 'shared', reason: null },
+ },
+ },
+ selectedBackendId: null,
+ resolvedBackendId: null,
+ availableBackends: [],
+ externalRuntimeDiagnostics: [],
+ backend: null,
+ connection: null,
+ },
+ ],
+ };
+}
+
+describe('ExtensionStoreView provider loading placeholders', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ storeState.fetchPluginCatalog = vi.fn().mockResolvedValue(undefined);
+ storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
+ storeState.fetchApiKeys = vi.fn().mockResolvedValue(undefined);
+ storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined);
+ storeState.mcpBrowse = vi.fn().mockResolvedValue(undefined);
+ storeState.mcpFetchInstalled = vi.fn().mockResolvedValue(undefined);
+ storeState.apiKeysLoading = false;
+ storeState.pluginCatalogLoading = false;
+ storeState.mcpBrowseLoading = false;
+ storeState.skillsLoading = false;
+ storeState.cliStatus = createLoadingMultimodelStatus();
+ storeState.cliStatusLoading = true;
+ storeState.cliProviderStatusLoading = {
+ anthropic: true,
+ codex: true,
+ };
+ storeState.openDashboard = vi.fn();
+ storeState.sessions = [];
+ storeState.projects = [];
+ storeState.repositoryGroups = [];
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ });
+
+ it('shows multimodel provider skeleton cards while provider status is still loading', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ExtensionStoreView));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Multimodel runtime capabilities');
+ expect(host.textContent).toContain('Anthropic');
+ expect(host.textContent).toContain('Codex');
+ expect(host.textContent).toContain('Checking provider status...');
+ expect(host.textContent).toContain('Loading...');
+ expect(host.textContent).not.toContain('Checking extensions runtime availability');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+});