import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, createMemberDraft, MembersEditorSection, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; 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 { 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 { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { LimitContextCheckbox } from './ExtendedContextCheckbox'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { getNextSuggestedTeamName } from './teamNameSets'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; const TEAM_COLOR_NAMES = [ 'blue', 'green', 'red', 'yellow', 'purple', 'cyan', 'orange', 'pink', ] as const; import type { EffortLevel, Project, TeamCreateRequest, TeamProvisioningMemberInput, TeamProvisioningPrepareResult, } from '@shared/types'; 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; clearProvisioningError?: (teamName?: string) => void; existingTeamNames: string[]; activeTeams?: ActiveTeamRef[]; initialData?: TeamCopyData; defaultProjectPath?: string | null; onClose: () => void; onCreate: (request: TeamCreateRequest) => Promise; 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 DEV_DEFAULT_TEAM = { teamName: 'signal-ops', } as const; const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [ { name: 'alice', roleSelection: 'reviewer' }, { name: 'bob', roleSelection: 'developer' }, { name: 'carol', 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 }; } export const CreateTeamDialog = ({ open, canCreate, provisioningErrorsByTeam, clearProvisioningError, existingTeamNames, activeTeams, initialData, defaultProjectPath, onClose, onCreate, onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { const isDev = process.env.NODE_ENV !== 'production'; const { isLight } = useTheme(); const [teamName, setTeamName] = useState(''); const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' }); const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' }); const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips'); const [members, setMembers] = useState([]); const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPath] = useState(''); const [customCwd, setCustomCwd] = useState(''); const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); const prepareRequestSeqRef = useRef(0); const lastAutoDescriptionRef = useRef(null); const [fieldErrors, setFieldErrors] = useState<{ teamName?: string; members?: string; cwd?: string; }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [launchTeam, setLaunchTeam] = useState(true); const [soloTeam, setSoloTeam] = useState(false); const [teamColor, setTeamColor] = useState(''); const [conflictDismissed, setConflictDismissed] = useState(false); const [selectedModel, setSelectedModelRaw] = useState(() => { const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; return stored === '__default__' ? '' : stored; }); const [limitContext, setLimitContextRaw] = useState( () => localStorage.getItem('team:lastLimitContext') === 'true' ); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); const [selectedEffort, setSelectedEffortRaw] = useState( () => localStorage.getItem('team:lastSelectedEffort') ?? '' ); // Advanced CLI section state (use teamName-derived key for localStorage) const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_'; const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false); const [worktreeName, setWorktreeNameRaw] = useState(''); const [customArgs, setCustomArgsRaw] = useState(''); // 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 = (value: string): void => { setSelectedModelRaw(value); localStorage.setItem('team:lastSelectedModel', value); }; const setLimitContext = (value: boolean): void => { setLimitContextRaw(value); localStorage.setItem('team:lastLimitContext', String(value)); }; const setSkipPermissions = (value: boolean): void => { setSkipPermissionsRaw(value); localStorage.setItem('team:lastSkipPermissions', String(value)); }; const setSelectedEffort = (value: string): void => { setSelectedEffortRaw(value); localStorage.setItem('team:lastSelectedEffort', 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([]); setConflictDismissed(false); }; const resetFormState = (): void => { setTeamName(''); lastAutoDescriptionRef.current = null; descriptionDraft.clearDraft(); promptDraft.clearDraft(); promptChipDraft.clearChipDraft(); setMembers([]); setTeamColor(''); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setLaunchTeam(true); setSoloTeam(false); resetUIState(); }; const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const dialogTeamNameKey = sanitizeTeamName(teamName.trim()); const suggestedTeamName = getNextSuggestedTeamName(existingTeamNames); // Clear stale provisioning error when dialog opens useEffect(() => { if (open && dialogTeamNameKey) { clearProvisioningError?.(dialogTeamNameKey); } }, [open, clearProvisioningError, dialogTeamNameKey]); useEffect(() => { if (!open || !canCreate || !launchTeam) { return; } if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); return; } if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); setPrepareMessage('Warming up CLI environment...'); setPrepareWarnings([]); // Defer so file list fetch (triggered by project select) can run first const timer = setTimeout(() => { void (async () => { try { const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(effectiveCwd); if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; setPrepareState(prepResult.ready ? 'ready' : 'failed'); setPrepareMessage(prepResult.message); setPrepareWarnings(prepResult.warnings ?? []); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; setPrepareState('failed'); setPrepareWarnings([]); setPrepareMessage( error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' ); } })(); }, 250); return () => { cancelled = true; clearTimeout(timer); }; }, [open, canCreate, launchTeam, effectiveCwd]); useEffect(() => { if (!open) { return; } setProjectsLoading(true); setProjectsError(null); let cancelled = false; void (async () => { try { const nextProjects = await api.getProjects(); 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]); useEffect(() => { if (!open) { return; } if (initialData) { 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 createMemberDraft({ name: m.name, roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), customRole: isCustom ? m.role : '', workflow: m.workflow, }); }) ); return; } if (members.length > 0) { return; } if (isDev) { setMembers( DEV_DEFAULT_MEMBERS.map((member) => createMemberDraft({ name: member.name, roleSelection: member.roleSelection, }) ) ); return; } setMembers([createMemberDraft()]); // eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open }, [open]); useEffect(() => { if (!open || initialData) { return; } setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev)); }, [initialData, open, suggestedTeamName]); useEffect(() => { if (!open || !isDev || 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, isDev, open, suggestedTeamName, teamName]); // Pre-select defaultProjectPath when projects loaded (only while dialog is open) useEffect(() => { if (!open) return; if (cwdMode !== 'project') { return; } if (selectedProjectPath || projects.length === 0) { return; } if (defaultProjectPath) { const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath); if (match) { setSelectedProjectPath(match.path); return; } } setSelectedProjectPath(projects[0].path); }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); 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: 'blue' }] : buildMemberDraftSuggestions(members, memberColorMap), [memberColorMap, members, soloTeam] ); const effectiveModel = useMemo(() => computeEffectiveTeamModel(selectedModel), [selectedModel]); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const request = useMemo( () => ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, members: soloTeam ? [] : buildMembersFromDrafts(members), cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, limitContext: limitContext || undefined, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }), [ sanitizedTeamName, description, teamColor, soloTeam, members, effectiveCwd, prompt, effectiveModel, selectedEffort, skipPermissions, limitContext, worktreeEnabled, worktreeName, customArgs, ] ); 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', '', '--disallowedTools', 'TeamDelete,TodoWrite'); if (skipPermissions) args.push('--dangerously-skip-permissions'); if (effectiveModel) args.push('--model', effectiveModel); if (selectedEffort) args.push('--effort', selectedEffort); return args; }, [skipPermissions, effectiveModel, selectedEffort]); const launchOptionalSummary = useMemo(() => { const summary: string[] = []; if (prompt.trim()) summary.push('Lead prompt'); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); if (limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; }, [ prompt, selectedModel, selectedEffort, limitContext, skipPermissions, worktreeEnabled, worktreeName, customArgs, ]); const teamDetailsSummary = useMemo(() => { const summary: string[] = []; if (description.trim()) summary.push('Description'); if (teamColor) summary.push(`Color: ${teamColor}`); return summary; }, [description, teamColor]); const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null; 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 (existingTeamNames.includes(sanitizedTeamName)) { setFieldErrors({ teamName: 'Team name already exists' }); setLocalError('Team name already exists'); return; } const validation = validateRequest(request, { requireCwd: launchTeam }); if (!validation.valid) { const errors = validation.errors ?? {}; setFieldErrors(errors); const messages = Object.values(errors).filter(Boolean) as string[]; setLocalError(messages.join(' · ') || 'Check form fields'); return; } setFieldErrors({}); setLocalError(null); setIsSubmitting(true); if (!launchTeam) { void (async () => { try { await api.teams.createConfig({ teamName: request.teamName, displayName: request.displayName, description: request.description, color: request.color, members: request.members, cwd: effectiveCwd || undefined, }); 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 { 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) as string[]; if (remaining.length === 0) { setLocalError(null); } else { setLocalError(remaining.join(' · ')); } return rest; }); }; return ( { if (!nextOpen) { resetUIState(); onClose(); } }} > {initialData ? 'Copy Team' : 'Create Team'} {initialData ? 'Create a new team based on an existing one.' : 'Team provisioning via local Claude CLI.'} {conflictingTeam && !conflictDismissed ? (

Another team “{conflictingTeam.displayName}” is already running for this working directory

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.

Working directory: {effectiveCwd}

) : null} {canCreate && launchTeam && prepareState === 'failed' ? (

CLI environment is not available — launch is blocked

{prepareMessage ?? 'Failed to prepare environment'}

{prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => (

{warning}

))}
) : null}

