/**
* ExtensionStoreView — top-level component for the Extensions tab.
* Uses per-tab UI state via useExtensionsTabState() hook.
* Global catalog data comes from Zustand store.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import {
formatCliExtensionCapabilityStatus,
getVisibleMultimodelProviders,
isMultimodelRuntimeStatus,
} from '@renderer/utils/multimodelProviderVisibility';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
import { getCliProviderExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import {
AlertTriangle,
BookOpen,
Info,
Key,
Loader2,
Plus,
Puzzle,
RefreshCw,
Server,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
import { McpServersPanel } from './mcp/McpServersPanel';
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 {
fetchPluginCatalog,
fetchCliStatus,
fetchApiKeys,
fetchSkillsCatalog,
mcpBrowse,
mcpFetchInstalled,
apiKeysLoading,
pluginCatalogLoading,
mcpBrowseLoading,
skillsLoading,
cliStatus,
cliStatusLoading,
cliProviderStatusLoading,
openDashboard,
sessions,
projects,
repositoryGroups,
} = useStore(
useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog,
fetchCliStatus: s.fetchCliStatus,
fetchApiKeys: s.fetchApiKeys,
fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse,
mcpFetchInstalled: s.mcpFetchInstalled,
apiKeysLoading: s.apiKeysLoading,
pluginCatalogLoading: s.pluginCatalogLoading,
mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
cliProviderStatusLoading: s.cliProviderStatusLoading,
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
}))
);
const cliInstalled = cliStatus?.installed ?? true;
const hasOngoingSessions = sessions.some((sess) => sess.isOngoing);
const extensionsTabProjectId = useStore((s) =>
tabId
? (s.paneLayout.panes.flatMap((pane) => pane.tabs).find((tab) => tab.id === tabId)
?.projectId ?? null)
: null
);
const tabState = useExtensionsTabState();
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
const resolvedProject = useMemo(
() => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups),
[extensionsTabProjectId, projects, repositoryGroups]
);
const projectPath = resolvedProject?.path ?? null;
const projectLabel = resolvedProject?.name ?? null;
const subTabs = useMemo(
() => [
{
value: 'plugins' as const,
label: 'Plugins',
icon: Puzzle,
description:
'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported.',
},
{
value: 'mcp-servers' as const,
label: 'MCP Servers',
icon: Server,
description:
'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.',
},
{
value: 'skills' as const,
label: 'Skills',
icon: BookOpen,
description:
'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.',
},
{
value: 'api-keys' as const,
label: 'API Keys',
icon: Key,
description:
'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.',
},
],
[]
);
// Fetch plugin catalog on mount
useEffect(() => {
void fetchPluginCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, projectPath]);
useEffect(() => {
void fetchCliStatus();
}, [fetchCliStatus]);
// Fetch MCP installed state on mount
useEffect(() => {
void mcpFetchInstalled(projectPath ?? undefined);
}, [mcpFetchInstalled, projectPath]);
// Fetch API keys on mount
useEffect(() => {
void fetchApiKeys();
}, [fetchApiKeys]);
// Fetch Skills catalog on mount / project change
useEffect(() => {
void fetchSkillsCatalog(projectPath ?? undefined);
}, [fetchSkillsCatalog, projectPath]);
// Refresh all data (plugins + MCP browse + installed + skills)
const handleRefresh = useCallback(() => {
void fetchCliStatus();
void fetchApiKeys();
void fetchPluginCatalog(projectPath ?? undefined, true);
void mcpBrowse(); // re-fetch first page
void mcpFetchInstalled(projectPath ?? undefined);
void fetchSkillsCatalog(projectPath ?? undefined);
}, [
fetchApiKeys,
fetchCliStatus,
fetchPluginCatalog,
fetchSkillsCatalog,
mcpBrowse,
mcpFetchInstalled,
projectPath,
]);
const isRefreshing =
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
const mcpMutationDisableReason = useMemo(
() =>
getExtensionActionDisableReason({
isInstalled: false,
cliStatus,
cliStatusLoading,
section: 'mcp',
}),
[cliStatus, cliStatusLoading]
);
const cliStatusBanner = useMemo(() => {
const providers = cliStatus?.providers ?? [];
const visibleProviders = getVisibleMultimodelProviders(providers);
const isMultimodel = isMultimodelRuntimeStatus(cliStatus);
const shouldShowMultimodelProviderCards =
isMultimodel && visibleProviders.length > 0 && cliStatus !== null;
if ((cliStatusLoading || cliStatus === null) && !shouldShowMultimodelProviderCards) {
return (
Checking extensions runtime availability
Extensions need the configured runtime to manage plugins, MCP servers, skills, and
provider connections.
);
}
if (!cliStatus.installed) {
const cliLaunchIssue = Boolean(cliStatus.binaryPath && cliStatus.launchError);
return (
{cliLaunchIssue
? 'The configured runtime was found but failed to start'
: 'The configured runtime is not available'}
{cliLaunchIssue
? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
{cliLaunchIssue && cliStatus.launchError && (
{cliStatus.launchError}
)}
Open Dashboard
);
}
if (!isMultimodel && !cliStatus.authLoggedIn) {
return (
Claude CLI needs sign-in
Claude CLI was found
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
installs are disabled until you sign in from the Dashboard.
Open Dashboard
);
}
if (isMultimodel) {
return (
Multimodel runtime capabilities
Provider support can differ by section. Plugins are shown only where the runtime
explicitly declares support.
{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
? 'border-amber-500/30 bg-amber-500/5 text-amber-300'
: 'border-border bg-surface-raised text-text-muted';
const statusLabel = provider.authenticated
? 'Connected'
: provider.supported
? 'Needs setup'
: 'Unsupported';
const extensionCapabilities = getCliProviderExtensionCapabilities(provider);
const pluginStatus = extensionCapabilities.plugins.status;
return (
{provider.displayName}
{provider.statusMessage ??
provider.backend?.label ??
'Ready to configure'}
{statusLabel}
Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)}
Skills: {extensionCapabilities.skills.ownership}
);
})}
)}
);
}
return (
Claude CLI is ready
Plugins can be installed from this page
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
);
}, [cliProviderStatusLoading, cliStatus, cliStatusLoading, openDashboard]);
// Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) {
return (
Extensions
Available in the desktop app only.
);
}
return (
{cliStatusBanner}
{/* Header */}
{/* Sub-tabs */}
{/* CLI not installed warning */}
{!cliInstalled && (
The configured runtime is required to install or uninstall extensions. Install or
repair it from the Dashboard.
)}
{/* Active sessions warning */}
{hasOngoingSessions && (
Running sessions won't pick up extension changes until restarted.
)}
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'skills' | 'api-keys')
}
>
{subTabs.map((subTab) => (
))}
{tabState.activeSubTab === 'mcp-servers' && (
setCustomMcpDialogOpen(true)}
className="mb-1 whitespace-nowrap"
disabled={Boolean(mcpMutationDisableReason)}
>
Add Custom
{mcpMutationDisableReason && (
{mcpMutationDisableReason}
)}
)}
{/* Custom MCP server dialog (lifted to store view level) */}
setCustomMcpDialogOpen(false)}
projectPath={projectPath}
/>
);
};