agent-ecosystem/src/renderer/components/team/dialogs/CreateTeamDialog.tsx

2620 lines
96 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import {
buildCodexFastModeArgs,
reconcileCodexRuntimeSelections,
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
import { api } from '@renderer/api';
import {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraft,
normalizeLeadProviderForMode,
normalizeMemberDraftForProviderMode,
validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import {
applyStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamEffort,
getStoredCreateTeamFastMode as getStoredTeamFastMode,
getStoredCreateTeamLimitContext,
getStoredCreateTeamMemberRuntimePreferences,
getStoredCreateTeamModel as getStoredTeamModel,
getStoredCreateTeamProvider as getStoredTeamProvider,
getStoredCreateTeamSkipPermissions,
migrateLegacyCreateTeamPreferences,
setStoredCreateTeamEffort,
setStoredCreateTeamFastMode,
setStoredCreateTeamLimitContext,
setStoredCreateTeamMemberRuntimePreferences,
setStoredCreateTeamModel,
setStoredCreateTeamProvider,
setStoredCreateTeamSkipPermissions,
} from '@renderer/services/createTeamPreferences';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
import {
getTeamModelSelectionError,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
import {
clearInheritedMemberModelsUnavailableForProvider,
resolveProviderScopedMemberModel,
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import {
isDeletedProjectPathSelection,
isSelectableProjectPathProject,
} from './projectPathOptions';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
mergeReusableProviderPrepareModelResults,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
} from './providerPrepareDiagnostics';
import { buildProviderPreparePlans, type ProviderPreparePlan } from './providerPreparePlans';
import {
buildProviderPrepareModelChecksSignature,
buildProviderPrepareRuntimeStatusSignature,
} from './providerPrepareRequestSignature';
import {
getShortLivedProviderPrepareModelIssueReasons,
storeShortLivedProviderPrepareModelResults,
} from './providerPrepareShortLivedCache';
import { getProvisioningModelIssue } from './provisioningModelIssues';
import { ProvisioningProviderRuntimeSettingsDialog } from './ProvisioningProviderRuntimeSettingsDialog';
import {
deriveEffectiveProvisioningPrepareState,
getPrimaryProvisioningFailureDetail,
getProvisioningFailureHint,
getProvisioningProviderBackendSummary,
getProvisioningProviderProgressMessage,
type ProvisioningProviderCheck,
ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList,
updateProviderCheck,
} from './ProvisioningProviderStatusList';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import {
analyzeTeammateRuntimeCompatibility,
useTmuxRuntimeReadiness,
} from './teammateRuntimeCompatibility';
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
useWorktreeGitReadiness,
WorktreeGitReadinessBanner,
} from './WorktreeGitReadinessBanner';
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
import type {
CliProviderId,
EffortLevel,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
TeamProvisioningMemberInput,
TeamProvisioningModelCheckRequest,
} from '@shared/types';
const TEAM_COLOR_NAMES = [
'blue',
'green',
'red',
'yellow',
'purple',
'cyan',
'orange',
'pink',
] as const;
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate';
function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
): ProvisioningProviderCheck[] {
const existingByProviderId = new Map(
existingChecks.map((check) => [check.providerId, check] as const)
);
return providerIds.map(
(providerId) =>
existingByProviderId.get(providerId) ?? {
providerId,
status: 'pending',
backendSummary: null,
details: [],
}
);
}
export interface TeamCopyData {
teamName: string;
description?: string;
color?: string;
members: TeamProvisioningMemberInput[];
}
export interface ActiveTeamRef {
teamName: string;
displayName: string;
projectPath: string;
}
interface CreateTeamDialogProps {
open: boolean;
canCreate: boolean;
provisioningErrorsByTeam: Record<string, string | null>;
clearProvisioningError?: (teamName?: string) => void;
existingTeamNames: string[];
/** Team names currently in active provisioning (launching) — used to prevent name conflicts. */
provisioningTeamNames?: string[];
activeTeams?: ActiveTeamRef[];
initialData?: TeamCopyData;
defaultProjectPath?: string | null;
onClose: () => void;
onCreate: (request: TeamCreateRequest) => Promise<void>;
onOpenTeam: (teamName: string, projectPath?: string) => void;
}
interface ValidationResult {
valid: boolean;
errors?: {
teamName?: string;
members?: string;
cwd?: string;
};
}
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [
{
name: 'alice',
roleSelection: 'reviewer',
workflow:
'Review every completed task in the project. Read the code changes, check for correctness, style, and potential issues. Approve the task or request changes with clear feedback.',
},
{
name: 'tom',
roleSelection: 'developer',
},
{ name: 'bob', roleSelection: 'developer' },
{ name: 'jack', roleSelection: 'developer' },
];
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
function sanitizeTeamName(name: string): string {
let result = name
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-{2,}/g, '-')
.toLowerCase();
// Trim leading/trailing dashes without backtracking-vulnerable regex
while (result.startsWith('-')) result = result.slice(1);
while (result.endsWith('-')) result = result.slice(0, -1);
return result;
}
function validateTeamNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
const sanitized = sanitizeTeamName(trimmed);
if (!sanitized) {
return 'Name must contain at least one letter or digit';
}
if (sanitized.length > 128) {
return 'Name is too long (max 128 chars)';
}
return null;
}
function buildDefaultTeamDescription(teamName: string): string {
const trimmedName = teamName.trim();
return trimmedName.length > 0
? `${trimmedName} team for provisioning flow`
: 'Team for provisioning flow';
}
function validateRequest(
request: TeamCreateRequest,
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
const sanitized = sanitizeTeamName(request.teamName);
if (!sanitized) {
return {
valid: false,
errors: {
teamName: 'Name must contain at least one letter or digit',
},
};
}
if (sanitized.length > 128) {
return {
valid: false,
errors: {
teamName: 'Name is too long (max 128 chars)',
},
};
}
if (requireCwd && !request.cwd.trim()) {
return {
valid: false,
errors: {
cwd: 'Select working directory (cwd)',
},
};
}
if (request.members.some((member) => !member.name.trim())) {
return {
valid: false,
errors: {
members: 'Member name cannot be empty',
},
};
}
if (request.members.some((member) => validateMemberNameInline(member.name.trim()) !== null)) {
return {
valid: false,
errors: {
members: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars',
},
};
}
const uniqueNames = new Set(request.members.map((member) => member.name.trim().toLowerCase()));
if (uniqueNames.size !== request.members.length) {
return {
valid: false,
errors: {
members: 'Member names must be unique',
},
};
}
return { valid: true };
}
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
interface ScheduledIdleHandle {
kind: 'idle' | 'timeout';
id: number;
}
function scheduleIdle(cb: () => void): ScheduledIdleHandle {
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === 'function') {
return { kind: 'idle', id: idleWindow.requestIdleCallback(cb, { timeout: 2000 }) };
}
return { kind: 'timeout', id: window.setTimeout(cb, 0) };
}
function cancelScheduledIdle(handle: ScheduledIdleHandle | null): void {
if (!handle) return;
if (handle.kind === 'idle') {
const idleWindow = window as IdleWindow;
if (typeof idleWindow.cancelIdleCallback === 'function') {
idleWindow.cancelIdleCallback(handle.id);
}
return;
}
window.clearTimeout(handle.id);
}
function cancelScheduledIdleSet(handles: Set<ScheduledIdleHandle>): void {
for (const handle of handles) {
cancelScheduledIdle(handle);
}
handles.clear();
}
function isCurrentPrepareGeneration(ref: { current: number }, generation: number): boolean {
return ref.current === generation;
}
export const CreateTeamDialog = ({
open,
canCreate,
provisioningErrorsByTeam,
clearProvisioningError,
existingTeamNames,
provisioningTeamNames = [],
activeTeams,
initialData,
defaultProjectPath,
onClose,
onCreate,
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
);
const { cliStatus, cliStatusLoading } = useStore(
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading }))
);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const openDashboard = useStore((s) => s.openDashboard);
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[loadingCliStatus, codexAccount.snapshot]
);
// ── Persisted draft state (survives tab navigation) ──────────────────
const {
teamName,
setTeamName,
members,
setMembers,
syncModelsWithLead,
setSyncModelsWithLead,
teammateWorktreeDefault,
setTeammateWorktreeDefault,
cwdMode,
setCwdMode,
selectedProjectPath,
setSelectedProjectPath,
customCwd,
setCustomCwd,
soloTeam,
setSoloTeam,
launchTeam,
setLaunchTeam,
teamColor,
setTeamColor,
isLoaded: draftLoaded,
clearDraft,
} = useCreateTeamDraft();
const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' });
const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' });
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
// ── Transient UI state (NOT persisted) ───────────────────────────────
const [projects, setProjects] = useState<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState<
Partial<Record<TeamProviderId, number>>
>({});
const [providerSettingsProviderId, setProviderSettingsProviderId] =
useState<TeamProviderId | null>(null);
const prepareRequestSeqRef = useRef(0);
const prepareIdleHandlesRef = useRef(new Set<ScheduledIdleHandle>());
const prepareUnmountGenerationRef = useRef(0);
const appliedDefaultProjectPathRef = useRef<string | null>(null);
const lastAutoDescriptionRef = useRef<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{
teamName?: string;
members?: string;
cwd?: string;
}>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false);
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
);
const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled))
);
const [limitContext, setLimitContextRaw] = useState(getStoredCreateTeamLimitContext);
const [skipPermissions, setSkipPermissionsRaw] = useState(getStoredCreateTeamSkipPermissions);
const [selectedEffort, setSelectedEffortRaw] = useState(getStoredCreateTeamEffort);
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
// Advanced CLI section state (use teamName-derived key for localStorage)
const advancedKey = useMemo(() => sanitizeTeamName(teamName.trim()) || '_new_', [teamName]);
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false);
const [worktreeName, setWorktreeNameRaw] = useState('');
const [customArgs, setCustomArgsRaw] = useState('');
useEffect(() => {
migrateLegacyCreateTeamPreferences();
}, []);
useEffect(() => {
if (!open) {
setProviderSettingsProviderId(null);
}
}, [open]);
// Re-read localStorage when advancedKey changes
useEffect(() => {
const storedEnabled =
localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
setWorktreeNameRaw(storedName);
setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? '');
}, [advancedKey]);
const setSelectedModel = useCallback(
(value: string): void => {
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
setSelectedModelRaw(normalizedValue);
setStoredCreateTeamModel(selectedProviderId, normalizedValue);
},
[selectedProviderId]
);
const setSelectedProviderId = useCallback(
(value: TeamProviderId): void => {
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
setSelectedProviderIdRaw(normalizedValue);
setStoredCreateTeamProvider(normalizedValue);
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
},
[multimodelEnabled]
);
const setLimitContext = useCallback((value: boolean): void => {
setLimitContextRaw(value);
setStoredCreateTeamLimitContext(value);
}, []);
const setSkipPermissions = useCallback((value: boolean): void => {
setSkipPermissionsRaw(value);
setStoredCreateTeamSkipPermissions(value);
}, []);
const setSelectedEffort = useCallback((value: string): void => {
setSelectedEffortRaw(value);
setStoredCreateTeamEffort(value);
}, []);
const setSelectedFastMode = useCallback((value: TeamFastMode): void => {
setSelectedFastModeRaw(value);
setStoredCreateTeamFastMode(value);
}, []);
const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value);
localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value));
if (!value) {
setWorktreeNameRaw('');
localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, '');
}
};
const setWorktreeName = (value: string): void => {
setWorktreeNameRaw(value);
localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, value);
};
const setCustomArgs = (value: string): void => {
setCustomArgsRaw(value);
localStorage.setItem(`team:lastCustomArgs:${advancedKey}`, value);
};
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
setPrepareChecks([]);
setConflictDismissed(false);
};
const resetFormState = (): void => {
clearDraft();
lastAutoDescriptionRef.current = null;
descriptionDraft.clearDraft();
promptDraft.clearDraft();
promptChipDraft.clearChipDraft();
resetUIState();
};
const persistCurrentMemberRuntimePreferences = useCallback(
(nextMembers: readonly MemberDraft[] = members): void => {
setStoredCreateTeamMemberRuntimePreferences(nextMembers);
},
[members]
);
const selectedProjectPathDeleted = useMemo(
() =>
cwdMode === 'project' &&
selectedProjectPath.length > 0 &&
isDeletedProjectPathSelection(projects, selectedProjectPath),
[cwdMode, projects, selectedProjectPath]
);
const selectedProjectCwd =
isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted
? ''
: selectedProjectPath.trim();
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
const dialogTeamNameKey = sanitizeTeamName(teamName.trim());
/** All taken names: existing teams + teams currently being provisioned. */
const allTakenTeamNames = useMemo(
() => [...new Set([...existingTeamNames, ...provisioningTeamNames])],
[existingTeamNames, provisioningTeamNames]
);
const suggestedTeamName = useMemo(
() => getNextSuggestedTeamName(allTakenTeamNames),
[allTakenTeamNames]
);
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (open && dialogTeamNameKey) {
clearProvisioningError?.(dialogTeamNameKey);
}
}, [open, clearProvisioningError, dialogTeamNameKey]);
const effectiveMemberDrafts = useMemo(
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
[members, syncModelsWithLead]
);
const hasSelectedWorktreeIsolation =
!soloTeam &&
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
const worktreeGitReadiness = useWorktreeGitReadiness(
effectiveCwd || null,
open && canCreate && hasSelectedWorktreeIsolation
);
const worktreeIsolationDisabledReason =
!soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null;
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
worktreeGitReadiness,
hasSelectedWorktreeIsolation
);
const worktreeGitBlocksSubmission = Boolean(worktreeGitBlockingMessage);
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
if (!multimodelEnabled) {
return ['anthropic'];
}
if (soloTeam || syncModelsWithLead) {
return [selectedProviderId];
}
return Array.from(
new Set([
selectedProviderId,
...members.flatMap((member) =>
!member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : []
),
])
);
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
const hasSelectedAnthropicRuntime = selectedMemberProviders.includes('anthropic');
const effectiveAnthropicRuntimeLimitContext = hasSelectedAnthropicRuntime ? limitContext : false;
const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: (readonly [TeamProviderId, string | null])[] = (
effectiveCliStatus?.providers ?? []
).map(
(provider) =>
[
provider.providerId as TeamProviderId,
getProvisioningProviderBackendSummary(provider),
] as const
);
return new Map<TeamProviderId, string | null>(entries);
}, [effectiveCliStatus?.providers]);
const runtimeProviderStatusById = useMemo(
() =>
new Map(
(effectiveCliStatus?.providers ?? []).map(
(provider) => [provider.providerId, provider] as const
)
),
[effectiveCliStatus?.providers]
);
const selectedProviderBackendId = useMemo(
() =>
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
[runtimeProviderStatusById, selectedProviderId]
);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareMessageRef = useRef<string | null>(null);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
const lastPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
const pendingPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
const prepareProviderRequestSeqByIdRef = useRef(new Map<TeamProviderId, number>());
const prepareWarningsByProviderIdRef = useRef(new Map<TeamProviderId, string[]>());
useEffect(() => {
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
}, [runtimeBackendSummaryByProvider]);
useEffect(() => {
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
members,
selectedProviderId,
runtimeProviderStatusById,
});
if (sanitized.changed) {
setMembers(sanitized.members);
}
}, [members, runtimeProviderStatusById, selectedProviderId, setMembers]);
useEffect(() => {
prepareChecksRef.current = prepareChecks;
}, [prepareChecks]);
useEffect(() => {
prepareMessageRef.current = prepareMessage;
}, [prepareMessage]);
const invalidatePrepareProvider = useCallback((providerId: CliProviderId): void => {
if (!isTeamProviderId(providerId)) {
return;
}
lastPrepareProviderSignatureByIdRef.current.delete(providerId);
pendingPrepareProviderSignatureByIdRef.current.delete(providerId);
prepareProviderRequestSeqByIdRef.current.set(
providerId,
(prepareProviderRequestSeqByIdRef.current.get(providerId) ?? 0) + 1
);
prepareWarningsByProviderIdRef.current.delete(providerId);
setPrepareProviderInvalidationEpochById((current) => ({
...current,
[providerId]: (current[providerId] ?? 0) + 1,
}));
}, []);
useEffect(() => {
if (!open) {
lastPrepareProviderSignatureByIdRef.current.clear();
pendingPrepareProviderSignatureByIdRef.current.clear();
prepareProviderRequestSeqByIdRef.current.clear();
prepareWarningsByProviderIdRef.current.clear();
}
}, [open]);
useEffect(() => {
const generation = ++prepareUnmountGenerationRef.current;
const idleHandles = prepareIdleHandlesRef.current;
const lastProviderSignatures = lastPrepareProviderSignatureByIdRef.current;
const pendingProviderSignatures = pendingPrepareProviderSignatureByIdRef.current;
const providerRequestSeqs = prepareProviderRequestSeqByIdRef.current;
const warningsByProviderId = prepareWarningsByProviderIdRef.current;
return () => {
// React StrictMode replays effect cleanup/setup in development; defer
// invalidation so the replay does not cancel the live prepare request.
queueMicrotask(() => {
if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) {
return;
}
cancelScheduledIdleSet(idleHandles);
prepareRequestSeqRef.current += 1;
lastProviderSignatures.clear();
pendingProviderSignatures.clear();
providerRequestSeqs.clear();
warningsByProviderId.clear();
});
};
}, []);
const selectedEffortForCurrentSelection = useMemo(
() =>
getAvailableTeamEffortValue({
providerId: selectedProviderId,
model: selectedModel,
limitContext: effectiveAnthropicRuntimeLimitContext,
providerStatus: runtimeProviderStatusById.get(selectedProviderId),
value: selectedEffort,
}),
[
effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
selectedEffort,
selectedModel,
selectedProviderId,
]
);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, TeamProvisioningModelCheckRequest[]>();
const leadEffort = (selectedEffortForCurrentSelection as EffortLevel | '') || undefined;
const addModel = (
providerId: TeamProviderId,
model: string | undefined,
effort?: EffortLevel
): void => {
const trimmed = model?.trim() ?? '';
if (!trimmed) {
return;
}
const existing = modelsByProvider.get(providerId) ?? [];
if (!existing.some((entry) => entry.model === trimmed && entry.effort === effort)) {
modelsByProvider.set(providerId, [
...existing,
{
providerId,
model: trimmed,
...(effort ? { effort } : {}),
},
]);
}
};
const addDefaultSelection = (providerId: TeamProviderId, effort?: EffortLevel): void => {
if (
providerId === 'codex' ||
providerId === 'gemini' ||
(providerId === 'anthropic' && selectedProviderId === 'anthropic')
) {
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION, effort);
}
};
const leadModel = computeEffectiveTeamModel(
selectedModel,
effectiveAnthropicRuntimeLimitContext,
selectedProviderId
);
if (selectedModel.trim()) {
addModel(selectedProviderId, leadModel, leadEffort);
} else {
addDefaultSelection(selectedProviderId, leadEffort);
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId);
const inheritsDefaultRuntime = !memberProviderId || memberProviderId === selectedProviderId;
const explicitMemberModel = member.model?.trim() ?? '';
const memberEffort =
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? leadEffort : undefined);
const scopedModel = resolveProviderScopedMemberModel({
memberProviderId: member.providerId,
memberModel: member.model,
selectedProviderId,
runtimeProviderStatusById,
});
if (scopedModel.model) {
addModel(scopedModel.providerId, scopedModel.model, memberEffort);
} else {
addDefaultSelection(scopedModel.providerId, memberEffort);
}
}
return modelsByProvider;
}, [
effectiveAnthropicRuntimeLimitContext,
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedEffortForCurrentSelection,
selectedModel,
selectedProviderId,
]);
const selectedModelChecksByProviderSignature = useMemo(
() => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider),
[selectedModelChecksByProvider]
);
const shortLivedModelIssueReasons = useMemo(() => {
void prepareChecks;
void selectedModelChecksByProviderSignature;
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
{};
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
const modelUnavailableReasonByProvider: Partial<
Record<TeamProviderId, Record<string, string>>
> = {};
for (const providerId of selectedMemberProviders) {
const backendSummary = runtimeBackendSummaryByProvider.get(providerId) ?? null;
const providerRuntimeStatusSignature = buildProviderPrepareRuntimeStatusSignature(
[providerId],
runtimeProviderStatusById
);
const providerModelChecksSignature = buildProviderPrepareModelChecksSignature(
new Map([[providerId, selectedModelChecksByProvider.get(providerId) ?? []]])
);
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeStatusSignature: providerRuntimeStatusSignature,
modelChecksSignature: providerModelChecksSignature,
});
const issueReasons = getShortLivedProviderPrepareModelIssueReasons({
providerId,
cacheKey,
});
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
}
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
}
if (Object.keys(issueReasons.modelUnavailableReasonByValue).length > 0) {
modelUnavailableReasonByProvider[providerId] = issueReasons.modelUnavailableReasonByValue;
}
}
return {
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider,
modelUnavailableReasonByProvider,
};
}, [
effectiveAnthropicRuntimeLimitContext,
effectiveCwd,
prepareChecks,
runtimeBackendSummaryByProvider,
runtimeProviderStatusById,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
selectedMemberProviders,
]);
useEffect(() => {
if (multimodelEnabled) {
return;
}
if (selectedProviderId !== 'anthropic') {
setSelectedProviderIdRaw('anthropic');
setSelectedModelRaw(getStoredTeamModel('anthropic'));
}
const nextMembers = members.map((member) => normalizeMemberDraftForProviderMode(member, false));
const changed = nextMembers.some((member, index) => member !== members[index]);
if (changed) {
setMembers(nextMembers);
}
}, [members, multimodelEnabled, selectedProviderId, setMembers]);
useEffect(() => {
if (!open || cliStatus || cliStatusLoading) {
return;
}
void refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = useCallback(
(mode: 'browser' | 'device_code' = 'browser') => {
void (async () => {
await codexAccount.startChatgptLogin(mode);
})();
},
[codexAccount]
);
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
cancelScheduledIdleSet(prepareIdleHandlesRef.current);
prepareRequestSeqRef.current += 1;
lastPrepareProviderSignatureByIdRef.current.clear();
pendingPrepareProviderSignatureByIdRef.current.clear();
prepareProviderRequestSeqByIdRef.current.clear();
prepareWarningsByProviderIdRef.current.clear();
return;
}
if (typeof api.teams.prepareProvisioning !== 'function') {
cancelScheduledIdleSet(prepareIdleHandlesRef.current);
prepareRequestSeqRef.current += 1;
lastPrepareProviderSignatureByIdRef.current.clear();
pendingPrepareProviderSignatureByIdRef.current.clear();
prepareProviderRequestSeqByIdRef.current.clear();
prepareWarningsByProviderIdRef.current.clear();
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
return;
}
if (!effectiveCwd) {
cancelScheduledIdleSet(prepareIdleHandlesRef.current);
prepareRequestSeqRef.current += 1;
lastPrepareProviderSignatureByIdRef.current.clear();
pendingPrepareProviderSignatureByIdRef.current.clear();
prepareProviderRequestSeqByIdRef.current.clear();
prepareWarningsByProviderIdRef.current.clear();
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
const selectedProviderIdSet = new Set(selectedMemberProviders);
for (const providerId of Array.from(lastPrepareProviderSignatureByIdRef.current.keys())) {
if (!selectedProviderIdSet.has(providerId)) {
lastPrepareProviderSignatureByIdRef.current.delete(providerId);
pendingPrepareProviderSignatureByIdRef.current.delete(providerId);
prepareProviderRequestSeqByIdRef.current.delete(providerId);
prepareWarningsByProviderIdRef.current.delete(providerId);
}
}
const providerPlans = buildProviderPreparePlans({
cwd: effectiveCwd,
providerIds: selectedMemberProviders,
selectedModelChecksByProvider,
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
limitContext: effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
cachedModelResultsByCacheKey: prepareModelResultsCacheRef.current,
});
const changedPlans = providerPlans.filter((plan) => {
const lastSignature = lastPrepareProviderSignatureByIdRef.current.get(plan.providerId);
const pendingSignature = pendingPrepareProviderSignatureByIdRef.current.get(plan.providerId);
return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature;
});
const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId),
selectedMemberProviders.length
);
const getSelectedWarnings = (): string[] =>
selectedMemberProviders.flatMap(
(providerId) => prepareWarningsByProviderIdRef.current.get(providerId) ?? []
);
const commitChecks = (nextChecks: ProvisioningProviderCheck[]): void => {
prepareChecksRef.current = nextChecks;
setPrepareChecks(nextChecks);
};
const applyPrepareOutcome = (
nextChecks: ProvisioningProviderCheck[],
pendingMessage: string | null
): void => {
const selectedWarnings = getSelectedWarnings();
setPrepareWarnings(selectedWarnings);
if (nextChecks.some((check) => check.status === 'pending' || check.status === 'checking')) {
setPrepareState('loading');
setPrepareMessage(pendingMessage);
return;
}
const anyFailure = nextChecks.some((check) => check.status === 'failed');
const anyNotes =
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(nextChecks) ??
'Some selected providers need attention.';
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
? 'All selected providers are ready, with notes.'
: 'All selected providers are ready.'
);
};
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders);
for (const plan of changedPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
}
commitChecks(checks);
applyPrepareOutcome(
checks,
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
);
if (changedPlans.length === 0) {
return;
}
for (const plan of changedPlans) {
pendingPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature);
}
const idleHandle = scheduleIdle(() => {
prepareIdleHandlesRef.current.delete(idleHandle);
const generation = prepareRequestSeqRef.current;
const runningPlans = changedPlans.flatMap((plan) => {
if (
pendingPrepareProviderSignatureByIdRef.current.get(plan.providerId) !==
plan.requestSignature
) {
return [];
}
pendingPrepareProviderSignatureByIdRef.current.delete(plan.providerId);
const requestSeq = (prepareProviderRequestSeqByIdRef.current.get(plan.providerId) ?? 0) + 1;
prepareProviderRequestSeqByIdRef.current.set(plan.providerId, requestSeq);
lastPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature);
return [{ ...plan, requestSeq }];
});
if (runningPlans.length === 0) {
return;
}
const isPlanCurrent = (plan: ProviderPreparePlan & { requestSeq: number }): boolean =>
prepareRequestSeqRef.current === generation &&
lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) ===
plan.requestSignature &&
prepareProviderRequestSeqByIdRef.current.get(plan.providerId) === plan.requestSeq &&
!pendingPrepareProviderSignatureByIdRef.current.has(plan.providerId);
void (async () => {
await Promise.all(
runningPlans.map(async (plan) => {
try {
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelIds,
selectedModelChecks: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext: effectiveAnthropicRuntimeLimitContext,
cachedModelResultsById: plan.cachedModelResultsById,
onModelProgress: ({ status, details }) => {
if (!isPlanCurrent(plan)) {
return;
}
const nextChecks = updateProviderCheck(
prepareChecksRef.current,
plan.providerId,
{
status,
backendSummary: plan.backendSummary,
details,
supportDiagnostics: undefined,
}
);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
},
});
if (!isPlanCurrent(plan)) {
return;
}
prepareWarningsByProviderIdRef.current.set(
plan.providerId,
prepResult.warnings.map(
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
)
);
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
mergeReusableProviderPrepareModelResults(
prepareModelResultsCacheRef.current.get(plan.cacheKey),
prepResult.modelResultsById
)
);
storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId,
cacheKey: plan.cacheKey,
modelResultsById: prepResult.modelResultsById,
});
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: prepResult.status,
backendSummary: plan.backendSummary,
details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
} catch (error) {
if (!isPlanCurrent(plan)) {
return;
}
const failureMessage =
error instanceof Error ? error.message : 'Failed to prepare selected providers';
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, failureMessage);
}
})
);
})();
});
prepareIdleHandlesRef.current.add(idleHandle);
}, [
open,
canCreate,
launchTeam,
effectiveCwd,
effectiveMemberDrafts,
effectiveAnthropicRuntimeLimitContext,
prepareProviderInvalidationEpochById,
runtimeProviderStatusById,
selectedModel,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
selectedProviderId,
selectedMemberProviders,
]);
useEffect(() => {
if (!open) {
return;
}
setProjectsLoading(true);
setProjectsError(null);
let cancelled = false;
void (async () => {
try {
const nextProjects = await loadProjectPathProjects({ defaultProjectPath });
if (cancelled) {
return;
}
setProjects(nextProjects);
} catch (error) {
if (cancelled) {
return;
}
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
setProjects([]);
} finally {
if (!cancelled) {
setProjectsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open, defaultProjectPath]);
useEffect(() => {
if (!open || !draftLoaded) {
return;
}
if (initialData) {
const nextSyncModelsWithLead = !initialData.members.some(
(member) => member.providerId || member.model || member.effort
);
setTeamName(initialData.teamName);
descriptionDraft.setValue(initialData.description ?? '');
setTeamColor(initialData.color ?? '');
setMembers(
initialData.members.map((m) => {
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = m.role != null && presetRoles.includes(m.role);
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
return normalizeMemberDraftForProviderMode(
createMemberDraft({
name: m.name,
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '',
workflow: m.workflow,
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
providerId: normalizeOptionalTeamProviderId(m.providerId),
model: m.model ?? '',
effort: m.effort,
mcpPolicy: m.mcpPolicy,
}),
multimodelEnabled
);
})
);
setTeammateWorktreeDefault(
initialData.members.length > 0 &&
initialData.members.every((member) => member.isolation === 'worktree')
);
setSyncModelsWithLead(nextSyncModelsWithLead, { persistStoredPreference: false });
return;
}
if (members.length > 0) {
return;
}
const nextDefaultMembers = DEFAULT_MEMBERS.map((member) =>
createMemberDraft({
name: member.name,
roleSelection: member.roleSelection,
workflow: member.workflow,
})
);
setMembers(
syncModelsWithLead
? nextDefaultMembers
: applyStoredCreateTeamMemberRuntimePreferences(nextDefaultMembers)
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open/draftLoaded
}, [open, draftLoaded]);
useEffect(() => {
if (!open || !draftLoaded || initialData || syncModelsWithLead || members.length === 0) {
return;
}
persistCurrentMemberRuntimePreferences(members);
}, [
draftLoaded,
initialData,
members,
open,
persistCurrentMemberRuntimePreferences,
syncModelsWithLead,
]);
useEffect(() => {
if (!open || initialData || !draftLoaded) {
return;
}
if (teamName.trim().length === 0) {
setTeamName(suggestedTeamName);
}
}, [initialData, open, suggestedTeamName, draftLoaded]); // eslint-disable-line react-hooks/exhaustive-deps -- teamName read once
useEffect(() => {
if (!open || initialData) {
return;
}
const resolvedTeamName = teamName.trim() || suggestedTeamName;
const nextAutoDescription = buildDefaultTeamDescription(resolvedTeamName);
const currentDescription = descriptionDraft.value.trim();
const previousAutoDescription = lastAutoDescriptionRef.current?.trim() ?? '';
const shouldSyncDescription =
currentDescription.length === 0 || currentDescription === previousAutoDescription;
if (shouldSyncDescription && descriptionDraft.value !== nextAutoDescription) {
lastAutoDescriptionRef.current = nextAutoDescription;
descriptionDraft.setValue(nextAutoDescription);
return;
}
if (currentDescription === nextAutoDescription) {
lastAutoDescriptionRef.current = nextAutoDescription;
}
}, [descriptionDraft, initialData, open, suggestedTeamName, teamName]);
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
useEffect(() => {
if (!open) {
appliedDefaultProjectPathRef.current = null;
return;
}
if (cwdMode !== 'project') {
return;
}
const selectableProjects = projects.filter(isSelectableProjectPathProject);
if (selectableProjects.length === 0) {
return;
}
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
const defaultAlreadyApplied =
appliedDefaultProjectPathRef.current === normalizedDefaultProjectPath;
const match = selectableProjects.find(
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
);
if (match && !defaultAlreadyApplied) {
appliedDefaultProjectPathRef.current = normalizedDefaultProjectPath;
if (normalizePath(selectedProjectPath) !== normalizedDefaultProjectPath) {
setSelectedProjectPath(match.path);
}
return;
}
}
if (selectedProjectPath) {
return;
}
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
const match = selectableProjects.find(
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
);
if (match) {
setSelectedProjectPath(match.path);
return;
}
}
setSelectedProjectPath(selectableProjects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath, setSelectedProjectPath]);
useEffect(() => {
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
return;
}
if (
!isEphemeralProjectPath(selectedProjectPath) &&
!isDeletedProjectPathSelection(projects, selectedProjectPath)
) {
return;
}
setSelectedProjectPath('');
}, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
useFileListCacheWarmer(effectiveCwd || null);
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
const description = descriptionDraft.value;
const prompt = promptDraft.value;
const memberColorMap = useMemo(() => buildMemberDraftColorMap(members), [members]);
const mentionSuggestions = useMemo(
() =>
soloTeam
? [
{
id: 'team-lead',
name: 'team-lead',
subtitle: 'Team Lead',
color: resolveTeamLeadColorName(),
},
]
: buildMemberDraftSuggestions(members, memberColorMap),
[memberColorMap, members, soloTeam]
);
const effectiveModel = useMemo(
() =>
computeEffectiveTeamModel(
selectedModel,
effectiveAnthropicRuntimeLimitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
[
effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
]
);
const teammateRuntimeCompatibility = useMemo(
() =>
analyzeTeammateRuntimeCompatibility({
leadProviderId: selectedProviderId,
leadProviderBackendId: selectedProviderBackendId,
members: effectiveMemberDrafts,
soloTeam: soloTeam || !canCreate,
extraCliArgs: launchTeam ? customArgs : undefined,
tmuxStatus: tmuxRuntime.status,
tmuxStatusLoading: tmuxRuntime.loading,
tmuxStatusError: tmuxRuntime.error,
}),
[
customArgs,
effectiveMemberDrafts,
launchTeam,
canCreate,
selectedProviderBackendId,
selectedProviderId,
soloTeam,
tmuxRuntime.error,
tmuxRuntime.loading,
tmuxRuntime.status,
]
);
const teammateRuntimeProviderNoticeById:
| Partial<Record<TeamProviderId, React.ReactNode>>
| undefined = teammateRuntimeCompatibility.providerNoticeProviderId
? {
[teammateRuntimeCompatibility.providerNoticeProviderId]: (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
onClose();
openDashboard();
}}
/>
),
}
: undefined;
const showRosterTeammateRuntimeCompatibility =
teammateRuntimeCompatibility.visible && !teammateRuntimeCompatibility.providerNoticeProviderId;
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
? resolveAnthropicRuntimeSelection({
source: {
modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog,
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
},
selectedModel,
limitContext: effectiveAnthropicRuntimeLimitContext,
})
: null,
[
effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
]
);
const anthropicFastModeResolution = useMemo(
() =>
selectedProviderId === 'anthropic' && anthropicRuntimeSelection
? resolveAnthropicFastMode({
selection: anthropicRuntimeSelection,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
})
: null,
[
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
selectedFastMode,
selectedProviderId,
]
);
const codexRuntimeSelection = useMemo(
() =>
selectedProviderId === 'codex'
? resolveCodexRuntimeSelection({
source: {
providerStatus: runtimeProviderStatusById.get('codex'),
providerBackendId: resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
),
},
selectedModel,
})
: null,
[runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const codexFastModeResolution = useMemo(
() =>
selectedProviderId === 'codex' && codexRuntimeSelection
? resolveCodexFastMode({
selection: codexRuntimeSelection,
selectedFastMode,
})
: null,
[codexRuntimeSelection, selectedFastMode, selectedProviderId]
);
useEffect(() => {
if (selectedProviderId !== 'anthropic' && selectedProviderId !== 'codex') {
setAnthropicRuntimeNotice(null);
return;
}
const reconciliation =
selectedProviderId === 'anthropic'
? reconcileAnthropicRuntimeSelections({
selection:
anthropicRuntimeSelection ??
resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel,
limitContext: effectiveAnthropicRuntimeLimitContext,
}),
selectedEffort: selectedEffortForCurrentSelection,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
})
: {
nextEffort: selectedEffortForCurrentSelection,
effortResetReason: null,
...reconcileCodexRuntimeSelections({
selection:
codexRuntimeSelection ??
resolveCodexRuntimeSelection({
source: {
providerStatus: runtimeProviderStatusById.get('codex'),
providerBackendId: resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
),
},
selectedModel,
}),
selectedFastMode,
}),
};
const notices: string[] = [];
if (selectedEffortForCurrentSelection !== selectedEffort) {
setSelectedEffortRaw(selectedEffortForCurrentSelection);
setStoredCreateTeamEffort(selectedEffortForCurrentSelection);
}
if (reconciliation.nextEffort !== selectedEffortForCurrentSelection) {
setSelectedEffortRaw(reconciliation.nextEffort);
setStoredCreateTeamEffort(reconciliation.nextEffort);
if (reconciliation.effortResetReason) {
notices.push(reconciliation.effortResetReason);
}
}
if (reconciliation.nextFastMode !== selectedFastMode) {
setSelectedFastModeRaw(reconciliation.nextFastMode);
setStoredCreateTeamFastMode(reconciliation.nextFastMode);
if (reconciliation.fastModeResetReason) {
notices.push(reconciliation.fastModeResetReason);
}
}
setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null);
}, [
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
codexRuntimeSelection,
effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
selectedEffort,
selectedEffortForCurrentSelection,
selectedFastMode,
selectedModel,
selectedProviderId,
]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const teamNameInlineError = validateTeamNameInline(teamName);
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
const isNameProvisioning =
provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: sanitizedTeamName,
description: description.trim() || undefined,
color: teamColor || undefined,
members: soloTeam
? []
: buildMembersFromDrafts(effectiveMemberDrafts, {
inheritedProviderId: selectedProviderId,
}),
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
providerId: selectedProviderId,
providerBackendId: selectedProviderBackendId ?? undefined,
model: effectiveModel,
effort: (selectedEffortForCurrentSelection as EffortLevel) || undefined,
fastMode:
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
? selectedFastMode
: undefined,
limitContext: effectiveAnthropicRuntimeLimitContext,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
}),
[
sanitizedTeamName,
description,
teamColor,
soloTeam,
effectiveMemberDrafts,
effectiveCwd,
prompt,
selectedProviderId,
selectedProviderBackendId,
effectiveModel,
selectedEffortForCurrentSelection,
selectedFastMode,
effectiveAnthropicRuntimeLimitContext,
skipPermissions,
worktreeEnabled,
worktreeName,
customArgs,
]
);
const requestValidation = useMemo(
() => validateRequest(request, { requireCwd: launchTeam }),
[request, launchTeam]
);
const modelValidationError = useMemo(() => {
if (selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
return 'OpenCode lead requires a selected model.';
}
const activeMemberCount = soloTeam
? 0
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
if (activeMemberCount === 0) {
return 'OpenCode lead requires at least one OpenCode teammate.';
}
}
const leadError = getTeamModelSelectionError(
selectedProviderId,
selectedModel,
runtimeProviderStatusById.get(selectedProviderId)
);
if (leadError) {
return leadError;
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
const memberError = getTeamModelSelectionError(
providerId,
member.model,
runtimeProviderStatusById.get(providerId)
);
if (!memberError) {
continue;
}
const memberName = member.name.trim();
return memberName ? `${memberName}: ${memberError}` : memberError;
}
return null;
}, [
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
soloTeam,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
prepareChecks,
selectedProviderId,
effectiveModel ?? selectedModel
);
return issue?.reason ?? issue?.detail ?? null;
}, [effectiveModel, prepareChecks, selectedModel, selectedProviderId]);
const memberModelIssueById = useMemo(() => {
const next: Record<string, string> = {};
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
if (syncModelsWithLead && leadModelIssueText) {
next[member.id] = leadModelIssueText;
continue;
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model);
const issueText = issue?.reason ?? issue?.detail ?? null;
if (issueText) {
next[member.id] = issueText;
}
}
return next;
}, [
effectiveMemberDrafts,
leadModelIssueText,
prepareChecks,
selectedProviderId,
syncModelsWithLead,
]);
const hasCreateFormErrors =
!!teamNameInlineError ||
isNameTakenByExistingTeam ||
isNameProvisioning ||
!requestValidation.valid ||
!!modelValidationError ||
teammateRuntimeCompatibility.blocksSubmission ||
worktreeGitBlocksSubmission;
const internalArgs = useMemo(() => {
const args: string[] = [];
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
if (skipPermissions) args.push('--dangerously-skip-permissions');
if (effectiveModel) args.push('--model', effectiveModel);
const effectiveEffort =
selectedProviderId === 'anthropic'
? selectedEffortForCurrentSelection || anthropicRuntimeSelection?.defaultEffort || ''
: selectedEffortForCurrentSelection;
if (effectiveEffort) args.push('--effort', effectiveEffort);
if (selectedProviderId === 'anthropic') {
const fastSettings = anthropicFastModeResolution?.resolvedFastMode
? { fastMode: true, fastModePerSessionOptIn: false }
: { fastMode: false };
args.push('--settings', JSON.stringify(fastSettings));
} else if (selectedProviderId === 'codex') {
args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode));
}
return args;
}, [
anthropicFastModeResolution?.resolvedFastMode,
anthropicRuntimeSelection?.defaultEffort,
codexFastModeResolution?.resolvedFastMode,
effectiveModel,
selectedEffortForCurrentSelection,
selectedProviderId,
skipPermissions,
]);
const launchOptionalSummary = useMemo(() => {
const summary: string[] = [];
if (prompt.trim()) summary.push('Lead prompt');
if (skipPermissions) summary.push('Auto-approve tools');
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
summary.push('Fast default');
}
}
if (effectiveAnthropicRuntimeLimitContext) {
summary.push('Anthropic limited to 200K context');
}
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
if (customArgs.trim()) summary.push('Custom CLI args');
return summary;
}, [
anthropicProviderFastModeDefault,
customArgs,
effectiveAnthropicRuntimeLimitContext,
prompt,
selectedFastMode,
selectedProviderId,
skipPermissions,
worktreeEnabled,
worktreeName,
]);
const teamDetailsSummary = useMemo(() => {
const summary: string[] = [];
if (description.trim()) summary.push('Description');
if (teamColor) summary.push(`Color: ${teamColor}`);
return summary;
}, [description, teamColor]);
const handleSyncModelsWithLeadChange = useCallback(
(checked: boolean): void => {
setSyncModelsWithLead(checked);
if (checked) {
persistCurrentMemberRuntimePreferences(members);
setMembers(members.map(clearMemberModelOverrides));
return;
}
if (getStoredCreateTeamMemberRuntimePreferences().length === 0) {
return;
}
const nextMembers = applyStoredCreateTeamMemberRuntimePreferences(members);
const hasRuntimeChanges = nextMembers.some((member, index) => {
const previousMember = members[index];
return (
member.providerId !== previousMember?.providerId ||
member.model !== previousMember?.model ||
member.effort !== previousMember?.effort
);
});
if (hasRuntimeChanges) {
setMembers(nextMembers);
}
},
[members, persistCurrentMemberRuntimePreferences, setMembers, setSyncModelsWithLead]
);
const activeError =
localError ?? modelValidationError ?? provisioningErrorsByTeam[request.teamName] ?? null;
const effectivePrepare = useMemo(
() =>
deriveEffectiveProvisioningPrepareState({
state: prepareState,
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
}),
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
selectedProviderIds: selectedMemberProviders,
prepareMessage: effectivePrepare.message,
prepareChecks,
});
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const conflictingTeam = useMemo(() => {
if (!launchTeam) return null;
if (!activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd);
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
}, [activeTeams, effectiveCwd, launchTeam]);
// Reset dismiss when conflict target changes
useEffect(() => {
setConflictDismissed(false);
}, [conflictingTeam?.teamName, effectiveCwd]);
const handleSubmit = (): void => {
if (allTakenTeamNames.includes(sanitizedTeamName)) {
const msg = isNameProvisioning ? 'Team is currently launching' : 'Team name already exists';
setFieldErrors({ teamName: msg });
setLocalError(msg);
return;
}
const validation = validateRequest(request, { requireCwd: launchTeam });
if (!validation.valid) {
const errors = validation.errors ?? {};
setFieldErrors(errors);
const messages = Object.values(errors).filter(Boolean);
setLocalError(messages.join(' · ') || 'Check form fields');
return;
}
if (modelValidationError) {
setLocalError(modelValidationError);
return;
}
if (teammateRuntimeCompatibility.blocksSubmission) {
setLocalError(teammateRuntimeCompatibility.message);
return;
}
if (worktreeGitBlockingMessage) {
setLocalError(worktreeGitBlockingMessage);
return;
}
setFieldErrors({});
setLocalError(null);
setIsSubmitting(true);
if (!launchTeam) {
void (async () => {
try {
if (!syncModelsWithLead) {
persistCurrentMemberRuntimePreferences(members);
}
await api.teams.createConfig({
teamName: request.teamName,
displayName: request.displayName,
description: request.description,
color: request.color,
members: request.members,
cwd: effectiveCwd || undefined,
prompt: request.prompt,
providerId: request.providerId,
providerBackendId: request.providerBackendId,
model: request.model,
effort: request.effort,
fastMode: request.fastMode,
limitContext: request.limitContext,
skipPermissions: request.skipPermissions,
worktree: request.worktree,
extraCliArgs: request.extraCliArgs,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
onClose();
} catch (error) {
setLocalError(error instanceof Error ? error.message : 'Failed to create team config');
} finally {
setIsSubmitting(false);
}
})();
return;
}
void (async () => {
try {
if (!syncModelsWithLead) {
persistCurrentMemberRuntimePreferences(members);
}
await onCreate(request);
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
onClose();
} catch {
// error is shown via provisioningError prop
} finally {
setIsSubmitting(false);
}
})();
};
const handleTeamNameChange = (value: string): void => {
setTeamName(value);
setFieldErrors((prev) => {
if (!prev.teamName) return prev;
// eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest
const { teamName: _teamName, ...rest } = prev;
const remaining = Object.values(rest).filter(Boolean);
if (remaining.length === 0) {
setLocalError(null);
} else {
setLocalError(remaining.join(' · '));
}
return rest;
});
};
const rosterHeaderTop = useMemo(
() => (
<div className="flex items-center gap-2">
<Checkbox
id="solo-team"
checked={soloTeam}
onCheckedChange={(checked) => setSoloTeam(checked === true)}
/>
<Label
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Solo team
</Label>
</div>
),
[setSoloTeam, soloTeam]
);
const rosterHeaderBottom = useMemo(
() =>
showRosterTeammateRuntimeCompatibility ||
soloTeam ||
(canCreate && hasSelectedWorktreeIsolation) ? (
<div className="space-y-2">
{showRosterTeammateRuntimeCompatibility ? (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
onClose();
openDashboard();
}}
/>
) : null}
{soloTeam ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; no teammates will be
spawned. Works like a regular agent session in your chosen runtime (Claude Code,
Codex, OpenCode, Gemini) but with access to the task board for planning. Saves
tokens by avoiding teammate coordination overhead. You can add members later from
the team settings.
</p>
</div>
) : null}
{canCreate && hasSelectedWorktreeIsolation ? (
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
) : null}
</div>
) : null,
[
canCreate,
hasSelectedWorktreeIsolation,
onClose,
openDashboard,
showRosterTeammateRuntimeCompatibility,
soloTeam,
teammateRuntimeCompatibility,
worktreeGitReadiness,
]
);
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
resetUIState();
onClose();
}
}}
>
<DialogContent className="max-w-[52rem]">
<DialogHeader>
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
<DialogDescription className="text-xs">
{initialData
? 'Create a new team based on an existing one.'
: 'Set up your team and choose how it starts.'}
</DialogDescription>
</DialogHeader>
{conflictingTeam && !conflictDismissed ? (
<div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory
</p>
<p className="opacity-80">
Running two teams in the same directory is risky they may conflict editing the
same files. Consider using a different directory or a git worktree for isolation.
</p>
<p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div>
<button
type="button"
className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
onClick={() => setConflictDismissed(true)}
>
<X className="size-3.5" />
</button>
</div>
</div>
) : null}
{!canCreate ? (
<p
className="rounded border p-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
Available only in local Electron mode.
</p>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-name">Team name</Label>
<Input
id="team-name"
className={cn(
'h-8 text-xs',
(fieldErrors.teamName || teamNameInlineError || isNameTakenByExistingTeam) &&
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
)}
value={teamName}
onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder={suggestedTeamName}
/>
{isNameTakenByExistingTeam ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
Team name already exists
</p>
) : teamNameInlineError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{teamNameInlineError}
</p>
) : isNameProvisioning ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
A team with this name is currently launching
</p>
) : fieldErrors.teamName ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldErrors.teamName}
</p>
) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
On disk: <span className="font-mono">{sanitizedTeamName}</span>
</p>
) : null}
</div>
<div className="md:col-span-2">
<TeamRosterEditorSection
members={members}
onMembersChange={setMembers}
fieldError={fieldErrors.members}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix="createTeam"
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffortForCurrentSelection as EffortLevel) || undefined}
inheritModelSettingsByDefault
lockProviderModel={syncModelsWithLead}
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
hideMembersContent={soloTeam}
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffortForCurrentSelection as EffortLevel) || undefined}
limitContext={effectiveAnthropicRuntimeLimitContext}
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
showWorktreeIsolationControls={!soloTeam}
teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
memberWarningById={teammateRuntimeCompatibility.memberWarningById}
memberModelIssueById={memberModelIssueById}
modelAdvisoryReasonByProvider={
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
}
modelIssueReasonByProvider={shortLivedModelIssueReasons.modelIssueReasonByProvider}
modelUnavailableReasonByProvider={
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
}
headerTop={rosterHeaderTop}
headerBottom={rosterHeaderBottom}
/>
</div>
<div
className="rounded-lg border border-[var(--color-border-emphasis)] p-4 shadow-sm md:col-span-2"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-overlay) 24%, white 76%)'
: 'var(--color-surface-overlay)',
}}
>
<div className="flex items-start gap-3">
<Checkbox
id="launch-team"
className="mt-1 shrink-0"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<div className="space-y-1">
<Label htmlFor="launch-team" className="cursor-pointer text-sm font-semibold">
Run command after create
</Label>
<p
className="text-xs"
style={{
color: isLight
? 'color-mix(in srgb, var(--color-text-muted) 54%, var(--color-text) 46%)'
: 'var(--color-text-muted)',
}}
>
Start the team immediately via local Claude CLI.
</p>
</div>
</div>
{launchTeam ? (
<div className="mt-4 space-y-4">
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
fieldError={fieldErrors.cwd}
/>
<OptionalSettingsSection
title="Optional launch settings"
description="Prompt, safety, and CLI overrides live here when you need them."
summary={launchOptionalSummary}
>
<div className="space-y-4">
{selectedProviderId === 'anthropic' ? (
<div className="space-y-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={effectiveAnthropicRuntimeLimitContext}
id="create-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="space-y-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ?? undefined
}
id="create-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="team-prompt" className="label-optional">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="team-prompt"
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onValueChange={promptDraft.setValue}
suggestions={soloTeam ? [] : mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={effectiveCwd || null}
chips={promptChipDraft.chips}
onChipRemove={promptChipDraft.removeChip}
onFileChipInsert={promptChipDraft.addChip}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">
Saved
</span>
) : null
}
/>
</div>
<SkipPermissionsCheckbox
id="create-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
<AdvancedCliSection
teamName={advancedKey}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div>
</OptionalSettingsSection>
</div>
) : null}
</div>
<div className="md:col-span-2">
<OptionalSettingsSection
title="Optional team details"
description="Keep the default flow compact and only open this when you want extra context or a custom color."
summary={teamDetailsSummary}
>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="team-description" className="label-optional">
Description (optional)
</Label>
<AutoResizeTextarea
id="team-description"
className="text-xs"
minRows={2}
maxRows={8}
value={description}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null}
</div>
<div className="space-y-1.5">
<Label className="label-optional">Color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === 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={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div>
</div>
</div>
</OptionalSettingsSection>
</div>
</div>
{activeError ? (
<p
className="rounded border p-2 text-xs"
style={{
color: 'var(--field-error-text)',
borderColor: 'var(--field-error-border)',
backgroundColor: 'var(--field-error-bg)',
}}
>
{activeError}
</p>
) : null}
<DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0">
{canCreate &&
launchTeam &&
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
? 'Checking selected providers...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
</p>
</div>
</div>
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
onOpenProviderSettings={(providerId) => setProviderSettingsProviderId(providerId)}
/>
</>
) : null}
{canCreate && launchTeam && effectivePrepare.state === 'ready' ? (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'Selected providers ready (with notes)'
: 'Selected providers ready'}
</span>
</div>
{effectivePrepare.message ? (
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
{effectivePrepare.message}
</p>
) : null}
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-1"
onOpenProviderSettings={(providerId) => setProviderSettingsProviderId(providerId)}
/>
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning, index) => (
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
{canCreate && launchTeam && effectivePrepare.state === 'failed' ? (
<div className="text-xs">
<div className="flex items-start gap-2 text-red-300">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Runtime environment is not available - launch is blocked
</p>
<p className="mt-0.5 text-red-300/80">
{effectivePrepare.message ?? 'Failed to prepare environment'}
</p>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before launch
</p>
</div>
</div>
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
suppressDetailsMatching={prepareMessage}
onOpenProviderSettings={(providerId) =>
setProviderSettingsProviderId(providerId)
}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning, index) => (
<p
key={`${index}:${warning}`}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
</p>
{showCodexReconnectPrompt ? (
<div className="pl-6">
<CodexReconnectPrompt
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
userCode={codexAccount.snapshot?.login.userCode ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={() => handleCodexReconnect('browser')}
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
/>
</div>
) : null}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{canOpenExistingTeam ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(request.teamName);
onClose();
}}
>
Open Existing Team
</Button>
) : null}
<Button
size="lg"
className="min-w-32 text-sm"
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : launchTeam &&
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
'Skip preflight and create'
) : (
'Create'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
<ProvisioningProviderRuntimeSettingsDialog
openProviderId={providerSettingsProviderId}
onOpenProviderIdChange={(providerId) => setProviderSettingsProviderId(providerId)}
providers={effectiveCliStatus?.providers ?? []}
projectPath={effectiveCwd || null}
disabled={isSubmitting}
onProviderRuntimeChanged={invalidatePrepareProvider}
/>
</Dialog>
);
};