agent-ecosystem/src/renderer/components/team/dialogs/EditTeamDialog.tsx
2026-05-05 10:35:33 +03:00

775 lines
28 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
import {
buildMembersFromDrafts,
createMemberDraft,
createMemberDraftsFromInputs,
filterEditableMemberInputs,
MembersEditorSection,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import {
agentAvatarUrl,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { Loader2 } from 'lucide-react';
import {
buildEditTeamMemberRosterSnapshot,
buildEditTeamSourceSnapshot,
getLiveRosterIdentityChanges,
getMemberRuntimeContractKey,
getMembersRequiringRuntimeRestart,
} from './editTeamRuntimeChanges';
import type { ResolvedTeamMember } from '@shared/types';
const TEAM_COLOR_NAMES = [
'blue',
'green',
'red',
'yellow',
'purple',
'cyan',
'orange',
'pink',
] as const;
interface EditTeamDialogProps {
open: boolean;
teamName: string;
currentName: string;
currentDescription: string;
currentColor: string;
currentMembers: ResolvedTeamMember[];
leadMember?: ResolvedTeamMember | null;
resolvedMemberColorMap?: ReadonlyMap<string, string>;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
projectPath?: string | null;
onClose: () => void;
onChangeLeadRuntime: () => void;
onSaved: () => Promise<void> | void;
}
function membersToDrafts(members: ResolvedTeamMember[]) {
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
}
function deriveTeammateWorktreeDefault(members: readonly ResolvedTeamMember[]): boolean {
const activeTeammates = filterEditableMemberInputs(members).filter((member) => !member.removedAt);
return (
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
);
}
function useEditTeamErrorReset(
setError: (value: string | null) => void,
setSaveOutcomeError: (value: string | null) => void
): () => void {
return () => {
setError(null);
setSaveOutcomeError(null);
};
}
function getInvalidMemberNamesError(
members: readonly {
name: string;
removedAt?: number | string | null;
}[]
): string | null {
for (const member of members) {
if (member.removedAt) {
continue;
}
const name = member.name.trim();
if (!name) {
return 'Member name cannot be empty';
}
if (validateMemberNameInline(name) !== null) {
return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
const lower = name.toLowerCase();
if (lower === 'user' || lower === 'team-lead') {
return `Member name "${name}" is reserved`;
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
}
}
return null;
}
function applyRemovedMembersToSnapshot(
members: readonly ResolvedTeamMember[],
removedMemberNames: readonly string[]
): ResolvedTeamMember[] {
if (removedMemberNames.length === 0) {
return [...members];
}
const removedKeys = new Set(removedMemberNames.map((name) => name.trim().toLowerCase()));
const removedAt = Date.now();
return members.map((member) =>
removedKeys.has(member.name.trim().toLowerCase()) ? { ...member, removedAt } : member
);
}
export const EditTeamDialog = ({
open,
teamName,
currentName,
currentDescription,
currentColor,
currentMembers,
leadMember = null,
resolvedMemberColorMap,
isTeamAlive = false,
isTeamProvisioning = false,
projectPath,
onClose,
onChangeLeadRuntime,
onSaved,
}: EditTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
const [name, setName] = useState(currentName);
const [description, setDescription] = useState(currentDescription);
const [color, setColor] = useState(currentColor);
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(() =>
deriveTeammateWorktreeDefault(currentMembers)
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState<
Record<string, string>
>({});
const wasOpenRef = useRef(false);
const initializedTeamNameRef = useRef<string | null>(null);
const baselineSourceSnapshotRef = useRef<string | null>(null);
const pendingCommittedSourceSnapshotRef = useRef<string | null>(null);
useFileListCacheWarmer(projectPath ?? null);
const clearTransientErrors = useEditTeamErrorReset(setError, setSaveOutcomeError);
const effectiveResolvedMemberColorMap = useMemo(
() => resolvedMemberColorMap ?? buildMemberColorMap(currentMembers),
[currentMembers, resolvedMemberColorMap]
);
const leadDraft = useMemo(() => {
if (!leadMember) return null;
return createMemberDraft({
id: `lead:${leadMember.name}`,
name: displayMemberName(leadMember.name),
originalName: leadMember.name,
roleSelection: '',
customRole: 'Team Lead',
workflow: leadMember.workflow,
providerId: leadMember.providerId,
model: leadMember.model ?? '',
effort: leadMember.effort,
});
}, [leadMember]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
if (open) {
const shouldInitialize = !wasOpen || initializedTeamNameRef.current !== teamName;
if (shouldInitialize) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(currentMembers));
setError(null);
setSaveOutcomeError(null);
setMembersPendingRestartRetry({});
initializedTeamNameRef.current = teamName;
baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
pendingCommittedSourceSnapshotRef.current = null;
} else if (pendingCommittedSourceSnapshotRef.current !== null) {
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
if (latestSourceSnapshot === pendingCommittedSourceSnapshotRef.current) {
baselineSourceSnapshotRef.current = latestSourceSnapshot;
pendingCommittedSourceSnapshotRef.current = null;
}
}
} else if (wasOpen) {
initializedTeamNameRef.current = null;
baselineSourceSnapshotRef.current = null;
pendingCommittedSourceSnapshotRef.current = null;
}
wasOpenRef.current = open;
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
const hasDuplicateMembers = useMemo(() => {
const names = members
.filter((member) => !member.removedAt)
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(names).size !== names.length;
}, [members]);
const membersToRestart = useMemo(
() =>
isTeamAlive
? getMembersRequiringRuntimeRestart({
previousMembers: currentMembers,
nextMembers: builtMembers,
})
: [],
[builtMembers, currentMembers, isTeamAlive]
);
const builtMembersByName = useMemo(
() =>
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
[builtMembers]
);
const currentMemberRosterSnapshot = useMemo(
() => buildEditTeamMemberRosterSnapshot(currentMembers),
[currentMembers]
);
const nextMemberRosterSnapshot = useMemo(
() => buildEditTeamMemberRosterSnapshot(builtMembers),
[builtMembers]
);
const hasMemberRosterChanges = currentMemberRosterSnapshot !== nextMemberRosterSnapshot;
const currentMembersByName = useMemo(
() =>
new Map(currentMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
[currentMembers]
);
const isLiveMixedOpenCodeSideLaneTeam = useMemo(
() =>
isTeamAlive &&
leadMember?.providerId !== 'opencode' &&
currentMembers.some((member) => !member.removedAt && member.providerId === 'opencode'),
[currentMembers, isTeamAlive, leadMember?.providerId]
);
const effectiveMembersToRestart = useMemo(() => {
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
([normalizedName, expectedRuntimeContractKey]) => {
const nextMember = builtMembersByName.get(normalizedName);
if (!nextMember) {
return [];
}
return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey
? [nextMember.name.trim()]
: [];
}
);
return Array.from(
new Set(
[...membersToRestart, ...retryMembers]
.map((memberName) => memberName.trim())
.filter((memberName) => {
const nextMember = builtMembersByName.get(memberName.toLowerCase());
return nextMember?.providerId !== 'opencode';
})
.filter(Boolean)
)
);
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
const openCodeMembersHandledByLiveRoster = useMemo(() => {
if (!isTeamAlive) {
return [];
}
return Array.from(
new Set(
membersToRestart
.map((memberName) => memberName.trim())
.filter((memberName) => {
const nextMember = builtMembersByName.get(memberName.toLowerCase());
return nextMember?.providerId === 'opencode';
})
.filter(Boolean)
)
);
}, [builtMembersByName, isTeamAlive, membersToRestart]);
const liveRuntimeRefreshMemberNames = useMemo(
() =>
Array.from(new Set([...effectiveMembersToRestart, ...openCodeMembersHandledByLiveRoster])),
[effectiveMembersToRestart, openCodeMembersHandledByLiveRoster]
);
const liveIdentityChanges = useMemo(
() =>
isTeamAlive
? getLiveRosterIdentityChanges({
previousMembers: currentMembers,
nextDrafts: members,
})
: { renamed: [], removed: [] },
[currentMembers, isTeamAlive, members]
);
const hasBlockedLiveIdentityChanges = liveIdentityChanges.renamed.length > 0;
const liveRemovedExistingMembers = useMemo(
() => (isTeamAlive ? liveIdentityChanges.removed : []),
[isTeamAlive, liveIdentityChanges.removed]
);
const unsupportedLiveMixedPrimaryRuntimeChangeNames = useMemo(() => {
if (!isLiveMixedOpenCodeSideLaneTeam) {
return [];
}
return membersToRestart.filter((memberName) => {
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
return nextMember?.providerId !== 'opencode';
});
}, [builtMembersByName, isLiveMixedOpenCodeSideLaneTeam, membersToRestart]);
const unsupportedLiveMixedPrimaryRemovalNames = useMemo(() => {
if (!isLiveMixedOpenCodeSideLaneTeam) {
return [];
}
return liveRemovedExistingMembers.filter((memberName) => {
const currentMember = currentMembersByName.get(memberName.trim().toLowerCase());
return currentMember?.providerId !== 'opencode';
});
}, [currentMembersByName, isLiveMixedOpenCodeSideLaneTeam, liveRemovedExistingMembers]);
const unsupportedLiveMixedPrimaryMutationNames = useMemo(
() =>
Array.from(
new Set([
...unsupportedLiveMixedPrimaryRuntimeChangeNames,
...unsupportedLiveMixedPrimaryRemovalNames,
])
),
[unsupportedLiveMixedPrimaryRemovalNames, unsupportedLiveMixedPrimaryRuntimeChangeNames]
);
const hasNewLiveTeammates = useMemo(
() =>
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
[isTeamAlive, members]
);
const memberWarningById = useMemo(() => {
const restartNames = new Set(
liveRuntimeRefreshMemberNames.map((memberName) => memberName.trim().toLowerCase())
);
if (restartNames.size === 0) {
return undefined;
}
return Object.fromEntries(
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, or effort changes.'
: null,
])
);
}, [liveRuntimeRefreshMemberNames, members]);
const handleSave = (): void => {
if (!name.trim()) {
setError('Team name cannot be empty');
return;
}
if (invalidMemberNamesError) {
setError(invalidMemberNamesError);
return;
}
if (hasDuplicateMembers) {
setError('Member names must be unique before saving');
return;
}
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
name: currentName,
description: currentDescription,
color: currentColor,
members: currentMembers,
});
const allowedSourceSnapshots = new Set(
[baselineSourceSnapshotRef.current, pendingCommittedSourceSnapshotRef.current].filter(
(value): value is string => value !== null
)
);
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
setError(
'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
);
return;
}
if (hasBlockedLiveIdentityChanges) {
setError(
`Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
);
return;
}
if (isTeamProvisioning) {
setError(
'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
);
return;
}
if (hasNewLiveTeammates) {
setError(
'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
);
return;
}
if (unsupportedLiveMixedPrimaryMutationNames.length > 0) {
setError(
`Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: ${unsupportedLiveMixedPrimaryMutationNames.join(', ')}`
);
return;
}
setSaving(true);
setError(null);
setSaveOutcomeError(null);
void (async () => {
let configSaved = false;
let membersSaved = false;
let refreshAfterSaveAttempted = false;
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
try {
await api.teams.updateConfig(teamName, {
name: name.trim(),
description: description.trim(),
color,
});
configSaved = true;
if (hasMemberRosterChanges) {
for (const removedMemberName of liveRemovedExistingMembers) {
await api.teams.removeMember(teamName, removedMemberName);
committedMembersForSnapshot = applyRemovedMembersToSnapshot(
committedMembersForSnapshot,
[removedMemberName]
);
}
await api.teams.replaceMembers(teamName, { members: builtMembers });
membersSaved = true;
}
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: builtMembers.map((member) => ({
name: member.name,
role: member.role,
workflow: member.workflow,
providerId: member.providerId,
model: member.model,
effort: member.effort,
isolation: member.isolation,
})) as ResolvedTeamMember[],
});
const restartFailures: string[] = [];
const failedRestartMembers: string[] = [];
for (const memberName of effectiveMembersToRestart) {
try {
await api.teams.restartMember(teamName, memberName);
} catch (restartError) {
const detail =
restartError instanceof Error ? restartError.message : String(restartError);
failedRestartMembers.push(memberName);
restartFailures.push(`${memberName} (${detail})`);
}
}
refreshAfterSaveAttempted = true;
await Promise.resolve(onSaved());
if (restartFailures.length === 0) {
setMembersPendingRestartRetry({});
onClose();
return;
}
setMembersPendingRestartRetry(
Object.fromEntries(
failedRestartMembers.flatMap((memberName) => {
const nextMember = builtMembersByName.get(memberName.trim().toLowerCase());
if (!nextMember) {
return [];
}
return [
[memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const,
];
})
)
);
setSaveOutcomeError(
`Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
);
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to save';
if (membersSaved) {
setSaveOutcomeError(
`Team changes were saved, but failed to refresh the latest view: ${message}`
);
} else if (configSaved) {
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
description: description.trim(),
color: color.trim(),
members: committedMembersForSnapshot,
});
if (refreshAfterSaveAttempted) {
setSaveOutcomeError(
`Team settings were saved, but failed to refresh the latest view: ${message}`
);
return;
}
let refreshErrorDetail: string | null = null;
try {
await Promise.resolve(onSaved());
} catch (refreshError) {
refreshErrorDetail =
refreshError instanceof Error ? refreshError.message : String(refreshError);
}
setSaveOutcomeError(
refreshErrorDetail
? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
: `Team settings were saved, but member changes failed: ${message}`
);
} else {
setError(message);
}
} finally {
setSaving(false);
}
})();
};
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Team</DialogTitle>
<DialogDescription>Change team name, description and color</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label
htmlFor="edit-team-name"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
Name
</label>
<input
id="edit-team-name"
type="text"
value={name}
onChange={(e) => {
clearTransientErrors();
setName(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
}}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team name"
/>
</div>
<div>
<label
htmlFor="edit-team-description"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
Description
</label>
<textarea
id="edit-team-description"
value={description}
onChange={(e) => {
clearTransientErrors();
setDescription(e.target.value);
}}
rows={3}
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team description (optional)"
/>
</div>
<div>
<MembersEditorSection
members={members}
onChange={(nextMembers) => {
clearTransientErrors();
setMembers(nextMembers);
}}
fieldError={invalidMemberNamesError ?? undefined}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor={!isTeamAlive}
draftKeyPrefix={`editTeam:${teamName}`}
projectPath={projectPath ?? null}
headerExtra={
leadDraft ? (
<div className="space-y-2">
<MemberDraftRow
member={leadDraft}
index={0}
avatarSrc={agentAvatarUrl('team-lead', 32)}
resolvedColor={effectiveResolvedMemberColorMap.get(
leadDraft.originalName ?? leadDraft.name
)}
nameError={null}
onNameChange={() => undefined}
onRoleChange={() => undefined}
onCustomRoleChange={() => undefined}
onRemove={() => undefined}
onProviderChange={() => undefined}
onModelChange={() => undefined}
onEffortChange={() => undefined}
projectPath={projectPath ?? null}
lockProviderModel
lockRole
lockedRoleLabel="Team Lead"
lockIdentity
hideActionButton
modelLockReason="Team lead runtime is managed from Relaunch Team."
lockedModelAction={{
label: 'Change lead runtime',
description:
'Open Relaunch Team to change the lead provider, model, or effort.',
onClick: onChangeLeadRuntime,
disabled: isTeamProvisioning,
}}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Team lead name and role stay read-only here. Open the runtime panel on the
lead row to change provider, model, or effort.
</p>
</div>
) : null
}
existingMembers={currentMembers}
existingMemberColorMap={effectiveResolvedMemberColorMap}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
lockProviderModel={false}
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
disableAddMember={isTeamAlive}
addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
memberWarningById={memberWarningById}
disableGeminiOption={isGeminiUiFrozen()}
/>
</div>
{isTeamProvisioning ? (
<p className="text-xs text-amber-300">
Team provisioning is still in progress. Editing is temporarily locked until launch
finishes.
</p>
) : null}
{isTeamAlive && hasNewLiveTeammates ? (
<p className="text-xs text-red-300">
New teammates cannot be added from Edit Team while the team is live. Use the Add
member dialog instead.
</p>
) : null}
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
<p className="text-xs text-red-300">
Live save is blocked because existing teammates were renamed. Revert those identity
changes or stop the team first.
</p>
) : null}
{unsupportedLiveMixedPrimaryMutationNames.length > 0 ? (
<p className="text-xs text-red-300">
Live edits/removals for primary-owned teammates in mixed OpenCode teams require
stopping and relaunching the team:{' '}
{unsupportedLiveMixedPrimaryMutationNames.join(', ')}.
</p>
) : null}
{isTeamAlive && liveRuntimeRefreshMemberNames.length > 0 ? (
<p className="text-xs text-amber-300">
Saving will restart or relaunch{' '}
{liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
{liveRuntimeRefreshMemberNames.join(', ')}.
</p>
) : null}
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
<label className="label-optional mb-1 block text-xs font-medium">
Color (optional)
</label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = color === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: getThemedBadge(colorSet, isLight),
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => {
clearTransientErrors();
setColor(isSelected ? '' : colorName);
}}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div>
</div>
{(error || saveOutcomeError) && (
<p className="text-xs text-red-400">{error ?? saveOutcomeError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={
saving ||
isTeamProvisioning ||
!name.trim() ||
hasDuplicateMembers ||
Boolean(invalidMemberNamesError) ||
unsupportedLiveMixedPrimaryMutationNames.length > 0
}
>
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};