feat(team): add relaunch flow and stabilize edit member colors
This commit is contained in:
parent
1e2241aead
commit
481965f1b4
11 changed files with 988 additions and 160 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
30
src/renderer/components/team/dialogs/teamRelaunchFlow.ts
Normal file
30
src/renderer/components/team/dialogs/teamRelaunchFlow.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
430
test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
Normal file
430
test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue