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 { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatBytes } from '@renderer/utils/formatters'; import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
@ -338,18 +339,19 @@ function formatRuntimeLabel(
} }
function formatRuntimeAuthSummary( function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']> cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[]
): string | null { ): string | null {
if (cliStatus.flavor === 'free-code' && cliStatus.providers.length > 0) { if (cliStatus.flavor === 'free-code' && visibleProviders.length > 0) {
if ( if (
cliStatus.providers.every( visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
) )
) { ) {
return 'Checking providers...'; return 'Checking providers...';
} }
const denominator = cliStatus.providers.length; const denominator = visibleProviders.length;
const connected = cliStatus.providers.filter((provider) => provider.authenticated).length; const connected = visibleProviders.filter((provider) => provider.authenticated).length;
return `Providers: ${connected}/${denominator} connected`; return `Providers: ${connected}/${denominator} connected`;
} }
@ -366,12 +368,13 @@ function formatRuntimeAuthSummary(
} }
function isCheckingMultimodelStatus( function isCheckingMultimodelStatus(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']> cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[]
): boolean { ): boolean {
return ( return (
cliStatus.flavor === 'free-code' && cliStatus.flavor === 'free-code' &&
cliStatus.providers.length > 0 && visibleProviders.length > 0 &&
cliStatus.providers.every( visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
) )
); );
@ -396,8 +399,12 @@ const InstalledBanner = ({
}: InstalledBannerProps): React.JSX.Element => { }: InstalledBannerProps): React.JSX.Element => {
const openExtensionsTab = useStore((s) => s.openExtensionsTab); const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const styles = VARIANT_STYLES[variant]; const styles = VARIANT_STYLES[variant];
const visibleProviders = useMemo(
() => filterMainScreenCliProviders(cliStatus.providers),
[cliStatus.providers]
);
const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeLabel = formatRuntimeLabel(cliStatus);
const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
return ( return (
<div <div
@ -498,12 +505,12 @@ const InstalledBanner = ({
Failed to check for updates. Check your network connection and try again. Failed to check for updates. Check your network connection and try again.
</p> </p>
)} )}
{cliStatus.providers.length > 0 && ( {visibleProviders.length > 0 && (
<div <div
className="mt-3 space-y-2 border-t pt-3" className="mt-3 space-y-2 border-t pt-3"
style={{ borderColor: 'var(--color-border-subtle)' }} style={{ borderColor: 'var(--color-border-subtle)' }}
> >
{cliStatus.providers.map((provider) => { {visibleProviders.map((provider) => {
const statusText = formatProviderStatus(provider); const statusText = formatProviderStatus(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath; const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = getProviderRuntimeBackendSummary(provider); const runtimeSummary = getProviderRuntimeBackendSummary(provider);
@ -661,12 +668,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerId: CliProviderId; providerId: CliProviderId;
action: 'login' | 'logout'; action: 'login' | 'logout';
} | null>(null); } | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini'); const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
const [manageDialogOpen, setManageDialogOpen] = useState(false); const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const visibleCliProviders = useMemo(
() => filterMainScreenCliProviders(cliStatus?.providers ?? []),
[cliStatus?.providers]
);
useEffect(() => { useEffect(() => {
if (!isElectron) return; if (!isElectron) return;
@ -799,7 +810,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (installerState === 'completed') return 'success'; if (installerState === 'completed') return 'success';
if (installerState !== 'idle') return 'info'; if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading'; if (!cliStatus) return 'loading';
if (isCheckingMultimodelStatus(cliStatus)) return 'info'; if (isCheckingMultimodelStatus(cliStatus, visibleCliProviders)) return 'info';
if (cliStatus.authStatusChecking) return 'info'; if (cliStatus.authStatusChecking) return 'info';
if (!cliStatus.installed) return 'error'; if (!cliStatus.installed) return 'error';
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
@ -1293,8 +1304,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<ProviderRuntimeSettingsDialog <ProviderRuntimeSettingsDialog
open={manageDialogOpen} open={manageDialogOpen}
onOpenChange={setManageDialogOpen} onOpenChange={setManageDialogOpen}
providers={cliStatus.providers} providers={visibleCliProviders}
initialProviderId={manageProviderId} initialProviderId={
visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
? manageProviderId
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
}
providerStatusLoading={cliProviderStatusLoading} providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath} disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
onSelectBackend={(providerId, backendId) => { onSelectBackend={(providerId, backendId) => {

View file

@ -36,7 +36,12 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import {
isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi,
} from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@ -81,7 +86,11 @@ import type {
function getStoredTeamProvider(): TeamProviderId { function getStoredTeamProvider(): TeamProviderId {
const stored = localStorage.getItem('team:lastSelectedProvider'); 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 { function getStoredTeamModel(providerId: TeamProviderId): string {
@ -89,7 +98,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) { if (stored === null) {
return providerId === 'anthropic' ? 'opus' : ''; return providerId === 'anthropic' ? 'opus' : '';
} }
return stored === '__default__' ? '' : stored; return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
} }
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
@ -368,8 +377,9 @@ export const CreateTeamDialog = ({
}, [advancedKey]); }, [advancedKey]);
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value); const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
}; };
const setSelectedProviderId = (value: TeamProviderId): void => { const setSelectedProviderId = (value: TeamProviderId): void => {
@ -1160,6 +1170,7 @@ export const CreateTeamDialog = ({
onLimitContextChange={setLimitContext} onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead} syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
disableGeminiOption={isGeminiUiFrozen()}
headerTop={ headerTop={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox

View file

@ -36,6 +36,11 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; 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 { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { import {
getCurrentProvisioningProgressForTeam, getCurrentProvisioningProgressForTeam,
@ -140,7 +145,11 @@ function getLocalTimezone(): string {
function getStoredTeamProvider(): TeamProviderId { function getStoredTeamProvider(): TeamProviderId {
const stored = localStorage.getItem('team:lastSelectedProvider'); 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 { function getStoredTeamModel(providerId: TeamProviderId): string {
@ -148,7 +157,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) { if (stored === null) {
return providerId === 'anthropic' ? 'opus' : ''; return providerId === 'anthropic' ? 'opus' : '';
} }
return stored === '__default__' ? '' : stored; return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
} }
function getProviderLabel(providerId: TeamProviderId): string { function getProviderLabel(providerId: TeamProviderId): string {
@ -401,8 +410,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}; };
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value); const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value); setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
}; };
const setLimitContext = (value: boolean): void => { const setLimitContext = (value: boolean): void => {
@ -496,7 +506,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
setSelectedProviderIdRaw(scheduleProviderId); setSelectedProviderIdRaw(scheduleProviderId);
setSelectedModelRaw( setSelectedModelRaw(
scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) schedule.launchConfig.providerId !== 'gemini' &&
scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true)
? (schedule.launchConfig.model ?? '') ? (schedule.launchConfig.model ?? '')
: getStoredTeamModel('anthropic') : getStoredTeamModel('anthropic')
); );
@ -576,6 +587,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
setSelectedModelRaw( setSelectedModelRaw(
typeof savedRequest?.model === 'string' && typeof savedRequest?.model === 'string' &&
rawNextProviderId !== 'gemini' &&
nextProviderId === normalizeProviderForMode(rawNextProviderId, true) nextProviderId === normalizeProviderForMode(rawNextProviderId, true)
? savedRequest.model ? savedRequest.model
: getStoredTeamModel( : getStoredTeamModel(
@ -1539,6 +1551,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
leadWarningText={leadRuntimeWarningText} leadWarningText={leadRuntimeWarningText}
memberWarningById={memberRuntimeWarningById} memberWarningById={memberRuntimeWarningById}
softDeleteMembers softDeleteMembers
disableGeminiOption={isGeminiUiFrozen()}
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -1678,6 +1691,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
value={selectedModel} value={selectedModel}
onValueChange={setSelectedModel} onValueChange={setSelectedModel}
id="dialog-model" id="dialog-model"
disableGeminiOption={isGeminiUiFrozen()}
/> />
<EffortLevelSelector <EffortLevelSelector
value={selectedEffort} value={selectedEffort}

View file

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

View file

@ -29,6 +29,7 @@ interface LeadModelRowProps {
syncModelsWithTeammates: boolean; syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void; onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null; warningText?: string | null;
disableGeminiOption?: boolean;
} }
export const LeadModelRow = ({ export const LeadModelRow = ({
@ -43,6 +44,7 @@ export const LeadModelRow = ({
syncModelsWithTeammates, syncModelsWithTeammates,
onSyncModelsWithTeammatesChange, onSyncModelsWithTeammatesChange,
warningText, warningText,
disableGeminiOption = false,
}: LeadModelRowProps): React.JSX.Element => { }: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false);
@ -120,6 +122,7 @@ export const LeadModelRow = ({
value={model} value={model}
onValueChange={onModelChange} onValueChange={onModelChange}
id="lead-model" id="lead-model"
disableGeminiOption={disableGeminiOption}
/> />
<EffortLevelSelector <EffortLevelSelector
value={effort ?? ''} value={effort ?? ''}

View file

@ -52,6 +52,7 @@ interface MemberDraftRowProps {
isRemoved?: boolean; isRemoved?: boolean;
onRestore?: (id: string) => void; onRestore?: (id: string) => void;
warningText?: string | null; warningText?: string | null;
disableGeminiOption?: boolean;
} }
export const MemberDraftRow = ({ export const MemberDraftRow = ({
@ -83,6 +84,7 @@ export const MemberDraftRow = ({
isRemoved = false, isRemoved = false,
onRestore, onRestore,
warningText, warningText,
disableGeminiOption = false,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const memberColorSet = getTeamColorSet( const memberColorSet = getTeamColorSet(
@ -344,6 +346,7 @@ export const MemberDraftRow = ({
onModelChange(member.id, value); onModelChange(member.id, value);
}} }}
id={`member-${member.id}-model`} id={`member-${member.id}-model`}
disableGeminiOption={disableGeminiOption}
/> />
<EffortLevelSelector <EffortLevelSelector
value={effectiveEffort ?? ''} value={effectiveEffort ?? ''}

View file

@ -100,6 +100,7 @@ export interface MembersEditorSectionProps {
modelLockReason?: string; modelLockReason?: string;
softDeleteMembers?: boolean; softDeleteMembers?: boolean;
memberWarningById?: Record<string, string | null | undefined>; memberWarningById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean;
} }
export const MembersEditorSection = ({ export const MembersEditorSection = ({
@ -126,6 +127,7 @@ export const MembersEditorSection = ({
modelLockReason, modelLockReason,
softDeleteMembers = false, softDeleteMembers = false,
memberWarningById, memberWarningById,
disableGeminiOption = false,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
@ -313,6 +315,7 @@ export const MembersEditorSection = ({
lockProviderModel={lockProviderModel} lockProviderModel={lockProviderModel}
modelLockReason={modelLockReason} modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null} warningText={memberWarningById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}
/> />
))} ))}
{softDeleteMembers && removedMembers.length > 0 ? ( {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." modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved isRemoved
warningText={null} warningText={null}
disableGeminiOption={disableGeminiOption}
/> />
))} ))}
</div> </div>

View file

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

View file

@ -1,4 +1,6 @@
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; 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 { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -28,14 +30,15 @@ function newDraftId(): string {
} }
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft { export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
const providerId = initial?.providerId;
return { return {
id: initial?.id ?? newDraftId(), id: initial?.id ?? newDraftId(),
name: initial?.name ?? '', name: initial?.name ?? '',
roleSelection: initial?.roleSelection ?? '', roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '', customRole: initial?.customRole ?? '',
workflow: initial?.workflow, workflow: initial?.workflow,
providerId: initial?.providerId, providerId,
model: initial?.model ?? '', model: normalizeTeamModelForUi(providerId, initial?.model ?? ''),
effort: initial?.effort, effort: initial?.effort,
removedAt: initial?.removedAt, removedAt: initial?.removedAt,
}; };
@ -84,23 +87,30 @@ export function normalizeProviderForMode(
providerId: TeamProviderId | undefined, providerId: TeamProviderId | undefined,
multimodelEnabled: boolean multimodelEnabled: boolean
): TeamProviderId { ): TeamProviderId {
if (multimodelEnabled && (providerId === 'codex' || providerId === 'gemini')) { return normalizeCreateLaunchProviderForUi(providerId, multimodelEnabled);
return providerId;
}
return 'anthropic';
} }
export function normalizeMemberDraftForProviderMode( export function normalizeMemberDraftForProviderMode(
member: MemberDraft, member: MemberDraft,
multimodelEnabled: boolean multimodelEnabled: boolean
): MemberDraft { ): MemberDraft {
if (multimodelEnabled) { const normalizedProviderId =
member.providerId == null
? undefined
: normalizeCreateLaunchProviderForUi(member.providerId, multimodelEnabled);
if (normalizedProviderId === member.providerId) {
return member; return member;
} }
if (member.providerId === 'codex' || member.providerId === 'gemini') {
if (
member.providerId === 'codex' ||
member.providerId === 'gemini' ||
normalizedProviderId !== member.providerId
) {
return { return {
...member, ...member,
providerId: 'anthropic', providerId: normalizedProviderId,
model: '', model: '',
}; };
} }
@ -200,7 +210,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
} }
const model = member.model?.trim(); const model = member.model?.trim();
if (model) { if (model) {
result.model = model; result.model = normalizeTeamModelForUi(providerId, model);
} }
const effort = normalizeDraftEffort(member.effort); const effort = normalizeDraftEffort(member.effort);
if (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 { describe, expect, it } from 'vitest';
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; 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', () => { describe('formatTeamModelSummary', () => {
it('shows cross-provider Anthropic models as backend-routed instead of brand-mismatched', () => { 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', () => { it('keeps native Codex-family models branded normally', () => {
expect(formatTeamModelSummary('codex', 'gpt-5.4', 'medium')).toBe('GPT-5.4 · Medium'); 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');
});
});