import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Combobox } from '@renderer/components/ui/combobox'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; import { Textarea } from '@renderer/components/ui/textarea'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { cn } from '@renderer/lib/utils'; import { Check, CheckCircle2, Loader2 } from 'lucide-react'; const TEAM_COLOR_NAMES = [ 'blue', 'green', 'red', 'yellow', 'purple', 'cyan', 'orange', 'pink', ] as const; import type { Project, TeamCreateRequest, TeamProvisioningMemberInput, TeamProvisioningPrepareResult, } from '@shared/types'; export interface TeamCopyData { teamName: string; description?: string; color?: string; members: TeamProvisioningMemberInput[]; } interface CreateTeamDialogProps { open: boolean; canCreate: boolean; provisioningError: string | null; existingTeamNames: string[]; initialData?: TeamCopyData; onClose: () => void; onCreate: (request: TeamCreateRequest) => Promise; onOpenTeam: (teamName: string, projectPath?: string) => void; } interface ValidationResult { valid: boolean; errors?: { teamName?: string; members?: string; cwd?: string; }; } const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const; const CUSTOM_ROLE = '__custom__'; const NO_ROLE = '__none__'; 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 escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function renderHighlightedText(text: string, query: string): React.JSX.Element { if (!query.trim()) { return {text}; } const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig'); const parts = text.split(pattern); return ( {parts.map((part, index) => { const isMatch = part.toLowerCase() === query.toLowerCase(); if (!isMatch) { return {part}; } return ( {part} ); })} ); } 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); } function validateRequest( request: TeamCreateRequest, options?: { requireCwd?: boolean } ): ValidationResult { const requireCwd = options?.requireCwd ?? true; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) { return { valid: false, errors: { teamName: 'Use kebab-case [a-z0-9-], max 64 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', }, }; } const memberNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; if (request.members.some((member) => !memberNamePattern.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, initialData, onClose, onCreate, onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { const isDev = process.env.NODE_ENV !== 'production'; const [teamName, setTeamName] = useState(''); const [description, setDescription] = useState(''); const [prompt, setPrompt] = useState(''); 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 resetFormState = (): void => { setTeamName(''); setDescription(''); setPrompt(''); setMembers([]); setTeamColor(''); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setLocalError(null); setFieldErrors({}); setIsSubmitting(false); setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); setLaunchTeam(true); }; 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); setDescription(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; } if (teamName.trim().length === 0) { setTeamName(DEV_DEFAULT_TEAM.teamName); } if (description.trim().length === 0) { setDescription(DEV_DEFAULT_TEAM.description); } }, [open, isDev, teamName, description, initialData]); useEffect(() => { if (cwdMode !== 'project') { return; } if (selectedProjectPath || projects.length === 0) { return; } setSelectedProjectPath(projects[0].path); }, [cwdMode, projects, selectedProjectPath]); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const request = useMemo( () => ({ teamName: teamName.trim(), description: description.trim() || undefined, color: teamColor || undefined, members: buildMembers(members), cwd: effectiveCwd, prompt: prompt.trim() || undefined, }), [teamName, description, teamColor, members, effectiveCwd, prompt] ); const activeError = localError ?? provisioningError; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; 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(request.teamName)) { 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) { resetFormState(); onClose(); } }} > {initialData ? 'Copy Team' : 'Create Team'} {initialData ? 'Create a new team based on an existing one.' : 'Team provisioning via local Claude CLI.'} {canCreate && launchTeam && prepareState === 'failed' ? (

{prepareMessage ?? 'Failed to prepare environment'}

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

{warning}

))}
) : null}
) : null} {!canCreate ? (

Available only in local Electron mode.

) : null}
setTeamName(event.target.value)} placeholder="team-alpha" /> {existingTeamNames.includes(teamName.trim()) ? (

Team name already exists

) : fieldErrors.teamName ? (

{fieldErrors.teamName}

) : null}