feat(team-ui): freeze unstable provider and model options

This commit is contained in:
iliya 2026-04-10 12:28:22 +03:00
parent 433bdf8bbc
commit a03c22aace
14 changed files with 455 additions and 88 deletions

View file

@ -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<ReturnType<typeof useCliInstaller>['cliStatus']>
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['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<ReturnType<typeof useCliInstaller>['cliStatus']>
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['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 (
<div
@ -498,12 +505,12 @@ const InstalledBanner = ({
Failed to check for updates. Check your network connection and try again.
</p>
)}
{cliStatus.providers.length > 0 && (
{visibleProviders.length > 0 && (
<div
className="mt-3 space-y-2 border-t pt-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
{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<CliProviderId>('gemini');
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('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 => {
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={cliStatus.providers}
initialProviderId={manageProviderId}
providers={visibleCliProviders}
initialProviderId={
visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
? manageProviderId
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
}
providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
onSelectBackend={(providerId, backendId) => {

View file

@ -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={
<div className="flex items-center gap-2">
<Checkbox

View file

@ -36,6 +36,11 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import {
isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import {
getCurrentProvisioningProgressForTeam,
@ -140,7 +145,11 @@ function getLocalTimezone(): string {
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 {
@ -148,7 +157,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return stored === '__default__' ? '' : stored;
return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getProviderLabel(providerId: TeamProviderId): string {
@ -401,8 +410,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
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 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()}
/>
<div className="space-y-1.5">
@ -1678,6 +1691,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
disableGeminiOption={isGeminiUiFrozen()}
/>
<EffortLevelSelector
value={selectedEffort}

View file

@ -9,6 +9,16 @@ import {
} from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import {
GEMINI_UI_DISABLED_BADGE_LABEL,
GEMINI_UI_DISABLED_REASON,
isGeminiUiFrozen,
} from '@renderer/utils/geminiUiFreeze';
import {
getTeamModelUiDisabledReason,
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelAvailability';
import { Check, ChevronDown, Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@ -45,6 +55,7 @@ interface ProviderDef {
const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
{ id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
// { id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
{ id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
];
@ -170,6 +181,7 @@ export interface TeamModelSelectorProps {
value: string;
onValueChange: (value: string) => void;
id?: string;
disableGeminiOption?: boolean;
}
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
@ -178,6 +190,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
label: getTeamModelLabel(model),
}));
return [{ value: '', label: 'Default' }, ...dynamicOptions];
}, [providerId, runtimeModels]);
}, [effectiveProviderId, runtimeModels]);
return (
<div className="mb-5">
@ -317,11 +345,21 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Coming Soon
</span>
)}
{!provider.comingSoon && !isProviderSelectable(provider.id) && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Multimodel off
{!provider.comingSoon && isProviderTemporarilyDisabled(provider.id) && (
<span
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={GEMINI_UI_DISABLED_REASON}
>
{GEMINI_UI_DISABLED_BADGE_LABEL}
</span>
)}
{!provider.comingSoon &&
!isProviderTemporarilyDisabled(provider.id) &&
!isProviderSelectable(provider.id) && (
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
Multimodel off
</span>
)}
{isActive && <Check className="size-3.5 shrink-0" />}
</button>
</React.Fragment>
@ -335,52 +373,97 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Codex and Gemini require Multimodel mode.
</p>
)}
{disableGeminiOption && isGeminiUiFrozen() && (
<p className="text-[11px] text-[var(--color-text-muted)]">{GEMINI_UI_DISABLED_REASON}.</p>
)}
<div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{modelOptions.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
!activeProviderSelectable && 'cursor-not-allowed opacity-45'
)}
style={{
borderColor: value === opt.value ? 'var(--color-border-emphasis)' : 'transparent',
}}
disabled={!activeProviderSelectable}
onClick={() => {
if (!activeProviderSelectable) return;
onValueChange(opt.value);
}}
>
<span className="leading-tight">{opt.label}</span>
{opt.value === '' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</button>
))}
{modelOptions.map((opt) =>
(() => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
opt.value
);
const modelSelectable = activeProviderSelectable && !modelDisabledReason;
return (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
normalizedValue === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
style={{
borderColor:
normalizedValue === opt.value
? 'var(--color-border-emphasis)'
: 'transparent',
}}
onClick={() => {
if (!modelSelectable) return;
onValueChange(opt.value);
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span className="leading-tight">{opt.label}</span>
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
>
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelDisabledReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
</span>
</button>
);
})()
)}
</div>
</div>
</div>

View file

@ -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}
/>
<EffortLevelSelector
value={effort ?? ''}

View file

@ -52,6 +52,7 @@ interface MemberDraftRowProps {
isRemoved?: boolean;
onRestore?: (id: string) => 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}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}

View file

@ -100,6 +100,7 @@ export interface MembersEditorSectionProps {
modelLockReason?: string;
softDeleteMembers?: boolean;
memberWarningById?: Record<string, string | null | undefined>;
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}
/>
))}
</div>

View file

@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps {
softDeleteMembers?: boolean;
leadWarningText?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
}
export const TeamRosterEditorSection = ({
@ -81,6 +82,7 @@ export const TeamRosterEditorSection = ({
softDeleteMembers = false,
leadWarningText,
memberWarningById,
disableGeminiOption = false,
}: TeamRosterEditorSectionProps): React.JSX.Element => {
return (
<MembersEditorSection
@ -105,6 +107,7 @@ export const TeamRosterEditorSection = ({
forceInheritedModelSettings={forceInheritedModelSettings}
modelLockReason={modelLockReason}
softDeleteMembers={softDeleteMembers}
disableGeminiOption={disableGeminiOption}
headerExtra={
<div className="space-y-3">
{headerTop}
@ -120,6 +123,7 @@ export const TeamRosterEditorSection = ({
syncModelsWithTeammates={syncModelsWithTeammates}
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText}
disableGeminiOption={disableGeminiOption}
/>
{headerBottom}
</div>

View file

@ -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>): 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) {

View file

@ -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<T extends { providerId: CliProviderId }>(
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;
}

View file

@ -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 ?? '');
}

View file

@ -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');
});
});

View file

@ -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();
});
});
});

View file

@ -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');
});
});