diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index be2ffe20..ff41320d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -33,6 +33,7 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -321,7 +322,11 @@ function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] ): string | null { - if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) { + if (isMultimodelRuntimeStatus(cliStatus)) { + if (visibleProviders.length === 0) { + return null; + } + if ( visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -351,7 +356,7 @@ function isCheckingMultimodelStatus( visibleProviders: readonly CliProviderStatus[] ): boolean { return ( - cliStatus.flavor === 'agent_teams_orchestrator' && + isMultimodelRuntimeStatus(cliStatus) && visibleProviders.length > 0 && visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 4d76908b..ffc9ff4c 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -20,6 +20,11 @@ import { 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 { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; @@ -178,9 +183,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; - const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); - const isMultimodel = - cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0; + const visibleProviders = getVisibleMultimodelProviders(providers); + const isMultimodel = isMultimodelRuntimeStatus(cliStatus); if (cliStatusLoading || cliStatus === null) { return ( @@ -260,57 +264,64 @@ export const ExtensionStoreView = (): React.JSX.Element => {

-
- {visibleProviders.map((provider) => { - 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 pluginStatus = provider.capabilities.extensions.plugins.status; + {visibleProviders.length > 0 && ( +
+ {visibleProviders.map((provider) => { + 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 pluginStatus = provider.capabilities.extensions.plugins.status; - return ( -
-
-
-

- - {provider.displayName} -

-

- {provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'} -

+ return ( +
+
+
+

+ + {provider.displayName} +

+

+ {provider.statusMessage ?? + provider.backend?.label ?? + 'Ready to configure'} +

+
+ + {statusLabel} + +
+
+ + Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)} + + + MCP:{' '} + {formatCliExtensionCapabilityStatus( + provider.capabilities.extensions.mcp.status + )} + + + Skills: {provider.capabilities.extensions.skills.ownership} +
- - {statusLabel} -
-
- - Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'} - - - MCP: {provider.capabilities.extensions.mcp.status} - - - Skills: {provider.capabilities.extensions.skills.ownership} - -
-
- ); - })} -
+ ); + })} +
+ )}
); } diff --git a/src/renderer/utils/multimodelProviderVisibility.ts b/src/renderer/utils/multimodelProviderVisibility.ts new file mode 100644 index 00000000..8fa4d753 --- /dev/null +++ b/src/renderer/utils/multimodelProviderVisibility.ts @@ -0,0 +1,32 @@ +import { filterMainScreenCliProviders } from './geminiUiFreeze'; + +import type { + CliExtensionCapability, + CliInstallationStatus, + CliProviderStatus, +} from '@shared/types'; + +export function getVisibleMultimodelProviders( + providers: readonly CliProviderStatus[] +): CliProviderStatus[] { + return filterMainScreenCliProviders(providers); +} + +export function isMultimodelRuntimeStatus( + cliStatus: Pick | null | undefined +): boolean { + return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0; +} + +export function formatCliExtensionCapabilityStatus( + status: CliExtensionCapability['status'] +): string { + switch (status) { + case 'supported': + return 'supported'; + case 'read-only': + return 'read-only'; + default: + return 'unsupported'; + } +} diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 96527c46..d383257d 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -170,7 +170,8 @@ function createApiKeyMisconfiguredProvider( connection: { supportsOAuth: true, supportsApiKey: true, - configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], + configurableAuthModes: + providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], configuredAuthMode: 'api_key', apiKeyBetaAvailable: providerId === 'codex' ? true : undefined, apiKeyBetaEnabled: providerId === 'codex' ? true : undefined, @@ -181,9 +182,7 @@ function createApiKeyMisconfiguredProvider( }; } -function createApiKeyModeProviderIssue( - providerId: 'anthropic' | 'codex' -): Record { +function createApiKeyModeProviderIssue(providerId: 'anthropic' | 'codex'): Record { return { ...createApiKeyMisconfiguredProvider(providerId), statusMessage: @@ -191,8 +190,8 @@ function createApiKeyModeProviderIssue( ? 'Anthropic API key was rejected by the runtime.' : 'OpenAI API key was rejected by the runtime.', connection: { - ...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) - .connection), + ...(createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) + .connection, apiKeyConfigured: true, apiKeySource: 'stored', apiKeySourceLabel: @@ -287,9 +286,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -345,6 +342,49 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + authLoggedIn: true, + providers: [ + { + providerId: 'gemini', + displayName: 'Gemini', + supported: true, + authenticated: true, + authMethod: 'cli_oauth_personal', + verificationState: 'verified', + statusMessage: 'Resolved to CLI SDK', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Authenticated'); + expect(host.textContent).not.toContain('Providers:'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows a degraded runtime warning when a binary is found but the health check fails', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -413,9 +453,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves settings runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -490,8 +528,8 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).not.toContain('Already logged in?'); expect(host.textContent).not.toContain('Login'); - const manageButton = Array.from(host.querySelectorAll('button')).find( - (button) => button.textContent?.includes('Manage Providers') + const manageButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Manage Providers') ); expect(manageButton).not.toBeUndefined(); diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts new file mode 100644 index 00000000..47cac0f4 --- /dev/null +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatCliExtensionCapabilityStatus, + getVisibleMultimodelProviders, + isMultimodelRuntimeStatus, +} from '@renderer/utils/multimodelProviderVisibility'; + +import type { CliInstallationStatus, CliProviderStatus } from '@shared/types'; + +function createProvider(providerId: CliProviderStatus['providerId']): CliProviderStatus { + return { + providerId, + displayName: + providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + statusMessage: null, + detailMessage: null, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + models: [], + backend: null, + connection: null, + }; +} + +describe('multimodelProviderVisibility', () => { + it('keeps multimodel runtime detection true even when all visible provider cards are hidden', () => { + const cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [createProvider('gemini')], + } satisfies Pick; + + expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true); + expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0); + }); + + it('filters Gemini from the visible provider cards while keeping supported providers', () => { + const providers = [ + createProvider('anthropic'), + createProvider('codex'), + createProvider('gemini'), + ]; + + expect(getVisibleMultimodelProviders(providers).map((provider) => provider.providerId)).toEqual( + ['anthropic', 'codex'] + ); + }); + + it('formats capability statuses without collapsing read-only into a vague limited label', () => { + expect(formatCliExtensionCapabilityStatus('supported')).toBe('supported'); + expect(formatCliExtensionCapabilityStatus('read-only')).toBe('read-only'); + expect(formatCliExtensionCapabilityStatus('unsupported')).toBe('unsupported'); + }); +});