From 53dec55b1da09b298aa4eb18e9ea06ed530c8360 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 25 May 2026 14:52:50 +0300 Subject: [PATCH] fix(runtime): gate codex install prompt on runtime status --- .../components/dashboard/CliStatusBanner.tsx | 44 ++++---- .../runtime/ProviderRuntimeSettingsDialog.tsx | 8 +- .../runtime/codexRuntimeInstallAction.ts | 47 ++++++++ .../settings/sections/CliStatusSection.tsx | 18 +++ .../ProviderRuntimeSettingsDialog.test.ts | 104 ++++++++++++++++++ .../runtime/codexRuntimeInstallAction.test.ts | 81 ++++++++++++++ 6 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 src/renderer/components/runtime/codexRuntimeInstallAction.ts create mode 100644 test/renderer/components/runtime/codexRuntimeInstallAction.test.ts diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index ed06f95b..acd87928 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -24,6 +24,10 @@ import { CodexLoginLinkCopyButton, CodexLoginUserCodeBadge, } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; +import { + isCodexProviderRuntimeMissing, + shouldOfferCodexRuntimeInstall, +} from '@renderer/components/runtime/codexRuntimeInstallAction'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -606,30 +610,12 @@ function shouldShowCodexInstallAction( showSkeleton: boolean, codexRuntimeStatus: CodexRuntimeStatus | null ): boolean { - const codexNativeBackend = provider.availableBackends?.find( - (backend) => backend.id === 'codex-native' - ); - const runtimeMissingText = [ - provider.statusMessage, - provider.detailMessage, - codexNativeBackend?.statusMessage, - codexNativeBackend?.detailMessage, - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); - const runtimeMissing = - provider.verificationState === 'error' && - (codexNativeBackend?.state === 'runtime-missing' || - runtimeMissingText.includes('codex cli not found') || - runtimeMissingText.includes('runtime missing')); - return ( provider.providerId === 'codex' && !showSkeleton && !provider.authenticated && - runtimeMissing && - !(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed') + isCodexProviderRuntimeMissing(provider) && + shouldOfferCodexRuntimeInstall(codexRuntimeStatus) ); } @@ -1368,6 +1354,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { bootstrapCliStatus, fetchCliStatus, fetchCliProviderStatus, + fetchCodexRuntimeStatus, invalidateCliStatus, installCli, installOpenCodeRuntime, @@ -1455,6 +1442,23 @@ export const CliStatusBanner = (): React.JSX.Element | null => { [loadingCliStatus, visibleCliProviders] ); const renderCliStatus = effectiveCliStatus; + + useEffect(() => { + if (!isElectron || codexRuntimeStatus || codexRuntimeStatusLoading) { + return; + } + + if (visibleCliProviders.some(isCodexProviderRuntimeMissing)) { + void fetchCodexRuntimeStatus(); + } + }, [ + codexRuntimeStatus, + codexRuntimeStatusLoading, + fetchCodexRuntimeStatus, + isElectron, + visibleCliProviders, + ]); + const shouldPollAnthropicSubscriptionLimits = useMemo(() => { if ( !renderCliStatus?.installed || diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 63048b5c..d494e068 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -47,6 +47,10 @@ import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useStore } from '@renderer/store'; import { AlertTriangle, Download, Key, Link2, Loader2, Save, Trash2 } from 'lucide-react'; +import { + isCodexProviderRuntimeMissing, + shouldOfferCodexRuntimeInstall, +} from './codexRuntimeInstallAction'; import { formatProviderAuthMethodLabelForProvider, formatProviderAuthModeLabelForProvider, @@ -1056,8 +1060,8 @@ export const ProviderRuntimeSettingsDialog = ({ const showCodexRuntimeInstallAction = selectedProvider?.providerId === 'codex' && typeof onInstallCodexRuntime === 'function' && - codexConnection?.appServerState === 'runtime-missing' && - !(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed'); + isCodexProviderRuntimeMissing(selectedProvider) && + shouldOfferCodexRuntimeInstall(codexRuntimeStatus); const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; const anthropicFastModeCapability = selectedProvider?.providerId === 'anthropic' diff --git a/src/renderer/components/runtime/codexRuntimeInstallAction.ts b/src/renderer/components/runtime/codexRuntimeInstallAction.ts new file mode 100644 index 00000000..89243a56 --- /dev/null +++ b/src/renderer/components/runtime/codexRuntimeInstallAction.ts @@ -0,0 +1,47 @@ +import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; +import type { CliProviderStatus } from '@shared/types'; + +const CODEX_NATIVE_BACKEND_ID = 'codex-native'; + +export function isCodexProviderRuntimeMissing(provider: CliProviderStatus): boolean { + if (provider.providerId !== 'codex') { + return false; + } + + const codexNativeBackend = provider.availableBackends?.find( + (backend) => backend.id === CODEX_NATIVE_BACKEND_ID + ); + const runtimeMissingText = [ + provider.statusMessage, + provider.detailMessage, + codexNativeBackend?.statusMessage, + codexNativeBackend?.detailMessage, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return ( + provider.connection?.codex?.appServerState === 'runtime-missing' || + codexNativeBackend?.state === 'runtime-missing' || + (provider.verificationState === 'error' && + (runtimeMissingText.includes('codex cli not found') || + runtimeMissingText.includes('runtime missing'))) + ); +} + +export function shouldOfferCodexRuntimeInstall( + codexRuntimeStatus: CodexRuntimeStatus | null | undefined +): boolean { + if (!codexRuntimeStatus || codexRuntimeStatus.installed) { + return false; + } + + return ( + codexRuntimeStatus.source === 'missing' || + codexRuntimeStatus.state === 'failed' || + codexRuntimeStatus.state === 'checking' || + codexRuntimeStatus.state === 'downloading' || + codexRuntimeStatus.state === 'installing' + ); +} diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 3351b4d5..c6e4c986 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -15,6 +15,7 @@ import { useAppTranslation } from '@features/localization/renderer'; import { isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { isCodexProviderRuntimeMissing } from '@renderer/components/runtime/codexRuntimeInstallAction'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -148,6 +149,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { bootstrapCliStatus, fetchCliStatus, fetchCliProviderStatus, + fetchCodexRuntimeStatus, installCodexRuntime, installCli, isBusy, @@ -226,6 +228,22 @@ export const CliStatusSection = (): React.JSX.Element | null => { } }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]); + useEffect(() => { + if (!isElectron || codexRuntimeStatus || codexRuntimeStatusLoading) { + return; + } + + if (visibleEffectiveProviders.some(isCodexProviderRuntimeMissing)) { + void fetchCodexRuntimeStatus(); + } + }, [ + codexRuntimeStatus, + codexRuntimeStatusLoading, + fetchCodexRuntimeStatus, + isElectron, + visibleEffectiveProviders, + ]); + const handleInstall = useCallback(() => { installCli(); }, [installCli]); diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index faa397a1..582d5a29 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -1755,6 +1755,110 @@ describe('ProviderRuntimeSettingsDialog', () => { expect(icon?.className).toContain('shrink-0'); }); + it('does not offer Codex runtime install before installer status is known', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onInstallCodexRuntime = vi.fn(); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + codex: { + appServerState: 'runtime-missing', + appServerStatusMessage: 'Codex CLI not found', + launchAllowed: false, + launchIssueMessage: 'Codex CLI not found', + launchReadinessState: 'runtime_missing', + }, + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use the local codex exec JSON seam.', + selectable: false, + recommended: true, + available: false, + state: 'runtime-missing', + audience: 'general', + statusMessage: 'Codex CLI not found', + }, + ], + }), + ], + initialProviderId: 'codex', + codexRuntimeStatus: null, + onInstallCodexRuntime, + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Install Codex CLI'); + expect(onInstallCodexRuntime).not.toHaveBeenCalled(); + }); + + it('offers Codex runtime install after installer status confirms the runtime is missing', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createCodexProvider({ + authenticated: false, + authMethod: null, + codex: { + appServerState: 'runtime-missing', + appServerStatusMessage: 'Codex CLI not found', + launchAllowed: false, + launchIssueMessage: 'Codex CLI not found', + launchReadinessState: 'runtime_missing', + }, + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use the local codex exec JSON seam.', + selectable: false, + recommended: true, + available: false, + state: 'runtime-missing', + audience: 'general', + statusMessage: 'Codex CLI not found', + }, + ], + }), + ], + initialProviderId: 'codex', + codexRuntimeStatus: { + installed: false, + source: 'missing', + state: 'idle', + }, + onInstallCodexRuntime: vi.fn(), + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Install Codex CLI'); + }); + it('keeps the API key form open and shows an error when delete fails', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/runtime/codexRuntimeInstallAction.test.ts b/test/renderer/components/runtime/codexRuntimeInstallAction.test.ts new file mode 100644 index 00000000..b8f0a613 --- /dev/null +++ b/test/renderer/components/runtime/codexRuntimeInstallAction.test.ts @@ -0,0 +1,81 @@ +import { + isCodexProviderRuntimeMissing, + shouldOfferCodexRuntimeInstall, +} from '@renderer/components/runtime/codexRuntimeInstallAction'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; +import { describe, expect, it } from 'vitest'; + +import type { CliProviderStatus } from '@shared/types'; + +function createCodexProvider(overrides?: Partial): CliProviderStatus { + return { + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: 'Codex CLI not found', + models: [], + modelAvailability: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + availableBackends: [ + { + id: 'codex-native', + label: 'Codex native', + description: 'Use codex exec JSON mode.', + selectable: false, + recommended: true, + available: false, + state: 'runtime-missing', + audience: 'general', + statusMessage: 'Codex CLI not found', + }, + ], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + ...overrides, + }; +} + +describe('codexRuntimeInstallAction', () => { + it('recognizes provider runtime-missing snapshots', () => { + expect(isCodexProviderRuntimeMissing(createCodexProvider())).toBe(true); + }); + + it('does not offer install before installer status is loaded', () => { + expect(shouldOfferCodexRuntimeInstall(null)).toBe(false); + }); + + it('offers install for confirmed missing or failed runtime status only', () => { + expect( + shouldOfferCodexRuntimeInstall({ + installed: false, + source: 'missing', + state: 'idle', + }) + ).toBe(true); + expect( + shouldOfferCodexRuntimeInstall({ + installed: false, + source: 'app-managed', + state: 'failed', + }) + ).toBe(true); + expect( + shouldOfferCodexRuntimeInstall({ + installed: true, + source: 'path', + state: 'ready', + }) + ).toBe(false); + }); +});