2620 lines
96 KiB
TypeScript
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 — 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 “{conflictingTeam.displayName}” 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>
|
|
);
|
|
};
|