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 { 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 { 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 { getMemberColor } from '@shared/constants/memberColors'; import { Check, CheckCircle2, Loader2 } from 'lucide-react'; 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[]; } 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 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 resetFormState = (): void => { setTeamName(''); descriptionDraft.clearDraft(); promptDraft.clearDraft(); 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); 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; } if (teamName.trim().length === 0) { setTeamName(DEV_DEFAULT_TEAM.teamName); } if (descriptionDraft.value.trim().length === 0) { descriptionDraft.setValue(DEV_DEFAULT_TEAM.description); } // eslint-disable-next-line react-hooks/exhaustive-deps -- dev default, intentional deps }, [open, isDev, teamName, 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 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 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}
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 ( ); })}
{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 } />
{member.roleSelection === CUSTOM_ROLE ? ( updateMemberCustomRole(member.id, event.target.value) } placeholder="e.g. architect" /> ) : null}
); })}
{fieldErrors.members ? (

{fieldErrors.members}

) : null}
setLaunchTeam(checked === true)} />
{launchTeam ? (
Draft saved ) : null } />
) : null} {launchTeam ? (
{cwdMode === 'project' ? (
({ value: project.path, label: project.name, description: project.path, }))} value={selectedProjectPath} onValueChange={setSelectedProjectPath} placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} searchPlaceholder="Search project by name or path" emptyMessage="Nothing found" disabled={projectsLoading || projects.length === 0} renderOption={(option, isSelected, query) => ( <>

{renderHighlightedText(option.label, query)}

{renderHighlightedText(option.description ?? '', query)}

)} /> {!selectedProjectPath ? (

Select a project from the list

) : null} {projectsError ? (

{projectsError}

) : null} {!projectsLoading && projects.length === 0 ? (

No projects found, switch to custom path.

) : null}
) : (
setCustomCwd(event.target.value)} placeholder="/absolute/path/to/project" />

If the directory does not exist, it will be created automatically.

)}
{fieldErrors.cwd ? (

{fieldErrors.cwd}

) : null}
) : null}
{activeError ? (

{activeError}

) : null} {canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
{prepareMessage ?? (prepareState === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')} · Team provisioning via local Claude CLI.
) : null} {canCreate && launchTeam && prepareState === 'ready' ? (
CLI environment ready
) : null} {canOpenExistingTeam ? ( ) : null}
); };