feat(team-ui): freeze unstable provider and model options
This commit is contained in:
parent
433bdf8bbc
commit
a03c22aace
14 changed files with 455 additions and 88 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
57
src/renderer/utils/geminiUiFreeze.ts
Normal file
57
src/renderer/utils/geminiUiFreeze.ts
Normal 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;
|
||||
}
|
||||
30
src/renderer/utils/teamModelAvailability.ts
Normal file
30
src/renderer/utils/teamModelAvailability.ts
Normal 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 ?? '');
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
29
test/renderer/utils/geminiUiFreeze.test.ts
Normal file
29
test/renderer/utils/geminiUiFreeze.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue