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(); + }); + }); +});