agent-ecosystem/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
2026-05-08 21:48:27 +03:00

2981 lines
112 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
reconcileAnthropicRuntimeSelections,
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
import {
mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
import {
buildCodexFastModeArgs,
reconcileCodexRuntimeSelections,
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
import { api } from '@renderer/api';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import {
buildMemberDraftColorMap,
buildMemberDraftSuggestions,
buildMembersFromDrafts,
clearMemberModelOverrides,
createMemberDraftsFromInputs,
filterEditableMemberInputs,
normalizeLeadProviderForMode,
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 { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import {
isTeamProvisioningActive,
selectResolvedMembersForTeamName,
} 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 { resolveUiOwnedProviderBackendId } from '@renderer/utils/providerBackendIdentity';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import {
getTeamModelSelectionError,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import {
AlertTriangle,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
Info,
Loader2,
RotateCcw,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CronScheduleInput } from '../schedule/CronScheduleInput';
import { AdvancedCliSection } from './AdvancedCliSection';
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
import { CodexFastModeSelector } from './CodexFastModeSelector';
import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt';
import { EffortLevelSelector } from './EffortLevelSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
import {
clearInheritedMemberModelsUnavailableForProvider,
resolveProviderScopedMemberModel,
} from './memberModelScope';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
} from './providerPrepareDiagnostics';
import {
buildProviderPrepareModelChecksSignature,
buildProviderPrepareRequestSignature,
buildProviderPrepareRuntimeStatusSignature,
} from './providerPrepareRequestSignature';
import {
getShortLivedProviderPrepareModelResults,
storeShortLivedProviderPrepareModelResults,
} from './providerPrepareShortLivedCache';
import { getProvisioningModelIssue } from './provisioningModelIssues';
import {
deriveEffectiveProvisioningPrepareState,
failIncompleteProviderChecks,
getPrimaryProvisioningFailureDetail,
getProvisioningFailureHint,
getProvisioningProviderBackendSummary,
type ProvisioningProviderCheck,
ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList,
updateProviderCheck,
} from './ProvisioningProviderStatusList';
import {
analyzeTeammateRuntimeCompatibility,
useTmuxRuntimeReadiness,
} from './teammateRuntimeCompatibility';
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
import {
computeEffectiveTeamModel,
formatTeamModelSummary,
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
OPENCODE_ONE_SHOT_DISABLED_REASON,
TeamModelSelector,
} from './TeamModelSelector';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
useWorktreeGitReadiness,
WorktreeGitReadinessBanner,
} from './WorktreeGitReadinessBanner';
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,
ResolvedTeamMember,
Schedule,
ScheduleLaunchConfig,
TeamCreateRequest,
TeamFastMode,
TeamLaunchRequest,
TeamProviderId,
UpdateSchedulePatch,
} from '@shared/types';
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
): ProvisioningProviderCheck[] {
const existingByProviderId = new Map(
existingChecks.map((check) => [check.providerId, check] as const)
);
return providerIds.map(
(providerId) =>
existingByProviderId.get(providerId) ?? {
providerId,
status: 'pending',
backendSummary: null,
details: [],
}
);
}
// =============================================================================
// Props — discriminated union
// =============================================================================
interface LaunchDialogBase {
open: boolean;
teamName: string;
onClose: () => void;
}
export type TeamLaunchDialogMode = 'launch' | 'relaunch';
interface LaunchDialogLaunchMode extends LaunchDialogBase {
mode: 'launch';
members: ResolvedTeamMember[];
defaultProjectPath?: string;
provisioningError: string | null;
clearProvisioningError?: (teamName?: string) => void;
activeTeams?: ActiveTeamRef[];
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
}
interface LaunchDialogRelaunchMode extends LaunchDialogBase {
mode: 'relaunch';
members: ResolvedTeamMember[];
defaultProjectPath?: string;
provisioningError: string | null;
clearProvisioningError?: (teamName?: string) => void;
activeTeams?: ActiveTeamRef[];
onRelaunch: (request: TeamLaunchRequest, members: TeamCreateRequest['members']) => Promise<void>;
}
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
| LaunchDialogRelaunchMode
| 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 normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
}
function normalizeOneShotProviderForMode(
providerId: TeamProviderId | undefined,
multimodelEnabled: boolean
): TeamProviderId {
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
}
function getStoredTeamModel(providerId: TeamProviderId): string {
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getStoredTeamFastMode(): TeamFastMode {
const stored = localStorage.getItem('team:lastSelectedFastMode');
return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit';
}
function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
function resolveMemberDraftRuntime(
member: Pick<MemberDraft, 'providerId' | 'model' | 'effort'>,
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<ResolvedTeamMember, 'providerId' | 'model' | 'effort'>,
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,
};
}
function deriveTeammateWorktreeDefault(
members: readonly {
name: string;
isolation?: 'worktree';
removedAt?: number | string | null;
}[]
): boolean {
const activeTeammates = members.filter(
(member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead'
);
return (
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
);
}
function buildWorktreePathByMemberName(
members: readonly {
name: string;
isolation?: 'worktree';
cwd?: string;
removedAt?: number | string | null;
}[]
): Record<string, string> {
const paths: Record<string, string> = {};
for (const member of members) {
const name = member.name.trim().toLowerCase();
const cwd = member.cwd?.trim();
if (!name || member.removedAt || member.isolation !== 'worktree' || !cwd) {
continue;
}
paths[name] = cwd;
}
return paths;
}
// =============================================================================
// 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 anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
const isRelaunch = props.mode === 'relaunch';
const loadingCliStatus = useMemo(
() =>
!cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelCliStatus()
: cliStatus,
[cliStatus, cliStatusLoading, multimodelEnabled]
);
const codexAccount = useCodexAccountSnapshot({
enabled:
multimodelEnabled &&
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
});
const effectiveCliStatus = useMemo(
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[loadingCliStatus, codexAccount.snapshot]
);
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<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
isLaunchMode
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
);
const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(
isLaunchMode
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
)
);
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
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;
});
const [selectedFastMode, setSelectedFastModeRaw] = useState<TeamFastMode>(getStoredTeamFastMode);
const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// 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<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0);
const appliedDefaultProjectPathRef = useRef<string | null>(null);
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
const previousLaunchParams = useStore((s) =>
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
);
const members = isLaunchMode ? props.members : storeMembers;
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
const [savedLaunchProviderBackendId, setSavedLaunchProviderBackendId] = useState<string | null>(
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 [scheduleHydrationKey, setScheduleHydrationKey] = useState<string | null>(null);
const [worktreePathByMemberName, setWorktreePathByMemberName] = useState<Record<string, string>>(
{}
);
const effectiveMemberDrafts = useMemo(
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
[membersDrafts, syncModelsWithLead]
);
const tmuxRuntime = useTmuxRuntimeReadiness(open && isLaunchMode);
const selectedMemberProviders = useMemo<TeamProviderId[]>(
() =>
!multimodelEnabled
? ['anthropic']
: Array.from(
new Set([
selectedProviderId,
...effectiveMemberDrafts.flatMap((member) =>
!member.removedAt && isTeamProviderId(member.providerId) ? [member.providerId] : []
),
])
),
[effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
);
const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: (readonly [TeamProviderId, string | null])[] = (
effectiveCliStatus?.providers ?? []
).map(
(provider) =>
[
provider.providerId as TeamProviderId,
getProvisioningProviderBackendSummary(provider),
] as const
);
return new Map<TeamProviderId, string | null>(entries);
}, [effectiveCliStatus?.providers]);
const runtimeBackendSummaryByProviderRef = useRef(runtimeBackendSummaryByProvider);
const prepareChecksRef = useRef<ProvisioningProviderCheck[]>([]);
const prepareModelResultsCacheRef = useRef(
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
);
const lastPrepareRequestSignatureRef = useRef<string | null>(null);
useEffect(() => {
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
}, [runtimeBackendSummaryByProvider]);
useEffect(() => {
prepareChecksRef.current = prepareChecks;
}, [prepareChecks]);
useEffect(() => {
if (!open) {
lastPrepareRequestSignatureRef.current = null;
}
}, [open]);
const runtimeProviderStatusById = useMemo(
() =>
new Map(
(effectiveCliStatus?.providers ?? []).map(
(provider) => [provider.providerId, provider] as const
)
),
[effectiveCliStatus?.providers]
);
useEffect(() => {
if (!open) {
return;
}
setMembersDrafts((prev) => {
const sanitized = clearInheritedMemberModelsUnavailableForProvider({
members: prev,
selectedProviderId,
runtimeProviderStatusById,
});
return sanitized.changed ? sanitized.members : prev;
});
}, [membersDrafts, open, runtimeProviderStatusById, selectedProviderId]);
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 refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = React.useCallback(
(mode: 'browser' | 'device_code' = 'browser') => {
void (async () => {
await codexAccount.startChatgptLogin(mode);
})();
},
[codexAccount]
);
// 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 = isLaunchMode
? normalizeLeadProviderForMode(value, multimodelEnabled)
: normalizeOneShotProviderForMode(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 = normalizeExplicitTeamModelForUi(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);
};
const setSelectedFastMode = (value: TeamFastMode): void => {
setSelectedFastModeRaw(value);
localStorage.setItem('team:lastSelectedFastMode', 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 (isLaunchMode) {
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 = normalizeOneShotProviderForMode(
schedule.launchConfig.providerId,
multimodelEnabled
);
const scheduleSourceProviderId = normalizeOptionalTeamProviderId(
schedule.launchConfig.providerId
);
setSelectedProviderIdRaw(scheduleProviderId);
setSelectedModelRaw(
scheduleSourceProviderId !== 'gemini' &&
scheduleSourceProviderId !== 'opencode' &&
scheduleProviderId ===
normalizeOneShotProviderForMode(schedule.launchConfig.providerId, true)
? (schedule.launchConfig.model ?? '')
: getStoredTeamModel('anthropic')
);
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
setSelectedFastModeRaw(schedule.launchConfig.fastMode ?? getStoredTeamFastMode());
setSavedLaunchProviderBackendId(schedule.launchConfig.providerBackendId ?? null);
setScheduleHydrationKey(`${schedule.id}:${schedule.updatedAt ?? ''}`);
} 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 = normalizeOneShotProviderForMode(
getStoredTeamProvider(),
multimodelEnabled
);
setSelectedProviderIdRaw(storedProviderId);
setSelectedModelRaw(getStoredTeamModel(storedProviderId));
setSelectedEffortRaw('medium');
setSelectedFastModeRaw(getStoredTeamFastMode());
setSavedLaunchProviderBackendId(null);
setScheduleHydrationKey(null);
}
setLocalError(null);
setIsSubmitting(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isSchedule, schedule?.id]);
useEffect(() => {
if (!open || !isLaunchMode) 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 = normalizeOptionalTeamProviderId(savedRequest?.providerId) ?? null;
const savedProviderBackendId =
typeof savedRequest?.providerBackendId === 'string' &&
savedRequest.providerBackendId.trim().length > 0
? savedRequest.providerBackendId.trim()
: null;
const storedProviderId = normalizeLeadProviderForMode(
getStoredTeamProvider(),
multimodelEnabled
);
const launchPrefill = resolveLaunchDialogPrefill({
members,
savedRequest,
previousLaunchParams,
multimodelEnabled,
storedProviderId,
storedEffort: storedEffort === null ? 'medium' : storedEffort,
storedFastMode: getStoredTeamFastMode(),
storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true',
getStoredModel: getStoredTeamModel,
});
setSavedLaunchProviderId(savedProviderId);
setSavedLaunchProviderBackendId(
launchPrefill.providerBackendId ?? savedProviderBackendId ?? null
);
setMembersDrafts(
createMemberDraftsFromInputs(editableMembersSource).map((member) =>
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
)
);
setWorktreePathByMemberName(buildWorktreePathByMemberName(editableMembersSource));
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource));
setSyncModelsWithLead(
!editableMembersSource.some((member) => member.providerId || member.model || member.effort)
);
const leadProviderId = normalizeLeadProviderForMode(
launchPrefill.providerId,
multimodelEnabled
);
setSelectedProviderIdRaw(leadProviderId);
setSelectedModelRaw(leadProviderId === launchPrefill.providerId ? launchPrefill.model : '');
setSelectedEffortRaw(launchPrefill.effort);
setSelectedFastModeRaw(launchPrefill.fastMode);
setLimitContextRaw(launchPrefill.limitContext);
setSkipPermissionsRaw(
savedRequest?.skipPermissions ??
localStorage.getItem('team:lastSkipPermissions') !== 'false'
);
})();
return () => {
cancelled = true;
};
}, [open, isLaunchMode, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]);
const previousProviderId = useMemo<TeamProviderId | null>(() => {
if (!isLaunchMode) {
return null;
}
return (
normalizeOptionalTeamProviderId(previousLaunchParams?.providerId) ?? savedLaunchProviderId
);
}, [isLaunchMode, previousLaunchParams?.providerId, savedLaunchProviderId]);
const providerChangeForcesFreshLeadContext = useMemo(() => {
if (!isLaunchMode || !previousProviderId) {
return false;
}
return previousProviderId !== selectedProviderId;
}, [isLaunchMode, previousProviderId, selectedProviderId]);
const effectiveAnthropicRuntimeLimitContext = isSchedule ? false : limitContext;
const effectiveLeadRuntimeModel = useMemo(
() =>
computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ?? '',
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
);
const selectedProviderBackendId = useMemo(
() =>
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
migrateProviderBackendId(
selectedProviderId,
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined,
[
previousLaunchParams?.providerBackendId,
runtimeProviderStatusById,
savedLaunchProviderBackendId,
selectedProviderId,
]
);
const teammateRuntimeCompatibility = useMemo(
() =>
analyzeTeammateRuntimeCompatibility({
leadProviderId: selectedProviderId,
leadProviderBackendId: selectedProviderBackendId,
members: isLaunchMode ? effectiveMemberDrafts : [],
extraCliArgs: isLaunchMode ? customArgs : undefined,
tmuxStatus: tmuxRuntime.status,
tmuxStatusLoading: tmuxRuntime.loading,
tmuxStatusError: tmuxRuntime.error,
}),
[
customArgs,
effectiveMemberDrafts,
isLaunchMode,
selectedProviderBackendId,
selectedProviderId,
tmuxRuntime.error,
tmuxRuntime.loading,
tmuxRuntime.status,
]
);
const anthropicRuntimeSelection = useMemo(
() =>
selectedProviderId === 'anthropic'
? resolveAnthropicRuntimeSelection({
source: {
modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog,
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
},
selectedModel,
limitContext: effectiveAnthropicRuntimeLimitContext,
})
: null,
[
effectiveAnthropicRuntimeLimitContext,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
]
);
const anthropicFastModeResolution = useMemo(
() =>
selectedProviderId === 'anthropic' && anthropicRuntimeSelection
? resolveAnthropicFastMode({
selection: anthropicRuntimeSelection,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
})
: null,
[
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
selectedFastMode,
selectedProviderId,
]
);
const codexRuntimeSelection = useMemo(
() =>
selectedProviderId === 'codex'
? resolveCodexRuntimeSelection({
source: {
providerStatus: runtimeProviderStatusById.get('codex'),
providerBackendId:
resolveUiOwnedProviderBackendId('codex', runtimeProviderStatusById.get('codex')) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined,
},
selectedModel,
})
: null,
[
previousLaunchParams?.providerBackendId,
runtimeProviderStatusById,
savedLaunchProviderBackendId,
selectedModel,
selectedProviderId,
]
);
const codexFastModeResolution = useMemo(
() =>
selectedProviderId === 'codex' && codexRuntimeSelection
? resolveCodexFastMode({
selection: codexRuntimeSelection,
selectedFastMode,
})
: null,
[codexRuntimeSelection, selectedFastMode, selectedProviderId]
);
useEffect(() => {
if (isSchedule && schedule) {
const nextHydrationKey = `${schedule.id}:${schedule.updatedAt ?? ''}`;
if (scheduleHydrationKey !== nextHydrationKey) {
return;
}
}
if (selectedProviderId !== 'anthropic' && selectedProviderId !== 'codex') {
setAnthropicRuntimeNotice(null);
return;
}
const reconciliation =
selectedProviderId === 'anthropic'
? reconcileAnthropicRuntimeSelections({
selection:
anthropicRuntimeSelection ??
resolveAnthropicRuntimeSelection({
source: {
modelCatalog: null,
runtimeCapabilities: null,
},
selectedModel,
limitContext: effectiveAnthropicRuntimeLimitContext,
}),
selectedEffort,
selectedFastMode,
providerFastModeDefault: anthropicProviderFastModeDefault,
})
: {
nextEffort: selectedEffort,
effortResetReason: null,
...reconcileCodexRuntimeSelections({
selection:
codexRuntimeSelection ??
resolveCodexRuntimeSelection({
source: {
providerStatus: runtimeProviderStatusById.get('codex'),
providerBackendId:
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined,
},
selectedModel,
}),
selectedFastMode,
}),
};
const notices: string[] = [];
if (reconciliation.nextEffort !== selectedEffort) {
setSelectedEffortRaw(reconciliation.nextEffort);
localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort);
if (reconciliation.effortResetReason) {
notices.push(reconciliation.effortResetReason);
}
}
if (reconciliation.nextFastMode !== selectedFastMode) {
setSelectedFastModeRaw(reconciliation.nextFastMode);
localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode);
if (reconciliation.fastModeResetReason) {
notices.push(reconciliation.fastModeResetReason);
}
}
setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null);
}, [
anthropicProviderFastModeDefault,
anthropicRuntimeSelection,
codexRuntimeSelection,
effectiveAnthropicRuntimeLimitContext,
previousLaunchParams?.providerBackendId,
runtimeProviderStatusById,
savedLaunchProviderBackendId,
selectedEffort,
selectedFastMode,
selectedModel,
selectedProviderId,
schedule,
scheduleHydrationKey,
isSchedule,
]);
const selectedModelChecksByProvider = useMemo(() => {
const modelsByProvider = new Map<TeamProviderId, string[]>();
const defaultSelectionByProvider = new Map<TeamProviderId, boolean>();
const addModel = (providerId: TeamProviderId, model: string | undefined): void => {
const trimmed = model?.trim() ?? '';
if (!trimmed) {
return;
}
const existing = modelsByProvider.get(providerId) ?? [];
if (!existing.includes(trimmed)) {
modelsByProvider.set(providerId, [...existing, trimmed]);
}
};
const addDefaultSelection = (providerId: TeamProviderId): void => {
if (
providerId === 'codex' ||
providerId === 'gemini' ||
(providerId === 'anthropic' && selectedProviderId === 'anthropic')
) {
defaultSelectionByProvider.set(providerId, true);
}
};
if (selectedModel.trim()) {
addModel(selectedProviderId, effectiveLeadRuntimeModel);
} else {
addDefaultSelection(selectedProviderId);
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const scopedModel = resolveProviderScopedMemberModel({
memberProviderId: member.providerId,
memberModel: member.model,
selectedProviderId,
runtimeProviderStatusById,
});
if (scopedModel.model) {
addModel(scopedModel.providerId, scopedModel.model);
} else {
addDefaultSelection(scopedModel.providerId);
}
}
for (const providerId of defaultSelectionByProvider.keys()) {
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION);
}
return modelsByProvider;
}, [
effectiveLeadRuntimeModel,
effectiveMemberDrafts,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
]);
const runtimeChangeNotes = useMemo(() => {
if (!isLaunchMode) {
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 ?? '') &&
(previousMember.isolation ?? '') === (member.isolation ?? '')
) {
continue;
}
const runtimeMessage =
previousProvider !== currentProviderId ||
previousModel !== currentModel ||
(previousEffort ?? '') !== (currentEffort ?? '')
? `${formatTeamModelSummary(
currentProviderId,
currentModel,
currentEffort
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`
: null;
const isolationMessage =
previousMember.isolation !== member.isolation
? `${member.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'} instead of ${
previousMember.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'
}`
: null;
notes.push({
key: `member:${name.toLowerCase()}`,
memberName: name,
message: [runtimeMessage, isolationMessage]
.filter((part): part is string => Boolean(part))
.join('; '),
});
}
return notes;
}, [
isLaunchMode,
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<string, string> = {};
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]);
const combinedMemberRuntimeWarningById = useMemo(() => {
const warnings: Record<string, string> = { ...memberRuntimeWarningById };
for (const [memberId, warning] of Object.entries(
teammateRuntimeCompatibility.memberWarningById
)) {
warnings[memberId] = warnings[memberId] ? `${warnings[memberId]} ${warning}` : warning;
}
return warnings;
}, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]);
const memberWorktreeContinuationInfoById = useMemo(() => {
if (!isLaunchMode) {
return {};
}
const info: Record<string, string> = {};
for (const member of effectiveMemberDrafts) {
if (member.removedAt || member.isolation !== 'worktree') {
continue;
}
const lookupName = (member.originalName?.trim() || member.name.trim()).toLowerCase();
if (!lookupName) {
continue;
}
const previousWorktreePath = worktreePathByMemberName[lookupName];
if (!previousWorktreePath) {
continue;
}
info[member.id] =
`This teammate will continue from its existing worktree: ${previousWorktreePath}`;
}
return info;
}, [effectiveMemberDrafts, isLaunchMode, worktreePathByMemberName]);
// ---------------------------------------------------------------------------
// Launch-only effects
// ---------------------------------------------------------------------------
const selectedProjectCwd = isEphemeralProjectPath(selectedProjectPath)
? ''
: selectedProjectPath.trim();
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
const hasSelectedWorktreeIsolation =
isLaunchMode &&
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
const worktreeGitReadiness = useWorktreeGitReadiness(
effectiveCwd || null,
open && hasSelectedWorktreeIsolation
);
const worktreeIsolationDisabledReason = isLaunchMode
? getWorktreeGitControlDisabledReason(worktreeGitReadiness)
: null;
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
worktreeGitReadiness,
hasSelectedWorktreeIsolation
);
const prepareRuntimeStatusSignature = useMemo(
() =>
buildProviderPrepareRuntimeStatusSignature(
selectedMemberProviders,
runtimeProviderStatusById
),
[runtimeProviderStatusById, selectedMemberProviders]
);
const selectedModelChecksByProviderSignature = useMemo(
() => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider),
[selectedModelChecksByProvider]
);
const prepareRequestSignature = useMemo(
() =>
buildProviderPrepareRequestSignature({
cwd: effectiveCwd,
selectedProviderId,
selectedModel,
selectedMemberProviders,
limitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
modelChecksSignature: selectedModelChecksByProviderSignature,
}),
[
effectiveCwd,
limitContext,
prepareRuntimeStatusSignature,
selectedMemberProviders,
selectedModel,
selectedModelChecksByProviderSignature,
selectedProviderId,
]
);
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (!open || !isLaunchMode) return;
props.clearProvisioningError?.(effectiveTeamName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLaunchMode, effectiveTeamName]);
// Warm up CLI for the currently selected working directory (launch mode only).
useEffect(() => {
if (!open || !isLaunchMode) {
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
return;
}
if (typeof api.teams.prepareProvisioning !== 'function') {
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
return;
}
if (!effectiveCwd) {
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
setPrepareMessage('Select a working directory to validate the launch environment.');
return;
}
if (lastPrepareRequestSignatureRef.current === prepareRequestSignature) {
return;
}
lastPrepareRequestSignatureRef.current = prepareRequestSignature;
const requestSeq = ++prepareRequestSeqRef.current;
const initialChecks = alignProvisioningChecks(
prepareChecksRef.current,
selectedMemberProviders
);
setPrepareState('loading');
setPrepareMessage('Checking selected providers in parallel...');
setPrepareWarnings([]);
setPrepareChecks(initialChecks);
void (async () => {
let checks = initialChecks;
const providerPlans = selectedMemberProviders.map((providerId) => {
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext,
runtimeStatusSignature: prepareRuntimeStatusSignature,
});
const cachedModelResultsById = {
...getShortLivedProviderPrepareModelResults({
providerId,
cacheKey,
}),
...(prepareModelResultsCacheRef.current.get(cacheKey) ?? {}),
};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
selectedModelIds: selectedModelChecks,
cachedModelResultsById,
});
return {
providerId,
selectedModelChecks,
backendSummary,
cacheKey,
cachedModelResultsById,
cachedSnapshot,
};
});
try {
for (const plan of providerPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
});
}
if (prepareRequestSeqRef.current === requestSeq) {
setPrepareChecks(checks);
}
const providerResults = await Promise.all(
providerPlans.map(async (plan) => {
const prepResult = await runProviderPrepareDiagnostics({
cwd: effectiveCwd,
providerId: plan.providerId,
selectedModelIds: plan.selectedModelChecks,
prepareProvisioning: api.teams.prepareProvisioning,
limitContext,
cachedModelResultsById: plan.cachedModelResultsById,
onModelProgress: ({ status, details }) => {
checks = updateProviderCheck(checks, plan.providerId, {
status,
backendSummary: plan.backendSummary,
details,
});
if (prepareRequestSeqRef.current === requestSeq) {
setPrepareChecks(checks);
}
},
});
return { ...plan, prepResult };
})
);
let anyFailure = false;
let anyNotes = false;
const collectedWarnings: string[] = [];
for (const plan of providerResults) {
if (plan.prepResult.warnings.length > 0) {
anyNotes = true;
collectedWarnings.push(
...plan.prepResult.warnings.map(
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
)
);
}
if (plan.prepResult.status === 'failed') {
anyFailure = true;
} else if (plan.prepResult.status === 'notes') {
anyNotes = true;
}
if (prepareRequestSeqRef.current === requestSeq) {
const reusableModelResults = buildReusableProviderPrepareModelResults(
plan.prepResult.modelResultsById
);
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId,
cacheKey: plan.cacheKey,
modelResultsById: plan.prepResult.modelResultsById,
});
}
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,
backendSummary: plan.backendSummary,
details: plan.prepResult.details,
});
}
if (prepareRequestSeqRef.current === requestSeq) {
setPrepareChecks(checks);
}
if (prepareRequestSeqRef.current !== requestSeq) return;
const failureMessage =
getPrimaryProvisioningFailureDetail(checks) ?? 'Some selected providers need attention.';
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
? 'Selected providers are ready with notes.'
: 'Selected providers are ready.'
);
setPrepareWarnings(collectedWarnings);
} catch (error) {
if (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);
}
})();
}, [
open,
isLaunchMode,
effectiveCwd,
prepareRequestSignature,
selectedProviderId,
selectedMemberProviders,
selectedModelChecksByProvider,
]);
// ---------------------------------------------------------------------------
// Shared effects: projects
// ---------------------------------------------------------------------------
const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
useEffect(() => {
if (!open) return;
setProjectsLoading(true);
setProjectsError(null);
let cancelled = false;
void (async () => {
try {
const nextProjects = await loadProjectPathProjects({
defaultProjectPath,
repositoryGroups,
});
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, repositoryGroups, defaultProjectPath]);
// Pre-select defaultProjectPath (launch mode) or first project
useEffect(() => {
if (!open) {
appliedDefaultProjectPathRef.current = null;
return;
}
if (cwdMode !== 'project') return;
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
if (selectableProjects.length === 0) return;
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
const defaultAlreadyApplied =
appliedDefaultProjectPathRef.current === normalizedDefaultProjectPath;
const match = selectableProjects.find(
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
);
if (match && !defaultAlreadyApplied) {
appliedDefaultProjectPathRef.current = normalizedDefaultProjectPath;
if (normalizePath(selectedProjectPath) !== normalizedDefaultProjectPath) {
setSelectedProjectPath(match.path);
}
return;
}
}
if (selectedProjectPath) return;
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
const match = selectableProjects.find(
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
);
if (match) {
setSelectedProjectPath(match.path);
return;
}
}
setSelectedProjectPath(selectableProjects[0].path);
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
useEffect(() => {
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
return;
}
if (!isEphemeralProjectPath(selectedProjectPath)) {
return;
}
setSelectedProjectPath('');
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
// Pre-warm file list cache so @-mention file search is instant
useFileListCacheWarmer(effectiveCwd || null);
// ---------------------------------------------------------------------------
// Launch-only: conflict detection
// ---------------------------------------------------------------------------
const activeTeams = isLaunchMode ? props.activeTeams : undefined;
const conflictingTeam = useMemo(() => {
if (!isLaunchMode || !activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd);
return (
activeTeams.find(
(t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm
) ?? null
);
}, [isLaunchMode, 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<MentionSuggestion[]>(
() => buildMemberDraftSuggestions(membersDrafts, memberColorMap),
[memberColorMap, membersDrafts]
);
// ---------------------------------------------------------------------------
// Launch-only: internal args preview
// ---------------------------------------------------------------------------
const internalArgs = useMemo(() => {
if (!isLaunchMode) 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', '<auto>', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS);
if (skipPermissions) args.push('--dangerously-skip-permissions');
const model = computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
);
if (model) args.push('--model', model);
const effectiveEffort =
selectedProviderId === 'anthropic'
? selectedEffort || anthropicRuntimeSelection?.defaultEffort || ''
: selectedEffort;
if (effectiveEffort) args.push('--effort', effectiveEffort);
if (selectedProviderId === 'anthropic') {
const fastSettings = anthropicFastModeResolution?.resolvedFastMode
? { fastMode: true, fastModePerSessionOptIn: false }
: { fastMode: false };
args.push('--settings', JSON.stringify(fastSettings));
} else if (selectedProviderId === 'codex') {
args.push(...buildCodexFastModeArgs(codexFastModeResolution?.resolvedFastMode));
}
if (!clearContext) args.push('--resume', '<previous>');
return args;
}, [
anthropicFastModeResolution?.resolvedFastMode,
anthropicRuntimeSelection?.defaultEffort,
codexFastModeResolution?.resolvedFastMode,
isLaunchMode,
skipPermissions,
selectedModel,
limitContext,
selectedEffort,
selectedProviderId,
clearContext,
runtimeProviderStatusById,
]);
const launchOptionalSummary = useMemo(() => {
if (!isLaunchMode) return [];
const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt');
const worktreeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.isolation === 'worktree'
).length;
if (worktreeMemberCount > 0) {
summary.push(
`${worktreeMemberCount} teammate worktree${worktreeMemberCount === 1 ? '' : 's'}`
);
}
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
if (selectedFastMode === 'on') summary.push('Fast mode');
else if (selectedFastMode === 'off') summary.push('Fast disabled');
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
summary.push('Fast default');
}
}
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;
}, [
isLaunchMode,
effectiveMemberDrafts,
promptDraft.value,
selectedModel,
selectedProviderId,
selectedEffort,
selectedFastMode,
anthropicProviderFastModeDefault,
limitContext,
skipPermissions,
clearContext,
worktreeEnabled,
worktreeName,
customArgs,
]);
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const validationErrors = useMemo(() => {
const errors: string[] = [];
if (!effectiveCwd) errors.push('Working directory is required');
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
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,
worktreeGitBlockingMessage,
isSchedule,
effectiveTeamName,
promptDraft.value,
cronExpression,
]);
const modelValidationError = useMemo(() => {
if (isLaunchMode && selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
return 'OpenCode lead requires a selected model.';
}
const activeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.name.trim()
).length;
if (activeMemberCount === 0) {
return 'OpenCode lead requires at least one OpenCode teammate.';
}
}
const leadError = getTeamModelSelectionError(
selectedProviderId,
selectedModel,
runtimeProviderStatusById.get(selectedProviderId)
);
if (leadError) {
return leadError;
}
if (!isLaunchMode) {
return null;
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
const memberError = getTeamModelSelectionError(
providerId,
member.model,
runtimeProviderStatusById.get(providerId)
);
if (!memberError) {
continue;
}
const memberName = member.name.trim();
return memberName ? `${memberName}: ${memberError}` : memberError;
}
return null;
}, [
effectiveMemberDrafts,
isLaunchMode,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
prepareChecks,
selectedProviderId,
effectiveLeadRuntimeModel || selectedModel
);
return issue?.reason ?? issue?.detail ?? null;
}, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]);
const memberModelIssueById = useMemo(() => {
const next: Record<string, string> = {};
if (!isLaunchMode) {
return next;
}
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
if (syncModelsWithLead && leadModelIssueText) {
next[member.id] = leadModelIssueText;
continue;
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
const issue = getProvisioningModelIssue(prepareChecks, providerId, member.model);
const issueText = issue?.reason ?? issue?.detail ?? null;
if (issueText) {
next[member.id] = issueText;
}
}
return next;
}, [
effectiveMemberDrafts,
isLaunchMode,
leadModelIssueText,
prepareChecks,
selectedProviderId,
syncModelsWithLead,
]);
const hasInvalidLaunchMemberNames = useMemo(
() =>
isLaunchMode &&
membersDrafts.some(
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
),
[isLaunchMode, membersDrafts]
);
const hasDuplicateLaunchMemberNames = useMemo(() => {
if (!isLaunchMode) return false;
const activeNames = membersDrafts
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(activeNames).size !== activeNames.length;
}, [isLaunchMode, membersDrafts]);
// ---------------------------------------------------------------------------
// Error
// ---------------------------------------------------------------------------
const provisioningError = isLaunchMode ? props.provisioningError : null;
const activeError = localError ?? modelValidationError ?? provisioningError;
const effectivePrepare = useMemo(
() =>
deriveEffectiveProvisioningPrepareState({
state: prepareState,
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
}),
[prepareChecks, prepareMessage, prepareState, prepareWarnings]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
selectedProviderIds: selectedMemberProviders,
prepareMessage: effectivePrepare.message,
prepareChecks,
});
const launchInFlight = useStore((s) =>
isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
);
useEffect(() => {
if (!open || !isLaunchMode || !effectiveTeamName || !launchInFlight) {
return;
}
openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath);
closeDialog();
}, [
closeDialog,
defaultProjectPath,
effectiveCwd,
effectiveTeamName,
isLaunchMode,
launchInFlight,
open,
openTeamTab,
]);
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const handleSubmit = (): void => {
if (validationErrors.length > 0) {
setLocalError(validationErrors[0]);
return;
}
if (modelValidationError) {
setLocalError(modelValidationError);
return;
}
if (isLaunchMode && teammateRuntimeCompatibility.blocksSubmission) {
setLocalError(teammateRuntimeCompatibility.message);
return;
}
if (isLaunchMode && !effectiveCwd) {
setLocalError('Select working directory (cwd)');
return;
}
if (
isLaunchMode &&
membersDrafts.some(
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
)
) {
setLocalError('Fix member names before launch');
return;
}
if (isLaunchMode) {
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 (isLaunchMode) {
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts);
const launchRequest: TeamLaunchRequest = {
teamName: effectiveTeamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
providerId: selectedProviderId,
providerBackendId:
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
selectedProviderBackendId ??
undefined,
model: computeEffectiveTeamModel(
selectedModel,
limitContext,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
),
effort: (selectedEffort as EffortLevel) || undefined,
fastMode:
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
? selectedFastMode
: undefined,
limitContext,
clearContext: clearContext || undefined,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
};
if (isRelaunch) {
await props.onRelaunch(launchRequest, nextMembers);
} else {
await api.teams.replaceMembers(effectiveTeamName, {
members: nextMembers,
});
await props.onLaunch(launchRequest);
}
openTeamTab(effectiveTeamName, effectiveCwd || defaultProjectPath);
closeDialog();
} else {
// Schedule mode: create or update
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
const scheduleProviderBackendId =
resolveUiOwnedProviderBackendId(
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
) ??
selectedProviderBackendId ??
undefined;
const scheduleModel = computeEffectiveTeamModel(
selectedModel,
false,
selectedProviderId,
runtimeProviderStatusById.get(selectedProviderId)
);
const explicitScheduleEffort = selectedEffort
? (selectedEffort as EffortLevel)
: undefined;
const scheduleEffort =
selectedProviderId === 'anthropic'
? (explicitScheduleEffort ?? anthropicRuntimeSelection?.defaultEffort ?? undefined)
: explicitScheduleEffort;
const launchConfig: ScheduleLaunchConfig = {
cwd: effectiveCwd,
prompt: promptDraft.value.trim(),
providerId: selectedProviderId,
providerBackendId: scheduleProviderBackendId,
model: scheduleModel,
effort: scheduleEffort,
fastMode:
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
? selectedFastMode
: undefined,
resolvedFastMode:
selectedProviderId === 'anthropic'
? (anthropicFastModeResolution?.resolvedFastMode ?? false)
: selectedProviderId === 'codex'
? (codexFastModeResolution?.resolvedFastMode ?? false)
: 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'
: isRelaunch
? 'Failed to relaunch team'
: 'Failed to launch team';
setLocalError(message);
if (isLaunchMode) {
console.error(
isRelaunch
? 'Failed to relaunch team from dialog:'
: 'Failed to launch team from dialog:',
err
);
}
} finally {
setIsSubmitting(false);
}
})();
};
// ---------------------------------------------------------------------------
// Disabled state
// ---------------------------------------------------------------------------
const isDisabled = isLaunchMode
? isSubmitting ||
launchInFlight ||
validationErrors.length > 0 ||
!!modelValidationError ||
hasInvalidLaunchMemberNames ||
hasDuplicateLaunchMemberNames ||
teammateRuntimeCompatibility.blocksSubmission
: isSubmitting || validationErrors.length > 0 || !!modelValidationError;
// ---------------------------------------------------------------------------
// Dynamic labels
// ---------------------------------------------------------------------------
const dialogTitle = isLaunchMode
? isRelaunch
? 'Relaunch Team'
: 'Launch Team'
: isEditing
? 'Edit Schedule'
: 'Create Schedule';
const dialogDescription = isLaunchMode ? (
isRelaunch ? (
<>
Stop the current run for <span className="font-mono font-medium">{effectiveTeamName}</span>{' '}
and start it again via local Claude CLI.
</>
) : (
<>
Start team <span className="font-mono font-medium">{effectiveTeamName}</span> via local
Claude CLI.
</>
)
) : isEditing ? (
`Editing schedule for team "${effectiveTeamName}"`
) : effectiveTeamName ? (
`Schedule automatic runs for team "${effectiveTeamName}"`
) : (
'Schedule automatic Claude task execution'
);
const submitLabel = isLaunchMode
? isRelaunch
? 'Relaunch team'
: 'Launch team'
: isEditing
? 'Save Changes'
: 'Create Schedule';
const submittingLabel = isLaunchMode
? isRelaunch
? 'Relaunching...'
: 'Launching...'
: isEditing
? 'Saving...'
: 'Creating...';
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
closeDialog();
}
}}
>
<DialogContent
className={isSchedule ? 'max-h-[90vh] max-w-3xl overflow-y-auto' : 'max-w-3xl'}
>
<DialogHeader>
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
<DialogDescription className="text-xs">{dialogDescription}</DialogDescription>
</DialogHeader>
{isRelaunch ? (
<div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">Relaunch will restart the current team run</p>
<p className="opacity-80">
Saving these settings will stop the current team process, persist the updated
roster, and launch the team again with the new runtime.
</p>
</div>
</div>
</div>
) : null}
{/* Launch-only: Conflict warning */}
{isLaunchMode && conflictingTeam && !conflictDismissed ? (
<div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium">
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory
</p>
<p className="opacity-80">
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.
</p>
<p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div>
<button
type="button"
className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
onClick={() => setConflictDismissed(true)}
>
<X className="size-3.5" />
</button>
</div>
</div>
) : null}
{isLaunchMode ? (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
closeDialog();
openDashboard();
}}
/>
) : null}
<div className="space-y-4">
{/* ═══════════════════════════════════════════════════════════════════
Schedule-only: Team selector (standalone mode)
═══════════════════════════════════════════════════════════════════ */}
{needsTeamSelector ? (
<div className="space-y-1.5">
<Label className="text-xs">Team</Label>
<Combobox
options={teamOptions}
value={selectedTeamName}
onValueChange={setSelectedTeamName}
placeholder="Select a team..."
searchPlaceholder="Search teams..."
emptyMessage={
teamOptions.length === 0
? 'No teams available. Create a team first.'
: 'No teams match your search.'
}
disabled={teamOptions.length === 0}
renderOption={(option, isSelected) => {
const colorName = option.meta?.color as string | undefined;
const colorSet = colorName
? getTeamColorSet(colorName)
: nameColorSet(option.label);
return (
<>
{isSelected ? (
<Check className="mr-2 size-3.5 shrink-0 text-[var(--color-text)]" />
) : (
<span
className="mr-2 size-3.5 shrink-0 rounded-full"
style={{ backgroundColor: colorSet.text }}
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{isSelected ? (
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: colorSet.text }}
/>
) : null}
<p className="truncate font-medium text-[var(--color-text)]">
{option.label}
</p>
</div>
{option.description ? (
<p className="truncate text-[var(--color-text-muted)]">
{option.description}
</p>
) : null}
</div>
</>
);
}}
/>
</div>
) : null}
{/* ═══════════════════════════════════════════════════════════════════
Schedule-only: Schedule configuration section
═══════════════════════════════════════════════════════════════════ */}
{isSchedule ? (
<div
className="rounded-lg border border-[var(--color-border-emphasis)] shadow-sm"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-overlay) 24%, white 76%)'
: 'var(--color-surface-overlay)',
}}
>
<button
type="button"
className="flex w-full items-center gap-1.5 px-3 py-2 text-left"
onClick={() => setSchedExpanded((v) => !v)}
>
{schedExpanded ? (
<ChevronDown className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
)}
<span className="text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Schedule
</span>
{!schedExpanded && (schedLabel || cronExpression) ? (
<span className="ml-auto truncate text-[11px] text-[var(--color-text-muted)] opacity-70">
{schedLabel || cronExpression}
</span>
) : null}
</button>
{schedExpanded ? (
<div className="space-y-3 border-t border-[var(--color-border)] px-3 pb-3 pt-2">
{/* Label */}
<div className="space-y-1.5">
<Label htmlFor="schedule-label" className="label-optional">
Label (optional)
</Label>
<Input
id="schedule-label"
className="h-8 text-xs"
value={schedLabel}
onChange={(e) => setSchedLabel(e.target.value)}
placeholder="e.g., Daily code review, Nightly tests..."
/>
</div>
{/* Cron + Timezone + Warmup */}
<CronScheduleInput
cronExpression={cronExpression}
onCronExpressionChange={setCronExpression}
timezone={timezone}
onTimezoneChange={setTimezone}
warmUpMinutes={warmUpMinutes}
onWarmUpMinutesChange={setWarmUpMinutes}
/>
</div>
) : null}
</div>
) : null}
{/* ═══════════════════════════════════════════════════════════════════
Shared: Working directory
═══════════════════════════════════════════════════════════════════ */}
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
/>
{/* ═══════════════════════════════════════════════════════════════════
Launch: optional settings
Schedule: prompt + execution defaults
═══════════════════════════════════════════════════════════════════ */}
{isLaunchMode ? (
<OptionalSettingsSection
title={isRelaunch ? 'Relaunch settings' : 'Optional launch settings'}
description={
isRelaunch
? 'Review the roster and lead runtime before restarting the team.'
: 'Keep the launch flow focused on the project path and only expand this when you want extra control.'
}
summary={launchOptionalSummary}
>
<div className="space-y-4">
{selectedProviderId === 'anthropic' ? (
<div className="space-y-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={effectiveAnthropicRuntimeLimitContext}
id="launch-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="space-y-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined
}
id="launch-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{anthropicRuntimeNotice}</p>
</div>
) : null}
</div>
) : null}
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
lockProviderModel={syncModelsWithLead}
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
memberWarningById={combinedMemberRuntimeWarningById}
memberInfoById={memberWorktreeContinuationInfoById}
leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById}
softDeleteMembers
disableGeminiOption={isGeminiUiFrozen()}
headerBottom={
hasSelectedWorktreeIsolation ? (
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
) : null
}
/>
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt" className="label-optional">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="dialog-prompt"
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for team lead..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
</div>
<div>
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
<div className="space-y-2">
{providerChangeForcesFreshLeadContext ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
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.
</p>
</div>
</div>
) : null}
<div className="flex items-center gap-2">
<Checkbox
id="clear-context"
checked={clearContext}
onCheckedChange={(checked) => setClearContext(checked === true)}
/>
<Label
htmlFor="clear-context"
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
<RotateCcw className="size-3 shrink-0" />
Clear context (fresh session)
</Label>
</div>
{clearContext && (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
The team lead will start a new session without resuming previous context.
All accumulated session memory and conversation history will not be
available.
</p>
</div>
</div>
)}
</div>
<AdvancedCliSection
teamName={effectiveTeamName}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div>
</OptionalSettingsSection>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt">Prompt</Label>
<MentionableTextarea
id="dialog-prompt"
className="min-h-[100px] text-xs"
minRows={4}
maxRows={12}
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
projectPath={effectiveCwd || null}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for Claude to execute on schedule..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
This prompt will be passed to <code className="font-mono">claude -p</code> for
one-shot execution
</p>
</div>
<div>
<TeamModelSelector
providerId={selectedProviderId}
onProviderChange={setSelectedProviderId}
value={selectedModel}
onValueChange={setSelectedModel}
id="dialog-model"
disableGeminiOption={isGeminiUiFrozen()}
providerDisabledReasonById={{
opencode: OPENCODE_ONE_SHOT_DISABLED_REASON,
}}
providerDisabledBadgeLabelById={{
opencode: OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
}}
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="dialog-effort"
providerId={selectedProviderId}
model={selectedModel}
limitContext={false}
/>
{selectedProviderId === 'anthropic' ? (
<div className="mt-2">
<AnthropicFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
providerFastModeDefault={anthropicProviderFastModeDefault}
model={selectedModel}
limitContext={false}
id="dialog-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 mt-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
{anthropicRuntimeNotice}
</div>
) : null}
</div>
) : null}
{selectedProviderId === 'codex' ? (
<div className="mt-2">
<CodexFastModeSelector
value={selectedFastMode}
onValueChange={setSelectedFastMode}
model={selectedModel}
providerBackendId={
resolveUiOwnedProviderBackendId(
'codex',
runtimeProviderStatusById.get('codex')
) ??
migrateProviderBackendId(
'codex',
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
) ??
undefined
}
id="dialog-fast-mode"
/>
{anthropicRuntimeNotice ? (
<div className="bg-amber-500/8 mt-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
{anthropicRuntimeNotice}
</div>
) : null}
</div>
) : null}
<SkipPermissionsCheckbox
id="dialog-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
</>
)}
{/* ═══════════════════════════════════════════════════════════════════
Schedule-only: Execution limits
═══════════════════════════════════════════════════════════════════ */}
{isSchedule ? (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label
htmlFor="schedule-max-turns"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max turns
</Label>
<Input
id="schedule-max-turns"
type="number"
min={1}
max={500}
className="h-8 text-xs"
value={maxTurns}
onChange={(e) => setMaxTurns(Math.max(1, parseInt(e.target.value) || 50))}
/>
</div>
<div className="space-y-1">
<Label
htmlFor="schedule-max-budget"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max budget (USD)
</Label>
<Input
id="schedule-max-budget"
type="number"
min={0}
step={0.5}
className="h-8 text-xs"
value={maxBudgetUsd}
onChange={(e) => setMaxBudgetUsd(e.target.value)}
placeholder="No limit"
/>
</div>
</div>
) : null}
</div>
{/* Error display */}
{activeError ? (
<div className="flex items-start gap-2 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{activeError}</span>
</div>
) : null}
<DialogFooter className={isLaunchMode ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */}
{isLaunchMode ? (
<div className="min-w-0">
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
<>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<div>
<span>
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>
Pre-flight check to catch errors before{' '}
{isRelaunch ? 'relaunch' : 'launch'}
</span>
</p>
</div>
</div>
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
</>
) : null}
{effectivePrepare.state === 'ready' ? (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
{effectivePrepare.message ? (
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
{effectivePrepare.message}
</p>
) : null}
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning, index) => (
<p key={`${index}:${warning}`} className="text-[11px] text-sky-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
{effectivePrepare.state === 'failed' ? (
<div className="text-xs">
<div className="flex items-start gap-2 text-red-300">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
blocked
</p>
<p className="mt-0.5 text-red-300/80">
{effectivePrepare.message ?? 'Failed to prepare environment'}
</p>
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
</p>
</div>
</div>
{!shouldHideProvisioningProviderStatusList(
prepareChecks,
effectivePrepare.message
) ? (
<ProvisioningProviderStatusList
checks={prepareChecks}
className="mt-2"
suppressDetailsMatching={effectivePrepare.message}
/>
) : null}
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
<div className="mt-1 space-y-0.5 pl-6">
{prepareWarnings.map((warning, index) => (
<p
key={`${index}:${warning}`}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning}
</p>
))}
</div>
) : null}
<div className="mt-1 flex items-center gap-2 pl-6">
<p className="text-[11px] text-[var(--color-text-muted)]">
{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
</p>
{(effectivePrepare.message ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
) ? (
<button
type="button"
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => {
closeDialog();
openDashboard();
}}
>
Go to Dashboard
</button>
) : null}
</div>
{showCodexReconnectPrompt ? (
<div className="pl-6">
<CodexReconnectPrompt
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
userCode={codexAccount.snapshot?.login.userCode ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={() => handleCodexReconnect('browser')}
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
/>
</div>
) : null}
</div>
) : null}
</div>
) : null}
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isDisabled}
onClick={handleSubmit}
>
{isSubmitting || launchInFlight ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
{submittingLabel}
</>
) : (
submitLabel
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};