From a03c22aace3836473f72b9714978c4e4ff9bfe80 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 10 Apr 2026 12:28:22 +0300 Subject: [PATCH] feat(team-ui): freeze unstable provider and model options --- .../components/dashboard/CliStatusBanner.tsx | 45 +++-- .../team/dialogs/CreateTeamDialog.tsx | 19 +- .../team/dialogs/LaunchTeamDialog.tsx | 24 ++- .../team/dialogs/TeamModelSelector.tsx | 191 +++++++++++++----- .../components/team/members/LeadModelRow.tsx | 3 + .../team/members/MemberDraftRow.tsx | 3 + .../team/members/MembersEditorSection.tsx | 4 + .../team/members/TeamRosterEditorSection.tsx | 4 + .../team/members/membersEditorUtils.ts | 30 ++- src/renderer/utils/geminiUiFreeze.ts | 57 ++++++ src/renderer/utils/teamModelAvailability.ts | 30 +++ .../components/team/TeamModelSelector.test.ts | 18 ++ .../TeamModelSelectorDisabledState.test.ts | 86 ++++++++ test/renderer/utils/geminiUiFreeze.test.ts | 29 +++ 14 files changed, 455 insertions(+), 88 deletions(-) create mode 100644 src/renderer/utils/geminiUiFreeze.ts create mode 100644 src/renderer/utils/teamModelAvailability.ts create mode 100644 test/renderer/components/team/TeamModelSelectorDisabledState.test.ts create mode 100644 test/renderer/utils/geminiUiFreeze.test.ts diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 34d7c7c7..1b4e1d23 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -20,6 +20,7 @@ import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useStore } from '@renderer/store'; import { formatBytes } from '@renderer/utils/formatters'; +import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; import { AlertTriangle, CheckCircle, @@ -338,18 +339,19 @@ function formatRuntimeLabel( } function formatRuntimeAuthSummary( - cliStatus: NonNullable['cliStatus']> + cliStatus: NonNullable['cliStatus']>, + visibleProviders: readonly CliProviderStatus[] ): string | null { - if (cliStatus.flavor === 'free-code' && cliStatus.providers.length > 0) { + if (cliStatus.flavor === 'free-code' && visibleProviders.length > 0) { if ( - cliStatus.providers.every( + visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated ) ) { return 'Checking providers...'; } - const denominator = cliStatus.providers.length; - const connected = cliStatus.providers.filter((provider) => provider.authenticated).length; + const denominator = visibleProviders.length; + const connected = visibleProviders.filter((provider) => provider.authenticated).length; return `Providers: ${connected}/${denominator} connected`; } @@ -366,12 +368,13 @@ function formatRuntimeAuthSummary( } function isCheckingMultimodelStatus( - cliStatus: NonNullable['cliStatus']> + cliStatus: NonNullable['cliStatus']>, + visibleProviders: readonly CliProviderStatus[] ): boolean { return ( cliStatus.flavor === 'free-code' && - cliStatus.providers.length > 0 && - cliStatus.providers.every( + visibleProviders.length > 0 && + visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated ) ); @@ -396,8 +399,12 @@ const InstalledBanner = ({ }: InstalledBannerProps): React.JSX.Element => { const openExtensionsTab = useStore((s) => s.openExtensionsTab); const styles = VARIANT_STYLES[variant]; + const visibleProviders = useMemo( + () => filterMainScreenCliProviders(cliStatus.providers), + [cliStatus.providers] + ); const runtimeLabel = formatRuntimeLabel(cliStatus); - const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus); + const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); return (
)} - {cliStatus.providers.length > 0 && ( + {visibleProviders.length > 0 && (
- {cliStatus.providers.map((provider) => { + {visibleProviders.map((provider) => { const statusText = formatProviderStatus(provider); const actionDisabled = isBusy || !cliStatus.binaryPath; const runtimeSummary = getProviderRuntimeBackendSummary(provider); @@ -661,12 +668,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => { providerId: CliProviderId; action: 'login' | 'logout'; } | null>(null); - const [manageProviderId, setManageProviderId] = useState('gemini'); + const [manageProviderId, setManageProviderId] = useState('anthropic'); const [manageDialogOpen, setManageDialogOpen] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false); const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; + const visibleCliProviders = useMemo( + () => filterMainScreenCliProviders(cliStatus?.providers ?? []), + [cliStatus?.providers] + ); useEffect(() => { if (!isElectron) return; @@ -799,7 +810,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'completed') return 'success'; if (installerState !== 'idle') return 'info'; if (!cliStatus) return 'loading'; - if (isCheckingMultimodelStatus(cliStatus)) return 'info'; + if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info'; if (cliStatus.authStatusChecking) return 'info'; if (!cliStatus.installed) return 'error'; if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; @@ -1293,8 +1304,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { provider.providerId === manageProviderId) + ? manageProviderId + : (visibleCliProviders[0]?.providerId ?? 'anthropic') + } providerStatusLoading={cliProviderStatusLoading} disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} onSelectBackend={(providerId, backendId) => { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index c4abf77a..5c4ed0d7 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -36,7 +36,12 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { + isGeminiUiFrozen, + normalizeCreateLaunchProviderForUi, +} from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -81,7 +86,11 @@ import type { function getStoredTeamProvider(): TeamProviderId { const stored = localStorage.getItem('team:lastSelectedProvider'); - return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; + // return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; + return normalizeCreateLaunchProviderForUi( + stored === 'codex' || stored === 'gemini' ? stored : 'anthropic', + true + ); } function getStoredTeamModel(providerId: TeamProviderId): string { @@ -89,7 +98,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string { if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } - return stored === '__default__' ? '' : stored; + return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { @@ -368,8 +377,9 @@ export const CreateTeamDialog = ({ }, [advancedKey]); const setSelectedModel = (value: string): void => { - setSelectedModelRaw(value); - localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); + const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + setSelectedModelRaw(normalizedValue); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; const setSelectedProviderId = (value: TeamProviderId): void => { @@ -1160,6 +1170,7 @@ export const CreateTeamDialog = ({ onLimitContextChange={setLimitContext} syncModelsWithTeammates={syncModelsWithLead} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} + disableGeminiOption={isGeminiUiFrozen()} headerTop={
{ - setSelectedModelRaw(value); - localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); + const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + setSelectedModelRaw(normalizedValue); + localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; const setLimitContext = (value: boolean): void => { @@ -496,7 +506,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setSelectedProviderIdRaw(scheduleProviderId); setSelectedModelRaw( - scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) + schedule.launchConfig.providerId !== 'gemini' && + scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) ? (schedule.launchConfig.model ?? '') : getStoredTeamModel('anthropic') ); @@ -576,6 +587,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setSelectedModelRaw( typeof savedRequest?.model === 'string' && + rawNextProviderId !== 'gemini' && nextProviderId === normalizeProviderForMode(rawNextProviderId, true) ? savedRequest.model : getStoredTeamModel( @@ -1539,6 +1551,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen leadWarningText={leadRuntimeWarningText} memberWarningById={memberRuntimeWarningById} softDeleteMembers + disableGeminiOption={isGeminiUiFrozen()} />
@@ -1678,6 +1691,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen value={selectedModel} onValueChange={setSelectedModel} id="dialog-model" + disableGeminiOption={isGeminiUiFrozen()} /> void; id?: string; + disableGeminiOption?: boolean; } export const TeamModelSelector: React.FC = ({ @@ -178,6 +190,7 @@ export const TeamModelSelector: React.FC = ({ value, onValueChange, id, + disableGeminiOption = false, }) => { const cliStatus = useStore((s) => s.cliStatus); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); @@ -199,27 +212,42 @@ export const TeamModelSelector: React.FC = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [dropdownOpen]); - const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; + const effectiveProviderId = + disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; + const activeProvider = + PROVIDERS.find((provider) => provider.id === effectiveProviderId) ?? PROVIDERS[0]; const ProviderIcon = activeProvider.icon; const defaultModelTooltip = useMemo(() => { - if (providerId === 'anthropic') { + if (effectiveProviderId === 'anthropic') { return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; } return 'Uses the runtime default for the selected provider.'; - }, [providerId]); + }, [effectiveProviderId]); + const isProviderTemporarilyDisabled = (candidateProviderId: string): boolean => + disableGeminiOption && isGeminiUiFrozen() && candidateProviderId === 'gemini'; const isProviderSelectable = (candidateProviderId: string): boolean => - multimodelAvailable || candidateProviderId === 'anthropic'; - const activeProviderSelectable = isProviderSelectable(providerId); + !isProviderTemporarilyDisabled(candidateProviderId) && + (multimodelAvailable || candidateProviderId === 'anthropic'); + const activeProviderSelectable = isProviderSelectable(effectiveProviderId); const runtimeModels = - cliStatus?.providers.find((provider) => provider.providerId === providerId)?.models ?? []; + cliStatus?.providers.find((provider) => provider.providerId === effectiveProviderId)?.models ?? + []; + const normalizedValue = normalizeTeamModelForUi(effectiveProviderId, value); + + useEffect(() => { + if (normalizedValue !== value) { + onValueChange(normalizedValue); + } + }, [normalizedValue, onValueChange, value]); + const modelOptions = useMemo(() => { const fallback = - providerId === 'codex' + effectiveProviderId === 'codex' ? CODEX_MODEL_OPTIONS - : providerId === 'gemini' + : effectiveProviderId === 'gemini' ? GEMINI_MODEL_OPTIONS : ANTHROPIC_MODEL_OPTIONS; - if (providerId === 'anthropic' || runtimeModels.length === 0) { + if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) { return [...fallback]; } const dynamicOptions = runtimeModels.map((model) => ({ @@ -227,7 +255,7 @@ export const TeamModelSelector: React.FC = ({ label: getTeamModelLabel(model), })); return [{ value: '', label: 'Default' }, ...dynamicOptions]; - }, [providerId, runtimeModels]); + }, [effectiveProviderId, runtimeModels]); return (
@@ -317,11 +345,21 @@ export const TeamModelSelector: React.FC = ({ Coming Soon )} - {!provider.comingSoon && !isProviderSelectable(provider.id) && ( - - Multimodel off + {!provider.comingSoon && isProviderTemporarilyDisabled(provider.id) && ( + + {GEMINI_UI_DISABLED_BADGE_LABEL} )} + {!provider.comingSoon && + !isProviderTemporarilyDisabled(provider.id) && + !isProviderSelectable(provider.id) && ( + + Multimodel off + + )} {isActive && } @@ -335,52 +373,97 @@ export const TeamModelSelector: React.FC = ({ Codex and Gemini require Multimodel mode.

)} + {disableGeminiOption && isGeminiUiFrozen() && ( +

{GEMINI_UI_DISABLED_REASON}.

+ )}
- {modelOptions.map((opt) => ( - - ))} + {modelOptions.map((opt) => + (() => { + const modelDisabledReason = getTeamModelUiDisabledReason( + effectiveProviderId, + opt.value + ); + const modelSelectable = activeProviderSelectable && !modelDisabledReason; + + return ( + + ); + })() + )}
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index df2aec13..b536726d 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -29,6 +29,7 @@ interface LeadModelRowProps { syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; warningText?: string | null; + disableGeminiOption?: boolean; } export const LeadModelRow = ({ @@ -43,6 +44,7 @@ export const LeadModelRow = ({ syncModelsWithTeammates, onSyncModelsWithTeammatesChange, warningText, + disableGeminiOption = false, }: LeadModelRowProps): React.JSX.Element => { const { isLight } = useTheme(); const [modelExpanded, setModelExpanded] = useState(false); @@ -120,6 +122,7 @@ export const LeadModelRow = ({ value={model} onValueChange={onModelChange} id="lead-model" + disableGeminiOption={disableGeminiOption} /> void; warningText?: string | null; + disableGeminiOption?: boolean; } export const MemberDraftRow = ({ @@ -83,6 +84,7 @@ export const MemberDraftRow = ({ isRemoved = false, onRestore, warningText, + disableGeminiOption = false, }: MemberDraftRowProps): React.JSX.Element => { const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( @@ -344,6 +346,7 @@ export const MemberDraftRow = ({ onModelChange(member.id, value); }} id={`member-${member.id}-model`} + disableGeminiOption={disableGeminiOption} /> ; + disableGeminiOption?: boolean; } export const MembersEditorSection = ({ @@ -126,6 +127,7 @@ export const MembersEditorSection = ({ modelLockReason, softDeleteMembers = false, memberWarningById, + disableGeminiOption = false, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -313,6 +315,7 @@ export const MembersEditorSection = ({ lockProviderModel={lockProviderModel} modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} + disableGeminiOption={disableGeminiOption} /> ))} {softDeleteMembers && removedMembers.length > 0 ? ( @@ -352,6 +355,7 @@ export const MembersEditorSection = ({ modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings." isRemoved warningText={null} + disableGeminiOption={disableGeminiOption} /> ))}
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 9326fa9c..252c3e34 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps { softDeleteMembers?: boolean; leadWarningText?: string | null; memberWarningById?: Record; + disableGeminiOption?: boolean; } export const TeamRosterEditorSection = ({ @@ -81,6 +82,7 @@ export const TeamRosterEditorSection = ({ softDeleteMembers = false, leadWarningText, memberWarningById, + disableGeminiOption = false, }: TeamRosterEditorSectionProps): React.JSX.Element => { return ( {headerTop} @@ -120,6 +123,7 @@ export const TeamRosterEditorSection = ({ syncModelsWithTeammates={syncModelsWithTeammates} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} + disableGeminiOption={disableGeminiOption} /> {headerBottom}
diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 1d19cb49..f3e575ef 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -1,4 +1,6 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; +import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -28,14 +30,15 @@ function newDraftId(): string { } export function createMemberDraft(initial?: Partial): MemberDraft { + const providerId = initial?.providerId; return { id: initial?.id ?? newDraftId(), name: initial?.name ?? '', roleSelection: initial?.roleSelection ?? '', customRole: initial?.customRole ?? '', workflow: initial?.workflow, - providerId: initial?.providerId, - model: initial?.model ?? '', + providerId, + model: normalizeTeamModelForUi(providerId, initial?.model ?? ''), effort: initial?.effort, removedAt: initial?.removedAt, }; @@ -84,23 +87,30 @@ export function normalizeProviderForMode( providerId: TeamProviderId | undefined, multimodelEnabled: boolean ): TeamProviderId { - if (multimodelEnabled && (providerId === 'codex' || providerId === 'gemini')) { - return providerId; - } - return 'anthropic'; + return normalizeCreateLaunchProviderForUi(providerId, multimodelEnabled); } export function normalizeMemberDraftForProviderMode( member: MemberDraft, multimodelEnabled: boolean ): MemberDraft { - if (multimodelEnabled) { + const normalizedProviderId = + member.providerId == null + ? undefined + : normalizeCreateLaunchProviderForUi(member.providerId, multimodelEnabled); + + if (normalizedProviderId === member.providerId) { return member; } - if (member.providerId === 'codex' || member.providerId === 'gemini') { + + if ( + member.providerId === 'codex' || + member.providerId === 'gemini' || + normalizedProviderId !== member.providerId + ) { return { ...member, - providerId: 'anthropic', + providerId: normalizedProviderId, model: '', }; } @@ -200,7 +210,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning } const model = member.model?.trim(); if (model) { - result.model = model; + result.model = normalizeTeamModelForUi(providerId, model); } const effort = normalizeDraftEffort(member.effort); if (effort) { diff --git a/src/renderer/utils/geminiUiFreeze.ts b/src/renderer/utils/geminiUiFreeze.ts new file mode 100644 index 00000000..52653672 --- /dev/null +++ b/src/renderer/utils/geminiUiFreeze.ts @@ -0,0 +1,57 @@ +import type { CliProviderId } from '@shared/types/cliInstaller'; +import type { TeamProviderId } from '@shared/types'; + +export const GEMINI_UI_FROZEN = true; +export const GEMINI_UI_DISABLED_REASON = 'Gemini in development'; +export const GEMINI_UI_DISABLED_BADGE_LABEL = 'In development'; + +export function isGeminiUiFrozen(): boolean { + return GEMINI_UI_FROZEN; +} + +export function isGeminiProviderId( + providerId: CliProviderId | TeamProviderId | undefined +): providerId is 'gemini' { + return providerId === 'gemini'; +} + +export function filterMainScreenCliProviders( + providers: readonly T[] +): T[] { + if (!GEMINI_UI_FROZEN) { + return [...providers]; + } + + return providers.filter((provider) => provider.providerId !== 'gemini'); +} + +export function normalizeCreateLaunchProviderForUi( + providerId: TeamProviderId | undefined, + multimodelEnabled: boolean +): TeamProviderId { + if (!multimodelEnabled) { + return 'anthropic'; + } + + // return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; + if (providerId === 'codex') { + return 'codex'; + } + if (providerId === 'gemini' && GEMINI_UI_FROZEN) { + return 'anthropic'; + } + return providerId === 'anthropic' ? 'anthropic' : 'anthropic'; +} + +export function isCreateLaunchProviderDisabled( + providerId: TeamProviderId, + multimodelEnabled: boolean +): boolean { + if (providerId === 'gemini' && GEMINI_UI_FROZEN) { + return true; + } + if (!multimodelEnabled && providerId !== 'anthropic') { + return true; + } + return false; +} diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts new file mode 100644 index 00000000..f322bb65 --- /dev/null +++ b/src/renderer/utils/teamModelAvailability.ts @@ -0,0 +1,30 @@ +import type { TeamProviderId } from '@shared/types'; + +export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled'; +export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini'; +export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON = + 'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.'; + +export function getTeamModelUiDisabledReason( + providerId: TeamProviderId | undefined, + model: string | undefined +): string | null { + if (providerId === 'codex' && model === GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL) { + return GPT_5_1_CODEX_MINI_UI_DISABLED_REASON; + } + return null; +} + +export function isTeamModelUiDisabled( + providerId: TeamProviderId | undefined, + model: string | undefined +): boolean { + return getTeamModelUiDisabledReason(providerId, model) !== null; +} + +export function normalizeTeamModelForUi( + providerId: TeamProviderId | undefined, + model: string | undefined +): string { + return isTeamModelUiDisabled(providerId, model) ? '' : (model ?? ''); +} diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts index b6a5078a..60aaa305 100644 --- a/test/renderer/components/team/TeamModelSelector.test.ts +++ b/test/renderer/components/team/TeamModelSelector.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { + GPT_5_1_CODEX_MINI_UI_DISABLED_REASON, + getTeamModelUiDisabledReason, + normalizeTeamModelForUi, +} from '@renderer/utils/teamModelAvailability'; describe('formatTeamModelSummary', () => { it('shows cross-provider Anthropic models as backend-routed instead of brand-mismatched', () => { @@ -12,4 +17,17 @@ describe('formatTeamModelSummary', () => { it('keeps native Codex-family models branded normally', () => { expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('GPT-5.4 ยท Medium'); }); + + it('marks GPT-5.1 Codex Mini as disabled only for Codex team selection', () => { + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.1-codex-mini')).toBe( + GPT_5_1_CODEX_MINI_UI_DISABLED_REASON + ); + expect(getTeamModelUiDisabledReason('codex', 'gpt-5.4-mini')).toBeNull(); + expect(getTeamModelUiDisabledReason('anthropic', 'gpt-5.1-codex-mini')).toBeNull(); + }); + + it('normalizes disabled Codex model selections back to default', () => { + expect(normalizeTeamModelForUi('codex', 'gpt-5.1-codex-mini')).toBe(''); + expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini'); + }); }); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts new file mode 100644 index 00000000..185f40a5 --- /dev/null +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -0,0 +1,86 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: unknown) => unknown) => + selector({ + cliStatus: null, + appConfig: { general: { multimodelEnabled: true } }, + }), +})); + +import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { GPT_5_1_CODEX_MINI_UI_DISABLED_REASON } from '@renderer/utils/teamModelAvailability'; + +describe('TeamModelSelector disabled Codex models', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders GPT-5.1 Codex Mini as disabled with an explanation tooltip', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('GPT-5.1 Codex Mini'); + expect(host.textContent).toContain('Disabled'); + expect(host.textContent).toContain(GPT_5_1_CODEX_MINI_UI_DISABLED_REASON); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('normalizes a stale disabled selection back to default', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onValueChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'codex', + onProviderChange: () => undefined, + value: 'gpt-5.1-codex-mini', + onValueChange, + }) + ); + await Promise.resolve(); + }); + + expect(onValueChange).toHaveBeenCalledWith(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/utils/geminiUiFreeze.test.ts b/test/renderer/utils/geminiUiFreeze.test.ts new file mode 100644 index 00000000..e6e72d05 --- /dev/null +++ b/test/renderer/utils/geminiUiFreeze.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterMainScreenCliProviders, + normalizeCreateLaunchProviderForUi, +} from '@renderer/utils/geminiUiFreeze'; + +describe('geminiUiFreeze', () => { + it('hides gemini from the dashboard provider list', () => { + expect( + filterMainScreenCliProviders([ + { providerId: 'anthropic', label: 'Anthropic' }, + { providerId: 'codex', label: 'Codex' }, + { providerId: 'gemini', label: 'Gemini' }, + ]) + ).toEqual([ + { providerId: 'anthropic', label: 'Anthropic' }, + { providerId: 'codex', label: 'Codex' }, + ]); + }); + + it('falls back to anthropic when a create or launch form receives gemini', () => { + expect(normalizeCreateLaunchProviderForUi('gemini', true)).toBe('anthropic'); + }); + + it('keeps codex available when multimodel is enabled', () => { + expect(normalizeCreateLaunchProviderForUi('codex', true)).toBe('codex'); + }); +});