feat(team): add relaunch flow and stabilize edit member colors

This commit is contained in:
777genius 2026-04-19 16:46:56 +03:00
parent 1e2241aead
commit 481965f1b4
11 changed files with 988 additions and 160 deletions

View file

@ -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<string | null>(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<HTMLDivElement>(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<Session[]>([]);
@ -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<void> => {
await launchTeam(request);
},
[launchTeam]
);
const handleRelaunchDialogSubmit = useCallback(
async (
request: TeamLaunchRequest,
nextMembers: TeamCreateRequest['members']
): Promise<void> => {
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 = ({
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => setLaunchDialogOpen(true)}
onClick={() => openLaunchDialog('launch')}
>
Launch
</button>
@ -2032,17 +2081,16 @@ export const TeamDetailView = ({
</div>
</div>
<LaunchTeamDialog
mode="launch"
mode={launchDialogState.mode}
open={launchDialogOpen}
teamName={teamName}
members={[]}
defaultProjectPath={draftTeamSummary?.projectPath}
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => {
await launchTeam(request);
}}
onClose={closeLaunchDialog}
onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
</>
);
@ -2304,7 +2352,7 @@ export const TeamDetailView = ({
{!data.isAlive && !isTeamProvisioning ? (
<TeamOfflineStatusBanner
teamName={teamName}
onLaunch={() => 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 = ({
</Dialog>
<LaunchTeamDialog
mode="launch"
mode={launchDialogState.mode}
open={launchDialogOpen}
teamName={teamName}
members={membersWithLiveBranches}
@ -2822,10 +2871,9 @@ export const TeamDetailView = ({
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
activeTeams={activeTeamsForLaunch}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => {
await launchTeam(request);
}}
onClose={closeLaunchDialog}
onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
<SendMessageDialog

View file

@ -60,6 +60,7 @@ interface EditTeamDialogProps {
isTeamProvisioning?: boolean;
projectPath?: string | null;
onClose: () => void;
onChangeLeadRuntime: () => void;
onSaved: () => Promise<void> | 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,
}}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
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.
</p>
</div>
) : null

View file

@ -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<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;
@ -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<TeamProviderId | null>(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<TeamProviderId | null>(() => {
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', '<previous>');
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<string, string> = {};
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 <span className="font-mono font-medium">{effectiveTeamName}</span> via local Claude
CLI.
</>
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 ? (
@ -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 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 */}
{isLaunch && conflictingTeam && !conflictDismissed ? (
{isLaunchMode && conflictingTeam && !conflictDismissed ? (
<div
className="rounded-md border p-3 text-xs"
style={{
@ -1694,10 +1765,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
Launch: optional settings
Schedule: prompt + execution defaults
*/}
{isLaunch ? (
{isLaunchMode ? (
<OptionalSettingsSection
title="Optional launch settings"
description="Keep the launch flow focused on the project path and only expand this when you want extra control."
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">
@ -1944,9 +2019,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
) : null}
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
<DialogFooter className={isLaunchMode ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */}
{isLaunch ? (
{isLaunchMode ? (
<div className="min-w-0">
{prepareState === 'idle' || prepareState === 'loading' ? (
<>
@ -1960,7 +2035,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: '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 launch</span>
<span>
Pre-flight check to catch errors before{' '}
{isRelaunch ? 'relaunch' : 'launch'}
</span>
</p>
</div>
</div>
@ -2003,13 +2081,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
CLI environment is not available - launch is blocked
CLI environment is not available - {isRelaunch ? 'relaunch' : 'launch'} is
blocked
</p>
<p className="mt-0.5 text-red-300/80">
{prepareMessage ?? '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 launch
Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
</p>
</div>
</div>
@ -2060,7 +2139,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={closeDialog}>
{isLaunch ? 'Close' : 'Cancel'}
{isLaunchMode ? 'Close' : 'Cancel'}
</Button>
<Button
size="sm"

View file

@ -0,0 +1,30 @@
import type { TeamCreateRequest, TeamLaunchRequest } from '@shared/types';
interface ExecuteTeamRelaunchOptions {
teamName: string;
isTeamAlive: boolean;
request: TeamLaunchRequest;
members: TeamCreateRequest['members'];
stopTeam: (teamName: string) => Promise<void>;
replaceMembers: (
teamName: string,
request: { members: TeamCreateRequest['members'] }
) => Promise<void>;
launchTeam: (request: TeamLaunchRequest) => Promise<unknown>;
}
export async function executeTeamRelaunch({
teamName,
isTeamAlive,
request,
members,
stopTeam,
replaceMembers,
launchTeam,
}: ExecuteTeamRelaunchOptions): Promise<void> {
if (isTeamAlive) {
await stopTeam(teamName);
}
await replaceMembers(teamName, { members });
await launchTeam(request);
}

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import {
formatTeamModelSummary,
getProviderScopedTeamModelLabel,
getTeamProviderLabel,
TeamModelSelector,
@ -62,6 +63,12 @@ interface MemberDraftRowProps {
warningText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
lockedModelAction?: {
label: string;
description?: string;
onClick: () => void;
disabled?: boolean;
};
}
export const MemberDraftRow = ({
@ -100,10 +107,12 @@ export const MemberDraftRow = ({
warningText,
disableGeminiOption = false,
modelIssueText,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
resolvedColor ?? getMemberColorByName(member.name.trim() || `member-${index}`)
resolvedColor ??
getMemberColorByName(member.originalName?.trim() || member.name.trim() || `member-${index}`)
);
const [workflowExpanded, setWorkflowExpanded] = useState(false);
const [modelExpanded, setModelExpanded] = useState(false);
@ -185,10 +194,16 @@ export const MemberDraftRow = ({
? `${modelButtonLabelBase} (lead)`
: modelButtonLabelBase;
const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: modelLockReason;
: (lockedModelAction?.description ?? modelLockReason);
const hasModelIssue = Boolean(modelIssueText);
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
effectiveModel?.trim() ?? '',
effectiveEffort
);
return (
<div
@ -267,7 +282,7 @@ export const MemberDraftRow = ({
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
)}
aria-label={modelButtonAriaLabel}
disabled={lockProviderModel || isRemoved}
disabled={(lockProviderModel && !canOpenLockedModelPanel) || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
@ -367,36 +382,66 @@ export const MemberDraftRow = ({
) : null}
{modelExpanded && (
<div className="space-y-2 pl-3 md:col-span-3">
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {
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
}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">
{modelLockReason ??
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
{lockProviderModel && lockedModelAction ? (
<div className="space-y-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="space-y-1">
<p className="text-[11px] font-medium text-[var(--color-text)]">
Current lead runtime
</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{runtimeSummary}</p>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
{lockedModelAction.description ??
'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.'}
</p>
<p className="text-[11px] text-amber-300">
Saving those runtime changes restarts the whole team.
</p>
<Button
type="button"
variant="secondary"
size="sm"
className="w-fit"
onClick={lockedModelAction.onClick}
disabled={lockedModelAction.disabled}
>
{lockedModelAction.label}
</Button>
</div>
) : (
<>
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {
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
}
/>
<EffortLevelSelector
value={effectiveEffort ?? ''}
onValueChange={(value) => {
if (lockProviderModel) return;
onEffortChange(member.id, value);
}}
id={`member-${member.id}-effort`}
/>
{lockProviderModel && (
<p className="text-[11px] text-amber-300">
{modelLockReason ??
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
</p>
)}
</>
)}
</div>
)}

View file

@ -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}

View file

@ -132,16 +132,16 @@ interface ExistingMemberColorInput {
removedAt?: number | string | null;
}
function getMemberDraftColorSeedKey(member: Pick<MemberDraft, 'id' | 'originalName'>): string {
const originalName = member.originalName?.trim();
return originalName || `draft:${member.id}`;
}
export function buildMemberDraftColorMap(
members: readonly Pick<MemberDraft, 'name'>[],
members: readonly Pick<MemberDraft, 'id' | 'name' | 'originalName'>[],
existingMembers?: readonly ExistingMemberColorInput[],
existingColorMap?: ReadonlyMap<string, string>
): Map<string, string> {
const draftEntries = members
.map((member) => member.name.trim())
.filter(Boolean)
.map((name) => ({ name }));
const normalizedExistingColorMap = new Map<string, string>(
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<string>();
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<string, string>();
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,
}));
}

View file

@ -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();
});
});
});

View file

@ -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<string, string>(),
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<string, unknown>) => 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<void> {
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();
});
});
});

View file

@ -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();
});
});

View file

@ -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));
});
});