From 481965f1b420c5b1f94a767a6fbbaf2e9f3aa562 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 16:46:56 +0300 Subject: [PATCH] feat(team): add relaunch flow and stabilize edit member colors --- .../components/team/TeamDetailView.tsx | 80 +++- .../team/dialogs/EditTeamDialog.tsx | 15 +- .../team/dialogs/LaunchTeamDialog.tsx | 219 ++++++--- .../team/dialogs/teamRelaunchFlow.ts | 30 ++ .../team/members/MemberDraftRow.tsx | 111 +++-- .../team/members/MembersEditorSection.tsx | 4 +- .../team/members/membersEditorUtils.ts | 45 +- .../team/dialogs/EditTeamDialog.test.ts | 91 +++- .../team/dialogs/LaunchTeamDialog.test.ts | 430 ++++++++++++++++++ .../team/dialogs/teamRelaunchFlow.test.ts | 66 +++ .../team/members/membersEditorUtils.test.ts | 57 ++- 11 files changed, 988 insertions(+), 160 deletions(-) create mode 100644 src/renderer/components/team/dialogs/teamRelaunchFlow.ts create mode 100644 test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts create mode 100644 test/renderer/components/team/dialogs/teamRelaunchFlow.test.ts diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 8142a16e..a34d71d7 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -76,10 +76,11 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; +import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { SendMessageDialog } from './dialogs/SendMessageDialog'; import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; +import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { KanbanSearchInput } from './kanban/KanbanSearchInput'; @@ -127,6 +128,8 @@ import type { ResolvedTeamMember, TaskRef, TeamAgentRuntimeEntry, + TeamCreateRequest, + TeamLaunchRequest, TeamTaskWithKanban, } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; @@ -924,7 +927,13 @@ export const TeamDetailView = ({ const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogOpen, setLaunchDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); @@ -1156,6 +1165,7 @@ export const TeamDetailView = ({ const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< { teamName: string; displayName: string; projectPath: string }[] >([]); + const launchDialogOpen = launchDialogState.open; // Session loading and filtering state const [sessions, setSessions] = useState([]); @@ -1666,10 +1676,49 @@ export const TeamDetailView = ({ setSendDialogOpen(true); }, []); - const handleRestartTeam = useCallback(() => { - setLaunchDialogOpen(true); + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); }, []); + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ + teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, + }); + }, + [data?.isAlive, launchTeam, teamName] + ); + + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { setSelectedMember(member); setSelectedMemberView(null); @@ -2015,7 +2064,7 @@ export const TeamDetailView = ({
@@ -2032,17 +2081,16 @@ export const TeamDetailView = ({
setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} + onClose={closeLaunchDialog} + onLaunch={handleLaunchDialogSubmit} + onRelaunch={handleRelaunchDialogSubmit} /> ); @@ -2304,7 +2352,7 @@ export const TeamDetailView = ({ {!data.isAlive && !isTeamProvisioning ? ( setLaunchDialogOpen(true)} + onLaunch={() => openLaunchDialog('launch')} /> ) : null} @@ -2724,6 +2772,7 @@ export const TeamDetailView = ({ isTeamProvisioning={isTeamProvisioning} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} onSaved={() => void selectTeam(teamName)} /> @@ -2814,7 +2863,7 @@ export const TeamDetailView = ({ setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} + onClose={closeLaunchDialog} + onLaunch={handleLaunchDialogSubmit} + onRelaunch={handleRelaunchDialogSubmit} /> void; + onChangeLeadRuntime: () => void; onSaved: () => Promise | void; } @@ -133,6 +134,7 @@ export const EditTeamDialog = ({ isTeamProvisioning = false, projectPath, onClose, + onChangeLeadRuntime, onSaved, }: EditTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); @@ -533,11 +535,18 @@ export const EditTeamDialog = ({ lockedRoleLabel="Team Lead" lockIdentity hideActionButton - modelLockReason="The team lead is shown for context only and cannot be edited from Edit Team." + modelLockReason="Team lead runtime is managed from Relaunch Team." + lockedModelAction={{ + label: 'Change lead runtime', + description: + 'Open Relaunch Team to change the lead provider, model, or effort.', + onClick: onChangeLeadRuntime, + disabled: isTeamProvisioning, + }} />

- Team lead is shown for context only. Edit Team changes only teammate roster - settings. + Team lead name and role stay read-only here. Open the runtime panel on the + lead row to change provider, model, or effort.

) : null diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7574de53..b9eb6f25 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -106,6 +106,7 @@ import type { ResolvedTeamMember, Schedule, ScheduleLaunchConfig, + TeamCreateRequest, TeamLaunchRequest, TeamProviderId, UpdateSchedulePatch, @@ -139,6 +140,8 @@ interface LaunchDialogBase { onClose: () => void; } +export type TeamLaunchDialogMode = 'launch' | 'relaunch'; + interface LaunchDialogLaunchMode extends LaunchDialogBase { mode: 'launch'; members: ResolvedTeamMember[]; @@ -149,6 +152,16 @@ interface LaunchDialogLaunchMode extends LaunchDialogBase { onLaunch: (request: TeamLaunchRequest) => Promise; } +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; +} + interface LaunchDialogScheduleMode { mode: 'schedule'; open: boolean; @@ -159,7 +172,10 @@ interface LaunchDialogScheduleMode { schedule?: Schedule | null; } -export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode; +export type LaunchTeamDialogProps = + | LaunchDialogLaunchMode + | LaunchDialogRelaunchMode + | LaunchDialogScheduleMode; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; @@ -233,7 +249,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const fetchCliStatus = useStore((s) => s.fetchCliStatus); - const isLaunch = props.mode === 'launch'; + const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch'; + const isRelaunch = props.mode === 'relaunch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? (props.schedule ?? null) : null; const isEditing = isSchedule && !!schedule; @@ -317,7 +334,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const previousLaunchParams = useStore((s) => effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined ); - const members = isLaunch ? props.members : storeMembers; + const members = isLaunchMode ? props.members : storeMembers; const [savedLaunchProviderId, setSavedLaunchProviderId] = useState(null); // Advanced CLI section state (with localStorage persistence) @@ -537,7 +554,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const closeDialog = (): void => { - if (isLaunch) { + if (isLaunchMode) { resetFormState(); } onClose(); @@ -595,7 +612,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [open, isSchedule, schedule?.id]); useEffect(() => { - if (!open || !isLaunch) return; + if (!open || !isLaunchMode) return; let cancelled = false; void (async () => { @@ -657,10 +674,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]); + }, [open, isLaunchMode, effectiveTeamName, members, multimodelEnabled, previousLaunchParams]); const previousProviderId = useMemo(() => { - if (!isLaunch) { + if (!isLaunchMode) { return null; } const fromLaunchParams = previousLaunchParams?.providerId; @@ -672,14 +689,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return fromLaunchParams; } return savedLaunchProviderId; - }, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]); + }, [isLaunchMode, previousLaunchParams?.providerId, savedLaunchProviderId]); const providerChangeForcesFreshLeadContext = useMemo(() => { - if (!isLaunch || !previousProviderId) { + if (!isLaunchMode || !previousProviderId) { return false; } return previousProviderId !== selectedProviderId; - }, [isLaunch, previousProviderId, selectedProviderId]); + }, [isLaunchMode, previousProviderId, selectedProviderId]); const effectiveLeadRuntimeModel = useMemo( () => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '', @@ -732,7 +749,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [effectiveLeadRuntimeModel, effectiveMemberDrafts, selectedModel, selectedProviderId]); const runtimeChangeNotes = useMemo(() => { - if (!isLaunch) { + if (!isLaunchMode) { return [] as { key: string; memberName: string; message: string }[]; } @@ -824,7 +841,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return notes; }, [ - isLaunch, + isLaunchMode, previousLaunchParams?.effort, previousLaunchParams?.model, previousProviderId, @@ -883,14 +900,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Clear stale provisioning error when dialog opens useEffect(() => { - if (!open || !isLaunch) return; + if (!open || !isLaunchMode) return; props.clearProvisioningError?.(effectiveTeamName); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, isLaunch, effectiveTeamName]); + }, [open, isLaunchMode, effectiveTeamName]); // Warm up CLI for the currently selected working directory (launch mode only). useEffect(() => { - if (!open || !isLaunch) return; + if (!open || !isLaunchMode) return; if (typeof api.teams.prepareProvisioning !== 'function') { setPrepareState('failed'); @@ -1040,7 +1057,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; }, [ open, - isLaunch, + isLaunchMode, effectiveCwd, selectedProviderId, selectedMemberProviders, @@ -1099,7 +1116,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [open, repositoryGroups]); // Pre-select defaultProjectPath (launch mode) or first project - const defaultProjectPath = isLaunch ? props.defaultProjectPath : undefined; + const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; useEffect(() => { if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return; @@ -1120,17 +1137,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Launch-only: conflict detection // --------------------------------------------------------------------------- - const activeTeams = isLaunch ? props.activeTeams : undefined; + const activeTeams = isLaunchMode ? props.activeTeams : undefined; const conflictingTeam = useMemo(() => { - if (!isLaunch || !activeTeams?.length || !effectiveCwd) return null; + if (!isLaunchMode || !activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return ( activeTeams.find( (t) => t.teamName !== effectiveTeamName && normalizePath(t.projectPath) === norm ) ?? null ); - }, [isLaunch, activeTeams, effectiveCwd, effectiveTeamName]); + }, [isLaunchMode, activeTeams, effectiveCwd, effectiveTeamName]); useEffect(() => { setConflictDismissed(false); @@ -1156,7 +1173,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const internalArgs = useMemo(() => { - if (!isLaunch) return []; + if (!isLaunchMode) return []; const args: string[] = []; args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); args.push('--verbose', '--setting-sources', 'user,project,local'); @@ -1168,7 +1185,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (!clearContext) args.push('--resume', ''); return args; }, [ - isLaunch, + isLaunchMode, skipPermissions, selectedModel, limitContext, @@ -1178,7 +1195,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ]); const launchOptionalSummary = useMemo(() => { - if (!isLaunch) return []; + if (!isLaunchMode) return []; const summary: string[] = []; if (promptDraft.value.trim()) summary.push('Lead prompt'); @@ -1192,7 +1209,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen if (customArgs.trim()) summary.push('Custom CLI args'); return summary; }, [ - isLaunch, + isLaunchMode, promptDraft.value, selectedModel, selectedProviderId, @@ -1229,7 +1246,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return leadError; } - if (!isLaunch) { + if (!isLaunchMode) { return null; } @@ -1255,7 +1272,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return null; }, [ effectiveMemberDrafts, - isLaunch, + isLaunchMode, runtimeProviderStatusById, selectedModel, selectedProviderId, @@ -1270,7 +1287,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }, [effectiveLeadRuntimeModel, prepareChecks, selectedModel, selectedProviderId]); const memberModelIssueById = useMemo(() => { const next: Record = {}; - if (!isLaunch) { + if (!isLaunchMode) { return next; } for (const member of effectiveMemberDrafts) { @@ -1291,7 +1308,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return next; }, [ effectiveMemberDrafts, - isLaunch, + isLaunchMode, leadModelIssueText, prepareChecks, selectedProviderId, @@ -1299,32 +1316,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ]); const hasInvalidLaunchMemberNames = useMemo( () => - isLaunch && + isLaunchMode && membersDrafts.some( (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null ), - [isLaunch, membersDrafts] + [isLaunchMode, membersDrafts] ); const hasDuplicateLaunchMemberNames = useMemo(() => { - if (!isLaunch) return false; + if (!isLaunchMode) return false; const activeNames = membersDrafts .map((member) => member.name.trim().toLowerCase()) .filter(Boolean); return new Set(activeNames).size !== activeNames.length; - }, [isLaunch, membersDrafts]); + }, [isLaunchMode, membersDrafts]); // --------------------------------------------------------------------------- // Error // --------------------------------------------------------------------------- - const provisioningError = isLaunch ? props.provisioningError : null; + const provisioningError = isLaunchMode ? props.provisioningError : null; const activeError = localError ?? modelValidationError ?? provisioningError; const launchInFlight = useStore((s) => - isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false + isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); useEffect(() => { - if (!open || !isLaunch || !effectiveTeamName || !launchInFlight) { + if (!open || !isLaunchMode || !effectiveTeamName || !launchInFlight) { return; } @@ -1335,7 +1352,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen defaultProjectPath, effectiveCwd, effectiveTeamName, - isLaunch, + isLaunchMode, launchInFlight, open, openTeamTab, @@ -1354,12 +1371,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError(modelValidationError); return; } - if (isLaunch && !effectiveCwd) { + if (isLaunchMode && !effectiveCwd) { setLocalError('Select working directory (cwd)'); return; } if ( - isLaunch && + isLaunchMode && membersDrafts.some( (member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null ) @@ -1367,7 +1384,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setLocalError('Fix member names before launch'); return; } - if (isLaunch) { + if (isLaunchMode) { const activeNames = membersDrafts .map((member) => member.name.trim().toLowerCase()) .filter(Boolean); @@ -1381,11 +1398,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen void (async () => { try { - if (isLaunch) { - await api.teams.replaceMembers(effectiveTeamName, { - members: buildMembersFromDrafts(effectiveMemberDrafts), - }); - await props.onLaunch({ + if (isLaunchMode) { + const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts); + const launchRequest: TeamLaunchRequest = { teamName: effectiveTeamName, cwd: effectiveCwd, prompt: promptDraft.value.trim() || undefined, @@ -1397,7 +1412,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen 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 { @@ -1444,10 +1467,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? err.message : isSchedule ? 'Failed to save schedule' - : 'Failed to launch team'; + : isRelaunch + ? 'Failed to relaunch team' + : 'Failed to launch team'; setLocalError(message); - if (isLaunch) { - console.error('Failed to launch team from dialog:', err); + if (isLaunchMode) { + console.error( + isRelaunch + ? 'Failed to relaunch team from dialog:' + : 'Failed to launch team from dialog:', + err + ); } } finally { setIsSubmitting(false); @@ -1459,7 +1489,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Disabled state // --------------------------------------------------------------------------- - const isDisabled = isLaunch + const isDisabled = isLaunchMode ? isSubmitting || launchInFlight || validationErrors.length > 0 || @@ -1472,13 +1502,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // Dynamic labels // --------------------------------------------------------------------------- - const dialogTitle = isLaunch ? 'Launch Team' : isEditing ? 'Edit Schedule' : 'Create Schedule'; + const dialogTitle = isLaunchMode + ? isRelaunch + ? 'Relaunch Team' + : 'Launch Team' + : isEditing + ? 'Edit Schedule' + : 'Create Schedule'; - const dialogDescription = isLaunch ? ( - <> - Start team {effectiveTeamName} via local Claude - CLI. - + const dialogDescription = isLaunchMode ? ( + isRelaunch ? ( + <> + Stop the current run for {effectiveTeamName}{' '} + and start it again via local Claude CLI. + + ) : ( + <> + Start team {effectiveTeamName} via local + Claude CLI. + + ) ) : isEditing ? ( `Editing schedule for team "${effectiveTeamName}"` ) : effectiveTeamName ? ( @@ -1487,15 +1530,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen 'Schedule automatic Claude task execution' ); - const submitLabel = isLaunch - ? prepareState === 'idle' || prepareState === 'loading' - ? 'Skip and Launch' - : 'Launch' + const submitLabel = isLaunchMode + ? isRelaunch + ? 'Relaunch team' + : 'Launch team' : isEditing ? 'Save Changes' : 'Create Schedule'; - const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; + const submittingLabel = isLaunchMode + ? isRelaunch + ? 'Relaunching...' + : 'Launching...' + : isEditing + ? 'Saving...' + : 'Creating...'; // --------------------------------------------------------------------------- // Render @@ -1518,8 +1567,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {dialogDescription} + {isRelaunch ? ( +
+
+ +
+

Relaunch will restart the current team run

+

+ Saving these settings will stop the current team process, persist the updated + roster, and launch the team again with the new runtime. +

+
+
+
+ ) : null} + {/* Launch-only: Conflict warning */} - {isLaunch && conflictingTeam && !conflictDismissed ? ( + {isLaunchMode && conflictingTeam && !conflictDismissed ? (
@@ -1944,9 +2019,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null} - + {/* Launch-only: CLI warm-up status */} - {isLaunch ? ( + {isLaunchMode ? (
{prepareState === 'idle' || prepareState === 'loading' ? ( <> @@ -1960,7 +2035,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : 'Preparing environment...')}

- Pre-flight check to catch errors before launch + + Pre-flight check to catch errors before{' '} + {isRelaunch ? 'relaunch' : 'launch'} +

@@ -2003,13 +2081,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

- CLI environment is not available - launch is blocked + CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is + blocked

{prepareMessage ?? 'Failed to prepare environment'}

- Pre-flight check to catch errors before launch + Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}

@@ -2060,7 +2139,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
+
+ ) : ( + <> + { + if (lockProviderModel) return; + onProviderChange(member.id, providerId); + }} + value={effectiveModel ?? ''} + onValueChange={(value) => { + if (lockProviderModel) return; + onModelChange(member.id, value); + }} + id={`member-${member.id}-model`} + disableGeminiOption={disableGeminiOption} + modelIssueReasonByValue={ + effectiveModel?.trim() ? { [effectiveModel.trim()]: modelIssueText } : undefined + } + /> + { + if (lockProviderModel) return; + onEffortChange(member.id, value); + }} + id={`member-${member.id}-effort`} + /> + {lockProviderModel && ( +

+ {modelLockReason ?? + 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'} +

+ )} + )} )} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index e844d2a9..40d07544 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -315,7 +315,7 @@ export const MembersEditorSection = ({ key={member.id} member={member} index={index} - resolvedColor={memberColorMap.get(member.name.trim())} + resolvedColor={memberColorMap.get(member.id)} nameError={validateMemberName?.(member.name) ?? null} onNameChange={updateMemberName} onRoleChange={updateMemberRole} @@ -356,7 +356,7 @@ export const MembersEditorSection = ({ key={member.id} member={member} index={activeMembers.length + index} - resolvedColor={memberColorMap.get(member.name.trim())} + resolvedColor={memberColorMap.get(member.id)} nameError={null} onNameChange={updateMemberName} onRoleChange={updateMemberRole} diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts index 43525e02..78bda46f 100644 --- a/src/renderer/components/team/members/membersEditorUtils.ts +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -132,16 +132,16 @@ interface ExistingMemberColorInput { removedAt?: number | string | null; } +function getMemberDraftColorSeedKey(member: Pick): string { + const originalName = member.originalName?.trim(); + return originalName || `draft:${member.id}`; +} + export function buildMemberDraftColorMap( - members: readonly Pick[], + members: readonly Pick[], existingMembers?: readonly ExistingMemberColorInput[], existingColorMap?: ReadonlyMap ): Map { - const draftEntries = members - .map((member) => member.name.trim()) - .filter(Boolean) - .map((name) => ({ name })); - const normalizedExistingColorMap = new Map( Array.from(existingColorMap?.entries() ?? []).map(([name, color]) => [ name.trim().toLowerCase(), @@ -160,15 +160,15 @@ export function buildMemberDraftColorMap( })) .filter((member) => member.name); const existingNames = new Set(existingSeedEntries.map((member) => member.name.toLowerCase())); - const unseenNewDraftNames = new Set(); - const uniqueNewDraftEntries = draftEntries.filter((entry) => { - const normalizedName = entry.name.toLowerCase(); - if (existingNames.has(normalizedName) || unseenNewDraftNames.has(normalizedName)) { - return false; - } - unseenNewDraftNames.add(normalizedName); - return true; - }); + const uniqueNewDraftEntries = members + .filter((member) => { + if (member.originalName?.trim()) { + return false; + } + const currentName = member.name.trim(); + return !currentName || !existingNames.has(currentName.toLowerCase()); + }) + .map((member) => ({ name: getMemberDraftColorSeedKey(member) })); const fullMap = buildTeamMemberColorMap([...existingSeedEntries, ...uniqueNewDraftEntries], { preferProvidedColors: true, @@ -178,9 +178,16 @@ export function buildMemberDraftColorMap( ); const draftMap = new Map(); - for (const entry of draftEntries) { - const color = fullColorByName.get(entry.name.toLowerCase()); - if (color) draftMap.set(entry.name, color); + for (const member of members) { + const originalName = member.originalName?.trim(); + const currentName = member.name.trim(); + const colorSeedKey = originalName + ? originalName + : currentName && existingNames.has(currentName.toLowerCase()) + ? currentName + : getMemberDraftColorSeedKey(member); + const color = fullColorByName.get(colorSeedKey.toLowerCase()); + if (color) draftMap.set(member.id, color); } return draftMap; } @@ -205,7 +212,7 @@ export function buildMemberDraftSuggestions( id: m.id, name: m.name.trim(), subtitle: getMemberDraftRole(m), - color: colorMap.get(m.name.trim()) ?? undefined, + color: colorMap.get(m.id) ?? undefined, })); } diff --git a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts index 77df79e8..45a77c3b 100644 --- a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts @@ -232,10 +232,32 @@ vi.mock('@renderer/components/team/members/MemberDraftRow', () => ({ MemberDraftRow: ({ member, lockedRoleLabel, + lockedModelAction, }: { member: { name: string }; lockedRoleLabel?: string; - }) => React.createElement('div', null, member.name, lockedRoleLabel ? ` ${lockedRoleLabel}` : ''), + lockedModelAction?: { + label: string; + onClick: () => void; + }; + }) => + React.createElement( + 'div', + null, + member.name, + lockedRoleLabel ? ` ${lockedRoleLabel}` : '', + lockedModelAction + ? React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'lead-runtime-action', + onClick: lockedModelAction.onClick, + }, + lockedModelAction.label + ) + : null + ), })); vi.mock('@renderer/components/ui/button', () => ({ @@ -312,6 +334,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }); @@ -378,6 +401,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -387,7 +411,7 @@ describe('EditTeamDialog', () => { expect(host.textContent).toContain('lead'); expect(host.textContent).toContain('Team Lead'); expect(host.textContent).toContain( - 'Team lead is shown for context only. Edit Team changes only teammate roster settings.' + 'Team lead name and role stay read-only here. Open the runtime panel on the lead row to change provider, model, or effort.' ); await act(async () => { @@ -417,6 +441,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -477,6 +502,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -527,6 +553,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -577,6 +604,7 @@ describe('EditTeamDialog', () => { isTeamProvisioning: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -620,6 +648,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -671,6 +700,7 @@ describe('EditTeamDialog', () => { isTeamAlive: false, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -714,6 +744,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -776,6 +807,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved, }) ); @@ -824,6 +856,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }); @@ -894,6 +927,7 @@ describe('EditTeamDialog', () => { isTeamAlive: false, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -939,6 +973,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }); @@ -996,6 +1031,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved, }); @@ -1033,6 +1069,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved, }) ); @@ -1078,6 +1115,7 @@ describe('EditTeamDialog', () => { isTeamAlive: true, projectPath: '/tmp/project', onClose: vi.fn(), + onChangeLeadRuntime: vi.fn(), onSaved: vi.fn(), }) ); @@ -1120,4 +1158,53 @@ describe('EditTeamDialog', () => { await Promise.resolve(); }); }); + + it('shows an inline lead runtime action inside the lead context row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const onChangeLeadRuntime = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(EditTeamDialog, { + open: true, + teamName: 'team-alpha', + currentName: 'Team Alpha', + currentDescription: 'desc', + currentColor: 'blue', + currentMembers: [{ name: 'alice', role: 'Reviewer' }] as any, + leadMember: { + name: 'lead', + role: 'Team Lead', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + } as any, + projectPath: '/tmp/project', + onClose: vi.fn(), + onChangeLeadRuntime, + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('[data-testid="lead-runtime-action"]'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onChangeLeadRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts new file mode 100644 index 00000000..59e152cb --- /dev/null +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -0,0 +1,430 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const openDashboard = vi.fn(); +const openTeamTab = vi.fn(); +const fetchCliStatus = vi.fn(); +const createSchedule = vi.fn(); +const updateSchedule = vi.fn(); + +const storeState = { + appConfig: { general: { multimodelEnabled: true } }, + cliStatus: { providers: [] }, + cliStatusLoading: false, + fetchCliStatus, + createSchedule, + updateSchedule, + repositoryGroups: [], + selectedTeamName: 'team-alpha', + launchParamsByTeam: {}, + teamByName: {}, + openDashboard, + openTeamTab, +}; + +vi.mock('@renderer/api', () => ({ + api: { + getProjects: vi.fn(async () => [ + { + id: 'project-1', + path: '/tmp/project', + name: 'project', + sessions: [], + totalSessions: 0, + createdAt: 1, + }, + ]), + teams: { + getSavedRequest: vi.fn(async () => null), + replaceMembers: vi.fn(async () => {}), + prepareProvisioning: vi.fn(async () => ({})), + }, + }, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/store/slices/teamSlice', () => ({ + isTeamProvisioningActive: () => false, + selectResolvedMembersForTeamName: () => [], +})); + +vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ + buildMemberDraftColorMap: () => new Map(), + buildMemberDraftSuggestions: () => [], + buildMembersFromDrafts: ( + drafts: Array<{ + name: string; + roleSelection?: string; + customRole?: string; + workflow?: string; + providerId?: string; + model?: string; + effort?: string; + }> + ) => + drafts.map((draft) => ({ + name: draft.name, + role: draft.customRole || undefined, + workflow: draft.workflow, + providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined, + model: draft.model, + effort: draft.effort as 'low' | 'medium' | 'high' | undefined, + })), + clearMemberModelOverrides: (member: unknown) => member, + createMemberDraftsFromInputs: ( + members: Array<{ + name: string; + role?: string; + workflow?: string; + providerId?: string; + model?: string; + effort?: string; + }> + ) => + members.map((member, index) => ({ + id: `draft-${index}`, + name: member.name, + originalName: member.name, + roleSelection: '', + customRole: member.role ?? '', + workflow: member.workflow ?? '', + providerId: member.providerId, + model: member.model ?? '', + effort: member.effort, + })), + filterEditableMemberInputs: (members: unknown) => members, + normalizeMemberDraftForProviderMode: (member: unknown) => member, + normalizeProviderForMode: (providerId: unknown) => providerId, + validateMemberNameInline: () => null, +})); + +vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({ + TeamRosterEditorSection: () => React.createElement('div', null, 'team-roster-editor'), +})); + +vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({ + SkipPermissionsCheckbox: () => React.createElement('div', null, 'skip-permissions'), +})); + +vi.mock('@renderer/components/team/dialogs/AdvancedCliSection', () => ({ + AdvancedCliSection: () => React.createElement('div', null, 'advanced-cli'), +})); + +vi.mock('@renderer/components/team/dialogs/OptionalSettingsSection', () => ({ + OptionalSettingsSection: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/team/dialogs/ProjectPathSelector', () => ({ + ProjectPathSelector: ({ selectedProjectPath }: { selectedProjectPath: string }) => + React.createElement('div', { 'data-testid': 'project-path' }, selectedProjectPath), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type, + disabled, + className, + }: { + children: React.ReactNode; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + className?: string; + }) => + React.createElement('button', { type: type ?? 'button', onClick, disabled, className }, children), +})); + +vi.mock('@renderer/components/ui/checkbox', () => ({ + Checkbox: ({ + checked, + onCheckedChange, + id, + }: { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + id?: string; + }) => + React.createElement('input', { + id, + type: 'checkbox', + checked, + onChange: (event: Event) => + onCheckedChange?.((event.target as HTMLInputElement).checked), + }), +})); + +vi.mock('@renderer/components/ui/combobox', () => ({ + Combobox: () => React.createElement('div', null, 'combobox'), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean; + children: React.ReactNode; + }) => (open ? React.createElement('div', null, children) : null), + DialogContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogHeader: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => + React.createElement('h2', null, children), + DialogDescription: ({ children }: { children: React.ReactNode }) => + React.createElement('p', null, children), + DialogFooter: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: Record) => React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ + children, + htmlFor, + className, + }: { + children: React.ReactNode; + htmlFor?: string; + className?: string; + }) => React.createElement('label', { htmlFor, className }, children), +})); + +vi.mock('@renderer/components/ui/MentionableTextarea', () => ({ + MentionableTextarea: ({ + value, + onValueChange, + id, + }: { + value: string; + onValueChange: (value: string) => void; + id?: string; + }) => + React.createElement('textarea', { + id, + value, + onChange: (event: Event) => onValueChange((event.target as HTMLTextAreaElement).value), + }), +})); + +vi.mock('@renderer/hooks/useChipDraftPersistence', () => ({ + useChipDraftPersistence: () => ({ + chips: [], + removeChip: vi.fn(), + addChip: vi.fn(), + clearChipDraft: vi.fn(), + }), +})); + +vi.mock('@renderer/hooks/useDraftPersistence', () => ({ + useDraftPersistence: () => ({ + value: '', + setValue: vi.fn(), + isSaved: false, + }), +})); + +vi.mock('@renderer/hooks/useFileListCacheWarmer', () => ({ + useFileListCacheWarmer: () => undefined, +})); + +vi.mock('@renderer/hooks/useTaskSuggestions', () => ({ + useTaskSuggestions: () => ({ suggestions: [] }), +})); + +vi.mock('@renderer/hooks/useTeamSuggestions', () => ({ + useTeamSuggestions: () => ({ suggestions: [] }), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/utils/geminiUiFreeze', () => ({ + isGeminiUiFrozen: () => false, + normalizeCreateLaunchProviderForUi: (providerId: unknown) => providerId, +})); + +vi.mock('@renderer/utils/teamModelAvailability', () => ({ + getTeamModelSelectionError: () => null, + normalizeExplicitTeamModelForUi: (_providerId: string, model: string) => model, +})); + +vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({ + buildProviderPrepareModelCacheKey: () => 'prepare-cache-key', +})); + +vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({ + buildReusableProviderPrepareModelResults: () => ({}), + getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }), + runProviderPrepareDiagnostics: vi.fn(async () => ({ + status: 'ready', + warnings: [], + details: [], + modelResultsById: {}, + })), +})); + +vi.mock('@renderer/components/team/dialogs/provisioningModelIssues', () => ({ + getProvisioningModelIssue: () => null, +})); + +vi.mock('@renderer/components/team/dialogs/ProvisioningProviderStatusList', () => ({ + ProvisioningProviderStatusList: () => React.createElement('div', null, 'provider-status-list'), + failIncompleteProviderChecks: (checks: unknown) => checks, + getPrimaryProvisioningFailureDetail: () => null, + getProvisioningFailureHint: () => 'hint', + getProvisioningProviderBackendSummary: () => null, + shouldHideProvisioningProviderStatusList: () => false, + updateProviderCheck: (checks: unknown) => checks, +})); + +vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ + TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'), + computeEffectiveTeamModel: (model: string) => model || undefined, + formatTeamModelSummary: (providerId: string, model: string, effort?: string) => + [providerId, model, effort].filter(Boolean).join(' '), +})); + +vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ + EffortLevelSelector: () => React.createElement('div', null, 'effort-selector'), +})); + +import { api } from '@renderer/api'; +import { LaunchTeamDialog } from '@renderer/components/team/dialogs/LaunchTeamDialog'; + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('LaunchTeamDialog', () => { + afterEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('renders relaunch-specific title, warning and submit label', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'relaunch', + open: true, + teamName: 'team-alpha', + members: [{ name: 'alice', role: 'Reviewer' }] as any, + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onRelaunch: vi.fn(async () => {}), + }) + ); + await flush(); + }); + + expect(host.textContent).toContain('Relaunch Team'); + expect(host.textContent).toContain('Relaunch will restart the current team run'); + expect( + Array.from(host.querySelectorAll('button')).some( + (button) => button.textContent === 'Relaunch team' + ) + ).toBe(true); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('submits relaunch through onRelaunch without replacing members in-dialog', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const onRelaunch = vi.fn(async () => {}); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'relaunch', + open: true, + teamName: 'team-alpha', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }, + ] as any, + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onRelaunch, + }) + ); + await flush(); + }); + + const submitButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Relaunch team' + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + }); + + expect(onRelaunch).toHaveBeenCalledTimes(1); + expect(vi.mocked(api.teams.replaceMembers)).not.toHaveBeenCalled(); + + const [request, members] = onRelaunch.mock.calls[0] as unknown as [ + { teamName: string; cwd: string; providerId?: string; model?: string }, + Array<{ name: string; providerId?: string; model?: string }> + ]; + + expect(request.teamName).toBe('team-alpha'); + expect(request.cwd).toBe('/tmp/project'); + expect(request.providerId).toBe('anthropic'); + expect(request.model).toBe('opus'); + expect(members).toEqual([ + { + name: 'alice', + role: 'Reviewer', + workflow: '', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + }, + ]); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); +}); diff --git a/test/renderer/components/team/dialogs/teamRelaunchFlow.test.ts b/test/renderer/components/team/dialogs/teamRelaunchFlow.test.ts new file mode 100644 index 00000000..28ef27d3 --- /dev/null +++ b/test/renderer/components/team/dialogs/teamRelaunchFlow.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { executeTeamRelaunch } from '@renderer/components/team/dialogs/teamRelaunchFlow'; + +describe('executeTeamRelaunch', () => { + it('runs stop, replaceMembers, then launch when the team is alive', async () => { + const calls: string[] = []; + const stopTeam = vi.fn(async () => { + calls.push('stop'); + }); + const replaceMembers = vi.fn(async () => { + calls.push('replace'); + }); + const launchTeam = vi.fn(async () => { + calls.push('launch'); + }); + + await executeTeamRelaunch({ + teamName: 'team-alpha', + isTeamAlive: true, + request: { + teamName: 'team-alpha', + cwd: '/tmp/project', + }, + members: [{ name: 'alice', role: 'Reviewer' }], + stopTeam, + replaceMembers, + launchTeam, + }); + + expect(calls).toEqual(['stop', 'replace', 'launch']); + expect(stopTeam).toHaveBeenCalledWith('team-alpha'); + expect(replaceMembers).toHaveBeenCalledWith('team-alpha', { + members: [{ name: 'alice', role: 'Reviewer' }], + }); + }); + + it('skips stop when the team is already offline', async () => { + const calls: string[] = []; + const stopTeam = vi.fn(async () => { + calls.push('stop'); + }); + const replaceMembers = vi.fn(async () => { + calls.push('replace'); + }); + const launchTeam = vi.fn(async () => { + calls.push('launch'); + }); + + await executeTeamRelaunch({ + teamName: 'team-alpha', + isTeamAlive: false, + request: { + teamName: 'team-alpha', + cwd: '/tmp/project', + }, + members: [{ name: 'alice', role: 'Reviewer' }], + stopTeam, + replaceMembers, + launchTeam, + }); + + expect(calls).toEqual(['replace', 'launch']); + expect(stopTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts index 434fd646..e590235e 100644 --- a/test/renderer/components/team/members/membersEditorUtils.test.ts +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -101,9 +101,9 @@ describe('members editor editable input filtering', () => { }); const draftColors = buildMemberDraftColorMap(drafts, existingMembers); - expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); - expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); - expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get('alice')); + expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get('tom')); + expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get('bob')); }); it('assigns new draft members after reserving existing team colors', () => { @@ -119,9 +119,9 @@ describe('members editor editable input filtering', () => { }); const draftColors = buildMemberDraftColorMap(drafts, existingMembers); - expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); - expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); - expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get('alice')); + expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get('tom')); + expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get('bob')); }); it('predicts the same colors as the team page for brand-new draft members', () => { @@ -129,15 +129,15 @@ describe('members editor editable input filtering', () => { const expectedColors = buildTeamMemberColorMap( drafts.map((draft) => ({ - name: draft.name, + name: `draft:${draft.id}`, })), { preferProvidedColors: false } ); const draftColors = buildMemberDraftColorMap(drafts); - expect(draftColors.get('alice')).toBe(expectedColors.get('alice')); - expect(draftColors.get('tom')).toBe(expectedColors.get('tom')); - expect(draftColors.get('bob')).toBe(expectedColors.get('bob')); + expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get(`draft:${drafts[0].id}`)); + expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get(`draft:${drafts[1].id}`)); + expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get(`draft:${drafts[2].id}`)); }); it('preserves the resolved team colors in edit and launch dialogs', () => { @@ -150,9 +150,9 @@ describe('members editor editable input filtering', () => { const draftColors = buildMemberDraftColorMap(drafts, existingMembers); - expect(draftColors.get('alice')).toBe(existingMembers[0].color); - expect(draftColors.get('bob')).toBe(existingMembers[1].color); - expect(draftColors.get('tom')).toBe(existingMembers[2].color); + expect(draftColors.get(drafts[0].id)).toBe(existingMembers[0].color); + expect(draftColors.get(drafts[1].id)).toBe(existingMembers[1].color); + expect(draftColors.get(drafts[2].id)).toBe(existingMembers[2].color); }); it('prefers an explicit resolved member color map from the team screen', () => { @@ -165,7 +165,34 @@ describe('members editor editable input filtering', () => { const draftColors = buildMemberDraftColorMap(drafts, existingMembers, resolvedColorMap); - expect(draftColors.get('alice')).toBe('blue'); - expect(draftColors.get('tom')).toBe('saffron'); + expect(draftColors.get(drafts[0].id)).toBe('blue'); + expect(draftColors.get(drafts[1].id)).toBe('saffron'); + }); + + it('keeps an existing teammate color stable while the name is being edited', () => { + const existingMembers = [{ name: 'alice', color: 'blue' }, { name: 'tom', color: 'saffron' }]; + const renamedAliceDraft = createMemberDraft({ + id: 'draft-alice', + name: 'alice-renamed', + originalName: 'alice', + }); + const tomDraft = createMemberDraft({ + id: 'draft-tom', + name: 'tom', + originalName: 'tom', + }); + + const draftColors = buildMemberDraftColorMap([renamedAliceDraft, tomDraft], existingMembers); + + expect(draftColors.get(renamedAliceDraft.id)).toBe('blue'); + expect(draftColors.get(tomDraft.id)).toBe('saffron'); + }); + + it('keeps a brand-new draft color stable while its name is edited', () => { + const draft = createMemberDraft({ id: 'draft-new', name: 'alice' }); + const beforeRename = buildMemberDraftColorMap([draft]); + const afterRename = buildMemberDraftColorMap([{ ...draft, name: 'charlie' }]); + + expect(afterRename.get(draft.id)).toBe(beforeRename.get(draft.id)); }); });