Make sure claude CLI is installed and available in PATH, then reopen this dialog.

) : null} {!canCreate ? (

Available only in local Electron mode.

) : null}
handleTeamNameChange(event.target.value)} placeholder={suggestedTeamName} /> {existingTeamNames.includes(sanitizedTeamName) ? (

Team name already exists

) : validateTeamNameInline(teamName) ? (

{validateTeamNameInline(teamName)}

) : fieldErrors.teamName ? (

{fieldErrors.teamName}

) : null} {sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (

On disk: {sanitizedTeamName}

) : null}
setSoloTeam(checked === true)} />
{soloTeam && (

Only the team lead (main process) will be started — no teammates will be spawned. Works like a regular Claude session 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.

)}
} />
setLaunchTeam(checked === true)} />

Start the team immediately via local Claude CLI.

{launchTeam ? (
Saved ) : null } />
) : null}
descriptionDraft.setValue(event.target.value)} placeholder="Brief description of the team purpose" /> {descriptionDraft.isSaved ? ( Saved ) : null}
{TEAM_COLOR_NAMES.map((colorName) => { const colorSet = getTeamColorSet(colorName); const isSelected = teamColor === colorName; return ( ); })}
{activeError ? (

{activeError}

) : null}
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
{prepareMessage ?? (prepareState === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')}
) : null} {canCreate && launchTeam && prepareState === 'ready' ? (
{prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'}
{prepareMessage ? (

{prepareMessage}

) : null} {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => (

{warning}

))}
) : null}
) : null}
{canOpenExistingTeam ? ( ) : null}
); };