import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { buildMemberDraftColorMap, buildMemberDraftSuggestions, buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraftsFromInputs, filterEditableMemberInputs, normalizeMemberDraftForProviderMode, normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; 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 { getTeamColorSet } 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 { useStore } from '@renderer/store'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, } from '@renderer/utils/geminiUiFreeze'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, Loader2, RotateCcw, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { createInitialProviderChecks, failIncompleteProviderChecks, getProvisioningFailureHint, getProvisioningProviderBackendSummary, type ProvisioningProviderCheck, ProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList, updateProviderCheck, } from './ProvisioningProviderStatusList'; import { computeEffectiveTeamModel, formatTeamModelSummary, TeamModelSelector, } from './TeamModelSelector'; import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, EffortLevel, Project, ResolvedTeamMember, Schedule, ScheduleLaunchConfig, TeamLaunchRequest, TeamProviderId, 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?: (teamName?: string) => 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; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; // ============================================================================= // Helpers // ============================================================================= function getLocalTimezone(): string { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } } function getStoredTeamProvider(): TeamProviderId { const stored = localStorage.getItem('team:lastSelectedProvider'); // return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic'; return normalizeCreateLaunchProviderForUi( stored === 'codex' || stored === 'gemini' ? stored : 'anthropic', true ); } function getStoredTeamModel(providerId: TeamProviderId): string { const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`); if (stored === null) { return providerId === 'anthropic' ? 'opus' : ''; } return normalizeTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } function resolveMemberDraftRuntime( member: Pick, inheritedProviderId: TeamProviderId, inheritedModel: string, inheritedEffort: EffortLevel | undefined ): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } { return { providerId: member.providerId ?? inheritedProviderId, model: member.model?.trim() || inheritedModel, effort: member.effort ?? inheritedEffort, }; } function resolveResolvedMemberRuntime( member: Pick, inheritedProviderId: TeamProviderId, inheritedModel: string, inheritedEffort: EffortLevel | undefined ): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } { return { providerId: normalizeOptionalTeamProviderId(member.providerId) ?? inheritedProviderId, model: member.model?.trim() || inheritedModel, effort: member.effort ?? inheritedEffort, }; } // ============================================================================= // Component // ============================================================================= export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { const { open, onClose } = props; const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const fetchCliStatus = useStore((s) => s.fetchCliStatus); const isLaunch = props.mode === 'launch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? (props.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, openDashboard } = useStore( useShallow((s) => ({ teamByName: s.teamByName, openDashboard: s.openDashboard, })) ); const openTeamTab = useStore((s) => s.openTeamTab); 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 [selectedProviderId, setSelectedProviderIdRaw] = useState(getStoredTeamProvider); const [selectedModel, setSelectedModelRaw] = useState(() => getStoredTeamModel(getStoredTeamProvider()) ); const [membersDrafts, setMembersDrafts] = useState([]); const [syncModelsWithLead, setSyncModelsWithLead] = useState(false); const [skipPermissions, setSkipPermissionsRaw] = useState( () => localStorage.getItem('team:lastSkipPermissions') !== 'false' ); const [selectedEffort, setSelectedEffortRaw] = useState(() => { const stored = localStorage.getItem('team:lastSelectedEffort'); return stored === null ? 'medium' : stored; }); // --------------------------------------------------------------------------- // Launch-only state // --------------------------------------------------------------------------- const [limitContext, setLimitContextRaw] = useState( () => localStorage.getItem('team:lastLimitContext') === '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([]); const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); const previousLaunchParams = useStore((s) => effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined ); const members = isLaunch ? props.members : storeMembers; const [savedLaunchProviderId, setSavedLaunchProviderId] = useState(null); // 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(''); const effectiveMemberDrafts = useMemo( () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), [membersDrafts, syncModelsWithLead] ); const selectedMemberProviders = useMemo( () => !multimodelEnabled ? ['anthropic'] : Array.from( new Set([ selectedProviderId, ...effectiveMemberDrafts.flatMap((member) => isTeamProviderId(member.providerId) ? [member.providerId] : [] ), ]) ), [effectiveMemberDrafts, multimodelEnabled, selectedProviderId] ); const runtimeBackendSummaryByProvider = useMemo(() => { const entries: (readonly [TeamProviderId, string | null])[] = (cliStatus?.providers ?? []).map( (provider) => [ provider.providerId as TeamProviderId, getProvisioningProviderBackendSummary(provider), ] as const ); return new Map(entries); }, [cliStatus?.providers]); useEffect(() => { if (multimodelEnabled) { return; } if (selectedProviderId !== 'anthropic') { setSelectedProviderIdRaw('anthropic'); setSelectedModelRaw(getStoredTeamModel('anthropic')); } setMembersDrafts((prev) => { let changed = false; const next = prev.map((member) => { const normalized = normalizeMemberDraftForProviderMode(member, false); if (normalized !== member) changed = true; return normalized; }); return changed ? next : prev; }); }, [multimodelEnabled, selectedProviderId]); useEffect(() => { if (!open || cliStatus || cliStatusLoading) { return; } void fetchCliStatus(); }, [open, cliStatus, cliStatusLoading, fetchCliStatus]); // 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 setSelectedProviderId = (value: TeamProviderId): void => { const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); localStorage.setItem('team:lastSelectedProvider', normalizedValue); if (normalizedValue !== 'anthropic') { setLimitContextRaw(false); localStorage.setItem('team:lastLimitContext', 'false'); } setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; const setSelectedModel = (value: string): void => { const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; 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); }; // --------------------------------------------------------------------------- // localStorage migration: schedule → team namespace (one-time) // --------------------------------------------------------------------------- useEffect(() => { const legacyTeamModel = localStorage.getItem('team:lastSelectedModel'); if ( legacyTeamModel != null && localStorage.getItem('team:lastSelectedModel:anthropic') == null ) { localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel); } localStorage.removeItem('team:lastSelectedModel'); for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) { const schedKey = `schedule:${suffix}`; const teamKey = suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `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([]); setPrepareChecks([]); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setClearContext(false); setConflictDismissed(false); setMembersDrafts([]); setSyncModelsWithLead(false); chipDraft.clearChipDraft(); // Schedule fields setSelectedTeamName(''); setSchedLabel(''); setCronExpression('0 9 * * 1-5'); setTimezone(getLocalTimezone()); setWarmUpMinutes(15); setMaxTurns(50); setMaxBudgetUsd(''); }; const closeDialog = (): void => { if (isLaunch) { resetFormState(); } onClose(); }; // 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'); const scheduleProviderId = normalizeProviderForMode( schedule.launchConfig.providerId, multimodelEnabled ); setSelectedProviderIdRaw(scheduleProviderId); setSelectedModelRaw( schedule.launchConfig.providerId !== 'gemini' && scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) ? (schedule.launchConfig.model ?? '') : getStoredTeamModel('anthropic') ); 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(''); const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); setSelectedProviderIdRaw(storedProviderId); setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); } setLocalError(null); setIsSubmitting(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isSchedule, schedule?.id]); useEffect(() => { if (!open || !isLaunch) return; let cancelled = false; void (async () => { let savedRequest = null; try { savedRequest = effectiveTeamName ? await api.teams.getSavedRequest(effectiveTeamName) : null; } catch { savedRequest = null; } if (cancelled) return; const nextMembersSource = members.length > 0 ? members : savedRequest?.members && savedRequest.members.length > 0 ? savedRequest.members : []; const editableMembersSource = filterEditableMemberInputs(nextMembersSource); const storedEffort = localStorage.getItem('team:lastSelectedEffort'); const savedProviderId = savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' ? savedRequest.providerId : savedRequest?.providerId === 'anthropic' ? 'anthropic' : null; const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); const launchPrefill = resolveLaunchDialogPrefill({ members, savedRequest, previousLaunchParams, multimodelEnabled, storedProviderId, storedEffort: storedEffort === null ? 'medium' : storedEffort, getStoredModel: getStoredTeamModel, }); setSavedLaunchProviderId(savedProviderId); setMembersDrafts( createMemberDraftsFromInputs(editableMembersSource).map((member) => normalizeMemberDraftForProviderMode(member, multimodelEnabled) ) ); setSyncModelsWithLead( !editableMembersSource.some((member) => member.providerId || member.model || member.effort) ); setSelectedProviderIdRaw(launchPrefill.providerId); setSelectedModelRaw(launchPrefill.model); setSelectedEffortRaw(launchPrefill.effort); setLimitContextRaw( savedRequest?.limitContext === true || localStorage.getItem('team:lastLimitContext') === 'true' ); setSkipPermissionsRaw( savedRequest?.skipPermissions ?? localStorage.getItem('team:lastSkipPermissions') !== 'false' ); })(); return () => { cancelled = true; }; }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]); const previousProviderId = useMemo(() => { if (!isLaunch) { return null; } const fromLaunchParams = previousLaunchParams?.providerId; if ( fromLaunchParams === 'anthropic' || fromLaunchParams === 'codex' || fromLaunchParams === 'gemini' ) { return fromLaunchParams; } return savedLaunchProviderId; }, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]); const providerChangeForcesFreshLeadContext = useMemo(() => { if (!isLaunch || !previousProviderId) { return false; } return previousProviderId !== selectedProviderId; }, [isLaunch, previousProviderId, selectedProviderId]); const effectiveLeadRuntimeModel = useMemo( () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', [selectedModel, limitContext, selectedProviderId] ); const runtimeChangeNotes = useMemo(() => { if (!isLaunch) { return [] as { key: string; memberName: string; message: string }[]; } const notes: { key: string; memberName: string; message: string }[] = []; const previousLeadModel = previousLaunchParams?.model?.trim() || ''; const previousLeadEffort = previousLaunchParams?.effort; const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel; if ( previousProviderId && (previousProviderId !== selectedProviderId || previousLeadModel !== currentLeadDisplayModel || (previousLeadEffort ?? '') !== ((selectedEffort as EffortLevel | '') || '')) ) { notes.push({ key: 'lead', memberName: 'lead', message: `${formatTeamModelSummary( selectedProviderId, currentLeadDisplayModel, (selectedEffort as EffortLevel) || undefined )} instead of ${formatTeamModelSummary( previousProviderId, previousLeadModel, previousLeadEffort )}`, }); } const previousMembersByName = new Map( members.map((member) => [member.name.trim().toLowerCase(), member] as const) ); for (const member of effectiveMemberDrafts) { if (member.removedAt) { continue; } const name = member.name.trim(); if (!name) { continue; } const previousMember = previousMembersByName.get(name.toLowerCase()); if (!previousMember) { continue; } const { providerId: currentProviderId, model: currentModel, effort: currentEffort, } = resolveMemberDraftRuntime( member, selectedProviderId, currentLeadDisplayModel, (selectedEffort as EffortLevel) || undefined ); const { providerId: previousProvider, model: previousModel, effort: previousEffort, } = resolveResolvedMemberRuntime( previousMember, previousProviderId ?? 'anthropic', previousLeadModel, previousLeadEffort ); if ( previousProvider === currentProviderId && previousModel === currentModel && (previousEffort ?? '') === (currentEffort ?? '') ) { continue; } notes.push({ key: `member:${name.toLowerCase()}`, memberName: name, message: `${formatTeamModelSummary( currentProviderId, currentModel, currentEffort )} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`, }); } return notes; }, [ isLaunch, previousLaunchParams?.effort, previousLaunchParams?.model, previousProviderId, selectedProviderId, selectedModel, effectiveLeadRuntimeModel, selectedEffort, members, effectiveMemberDrafts, ]); const runtimeChangeNoteByKey = useMemo( () => new Map(runtimeChangeNotes.map((note) => [note.key, note.message] as const)), [runtimeChangeNotes] ); const leadRuntimeWarningText = useMemo(() => { const parts: string[] = []; if (providerChangeForcesFreshLeadContext && previousProviderId) { parts.push( `Provider changed from ${getProviderLabel(previousProviderId)} to ${getProviderLabel(selectedProviderId)}. The previous lead session will not be resumed and lead will start with a fresh context.` ); } const runtimeChange = runtimeChangeNoteByKey.get('lead'); if (runtimeChange) { parts.push(`Next launch will use ${runtimeChange}.`); } return parts.length > 0 ? parts.join(' ') : null; }, [ providerChangeForcesFreshLeadContext, previousProviderId, selectedProviderId, runtimeChangeNoteByKey, ]); const memberRuntimeWarningById = useMemo(() => { const warnings: Record = {}; for (const member of effectiveMemberDrafts) { const name = member.name.trim(); if (!name || member.removedAt) { continue; } const note = runtimeChangeNoteByKey.get(`member:${name.toLowerCase()}`); if (note) { warnings[member.id] = `Next launch will use ${note}.`; } } return warnings; }, [effectiveMemberDrafts, runtimeChangeNoteByKey]); // --------------------------------------------------------------------------- // Launch-only effects // --------------------------------------------------------------------------- const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); // Clear stale provisioning error when dialog opens useEffect(() => { if (!open || !isLaunch) return; props.clearProvisioningError?.(effectiveTeamName); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, isLaunch, effectiveTeamName]); // Warm up CLI for the currently selected working directory (launch mode only). useEffect(() => { if (!open || !isLaunch) return; if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks([]); setPrepareMessage( 'Current preload version does not support team:prepareProvisioning. Restart the dev app.' ); return; } if (!effectiveCwd) { setPrepareState('idle'); setPrepareWarnings([]); setPrepareChecks([]); setPrepareMessage('Select a working directory to validate the launch environment.'); return; } let cancelled = false; const requestSeq = ++prepareRequestSeqRef.current; setPrepareState('loading'); setPrepareMessage('Checking selected providers...'); setPrepareWarnings([]); setPrepareChecks(createInitialProviderChecks(selectedMemberProviders)); void (async () => { let checks = createInitialProviderChecks(selectedMemberProviders); let anyFailure = false; let anyNotes = false; const collectedWarnings: string[] = []; try { for (const providerId of selectedMemberProviders) { checks = updateProviderCheck(checks, providerId, { status: 'checking', backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, details: [], }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`); } const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning( effectiveCwd, providerId, [providerId] ); const detailLines = [ ...(prepResult.warnings ?? []).filter(Boolean), ...(!prepResult.ready && prepResult.message ? [prepResult.message] : []), ]; if (prepResult.warnings?.length) { anyNotes = true; collectedWarnings.push( ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) ); } if (!prepResult.ready) { anyFailure = true; } checks = updateProviderCheck(checks, providerId, { status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null, details: detailLines, }); if (!cancelled && prepareRequestSeqRef.current === requestSeq) { setPrepareChecks(checks); } } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; setPrepareState(anyFailure ? 'failed' : 'ready'); setPrepareMessage( anyFailure ? 'Some selected providers need attention.' : anyNotes ? 'Selected providers are ready with notes.' : 'Selected providers are ready.' ); setPrepareWarnings(collectedWarnings); } catch (error) { if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'; setPrepareState('failed'); setPrepareWarnings([]); setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage)); setPrepareMessage(failureMessage); } })(); return () => { cancelled = true; }; }, [ open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders, runtimeBackendSummaryByProvider, ]); // --------------------------------------------------------------------------- // Shared effects: projects // --------------------------------------------------------------------------- const repositoryGroups = useStore(useShallow((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.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]); // Pre-warm file list cache so @-mention file search is instant useFileListCacheWarmer(effectiveCwd || null); // --------------------------------------------------------------------------- // Launch-only: conflict detection // --------------------------------------------------------------------------- const activeTeams = isLaunch ? props.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 { suggestions: taskSuggestions } = useTaskSuggestions(null); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null); const memberColorMap = useMemo( () => buildMemberDraftColorMap(membersDrafts, members), [membersDrafts, members] ); const mentionSuggestions = useMemo( () => buildMemberDraftSuggestions(membersDrafts, memberColorMap), [memberColorMap, membersDrafts] ); // --------------------------------------------------------------------------- // 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', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId); if (model) args.push('--model', model); if (selectedEffort) args.push('--effort', selectedEffort); if (!clearContext) args.push('--resume', ''); return args; }, [ isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext, selectedProviderId, ]); const launchOptionalSummary = useMemo(() => { if (!isLaunch) return []; const summary: string[] = []; if (promptDraft.value.trim()) summary.push('Lead prompt'); summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K 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, selectedProviderId, selectedEffort, limitContext, 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]); const hasInvalidLaunchMemberNames = useMemo( () => isLaunch && membersDrafts.some( (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null ), [isLaunch, membersDrafts] ); const hasDuplicateLaunchMemberNames = useMemo(() => { if (!isLaunch) return false; const activeNames = membersDrafts .map((member) => member.name.trim().toLowerCase()) .filter(Boolean); return new Set(activeNames).size !== activeNames.length; }, [isLaunch, membersDrafts]); // --------------------------------------------------------------------------- // Error // --------------------------------------------------------------------------- const provisioningError = isLaunch ? props.provisioningError : null; const activeError = localError ?? provisioningError; const launchInFlight = useStore((s) => isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); useEffect(() => { if (!open || !isLaunch || !effectiveTeamName || !launchInFlight) { return; } openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath); closeDialog(); }, [ closeDialog, defaultProjectPath, effectiveCwd, effectiveTeamName, isLaunch, launchInFlight, open, openTeamTab, ]); // --------------------------------------------------------------------------- // Submit // --------------------------------------------------------------------------- const handleSubmit = (): void => { if (validationErrors.length > 0) { setLocalError(validationErrors[0]); return; } if (isLaunch && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; } if ( isLaunch && membersDrafts.some( (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null ) ) { setLocalError('Fix member names before launch'); return; } if (isLaunch) { const activeNames = membersDrafts .map((member) => member.name.trim().toLowerCase()) .filter(Boolean); if (new Set(activeNames).size !== activeNames.length) { setLocalError('Member names must be unique before launch'); return; } } setLocalError(null); setIsSubmitting(true); void (async () => { try { if (isLaunch) { await api.teams.replaceMembers(effectiveTeamName, { members: buildMembersFromDrafts(effectiveMemberDrafts), }); await props.onLaunch({ teamName: effectiveTeamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, providerId: selectedProviderId, model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId), effort: (selectedEffort as EffortLevel) || undefined, limitContext, clearContext: clearContext || undefined, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }); openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath); closeDialog(); } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; const launchConfig: ScheduleLaunchConfig = { cwd: effectiveCwd, prompt: promptDraft.value.trim(), providerId: selectedProviderId, 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); } closeDialog(); } } catch (err) { const message = err instanceof Error ? err.message : isSchedule ? 'Failed to save schedule' : 'Failed to launch team'; setLocalError(message); if (isLaunch) { console.error('Failed to launch team from dialog:', err); } } finally { setIsSubmitting(false); } })(); }; // --------------------------------------------------------------------------- // Disabled state // --------------------------------------------------------------------------- const isDisabled = isLaunch ? isSubmitting || launchInFlight || validationErrors.length > 0 || hasInvalidLaunchMemberNames || hasDuplicateLaunchMemberNames : 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 ? prepareState === 'idle' || prepareState === 'loading' ? 'Skip and Launch' : 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( { if (!nextOpen) { closeDialog(); } }} > {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'}

{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? ( ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
{prepareWarnings.map((warning) => (

{warning}

))}
) : null}

{getProvisioningFailureHint(prepareMessage, prepareChecks)}

{(prepareMessage ?? '').toLowerCase().includes('spawn ') || prepareChecks.some((check) => check.details.some((detail) => detail.toLowerCase().includes('spawn ')) ) ? ( ) : null}
) : 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 ? (
Saved ) : null } />
{providerChangeForcesFreshLeadContext ? (

Provider changed from {getProviderLabel(previousProviderId!)} to{' '} {getProviderLabel(selectedProviderId)}. The previous lead session will not be resumed, and the lead will start with fresh context so the new runtime is applied correctly.

) : 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.

)}
) : ( <>
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...')}

Pre-flight check to catch errors before launch

) : null} {prepareState === 'ready' ? (
{prepareChecks.some((check) => check.status === 'notes') || prepareWarnings.length > 0 ? 'CLI environment ready (with notes)' : 'CLI environment ready'}
{prepareMessage ? (

{prepareMessage}

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

{warning}

))}
) : null}
) : null} {prepareState === 'failed' ?
: null}
) : null}
); };