fix(extensions): show provider loading placeholders
This commit is contained in:
parent
14ee2fc550
commit
6f4fd254cf
2 changed files with 367 additions and 3 deletions
|
|
@ -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 => (
|
||||
<div className="rounded-md border border-border bg-surface-raised px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium text-text">
|
||||
<ProviderBrandLogo providerId={providerId} className="size-4 shrink-0" />
|
||||
<span>{displayName}</span>
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-text-muted">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span>Checking provider status...</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-text-muted">
|
||||
Loading...
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-7 w-28 animate-pulse rounded-md border border-border bg-surface"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
|
||||
|
|
@ -268,6 +316,17 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
{visibleProviders.length > 0 && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
if (providerLoading) {
|
||||
return (
|
||||
<ProviderCapabilityCardSkeleton
|
||||
key={provider.providerId}
|
||||
providerId={provider.providerId}
|
||||
displayName={provider.displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [cliStatus, cliStatusLoading, openDashboard]);
|
||||
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
|
||||
|
||||
// Browser mode guard
|
||||
if (!api.plugins && !api.mcpRegistry && !api.skills) {
|
||||
|
|
|
|||
305
test/renderer/components/extensions/ExtensionStoreView.test.ts
Normal file
305
test/renderer/components/extensions/ExtensionStoreView.test.ts
Normal file
|
|
@ -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<typeof vi.fn>;
|
||||
fetchCliStatus: ReturnType<typeof vi.fn>;
|
||||
fetchApiKeys: ReturnType<typeof vi.fn>;
|
||||
fetchSkillsCatalog: ReturnType<typeof vi.fn>;
|
||||
mcpBrowse: ReturnType<typeof vi.fn>;
|
||||
mcpFetchInstalled: ReturnType<typeof vi.fn>;
|
||||
apiKeysLoading: boolean;
|
||||
pluginCatalogLoading: boolean;
|
||||
mcpBrowseLoading: boolean;
|
||||
skillsLoading: boolean;
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Record<string, boolean>;
|
||||
openDashboard: ReturnType<typeof vi.fn>;
|
||||
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: <T,>(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<SVGSVGElement>) => 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue