/** * 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}

)}
); } 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.

); } 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 */}

Extensions

Refresh catalog
{/* 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' && ( {mcpMutationDisableReason && ( {mcpMutationDisableReason} )} )}
{/* Custom MCP server dialog (lifted to store view level) */} setCustomMcpDialogOpen(false)} projectPath={projectPath} />
); };