Keep connected provider details visible while refreshes are in flight, restore reusable provider status UI, and separate fast startup summaries from heavier provider hydration. Replace the fixed 30s startup wait with an idle-aware scheduler with a 30s safety cap and cover the Electron timer binding crash.
3231 lines
123 KiB
TypeScript
3231 lines
123 KiB
TypeScript
import React, { useCallback, 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 { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
|
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 { 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 { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
|
|
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,
|
|
ExternalLink,
|
|
Info,
|
|
Loader2,
|
|
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 {
|
|
isDeletedProjectPathSelection,
|
|
isSelectableProjectPathProject,
|
|
} from './projectPathOptions';
|
|
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects';
|
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
|
import {
|
|
mergeReusableProviderPrepareModelResults,
|
|
type ProviderPrepareDiagnosticsModelResult,
|
|
runProviderPrepareDiagnostics,
|
|
} from './providerPrepareDiagnostics';
|
|
import { buildProviderPreparePlans, type ProviderPreparePlan } from './providerPreparePlans';
|
|
import {
|
|
buildProviderPrepareModelChecksSignature,
|
|
buildProviderPrepareRuntimeStatusSignature,
|
|
} from './providerPrepareRequestSignature';
|
|
import {
|
|
getShortLivedProviderPrepareModelIssueReasons,
|
|
storeShortLivedProviderPrepareModelResults,
|
|
} from './providerPrepareShortLivedCache';
|
|
import { getProvisioningModelIssue } from './provisioningModelIssues';
|
|
import { ProvisioningProviderRuntimeSettingsDialog } from './ProvisioningProviderRuntimeSettingsDialog';
|
|
import {
|
|
deriveEffectiveProvisioningPrepareState,
|
|
getPrimaryProvisioningFailureDetail,
|
|
getProvisioningFailureHint,
|
|
getProvisioningProviderBackendSummary,
|
|
getProvisioningProviderProgressMessage,
|
|
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 {
|
|
CliProviderId,
|
|
CreateScheduleInput,
|
|
EffortLevel,
|
|
ResolvedTeamMember,
|
|
Schedule,
|
|
ScheduleLaunchConfig,
|
|
TeamCreateRequest,
|
|
TeamFastMode,
|
|
TeamLaunchRequest,
|
|
TeamProviderId,
|
|
TeamProvisioningModelCheckRequest,
|
|
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';
|
|
const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL =
|
|
'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan';
|
|
|
|
// =============================================================================
|
|
// 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 cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
|
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 codexSnapshotPending =
|
|
codexAccount.loading &&
|
|
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
|
!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 ? '' : 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 [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 [prepareProviderInvalidationEpochById, setPrepareProviderInvalidationEpochById] = useState<
|
|
Partial<Record<TeamProviderId, number>>
|
|
>({});
|
|
const [providerSettingsProviderId, setProviderSettingsProviderId] =
|
|
useState<TeamProviderId | null>(null);
|
|
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
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setProviderSettingsProviderId(null);
|
|
}
|
|
}, [open]);
|
|
|
|
// 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 hasSelectedAnthropicRuntime = isLaunchMode && selectedMemberProviders.includes('anthropic');
|
|
const effectiveAnthropicRuntimeLimitContext =
|
|
hasSelectedAnthropicRuntime && !isSchedule ? limitContext : false;
|
|
|
|
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 prepareMessageRef = useRef<string | null>(null);
|
|
const prepareModelResultsCacheRef = useRef(
|
|
new Map<string, Record<string, ProviderPrepareDiagnosticsModelResult>>()
|
|
);
|
|
const lastPrepareProviderSignatureByIdRef = useRef(new Map<TeamProviderId, string>());
|
|
const prepareProviderRequestSeqByIdRef = useRef(new Map<TeamProviderId, number>());
|
|
const prepareWarningsByProviderIdRef = useRef(new Map<TeamProviderId, string[]>());
|
|
|
|
useEffect(() => {
|
|
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
|
}, [runtimeBackendSummaryByProvider]);
|
|
useEffect(() => {
|
|
prepareChecksRef.current = prepareChecks;
|
|
}, [prepareChecks]);
|
|
useEffect(() => {
|
|
prepareMessageRef.current = prepareMessage;
|
|
}, [prepareMessage]);
|
|
|
|
const invalidatePrepareProvider = useCallback((providerId: CliProviderId): void => {
|
|
if (!isTeamProviderId(providerId)) {
|
|
return;
|
|
}
|
|
|
|
lastPrepareProviderSignatureByIdRef.current.delete(providerId);
|
|
prepareProviderRequestSeqByIdRef.current.set(
|
|
providerId,
|
|
(prepareProviderRequestSeqByIdRef.current.get(providerId) ?? 0) + 1
|
|
);
|
|
prepareWarningsByProviderIdRef.current.delete(providerId);
|
|
setPrepareProviderInvalidationEpochById((current) => ({
|
|
...current,
|
|
[providerId]: (current[providerId] ?? 0) + 1,
|
|
}));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
lastPrepareProviderSignatureByIdRef.current.clear();
|
|
prepareProviderRequestSeqByIdRef.current.clear();
|
|
prepareWarningsByProviderIdRef.current.clear();
|
|
}
|
|
}, [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);
|
|
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('');
|
|
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('');
|
|
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 ? '' : 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 effectiveLeadRuntimeModel = useMemo(
|
|
() =>
|
|
computeEffectiveTeamModel(
|
|
selectedModel,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
selectedProviderId,
|
|
runtimeProviderStatusById.get(selectedProviderId)
|
|
) ?? '',
|
|
[
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
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 teammateRuntimeProviderNoticeById:
|
|
| Partial<Record<TeamProviderId, React.ReactNode>>
|
|
| undefined = teammateRuntimeCompatibility.providerNoticeProviderId
|
|
? {
|
|
[teammateRuntimeCompatibility.providerNoticeProviderId]: (
|
|
<TeammateRuntimeCompatibilityNotice
|
|
analysis={teammateRuntimeCompatibility}
|
|
onOpenDashboard={() => {
|
|
closeDialog();
|
|
openDashboard();
|
|
}}
|
|
/>
|
|
),
|
|
}
|
|
: undefined;
|
|
const showRosterTeammateRuntimeCompatibility =
|
|
teammateRuntimeCompatibility.visible && !teammateRuntimeCompatibility.providerNoticeProviderId;
|
|
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]
|
|
);
|
|
|
|
const selectedEffortForCurrentSelection = useMemo(
|
|
() =>
|
|
getAvailableTeamEffortValue({
|
|
providerId: selectedProviderId,
|
|
model: selectedModel,
|
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
|
providerStatus: runtimeProviderStatusById.get(selectedProviderId),
|
|
value: selectedEffort,
|
|
}),
|
|
[
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
runtimeProviderStatusById,
|
|
selectedEffort,
|
|
selectedModel,
|
|
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: selectedEffortForCurrentSelection,
|
|
selectedFastMode,
|
|
providerFastModeDefault: anthropicProviderFastModeDefault,
|
|
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
|
|
})
|
|
: {
|
|
nextEffort: selectedEffortForCurrentSelection,
|
|
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 (selectedEffortForCurrentSelection !== selectedEffort) {
|
|
setSelectedEffortRaw(selectedEffortForCurrentSelection);
|
|
localStorage.setItem('team:lastSelectedEffort', selectedEffortForCurrentSelection);
|
|
}
|
|
if (reconciliation.nextEffort !== selectedEffortForCurrentSelection) {
|
|
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,
|
|
selectedEffortForCurrentSelection,
|
|
selectedFastMode,
|
|
selectedModel,
|
|
selectedProviderId,
|
|
schedule,
|
|
scheduleHydrationKey,
|
|
isSchedule,
|
|
]);
|
|
|
|
const selectedModelChecksByProvider = useMemo(() => {
|
|
const modelsByProvider = new Map<TeamProviderId, TeamProvisioningModelCheckRequest[]>();
|
|
const leadEffort = (selectedEffortForCurrentSelection as EffortLevel | '') || undefined;
|
|
const addModel = (
|
|
providerId: TeamProviderId,
|
|
model: string | undefined,
|
|
effort?: EffortLevel
|
|
): void => {
|
|
const trimmed = model?.trim() ?? '';
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
const existing = modelsByProvider.get(providerId) ?? [];
|
|
if (!existing.some((entry) => entry.model === trimmed && entry.effort === effort)) {
|
|
modelsByProvider.set(providerId, [
|
|
...existing,
|
|
{
|
|
providerId,
|
|
model: trimmed,
|
|
...(effort ? { effort } : {}),
|
|
},
|
|
]);
|
|
}
|
|
};
|
|
const addDefaultSelection = (providerId: TeamProviderId, effort?: EffortLevel): void => {
|
|
if (
|
|
providerId === 'codex' ||
|
|
providerId === 'gemini' ||
|
|
(providerId === 'anthropic' && selectedProviderId === 'anthropic')
|
|
) {
|
|
addModel(providerId, DEFAULT_PROVIDER_MODEL_SELECTION, effort);
|
|
}
|
|
};
|
|
|
|
if (selectedModel.trim()) {
|
|
addModel(selectedProviderId, effectiveLeadRuntimeModel, leadEffort);
|
|
} else {
|
|
addDefaultSelection(selectedProviderId, leadEffort);
|
|
}
|
|
for (const member of effectiveMemberDrafts) {
|
|
if (member.removedAt) {
|
|
continue;
|
|
}
|
|
const memberProviderId = normalizeOptionalTeamProviderId(member.providerId);
|
|
const inheritsDefaultRuntime = !memberProviderId || memberProviderId === selectedProviderId;
|
|
const explicitMemberModel = member.model?.trim() ?? '';
|
|
const memberEffort =
|
|
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? leadEffort : undefined);
|
|
const scopedModel = resolveProviderScopedMemberModel({
|
|
memberProviderId: member.providerId,
|
|
memberModel: member.model,
|
|
selectedProviderId,
|
|
runtimeProviderStatusById,
|
|
});
|
|
if (scopedModel.model) {
|
|
addModel(scopedModel.providerId, scopedModel.model, memberEffort);
|
|
} else {
|
|
addDefaultSelection(scopedModel.providerId, memberEffort);
|
|
}
|
|
}
|
|
|
|
return modelsByProvider;
|
|
}, [
|
|
effectiveLeadRuntimeModel,
|
|
effectiveMemberDrafts,
|
|
runtimeProviderStatusById,
|
|
selectedEffortForCurrentSelection,
|
|
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 ?? '') !==
|
|
((selectedEffortForCurrentSelection as EffortLevel | '') || ''))
|
|
) {
|
|
notes.push({
|
|
key: 'lead',
|
|
memberName: 'lead',
|
|
message: `${formatTeamModelSummary(
|
|
selectedProviderId,
|
|
currentLeadDisplayModel,
|
|
(selectedEffortForCurrentSelection 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,
|
|
(selectedEffortForCurrentSelection 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,
|
|
selectedEffortForCurrentSelection,
|
|
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 selectedProjectPathDeleted = useMemo(
|
|
() =>
|
|
cwdMode === 'project' &&
|
|
selectedProjectPath.length > 0 &&
|
|
isDeletedProjectPathSelection(projects, selectedProjectPath),
|
|
[cwdMode, projects, selectedProjectPath]
|
|
);
|
|
const selectedProjectCwd =
|
|
isEphemeralProjectPath(selectedProjectPath) || selectedProjectPathDeleted
|
|
? ''
|
|
: 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 selectedModelChecksByProviderSignature = useMemo(
|
|
() => buildProviderPrepareModelChecksSignature(selectedModelChecksByProvider),
|
|
[selectedModelChecksByProvider]
|
|
);
|
|
const shortLivedModelIssueReasons = useMemo(() => {
|
|
void prepareChecks;
|
|
void selectedModelChecksByProviderSignature;
|
|
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
|
|
{};
|
|
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
|
|
const modelUnavailableReasonByProvider: Partial<
|
|
Record<TeamProviderId, Record<string, string>>
|
|
> = {};
|
|
|
|
if (!isLaunchMode) {
|
|
return {
|
|
modelAdvisoryReasonByProvider,
|
|
modelIssueReasonByProvider,
|
|
modelUnavailableReasonByProvider,
|
|
};
|
|
}
|
|
|
|
for (const providerId of selectedMemberProviders) {
|
|
const backendSummary = runtimeBackendSummaryByProvider.get(providerId) ?? null;
|
|
const providerRuntimeStatusSignature = buildProviderPrepareRuntimeStatusSignature(
|
|
[providerId],
|
|
runtimeProviderStatusById
|
|
);
|
|
const providerModelChecksSignature = buildProviderPrepareModelChecksSignature(
|
|
new Map([[providerId, selectedModelChecksByProvider.get(providerId) ?? []]])
|
|
);
|
|
const cacheKey = buildProviderPrepareModelCacheKey({
|
|
cwd: effectiveCwd,
|
|
providerId,
|
|
backendSummary,
|
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
|
runtimeStatusSignature: providerRuntimeStatusSignature,
|
|
modelChecksSignature: providerModelChecksSignature,
|
|
});
|
|
const issueReasons = getShortLivedProviderPrepareModelIssueReasons({
|
|
providerId,
|
|
cacheKey,
|
|
});
|
|
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
|
|
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
|
|
}
|
|
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
|
|
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
|
|
}
|
|
if (Object.keys(issueReasons.modelUnavailableReasonByValue).length > 0) {
|
|
modelUnavailableReasonByProvider[providerId] = issueReasons.modelUnavailableReasonByValue;
|
|
}
|
|
}
|
|
|
|
return {
|
|
modelAdvisoryReasonByProvider,
|
|
modelIssueReasonByProvider,
|
|
modelUnavailableReasonByProvider,
|
|
};
|
|
}, [
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
effectiveCwd,
|
|
isLaunchMode,
|
|
prepareChecks,
|
|
runtimeBackendSummaryByProvider,
|
|
runtimeProviderStatusById,
|
|
selectedModelChecksByProvider,
|
|
selectedModelChecksByProviderSignature,
|
|
selectedMemberProviders,
|
|
]);
|
|
|
|
// 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;
|
|
lastPrepareProviderSignatureByIdRef.current.clear();
|
|
prepareProviderRequestSeqByIdRef.current.clear();
|
|
prepareWarningsByProviderIdRef.current.clear();
|
|
return;
|
|
}
|
|
|
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
|
prepareRequestSeqRef.current += 1;
|
|
lastPrepareProviderSignatureByIdRef.current.clear();
|
|
prepareProviderRequestSeqByIdRef.current.clear();
|
|
prepareWarningsByProviderIdRef.current.clear();
|
|
setPrepareState('failed');
|
|
setPrepareWarnings([]);
|
|
setPrepareChecks([]);
|
|
setPrepareMessage(
|
|
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!effectiveCwd) {
|
|
prepareRequestSeqRef.current += 1;
|
|
lastPrepareProviderSignatureByIdRef.current.clear();
|
|
prepareProviderRequestSeqByIdRef.current.clear();
|
|
prepareWarningsByProviderIdRef.current.clear();
|
|
setPrepareState('idle');
|
|
setPrepareWarnings([]);
|
|
setPrepareChecks([]);
|
|
setPrepareMessage('Select a working directory to validate the launch environment.');
|
|
return;
|
|
}
|
|
|
|
const selectedProviderIdSet = new Set(selectedMemberProviders);
|
|
for (const providerId of Array.from(lastPrepareProviderSignatureByIdRef.current.keys())) {
|
|
if (!selectedProviderIdSet.has(providerId)) {
|
|
lastPrepareProviderSignatureByIdRef.current.delete(providerId);
|
|
prepareProviderRequestSeqByIdRef.current.delete(providerId);
|
|
prepareWarningsByProviderIdRef.current.delete(providerId);
|
|
}
|
|
}
|
|
|
|
const providerPlans = buildProviderPreparePlans({
|
|
cwd: effectiveCwd,
|
|
providerIds: selectedMemberProviders,
|
|
selectedModelChecksByProvider,
|
|
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
|
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
|
runtimeProviderStatusById,
|
|
cachedModelResultsByCacheKey: prepareModelResultsCacheRef.current,
|
|
});
|
|
const changedPlans = providerPlans.filter(
|
|
(plan) =>
|
|
lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature
|
|
);
|
|
const loadingMessage = getProvisioningProviderProgressMessage(
|
|
changedPlans.map((plan) => plan.providerId),
|
|
selectedMemberProviders.length
|
|
);
|
|
const getSelectedWarnings = (): string[] =>
|
|
selectedMemberProviders.flatMap(
|
|
(providerId) => prepareWarningsByProviderIdRef.current.get(providerId) ?? []
|
|
);
|
|
const commitChecks = (nextChecks: ProvisioningProviderCheck[]): void => {
|
|
prepareChecksRef.current = nextChecks;
|
|
setPrepareChecks(nextChecks);
|
|
};
|
|
const applyPrepareOutcome = (
|
|
nextChecks: ProvisioningProviderCheck[],
|
|
pendingMessage: string | null
|
|
): void => {
|
|
const selectedWarnings = getSelectedWarnings();
|
|
setPrepareWarnings(selectedWarnings);
|
|
|
|
if (nextChecks.some((check) => check.status === 'pending' || check.status === 'checking')) {
|
|
setPrepareState('loading');
|
|
setPrepareMessage(pendingMessage);
|
|
return;
|
|
}
|
|
|
|
const anyFailure = nextChecks.some((check) => check.status === 'failed');
|
|
const anyNotes =
|
|
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
|
|
const failureMessage =
|
|
getPrimaryProvisioningFailureDetail(nextChecks) ??
|
|
'Some selected providers need attention.';
|
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
|
setPrepareMessage(
|
|
anyFailure
|
|
? failureMessage
|
|
: anyNotes
|
|
? 'All selected providers are ready, with notes.'
|
|
: 'All selected providers are ready.'
|
|
);
|
|
};
|
|
|
|
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders);
|
|
for (const plan of changedPlans) {
|
|
checks = updateProviderCheck(checks, plan.providerId, {
|
|
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
|
backendSummary: plan.backendSummary,
|
|
details: plan.cachedSnapshot.details,
|
|
supportDiagnostics: undefined,
|
|
});
|
|
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
|
}
|
|
commitChecks(checks);
|
|
applyPrepareOutcome(
|
|
checks,
|
|
changedPlans.length > 0
|
|
? loadingMessage
|
|
: (prepareMessageRef.current ??
|
|
getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
|
|
);
|
|
|
|
if (changedPlans.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const generation = prepareRequestSeqRef.current;
|
|
const runningPlans = changedPlans.map((plan) => {
|
|
const requestSeq = (prepareProviderRequestSeqByIdRef.current.get(plan.providerId) ?? 0) + 1;
|
|
prepareProviderRequestSeqByIdRef.current.set(plan.providerId, requestSeq);
|
|
lastPrepareProviderSignatureByIdRef.current.set(plan.providerId, plan.requestSignature);
|
|
return { ...plan, requestSeq };
|
|
});
|
|
const isPlanCurrent = (plan: ProviderPreparePlan & { requestSeq: number }): boolean =>
|
|
prepareRequestSeqRef.current === generation &&
|
|
lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) === plan.requestSignature &&
|
|
prepareProviderRequestSeqByIdRef.current.get(plan.providerId) === plan.requestSeq;
|
|
|
|
void (async () => {
|
|
await Promise.all(
|
|
runningPlans.map(async (plan) => {
|
|
try {
|
|
const prepResult = await runProviderPrepareDiagnostics({
|
|
cwd: effectiveCwd,
|
|
providerId: plan.providerId,
|
|
selectedModelIds: plan.selectedModelIds,
|
|
selectedModelChecks: plan.selectedModelChecks,
|
|
prepareProvisioning: api.teams.prepareProvisioning,
|
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
|
cachedModelResultsById: plan.cachedModelResultsById,
|
|
onModelProgress: ({ status, details }) => {
|
|
if (!isPlanCurrent(plan)) {
|
|
return;
|
|
}
|
|
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
|
|
status,
|
|
backendSummary: plan.backendSummary,
|
|
details,
|
|
supportDiagnostics: undefined,
|
|
});
|
|
commitChecks(nextChecks);
|
|
applyPrepareOutcome(nextChecks, loadingMessage);
|
|
},
|
|
});
|
|
if (!isPlanCurrent(plan)) {
|
|
return;
|
|
}
|
|
prepareWarningsByProviderIdRef.current.set(
|
|
plan.providerId,
|
|
prepResult.warnings.map(
|
|
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
|
)
|
|
);
|
|
prepareModelResultsCacheRef.current.set(
|
|
plan.cacheKey,
|
|
mergeReusableProviderPrepareModelResults(
|
|
prepareModelResultsCacheRef.current.get(plan.cacheKey),
|
|
prepResult.modelResultsById
|
|
)
|
|
);
|
|
storeShortLivedProviderPrepareModelResults({
|
|
providerId: plan.providerId,
|
|
cacheKey: plan.cacheKey,
|
|
modelResultsById: prepResult.modelResultsById,
|
|
});
|
|
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
|
|
status: prepResult.status,
|
|
backendSummary: plan.backendSummary,
|
|
details: prepResult.details,
|
|
supportDiagnostics: prepResult.supportDiagnostics,
|
|
});
|
|
commitChecks(nextChecks);
|
|
applyPrepareOutcome(nextChecks, loadingMessage);
|
|
} catch (error) {
|
|
if (!isPlanCurrent(plan)) {
|
|
return;
|
|
}
|
|
const failureMessage =
|
|
error instanceof Error ? error.message : 'Failed to prepare selected providers';
|
|
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
|
|
status: 'failed',
|
|
backendSummary: plan.backendSummary,
|
|
details: [failureMessage],
|
|
supportDiagnostics: undefined,
|
|
});
|
|
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
|
|
commitChecks(nextChecks);
|
|
applyPrepareOutcome(nextChecks, failureMessage);
|
|
}
|
|
})
|
|
);
|
|
})();
|
|
}, [
|
|
open,
|
|
isLaunchMode,
|
|
effectiveCwd,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
prepareProviderInvalidationEpochById,
|
|
runtimeProviderStatusById,
|
|
selectedMemberProviders,
|
|
selectedModelChecksByProvider,
|
|
selectedModelChecksByProviderSignature,
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(isSelectableProjectPathProject);
|
|
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, setSelectedProjectPath]);
|
|
|
|
useEffect(() => {
|
|
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
|
return;
|
|
}
|
|
if (
|
|
!isEphemeralProjectPath(selectedProjectPath) &&
|
|
!isDeletedProjectPathSelection(projects, selectedProjectPath)
|
|
) {
|
|
return;
|
|
}
|
|
setSelectedProjectPath('');
|
|
}, [open, cwdMode, projects, 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,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
selectedProviderId,
|
|
runtimeProviderStatusById.get(selectedProviderId)
|
|
);
|
|
if (model) args.push('--model', model);
|
|
const effectiveEffort =
|
|
selectedProviderId === 'anthropic'
|
|
? selectedEffortForCurrentSelection || anthropicRuntimeSelection?.defaultEffort || ''
|
|
: selectedEffortForCurrentSelection;
|
|
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));
|
|
}
|
|
return args;
|
|
}, [
|
|
anthropicFastModeResolution?.resolvedFastMode,
|
|
anthropicRuntimeSelection?.defaultEffort,
|
|
codexFastModeResolution?.resolvedFastMode,
|
|
isLaunchMode,
|
|
skipPermissions,
|
|
selectedModel,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
selectedEffortForCurrentSelection,
|
|
selectedProviderId,
|
|
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 (selectedEffortForCurrentSelection) {
|
|
summary.push(`Effort: ${selectedEffortForCurrentSelection}`);
|
|
}
|
|
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 (effectiveAnthropicRuntimeLimitContext) {
|
|
summary.push('Anthropic limited to 200K context');
|
|
}
|
|
if (skipPermissions) summary.push('Auto-approve tools');
|
|
summary.push('Fresh lead 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,
|
|
selectedEffortForCurrentSelection,
|
|
selectedFastMode,
|
|
anthropicProviderFastModeDefault,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
skipPermissions,
|
|
worktreeEnabled,
|
|
worktreeName,
|
|
customArgs,
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const validationErrors = useMemo(() => {
|
|
const errors: string[] = [];
|
|
if (selectedProjectPathDeleted) {
|
|
errors.push('Project folder no longer exists');
|
|
} else 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,
|
|
selectedProjectPathDeleted,
|
|
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, {
|
|
inheritedProviderId: selectedProviderId,
|
|
});
|
|
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,
|
|
effectiveAnthropicRuntimeLimitContext,
|
|
selectedProviderId,
|
|
runtimeProviderStatusById.get(selectedProviderId)
|
|
),
|
|
effort: (selectedEffortForCurrentSelection as EffortLevel) || undefined,
|
|
fastMode:
|
|
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
|
|
? selectedFastMode
|
|
: undefined,
|
|
limitContext: effectiveAnthropicRuntimeLimitContext,
|
|
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 = selectedEffortForCurrentSelection
|
|
? (selectedEffortForCurrentSelection 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-[52rem] overflow-y-auto' : 'max-w-[52rem]'}
|
|
>
|
|
<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 “{conflictingTeam.displayName}” 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}
|
|
|
|
<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={(selectedEffortForCurrentSelection 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={(selectedEffortForCurrentSelection as EffortLevel) || undefined}
|
|
limitContext={effectiveAnthropicRuntimeLimitContext}
|
|
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
|
|
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}
|
|
modelAdvisoryReasonByProvider={
|
|
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
|
|
}
|
|
modelIssueReasonByProvider={
|
|
shortLivedModelIssueReasons.modelIssueReasonByProvider
|
|
}
|
|
modelUnavailableReasonByProvider={
|
|
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
|
|
}
|
|
softDeleteMembers
|
|
disableGeminiOption={isGeminiUiFrozen()}
|
|
headerBottom={
|
|
showRosterTeammateRuntimeCompatibility || hasSelectedWorktreeIsolation ? (
|
|
<div className="space-y-2">
|
|
{showRosterTeammateRuntimeCompatibility ? (
|
|
<TeammateRuntimeCompatibilityNotice
|
|
analysis={teammateRuntimeCompatibility}
|
|
onOpenDashboard={() => {
|
|
closeDialog();
|
|
openDashboard();
|
|
}}
|
|
/>
|
|
) : null}
|
|
{hasSelectedWorktreeIsolation ? (
|
|
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
|
) : null}
|
|
</div>
|
|
) : 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="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">
|
|
<Info className="mt-0.5 size-3.5 shrink-0" />
|
|
<p>
|
|
Team relaunch starts a fresh lead session. Durable team state, task board,
|
|
and member configuration are rehydrated into the launch prompt.
|
|
</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>
|
|
{selectedProviderId === 'anthropic' ? (
|
|
<div className="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] leading-relaxed text-amber-100">
|
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
|
<p>
|
|
Starting June 15, 2026, Anthropic bills <code>claude -p</code> and Agent SDK
|
|
usage from the monthly Agent SDK credit, separate from interactive Claude Code
|
|
limits. The credit resets each billing cycle and unused credit does not roll
|
|
over.{' '}
|
|
<a
|
|
href={ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:text-white"
|
|
>
|
|
Read Anthropic article
|
|
<ExternalLink className="size-3" />
|
|
</a>
|
|
.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</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={selectedEffortForCurrentSelection}
|
|
onValueChange={setSelectedEffort}
|
|
id="dialog-effort"
|
|
providerId={selectedProviderId}
|
|
model={selectedModel}
|
|
limitContext={effectiveAnthropicRuntimeLimitContext}
|
|
/>
|
|
{selectedProviderId === 'anthropic' ? (
|
|
<div className="mt-2">
|
|
<AnthropicFastModeSelector
|
|
value={selectedFastMode}
|
|
onValueChange={setSelectedFastMode}
|
|
providerFastModeDefault={anthropicProviderFastModeDefault}
|
|
model={selectedModel}
|
|
limitContext={effectiveAnthropicRuntimeLimitContext}
|
|
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">
|
|
<ProviderActivityStatusStrip
|
|
cliStatus={effectiveCliStatus}
|
|
sourceCliStatus={loadingCliStatus}
|
|
cliStatusLoading={cliStatusLoading}
|
|
cliProviderStatusLoading={cliProviderStatusLoading}
|
|
multimodelEnabled={multimodelEnabled}
|
|
codexSnapshotPending={codexSnapshotPending}
|
|
providerIds={selectedMemberProviders}
|
|
className="mb-2"
|
|
/>
|
|
{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'
|
|
? 'Checking selected providers...'
|
|
: '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"
|
|
onOpenProviderSettings={(providerId) =>
|
|
setProviderSettingsProviderId(providerId)
|
|
}
|
|
/>
|
|
</>
|
|
) : 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
|
|
? 'Selected providers ready (with notes)'
|
|
: 'Selected providers 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"
|
|
onOpenProviderSettings={(providerId) =>
|
|
setProviderSettingsProviderId(providerId)
|
|
}
|
|
/>
|
|
{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">
|
|
Runtime 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}
|
|
onOpenProviderSettings={(providerId) =>
|
|
setProviderSettingsProviderId(providerId)
|
|
}
|
|
/>
|
|
) : 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>
|
|
<ProvisioningProviderRuntimeSettingsDialog
|
|
openProviderId={providerSettingsProviderId}
|
|
onOpenProviderIdChange={(providerId) => setProviderSettingsProviderId(providerId)}
|
|
providers={effectiveCliStatus?.providers ?? []}
|
|
projectPath={effectiveCwd || null}
|
|
disabled={isSubmitting || launchInFlight}
|
|
onProviderRuntimeChanged={invalidatePrepareProvider}
|
|
/>
|
|
</Dialog>
|
|
);
|
|
};
|