import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; 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 { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useStore } from '@renderer/store'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, Loader2, RotateCcw, X, } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, EffortLevel, Project, ResolvedTeamMember, Schedule, ScheduleLaunchConfig, TeamLaunchRequest, TeamProvisioningPrepareResult, UpdateSchedulePatch, } from '@shared/types'; // ============================================================================= // Props — discriminated union // ============================================================================= interface LaunchDialogBase { open: boolean; teamName: string; onClose: () => void; } interface LaunchDialogLaunchMode extends LaunchDialogBase { mode: 'launch'; members: ResolvedTeamMember[]; defaultProjectPath?: string; provisioningError: string | null; clearProvisioningError?: () => void; activeTeams?: ActiveTeamRef[]; onLaunch: (request: TeamLaunchRequest) => Promise; } interface LaunchDialogScheduleMode { mode: 'schedule'; open: boolean; /** Team name — optional when creating from standalone schedules page */ teamName?: string; onClose: () => void; /** When provided → edit mode; null/undefined → create mode */ schedule?: Schedule | null; } export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode; // ============================================================================= // Helpers // ============================================================================= function getLocalTimezone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } } // ============================================================================= // Component // ============================================================================= export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { const { open, onClose } = props; const isLaunch = props.mode === 'launch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? ((props as LaunchDialogScheduleMode).schedule ?? null) : null; const isEditing = isSchedule && !!schedule; // Team name: always present for launch mode, may be absent in schedule mode (standalone page) const propsTeamName = props.teamName ?? ''; const [selectedTeamName, setSelectedTeamName] = useState(''); const teamByName = useStore((s) => s.teamByName); const teamOptions = useMemo( () => Object.values(teamByName) .sort((a, b) => a.teamName.localeCompare(b.teamName)) .map((team) => ({ value: team.teamName, label: team.displayName || team.teamName, description: team.description || undefined, meta: { color: team.color }, })), [teamByName] ); // Effective team name: from props if provided, otherwise from local selection const effectiveTeamName = propsTeamName || selectedTeamName; const needsTeamSelector = isSchedule && !propsTeamName; // --------------------------------------------------------------------------- // Shared form state // --------------------------------------------------------------------------- const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPath] = useState(''); const [customCwd, setCustomCwd] = useState(''); const promptDraft = useDraftPersistence({ key: `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:prompt`, }); const chipDraft = useChipDraftPersistence( `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips` ); const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModel, setSelectedModelRaw] = useState(() => { const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; return stored === '__default__' ? '' : stored; }); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); const [selectedEffort, setSelectedEffortRaw] = useState( () => localStorage.getItem('team:lastSelectedEffort') ?? '' ); // --------------------------------------------------------------------------- // Launch-only state // --------------------------------------------------------------------------- const [extendedContext, setExtendedContextRaw] = useState( () => localStorage.getItem('team:lastExtendedContext') === 'true' ); const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle'); const [prepareMessage, setPrepareMessage] = useState(null); const [prepareWarnings, setPrepareWarnings] = useState([]); // Advanced CLI section state (with localStorage persistence) const [worktreeEnabled, setWorktreeEnabledRaw] = useState( () => localStorage.getItem(`team:lastWorktreeEnabled:${effectiveTeamName}`) === 'true' && Boolean(localStorage.getItem(`team:lastWorktreeName:${effectiveTeamName}`)) ); const [worktreeName, setWorktreeNameRaw] = useState( () => localStorage.getItem(`team:lastWorktreeName:${effectiveTeamName}`) ?? '' ); const [customArgs, setCustomArgsRaw] = useState( () => localStorage.getItem(`team:lastCustomArgs:${effectiveTeamName}`) ?? '' ); // --------------------------------------------------------------------------- // Schedule-only state // --------------------------------------------------------------------------- const [schedLabel, setSchedLabel] = useState(''); const [schedExpanded, setSchedExpanded] = useState(true); const [cronExpression, setCronExpression] = useState('0 9 * * 1-5'); const [timezone, setTimezone] = useState(getLocalTimezone); const [warmUpMinutes, setWarmUpMinutes] = useState(15); const [maxTurns, setMaxTurns] = useState(50); const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); const updateSchedule = useStore((s) => s.updateSchedule); // --------------------------------------------------------------------------- // localStorage persistence wrappers // --------------------------------------------------------------------------- const setWorktreeEnabled = (value: boolean): void => { setWorktreeEnabledRaw(value); localStorage.setItem(`team:lastWorktreeEnabled:${effectiveTeamName}`, String(value)); if (!value) { setWorktreeNameRaw(''); localStorage.setItem(`team:lastWorktreeName:${effectiveTeamName}`, ''); } }; const setWorktreeName = (value: string): void => { setWorktreeNameRaw(value); localStorage.setItem(`team:lastWorktreeName:${effectiveTeamName}`, value); }; const setCustomArgs = (value: string): void => { setCustomArgsRaw(value); localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value); }; const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); localStorage.setItem('team:lastSelectedModel', value); }; const setExtendedContext = (value: boolean): void => { setExtendedContextRaw(value); localStorage.setItem('team:lastExtendedContext', 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); }; // --------------------------------------------------------------------------- // localStorage migration: schedule → team namespace (one-time) // --------------------------------------------------------------------------- useEffect(() => { for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) { const schedKey = `schedule:${suffix}`; const teamKey = `team:${suffix}`; const schedVal = localStorage.getItem(schedKey); if (schedVal != null && localStorage.getItem(teamKey) == null) { localStorage.setItem(teamKey, schedVal); } localStorage.removeItem(schedKey); } }, []); // --------------------------------------------------------------------------- // Form reset / populate // --------------------------------------------------------------------------- const resetFormState = (): void => { setLocalError(null); setIsSubmitting(false); setPrepareState('idle'); setPrepareMessage(null); setPrepareWarnings([]); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setClearContext(false); setConflictDismissed(false); chipDraft.clearChipDraft(); // Schedule fields setSelectedTeamName(''); setSchedLabel(''); setCronExpression('0 9 * * 1-5'); setTimezone(getLocalTimezone()); setWarmUpMinutes(15); setMaxTurns(50); setMaxBudgetUsd(''); }; // Populate form in schedule edit mode useEffect(() => { if (!open || !isSchedule) return; if (schedule) { // Edit mode — populate from existing schedule setSchedLabel(schedule.label ?? ''); setCronExpression(schedule.cronExpression); setTimezone(schedule.timezone); setWarmUpMinutes(schedule.warmUpMinutes); setMaxTurns(schedule.maxTurns); setMaxBudgetUsd(schedule.maxBudgetUsd != null ? String(schedule.maxBudgetUsd) : ''); promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); setSelectedModelRaw(schedule.launchConfig.model ?? ''); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); } else { // Create mode — reset to defaults setSchedLabel(''); setCronExpression('0 9 * * 1-5'); setTimezone(getLocalTimezone()); setWarmUpMinutes(15); setMaxTurns(50); setMaxBudgetUsd(''); promptDraft.setValue(''); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); } setLocalError(null); setIsSubmitting(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isSchedule, schedule?.id]); // --------------------------------------------------------------------------- // Launch-only effects // --------------------------------------------------------------------------- // Clear stale provisioning error when dialog opens useEffect(() => { if (!open || !isLaunch) return; (props as LaunchDialogLaunchMode).clearProvisioningError?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isLaunch]); // Warm up CLI on open (launch mode only) useEffect(() => { if (!open || !isLaunch) 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, isLaunch]); // --------------------------------------------------------------------------- // Shared effects: projects // --------------------------------------------------------------------------- const repositoryGroups = useStore((s) => s.repositoryGroups); useEffect(() => { if (!open) return; setProjectsLoading(true); setProjectsError(null); let cancelled = false; void (async () => { try { const apiProjects = await api.getProjects(); if (cancelled) return; const pathSet = new Set(apiProjects.map((p) => p.path)); const extras: Project[] = []; for (const repo of repositoryGroups) { for (const wt of repo.worktrees) { if (!pathSet.has(wt.path)) { pathSet.add(wt.path); extras.push({ id: wt.id, path: wt.path, name: wt.name, sessions: [], totalSessions: 0, createdAt: wt.createdAt ?? Date.now(), }); } } } setProjects([...apiProjects, ...extras]); } 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, repositoryGroups]); // Pre-select defaultProjectPath (launch mode) or first project const defaultProjectPath = isLaunch ? (props as LaunchDialogLaunchMode).defaultProjectPath : undefined; useEffect(() => { if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return; if (defaultProjectPath) { const match = projects.find((p) => p.path === defaultProjectPath); if (match) { setSelectedProjectPath(match.path); return; } } setSelectedProjectPath(projects[0].path); }, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); // Pre-warm file list cache so @-mention file search is instant useFileListCacheWarmer(effectiveCwd || null); // --------------------------------------------------------------------------- // Launch-only: conflict detection // --------------------------------------------------------------------------- const activeTeams = isLaunch ? (props as LaunchDialogLaunchMode).activeTeams : undefined; const conflictingTeam = useMemo(() => { if (!isLaunch || !activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return ( activeTeams.find( (t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm ) ?? null ); }, [isLaunch, activeTeams, effectiveCwd, effectiveTeamName]); useEffect(() => { setConflictDismissed(false); }, [conflictingTeam?.teamName, effectiveCwd]); // --------------------------------------------------------------------------- // Mention suggestions (shared — from props in launch, from store in schedule) // --------------------------------------------------------------------------- const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); const members = isLaunch ? (props as LaunchDialogLaunchMode).members : storeMembers; const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const mentionSuggestions = useMemo( () => members.map((m) => ({ id: m.name, name: m.name, subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, color: colorMap.get(m.name), })), [members, colorMap] ); // --------------------------------------------------------------------------- // Launch-only: internal args preview // --------------------------------------------------------------------------- const internalArgs = useMemo(() => { if (!isLaunch) return []; 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'); const model = computeEffectiveTeamModel(selectedModel, extendedContext); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); return args; }, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); const launchOptionalSummary = useMemo(() => { if (!isLaunch) return []; const summary: string[] = []; if (promptDraft.value.trim()) summary.push('Lead prompt'); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); if (extendedContext) summary.push('Extended context'); if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; }, [ isLaunch, promptDraft.value, selectedModel, selectedEffort, extendedContext, skipPermissions, clearContext, worktreeEnabled, worktreeName, customArgs, ]); // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- const validationErrors = useMemo(() => { const errors: string[] = []; if (!effectiveCwd) errors.push('Working directory is required'); if (isSchedule) { if (!effectiveTeamName) errors.push('Team is required'); if (!promptDraft.value.trim()) errors.push('Prompt is required'); if (!cronExpression.trim()) errors.push('Cron expression is required'); } return errors; }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); // --------------------------------------------------------------------------- // Error // --------------------------------------------------------------------------- const provisioningError = isLaunch ? (props as LaunchDialogLaunchMode).provisioningError : null; const activeError = localError ?? provisioningError; // --------------------------------------------------------------------------- // Submit // --------------------------------------------------------------------------- const handleSubmit = (): void => { if (validationErrors.length > 0) { setLocalError(validationErrors[0]); return; } if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; } setLocalError(null); setIsSubmitting(true); void (async () => { try { if (isLaunch) { await (props as LaunchDialogLaunchMode).onLaunch({ teamName: effectiveTeamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, model: computeEffectiveTeamModel(selectedModel, extendedContext), effort: (selectedEffort as EffortLevel) || undefined, clearContext: clearContext || undefined, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }); resetFormState(); onClose(); } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; const launchConfig: ScheduleLaunchConfig = { cwd: effectiveCwd, prompt: promptDraft.value.trim(), model: selectedModel || undefined, effort: (selectedEffort as EffortLevel) || undefined, skipPermissions, }; if (isEditing && schedule) { const patch: UpdateSchedulePatch = { label: schedLabel.trim() || undefined, cronExpression: cronExpression.trim(), timezone, warmUpMinutes, maxTurns, maxBudgetUsd: parsedBudget, launchConfig, }; await updateSchedule(schedule.id, patch); } else { const input: CreateScheduleInput = { teamName: effectiveTeamName, label: schedLabel.trim() || undefined, cronExpression: cronExpression.trim(), timezone, warmUpMinutes, maxTurns, maxBudgetUsd: parsedBudget, launchConfig, }; await createSchedule(input); } onClose(); } } catch (err) { if (isSchedule) { setLocalError(err instanceof Error ? err.message : 'Failed to save schedule'); } // launch errors shown via provisioningError prop } finally { setIsSubmitting(false); } })(); }; // --------------------------------------------------------------------------- // Disabled state // --------------------------------------------------------------------------- const isDisabled = isLaunch ? isSubmitting || prepareState !== 'ready' : isSubmitting || validationErrors.length > 0; // --------------------------------------------------------------------------- // Dynamic labels // --------------------------------------------------------------------------- const dialogTitle = isLaunch ? 'Launch Team' : isEditing ? 'Edit Schedule' : 'Create Schedule'; const dialogDescription = isLaunch ? ( <> Start team {effectiveTeamName} via local Claude CLI. ) : isEditing ? ( `Editing schedule for team "${effectiveTeamName}"` ) : effectiveTeamName ? ( `Schedule automatic runs for team "${effectiveTeamName}"` ) : ( 'Schedule automatic Claude task execution' ); const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( { if (!nextOpen) { if (isLaunch) resetFormState(); onClose(); } }} > {dialogTitle} {dialogDescription} {/* Launch-only: Conflict warning */} {isLaunch && 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} {/* Launch-only: CLI env failed */} {isLaunch && 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}
{/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Team selector (standalone mode) ═══════════════════════════════════════════════════════════════════ */} {needsTeamSelector ? (
{ const colorName = option.meta?.color as string | undefined; const colorSet = colorName ? getTeamColorSet(colorName) : nameColorSet(option.label); return ( <> {isSelected ? ( ) : ( )}
{isSelected ? ( ) : null}

{option.label}

{option.description ? (

{option.description}

) : null}
); }} />
) : null} {/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Schedule configuration section ═══════════════════════════════════════════════════════════════════ */} {isSchedule ? (
{schedExpanded ? (
{/* Label */}
setSchedLabel(e.target.value)} placeholder="e.g., Daily code review, Nightly tests..." />
{/* Cron + Timezone + Warmup */}
) : null}
) : null} {/* ═══════════════════════════════════════════════════════════════════ Shared: Working directory ═══════════════════════════════════════════════════════════════════ */} {/* ═══════════════════════════════════════════════════════════════════ Launch: optional settings Schedule: prompt + execution defaults ═══════════════════════════════════════════════════════════════════ */} {isLaunch ? (
Draft saved ) : null } />
setClearContext(checked === true)} />
{clearContext && (

The team lead will start a new session without resuming previous context. All accumulated session memory and conversation history will not be available.

)}
) : ( <>
Draft saved ) : null } />

This prompt will be passed to claude -p for one-shot execution

)} {/* ═══════════════════════════════════════════════════════════════════ Schedule-only: Execution limits ═══════════════════════════════════════════════════════════════════ */} {isSchedule ? (
setMaxTurns(Math.max(1, parseInt(e.target.value) || 50))} />
setMaxBudgetUsd(e.target.value)} placeholder="No limit" />
) : null}
{/* Error display */} {activeError ? (
{activeError}
) : null} {/* Launch-only: CLI warm-up status */} {isLaunch ? (
{prepareState === 'idle' || prepareState === 'loading' ? (
{prepareMessage ?? (prepareState === 'idle' ? 'Warming up CLI environment...' : 'Preparing environment...')}
) : null} {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} {prepareState === 'failed' ?
: null}
) : null}
); };