775 lines
28 KiB
TypeScript
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>
|
|
);
|
|
};
|