import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import { MembersJsonEditor } from './MembersJsonEditor'; import { ProjectPathSelector } from './ProjectPathSelector'; const TEAM_COLOR_NAMES = [ 'blue', 'green', 'red', 'yellow', 'purple', 'cyan', 'orange', 'pink', ] as const; import type { MentionSuggestion } from '@renderer/types/mention'; import type { 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; provisioningError: string | null; 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, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; const DEV_DEFAULT_TEAM = { teamName: 'team-alpha', description: 'Dev test team for provisioning flow', } as const; interface MemberDraft { id: string; name: string; roleSelection: string; customRole: string; } const DEV_DEFAULT_MEMBERS: Pick[] = [ { name: 'alice', roleSelection: 'reviewer' }, { name: 'bob', roleSelection: 'developer' }, { name: 'carol', roleSelection: 'developer' }, ]; function newDraftId(): string { // eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function createMemberDraft(initial?: Partial): MemberDraft { return { id: initial?.id ?? newDraftId(), name: initial?.name ?? '', roleSelection: initial?.roleSelection ?? '', customRole: initial?.customRole ?? '', }; } function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] { return members .map((member) => { const name = member.name.trim(); if (!name) { return null; } const role = member.roleSelection === CUSTOM_ROLE ? member.customRole.trim() || undefined : member.roleSelection === NO_ROLE ? undefined : member.roleSelection.trim() || undefined; return { name, role, }; }) .filter((member): member is NonNullable => member !== null); } /** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */ function sanitizeTeamName(name: string): string { return name .replace(/[^a-zA-Z0-9]/g, '-') .replace(/-{2,}/g, '-') .replace(/^-+/g, '') .replace(/-+$/g, '') .toLowerCase(); } const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; 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 validateMemberNameInline(name: string): string | null { const trimmed = name.trim(); if (!trimmed) return null; if (!MEMBER_NAME_RE.test(trimmed)) { return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars'; } return null; } 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.length === 0) { return { valid: false, errors: { members: 'At least one member is required', }, }; } if (request.members.some((member) => !member.name.trim())) { return { valid: false, errors: { members: 'Member name cannot be empty', }, }; } if (request.members.some((member) => !MEMBER_NAME_RE.test(member.name.trim()))) { 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, provisioningError, existingTeamNames, activeTeams, initialData, defaultProjectPath, onClose, onCreate, onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { const isDev = process.env.NODE_ENV !== 'production'; const [teamName, setTeamName] = useState(''); const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' }); const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' }); 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 [fieldErrors, setFieldErrors] = useState<{ teamName?: string; members?: string; cwd?: string; }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [launchTeam, setLaunchTeam] = useState(true); const [teamColor, setTeamColor] = useState(''); const [selectedModel, setSelectedModel] = useState(''); const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); const [jsonError, setJsonError] = useState(null); const resetUIState = (): void => { setLocalError(null); setFieldErrors({}); setIsSubmitting(false); setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); }; const resetFormState = (): void => { setTeamName(''); descriptionDraft.clearDraft(); promptDraft.clearDraft(); setMembers([]); setTeamColor(''); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setLaunchTeam(true); setSelectedModel(''); setJsonEditorOpen(false); setJsonText(''); setJsonError(null); resetUIState(); }; 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; } let cancelled = false; setPrepareState('loading'); setPrepareMessage('Warming up CLI environment...'); setPrepareWarnings([]); void (async () => { try { const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(); if (cancelled) { return; } setPrepareState(prepResult.ready ? 'ready' : 'failed'); setPrepareMessage(prepResult.message); setPrepareWarnings(prepResult.warnings ?? []); } catch (error) { if (cancelled) { return; } setPrepareState('failed'); setPrepareWarnings([]); setPrepareMessage( error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' ); } })(); return () => { cancelled = true; }; }, [open, canCreate, launchTeam]); 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 : '', }); }) ); 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 || !isDev || initialData) { return; } setTeamName((prev) => (prev.trim().length === 0 ? DEV_DEFAULT_TEAM.teamName : prev)); if (descriptionDraft.value.trim().length === 0) { descriptionDraft.setValue(DEV_DEFAULT_TEAM.description); } // eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open }, [open]); useEffect(() => { 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); }, [cwdMode, projects, selectedProjectPath, defaultProjectPath]); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const membersToJsonText = (drafts: MemberDraft[]): string => { const arr = drafts .filter((d) => d.name.trim()) .map((d) => { const role = d.roleSelection === CUSTOM_ROLE ? d.customRole.trim() || undefined : d.roleSelection === NO_ROLE ? undefined : d.roleSelection.trim() || undefined; return role ? { name: d.name.trim(), role } : { name: d.name.trim() }; }); return JSON.stringify(arr, null, 2); }; const handleJsonChange = (text: string): void => { setJsonText(text); try { const arr: unknown = JSON.parse(text); if (!Array.isArray(arr)) { setJsonError('Root must be an array'); return; } const drafts: MemberDraft[] = (arr as Record[]).map((item) => { const name = typeof item.name === 'string' ? item.name : ''; const role = typeof item.role === 'string' ? item.role.trim() : ''; const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = presetRoles.includes(role); return createMemberDraft({ name, roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', customRole: role && !isPreset ? role : '', }); }); setMembers(drafts); setJsonError(null); } catch (e) { setJsonError(e instanceof Error ? e.message : 'Invalid JSON'); } }; const toggleJsonEditor = (): void => { if (!jsonEditorOpen) { setJsonText(membersToJsonText(members)); setJsonError(null); } setJsonEditorOpen((prev) => !prev); }; useEffect(() => { if (!jsonEditorOpen || jsonError !== null) return; setJsonText(membersToJsonText(members)); }, [members, jsonEditorOpen, jsonError]); const description = descriptionDraft.value; const prompt = promptDraft.value; const mentionSuggestions = useMemo( () => members .filter((m) => m.name.trim()) .map((m, index) => ({ id: m.id, name: m.name.trim(), subtitle: m.roleSelection === CUSTOM_ROLE ? m.customRole.trim() || undefined : m.roleSelection && m.roleSelection !== NO_ROLE ? m.roleSelection : undefined, color: getMemberColor(index), })), [members] ); const effectiveModel = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined; const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const request = useMemo( () => ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, members: buildMembers(members), cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, }), [sanitizedTeamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel] ); const activeError = localError ?? provisioningError; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; const conflictingTeam = useMemo(() => { if (!activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; }, [activeTeams, effectiveCwd]); const updateMemberName = (memberId: string, name: string): void => { setMembers((prev) => prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate)) ); }; const updateMemberRole = (memberId: string, roleSelection: string): void => { const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection; setMembers((prev) => prev.map((candidate) => candidate.id === memberId ? { ...candidate, roleSelection: resolvedRole, customRole: resolvedRole === CUSTOM_ROLE ? candidate.customRole : '', } : candidate ) ); }; const updateMemberCustomRole = (memberId: string, customRole: string): void => { setMembers((prev) => prev.map((candidate) => candidate.id === memberId ? { ...candidate, customRole } : candidate ) ); }; const removeMember = (memberId: string): void => { setMembers((prev) => prev.filter((candidate) => candidate.id !== memberId)); }; const handleSubmit = (): void => { if (existingTeamNames.includes(sanitizedTeamName)) { setFieldErrors({ teamName: 'Team name already exists' }); setLocalError('Check form fields'); return; } const validation = validateRequest(request, { requireCwd: launchTeam }); if (!validation.valid) { setFieldErrors(validation.errors ?? {}); setLocalError('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, }); 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); } })(); }; 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 ? (

Team “{conflictingTeam.displayName}” is already running in this project

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.

) : 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}
setTeamName(event.target.value)} placeholder="team-alpha" /> {existingTeamNames.includes(sanitizedTeamName) ? (

Team name already exists

) : validateTeamNameInline(teamName) ? (

{validateTeamNameInline(teamName)}

) : fieldErrors.teamName ? (

{fieldErrors.teamName}

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

On disk: {sanitizedTeamName}

) : null}
{members.map((member, index) => { const memberColorSet = getTeamColorSet(getMemberColor(index)); return (
updateMemberName(member.id, event.target.value)} placeholder="member-name" style={ member.name.trim() ? { color: memberColorSet.text, } : undefined } /> {validateMemberNameInline(member.name) ? (

{validateMemberNameInline(member.name)}

) : null}
{member.roleSelection === CUSTOM_ROLE ? ( updateMemberCustomRole(member.id, event.target.value) } placeholder="e.g. architect" /> ) : null}
); })} {jsonEditorOpen ? ( ) : null}
{(() => { const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); const hasDuplicates = new Set(names).size !== names.length; if (hasDuplicates) return

Member names must be unique

; if (fieldErrors.members) return

{fieldErrors.members}

; return null; })()}
setLaunchTeam(checked === true)} />
{launchTeam ? (
Draft saved ) : null } />
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
{prepareMessage ?? (prepareState === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')}
) : null} {canCreate && prepareState === 'ready' ? (
CLI environment ready
) : null}
) : null}
descriptionDraft.setValue(event.target.value)} placeholder="Brief description of the team purpose" /> {descriptionDraft.isSaved ? ( Draft saved ) : null}
{TEAM_COLOR_NAMES.map((colorName) => { const colorSet = getTeamColorSet(colorName); const isSelected = teamColor === colorName; return ( ); })}
{activeError ? (

{activeError}

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