feat: improve team launch model and status UX

This commit is contained in:
iliya 2026-04-02 15:15:32 +03:00
parent 759cae2669
commit 074b614469
20 changed files with 786 additions and 220 deletions

View file

@ -433,6 +433,7 @@ export class CliInstallerService {
latestVersion: null, latestVersion: null,
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authStatusChecking: true,
authMethod: null, authMethod: null,
providers, providers,
}; };
@ -497,6 +498,7 @@ export class CliInstallerService {
if (binaryPath) { if (binaryPath) {
r.installed = true; r.installed = true;
r.binaryPath = binaryPath; r.binaryPath = binaryPath;
r.authStatusChecking = true;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
try { try {
@ -522,6 +524,7 @@ export class CliInstallerService {
]); ]);
} else { } else {
// No binary — still check latest version for "install" prompt // No binary — still check latest version for "install" prompt
r.authStatusChecking = false;
if (r.supportsSelfUpdate) { if (r.supportsSelfUpdate) {
await this.fetchLatestVersion(r); await this.fetchLatestVersion(r);
} }
@ -541,6 +544,7 @@ export class CliInstallerService {
diag: CliInstallerStatusRunDiag diag: CliInstallerStatusRunDiag
): Promise<void> { ): Promise<void> {
if (result.flavor === 'free-code') { if (result.flavor === 'free-code') {
result.authStatusChecking = true;
try { try {
const providers = await this.multimodelBridgeService.getProviderStatuses( const providers = await this.multimodelBridgeService.getProviderStatuses(
binaryPath, binaryPath,
@ -556,10 +560,12 @@ export class CliInstallerService {
result.authLoggedIn = providers.some((provider) => provider.authenticated); result.authLoggedIn = providers.some((provider) => provider.authenticated);
result.authMethod = result.authMethod =
providers.find((provider) => provider.authenticated)?.authMethod ?? null; providers.find((provider) => provider.authenticated)?.authMethod ?? null;
result.authStatusChecking = false;
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
diag.authLastError = msg; diag.authLastError = msg;
result.authStatusChecking = false;
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`); logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
} }
return; return;
@ -578,6 +584,7 @@ export class CliInstallerService {
const auth = parseClaudeAuthStatusStdout(authStdout); const auth = parseClaudeAuthStatusStdout(authStdout);
result.authLoggedIn = auth.loggedIn === true; result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null; result.authMethod = auth.authMethod ?? null;
result.authStatusChecking = false;
diag.authLastError = null; diag.authLastError = null;
logger.info( logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
@ -598,6 +605,7 @@ export class CliInstallerService {
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}` `Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}`
); );
result.authLoggedIn = false; result.authLoggedIn = false;
result.authStatusChecking = false;
} }
} }
} }
@ -621,6 +629,7 @@ export class CliInstallerService {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
} }
result.authStatusChecking = false;
diag.authTimedOut = hitAuthTimeout; diag.authTimedOut = hitAuthTimeout;
} }
} }

View file

@ -69,6 +69,13 @@ function getSourceServerEntry(): string {
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
} }
function getWorkspaceTsxBinCandidates(): string[] {
return [
path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'),
path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'),
];
}
async function pathExists(targetPath: string): Promise<boolean> { async function pathExists(targetPath: string): Promise<boolean> {
try { try {
await fs.promises.access(targetPath, fs.constants.F_OK); await fs.promises.access(targetPath, fs.constants.F_OK);
@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
} }
// 2. Dev mode — prefer source for hot changes // 2. Dev mode — prefer built dist for reliable direct execution
const sourceEntry = getSourceServerEntry();
checked.push(sourceEntry);
if (await pathExists(sourceEntry)) {
return {
command: 'pnpm',
args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry],
};
}
// 3. Dev mode — built dist
const builtEntry = getBuiltServerEntry(); const builtEntry = getBuiltServerEntry();
checked.push(builtEntry); checked.push(builtEntry);
if (await pathExists(builtEntry)) { if (await pathExists(builtEntry)) {
@ -231,6 +228,21 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
}; };
} }
// 3. Dev mode fallback — run source directly through a local tsx binary
const sourceEntry = getSourceServerEntry();
checked.push(sourceEntry);
if (await pathExists(sourceEntry)) {
for (const tsxBin of getWorkspaceTsxBinCandidates()) {
checked.push(tsxBin);
if (await pathExists(tsxBin)) {
return {
command: tsxBin,
args: [sourceEntry],
};
}
}
}
throw new Error( throw new Error(
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
); );

View file

@ -8,6 +8,7 @@ import type {
MemberStatus, MemberStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamConfig, TeamConfig,
TeamMember,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
@ -134,15 +135,26 @@ export class TeamMemberResolver {
if (Array.isArray(config.members)) { if (Array.isArray(config.members)) {
for (const m of config.members) { for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') { if (typeof m?.name === 'string' && m.name.trim() !== '') {
const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' };
const providerId =
configMember.providerId === 'anthropic' ||
configMember.providerId === 'codex' ||
configMember.providerId === 'gemini'
? configMember.providerId
: configMember.provider === 'anthropic' ||
configMember.provider === 'codex' ||
configMember.provider === 'gemini'
? configMember.provider
: undefined;
configMemberMap.set(m.name.trim(), { configMemberMap.set(m.name.trim(), {
agentType: m.agentType, agentType: configMember.agentType,
role: m.role, role: configMember.role,
workflow: m.workflow, workflow: configMember.workflow,
providerId: m.providerId, providerId,
model: m.model, model: configMember.model,
effort: m.effort, effort: configMember.effort,
color: m.color, color: configMember.color,
cwd: m.cwd, cwd: configMember.cwd,
}); });
} }
} }

View file

@ -295,6 +295,7 @@ interface ProvisioningRun {
onProgress: (progress: TeamProvisioningProgress) => void; onProgress: (progress: TeamProvisioningProgress) => void;
expectedMembers: string[]; expectedMembers: string[];
request: TeamCreateRequest; request: TeamCreateRequest;
effectiveMembers: TeamCreateRequest['members'];
lastLogProgressAt: number; lastLogProgressAt: number;
/** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */
lastDataReceivedAt: number; lastDataReceivedAt: number;
@ -491,6 +492,45 @@ function formatWorkflowBlock(workflow: string, indent: string): string {
return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`;
} }
type TeamMemberInput = TeamCreateRequest['members'][number];
function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined {
return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic'
? providerId
: undefined;
}
function buildEffectiveTeamMemberSpec(
member: TeamMemberInput,
defaults: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
}
): TeamMemberInput {
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
const model = member.model?.trim() || defaults.model?.trim() || undefined;
return {
...member,
providerId: memberProviderId ?? defaultProviderId ?? 'anthropic',
model,
effort: member.effort ?? defaults.effort,
};
}
function buildEffectiveTeamMemberSpecs(
members: TeamCreateRequest['members'],
defaults: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
}
): TeamCreateRequest['members'] {
return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults));
}
function buildMembersPrompt(members: TeamCreateRequest['members']): string { function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members return members
.map((member) => { .map((member) => {
@ -983,17 +1023,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`;
} }
function buildProvisioningPrompt(request: TeamCreateRequest): string { function buildProvisioningPrompt(
request: TeamCreateRequest,
effectiveMembers: TeamCreateRequest['members']
): string {
const displayName = request.displayName?.trim() || request.teamName; const displayName = request.displayName?.trim() || request.teamName;
const userPromptBlock = request.prompt?.trim() const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
const leadName = const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const projectName = path.basename(request.cwd); const projectName = path.basename(request.cwd);
const isSolo = request.members.length === 0; const isSolo = effectiveMembers.length === 0;
const step3Block = isSolo const step3Block = isSolo
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` + ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
@ -1036,14 +1079,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn. With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.
Per-member spawn instructions: Per-member spawn instructions:
${request.members ${effectiveMembers
.map((m) => { .map((m) => {
const providerLine = const providerLine =
m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}\n` : ''; m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}\n` : '';
const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}\n` : ''; const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}\n` : '';
const effortLine = m.effort ? ` - effort: “${m.effort}\n` : '';
return ` For “${m.name}”: return ` For “${m.name}”:
- name: ${m.name} - name: ${m.name}
${providerLine}${modelLine} - prompt: ${providerLine}${modelLine}${effortLine} - prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
.split('\n') .split('\n')
.map((line) => ` ${line}`) .map((line) => ` ${line}`)
@ -1055,7 +1099,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
teamName: request.teamName, teamName: request.teamName,
leadName, leadName,
isSolo, isSolo,
members: request.members, members: effectiveMembers,
}); });
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it. return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
@ -3051,6 +3095,14 @@ export class TeamProvisioningService {
// Respawn with saved context — CLI handles its own auth refresh. // Respawn with saved context — CLI handles its own auth refresh.
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
try { try {
if (mcpFlagIdx !== -1 && mcpFlagIdx + 1 < ctx.args.length) {
await this.validateAgentTeamsMcpRuntime(
ctx.claudePath,
ctx.cwd,
ctx.env,
ctx.args[mcpFlagIdx + 1]!
);
}
child = spawnCli(ctx.claudePath, ctx.args, { child = spawnCli(ctx.claudePath, ctx.args, {
cwd: ctx.cwd, cwd: ctx.cwd,
env: { ...ctx.env }, env: { ...ctx.env },
@ -3292,6 +3344,11 @@ export class TeamProvisioningService {
throw new Error('Claude CLI not found; install it or provide a valid path'); throw new Error('Claude CLI not found; install it or provide a valid path');
} }
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
});
const runId = randomUUID(); const runId = randomUUID();
const startedAt = nowIso(); const startedAt = nowIso();
const run: ProvisioningRun = { const run: ProvisioningRun = {
@ -3318,6 +3375,7 @@ export class TeamProvisioningService {
onProgress, onProgress,
expectedMembers: request.members.map((member) => member.name), expectedMembers: request.members.map((member) => member.name),
request, request,
effectiveMembers: effectiveMemberSpecs,
lastLogProgressAt: 0, lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
lastStdoutReceivedAt: 0, lastStdoutReceivedAt: 0,
@ -3372,13 +3430,14 @@ export class TeamProvisioningService {
this.provisioningRunByTeam.set(request.teamName, runId); this.provisioningRunByTeam.set(request.teamName, runId);
run.onProgress(run.progress); run.onProgress(run.progress);
const prompt = buildProvisioningPrompt(request); const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs);
let child: ReturnType<typeof spawn>; let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
let mcpConfigPath: string; let mcpConfigPath: string;
try { try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
run.mcpConfigPath = mcpConfigPath; run.mcpConfigPath = mcpConfigPath;
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
} catch (error) { } catch (error) {
this.runs.delete(runId); this.runs.delete(runId);
this.provisioningRunByTeam.delete(request.teamName); this.provisioningRunByTeam.delete(request.teamName);
@ -3718,12 +3777,20 @@ export class TeamProvisioningService {
const runId = randomUUID(); const runId = randomUUID();
const startedAt = nowIso(); const startedAt = nowIso();
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
});
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure // Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = { const syntheticRequest: TeamCreateRequest = {
teamName: request.teamName, teamName: request.teamName,
members: expectedMemberSpecs, members: expectedMemberSpecs,
cwd: request.cwd, cwd: request.cwd,
providerId: request.providerId, providerId: request.providerId,
model: request.model,
effort: request.effort,
skipPermissions: request.skipPermissions, skipPermissions: request.skipPermissions,
}; };
@ -3764,6 +3831,7 @@ export class TeamProvisioningService {
onProgress, onProgress,
expectedMembers, expectedMembers,
request: syntheticRequest, request: syntheticRequest,
effectiveMembers: effectiveMemberSpecs,
lastLogProgressAt: 0, lastLogProgressAt: 0,
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
lastStdoutReceivedAt: 0, lastStdoutReceivedAt: 0,
@ -3837,7 +3905,7 @@ export class TeamProvisioningService {
const prompt = buildLaunchPrompt( const prompt = buildLaunchPrompt(
request, request,
expectedMemberSpecs, effectiveMemberSpecs,
existingTasks, existingTasks,
Boolean(previousSessionId) Boolean(previousSessionId)
); );
@ -3847,6 +3915,7 @@ export class TeamProvisioningService {
try { try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
run.mcpConfigPath = mcpConfigPath; run.mcpConfigPath = mcpConfigPath;
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
} catch (error) { } catch (error) {
this.runs.delete(runId); this.runs.delete(runId);
this.provisioningRunByTeam.delete(request.teamName); this.provisioningRunByTeam.delete(request.teamName);
@ -6862,7 +6931,13 @@ export class TeamProvisioningService {
run.teamName, run.teamName,
run.request.cwd, run.request.cwd,
run.detectedSessionId, run.detectedSessionId,
run.request.color run.request.color,
{
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
}
); );
await this.cleanupPrelaunchBackup(run.teamName); await this.cleanupPrelaunchBackup(run.teamName);
@ -6996,7 +7071,13 @@ export class TeamProvisioningService {
run.teamName, run.teamName,
run.request.cwd, run.request.cwd,
run.detectedSessionId, run.detectedSessionId,
run.request.color run.request.color,
{
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
}
); );
// Clean up team.meta.json — provisioning succeeded, config.json is now authoritative. // Clean up team.meta.json — provisioning succeeded, config.json is now authoritative.
@ -7789,7 +7870,13 @@ export class TeamProvisioningService {
run.teamName, run.teamName,
run.request.cwd, run.request.cwd,
run.detectedSessionId, run.detectedSessionId,
run.request.color run.request.color,
{
providerId: run.request.providerId,
model: run.request.model,
effort: run.request.effort,
members: run.effectiveMembers,
}
); );
// Process was killed by timeout — mark as disconnected, not ready // Process was killed by timeout — mark as disconnected, not ready
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', { const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
@ -7963,6 +8050,96 @@ export class TeamProvisioningService {
} }
} }
private applyEffectiveLaunchStateToConfig(
config: Record<string, unknown>,
launchState?: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
members?: TeamCreateRequest['members'];
}
): void {
if (!launchState || !Array.isArray(config.members)) {
return;
}
const effectiveLeadProviderId =
normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic';
const effectiveLeadModel = launchState.model?.trim() || undefined;
const effectiveLeadEffort =
launchState.effort === 'low' ||
launchState.effort === 'medium' ||
launchState.effort === 'high'
? launchState.effort
: undefined;
const membersByName = new Map(
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
);
config.members = (config.members as Record<string, unknown>[]).map((member) => {
if (!member || typeof member !== 'object') {
return member;
}
const rawName = typeof member.name === 'string' ? member.name.trim() : '';
const nextMember = { ...member };
const assignRuntimeState = (state: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
}): void => {
const providerId = normalizeTeamMemberProviderId(state.providerId);
if (providerId) {
nextMember.provider = providerId;
nextMember.providerId = providerId;
} else {
delete nextMember.provider;
delete nextMember.providerId;
}
const model = state.model?.trim() || undefined;
if (model) {
nextMember.model = model;
} else {
delete nextMember.model;
}
const effort =
state.effort === 'low' || state.effort === 'medium' || state.effort === 'high'
? state.effort
: undefined;
if (effort) {
nextMember.effort = effort;
} else {
delete nextMember.effort;
}
};
if (isLeadMember(nextMember) || rawName.toLowerCase() === 'team-lead') {
assignRuntimeState({
providerId: effectiveLeadProviderId,
model: effectiveLeadModel,
effort: effectiveLeadEffort,
});
return nextMember;
}
const effectiveMember = membersByName.get(rawName.toLowerCase());
if (!effectiveMember) {
return nextMember;
}
assignRuntimeState({
providerId: effectiveMember.providerId,
model: effectiveMember.model,
effort: effectiveMember.effort,
});
return nextMember;
});
}
/** /**
* Single atomic read-mutate-write for post-launch config updates. * Single atomic read-mutate-write for post-launch config updates.
* Combines session history append and projectPath update to avoid * Combines session history append and projectPath update to avoid
@ -7972,7 +8149,13 @@ export class TeamProvisioningService {
teamName: string, teamName: string,
projectPath: string, projectPath: string,
detectedSessionId: string | null, detectedSessionId: string | null,
color?: string color?: string,
launchState?: {
providerId?: TeamProviderId;
model?: string;
effort?: TeamCreateRequest['effort'];
members?: TeamCreateRequest['members'];
}
): Promise<void> { ): Promise<void> {
const MAX_SESSION_HISTORY = 5000; const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500; const MAX_PROJECT_PATH_HISTORY = 500;
@ -8049,6 +8232,8 @@ export class TeamProvisioningService {
: pathHistory; : pathHistory;
} }
this.applyEffectiveLaunchStateToConfig(config, launchState);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@ -8751,6 +8936,7 @@ export class TeamProvisioningService {
role?: string; role?: string;
workflow?: string; workflow?: string;
agentType?: string; agentType?: string;
providerId?: string;
provider?: string; provider?: string;
model?: string; model?: string;
effort?: string; effort?: string;
@ -8771,10 +8957,7 @@ export class TeamProvisioningService {
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow: workflow:
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
member.provider === 'codex' || member.provider === 'gemini'
? member.provider
: undefined,
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
@ -8985,6 +9168,56 @@ export class TeamProvisioningService {
return output; return output;
} }
private buildAgentTeamsMcpValidationError(output: string): string {
const detail = this.normalizeApiRetryErrorMessage(output) || output.trim();
if (!detail) {
return (
'agent-teams MCP loaded config but did not expose member_briefing. ' +
'The leader would start without required team MCP tools.'
);
}
return (
'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}`
);
}
private async validateAgentTeamsMcpRuntime(
claudePath: string,
cwd: string,
env: NodeJS.ProcessEnv,
mcpConfigPath: string
): Promise<void> {
const result = await this.spawnProbe(
claudePath,
[
'--setting-sources',
'user,project,local',
'--mcp-config',
mcpConfigPath,
'--',
'mcp',
'get',
'agent-teams',
],
cwd,
env,
VERIFY_TIMEOUT_MS
);
const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim();
if (result.exitCode !== 0) {
throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput));
}
const normalizedOutput = combinedOutput.toLowerCase();
if (
!normalizedOutput.includes('status: ✓ connected') &&
!normalizedOutput.includes('status: connected')
) {
throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput));
}
}
private async spawnProbe( private async spawnProbe(
claudePath: string, claudePath: string,
args: string[], args: string[],

View file

@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI {
latestVersion: null, latestVersion: null,
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authStatusChecking: false,
authMethod: null, authMethod: null,
providers: [], providers: [],
}), }),

View file

@ -125,8 +125,10 @@ const SLOW_CHECK_DELAY_MS = 5_000;
const CliCheckingSpinner = ({ const CliCheckingSpinner = ({
styles, styles,
label,
}: { }: {
styles: { border: string; bg: string }; styles: { border: string; bg: string };
label: string;
}): React.JSX.Element => { }): React.JSX.Element => {
const [showHint, setShowHint] = useState(false); const [showHint, setShowHint] = useState(false);
@ -146,7 +148,7 @@ const CliCheckingSpinner = ({
/> />
<div> <div>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}> <span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking AI Providers... {label}
</span> </span>
{showHint && ( {showHint && (
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}> <p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
@ -309,6 +311,10 @@ function formatRuntimeAuthSummary(
return `Providers: ${connected}/${denominator} connected`; return `Providers: ${connected}/${denominator} connected`;
} }
if (cliStatus.authStatusChecking) {
return 'Checking authentication...';
}
if (cliStatus.authLoggedIn) { if (cliStatus.authLoggedIn) {
return 'Authenticated'; return 'Authenticated';
} }
@ -316,6 +322,18 @@ function formatRuntimeAuthSummary(
return null; return null;
} }
function isCheckingMultimodelStatus(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): boolean {
return (
cliStatus.flavor === 'free-code' &&
cliStatus.providers.length > 0 &&
cliStatus.providers.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
)
);
}
function createLoadingMultimodelStatus(): CliInstallationStatus { function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = [ const providers: CliProviderStatus[] = [
{ providerId: 'anthropic' as const, displayName: 'Anthropic' }, { providerId: 'anthropic' as const, displayName: 'Anthropic' },
@ -349,6 +367,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
latestVersion: null, latestVersion: null,
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authStatusChecking: true,
authMethod: null, authMethod: null,
providers, providers,
}; };
@ -643,6 +662,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
async (enabled: boolean) => { async (enabled: boolean) => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
try { try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
cliStatusLoading: true,
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); await fetchCliStatus();
@ -695,6 +719,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (installerState === 'completed') return 'success'; if (installerState === 'completed') return 'success';
if (installerState !== 'idle') return 'info'; if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading'; if (!cliStatus) return 'loading';
if (isCheckingMultimodelStatus(cliStatus)) return 'info';
if (cliStatus.authStatusChecking) return 'info';
if (!cliStatus.installed) return 'error'; if (!cliStatus.installed) return 'error';
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info'; if (cliStatus.updateAvailable) return 'info';
@ -785,7 +811,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
// Claude-only mode: keep the generic loading spinner. // Claude-only mode: keep the generic loading spinner.
return <CliCheckingSpinner styles={styles} />; return (
<CliCheckingSpinner
styles={styles}
label={multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
/>
);
} }
// ── Downloading ──────────────────────────────────────────────────────── // ── Downloading ────────────────────────────────────────────────────────
@ -937,8 +968,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
} }
// Installed but not logged in — yellow warning banner // Installed but not logged in — yellow warning banner
if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) { if (
if (isVerifyingAuth) { cliStatus.installed &&
cliStatus.flavor !== 'free-code' &&
(cliStatus.authStatusChecking || isVerifyingAuth)
) {
if (cliStatus.authStatusChecking || isVerifyingAuth) {
return ( return (
<div <div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4" className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4"
@ -954,6 +989,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</div> </div>
); );
} }
}
if (
cliStatus.installed &&
cliStatus.flavor !== 'free-code' &&
!cliStatus.authStatusChecking &&
!cliStatus.authLoggedIn
) {
return ( return (
<> <>
<div <div

View file

@ -131,6 +131,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
latestVersion: null, latestVersion: null,
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authStatusChecking: true,
authMethod: null, authMethod: null,
providers: providers.map((provider) => ({ providers: providers.map((provider) => ({
...provider, ...provider,
@ -223,6 +224,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
async (enabled: boolean) => { async (enabled: boolean) => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
try { try {
useStore.setState({
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
cliStatusLoading: true,
cliStatusError: null,
});
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); await fetchCliStatus();
@ -261,7 +267,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Checking AI Providers... {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
</div> </div>
)} )}

View file

@ -1136,30 +1136,35 @@ export const TeamDetailView = ({
const teamSummary = teams.find((t) => t.teamName === teamName); const teamSummary = teams.find((t) => t.teamName === teamName);
return ( return (
<> <>
<div className="flex size-full items-center justify-center p-6"> <div className="size-full overflow-auto p-6">
<div className="max-w-md text-center"> <div ref={provisioningBannerRef}>
<p className="text-sm font-medium text-text">Team not launched yet</p> <TeamProvisioningBanner teamName={teamName} />
<p className="mt-2 text-xs text-text-secondary"> </div>
This is a draft team <strong>{teamSummary?.displayName || teamName}</strong> has <div className="flex min-h-[calc(100vh-12rem)] items-center justify-center">
been configured with {teamSummary?.memberCount ?? 0} member <div className="max-w-md text-center">
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI <p className="text-sm font-medium text-text">Team not launched yet</p>
yet. Click Launch to select a model and start the team. <p className="mt-2 text-xs text-text-secondary">
</p> This is a draft team <strong>{teamSummary?.displayName || teamName}</strong> has
<div className="mt-4 flex justify-center gap-2"> been configured with {teamSummary?.memberCount ?? 0} member
<button {teamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500" yet. Click Launch to select a model and start the team.
onClick={() => setLaunchDialogOpen(true)} </p>
> <div className="mt-4 flex justify-center gap-2">
Launch <button
</button> className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
<button onClick={() => setLaunchDialogOpen(true)}
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text" >
onClick={() => { Launch
void api.teams.deleteDraft(teamName).catch(() => {}); </button>
}} <button
> className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
Delete onClick={() => {
</button> void api.teams.deleteDraft(teamName).catch(() => {});
}}
>
Delete
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -592,14 +592,14 @@ export const TeamListView = (): React.JSX.Element => {
setLaunchingTeamName(request.teamName); setLaunchingTeamName(request.teamName);
try { try {
await launchTeam(request); await launchTeam(request);
openTeamTab(request.teamName, request.cwd);
} catch (err) { } catch (err) {
console.error('Failed to launch team:', err); console.error('Failed to launch team:', err);
throw err;
} finally { } finally {
setLaunchingTeamName(null); setLaunchingTeamName(null);
} }
}, },
[launchTeam, openTeamTab] [launchTeam]
); );
useEffect(() => { useEffect(() => {

View file

@ -7,6 +7,8 @@ import {
buildMembersFromDrafts, buildMembersFromDrafts,
clearMemberModelOverrides, clearMemberModelOverrides,
createMemberDraft, createMemberDraft,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
@ -33,6 +35,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@ -266,6 +269,7 @@ export const CreateTeamDialog = ({
onOpenTeam, onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => { }: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
// ── Persisted draft state (survives tab navigation) ────────────────── // ── Persisted draft state (survives tab navigation) ──────────────────
const { const {
@ -362,13 +366,14 @@ export const CreateTeamDialog = ({
}; };
const setSelectedProviderId = (value: TeamProviderId): void => { const setSelectedProviderId = (value: TeamProviderId): void => {
setSelectedProviderIdRaw(value); const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
localStorage.setItem('team:lastSelectedProvider', value); setSelectedProviderIdRaw(normalizedValue);
if (value !== 'anthropic') { localStorage.setItem('team:lastSelectedProvider', normalizedValue);
if (normalizedValue !== 'anthropic') {
setLimitContextRaw(false); setLimitContextRaw(false);
localStorage.setItem('team:lastLimitContext', 'false'); localStorage.setItem('team:lastLimitContext', 'false');
} }
setSelectedModelRaw(getStoredTeamModel(value)); setSelectedModelRaw(getStoredTeamModel(normalizedValue));
}; };
const setLimitContext = (value: boolean): void => { const setLimitContext = (value: boolean): void => {
@ -444,7 +449,10 @@ export const CreateTeamDialog = ({
[members, syncModelsWithLead] [members, syncModelsWithLead]
); );
const selectedMemberProviders = useMemo(() => { const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
if (!multimodelEnabled) {
return ['anthropic'];
}
if (soloTeam || syncModelsWithLead) { if (soloTeam || syncModelsWithLead) {
return [selectedProviderId]; return [selectedProviderId];
} }
@ -456,7 +464,22 @@ export const CreateTeamDialog = ({
), ),
]) ])
); );
}, [members, selectedProviderId, soloTeam, syncModelsWithLead]); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
useEffect(() => {
if (multimodelEnabled) {
return;
}
if (selectedProviderId !== 'anthropic') {
setSelectedProviderIdRaw('anthropic');
setSelectedModelRaw(getStoredTeamModel('anthropic'));
}
const nextMembers = members.map((member) => normalizeMemberDraftForProviderMode(member, false));
const changed = nextMembers.some((member, index) => member !== members[index]);
if (changed) {
setMembers(nextMembers);
}
}, [members, multimodelEnabled, selectedProviderId, setMembers]);
useEffect(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {
@ -631,15 +654,22 @@ export const CreateTeamDialog = ({
const presetRoles: readonly string[] = PRESET_ROLES; const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = m.role != null && presetRoles.includes(m.role); const isPreset = m.role != null && presetRoles.includes(m.role);
const isCustom = m.role != null && m.role.length > 0 && !isPreset; const isCustom = m.role != null && m.role.length > 0 && !isPreset;
return createMemberDraft({ return normalizeMemberDraftForProviderMode(
name: m.name, createMemberDraft({
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), name: m.name,
customRole: isCustom ? m.role : '', roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
workflow: m.workflow, customRole: isCustom ? m.role : '',
providerId: m.providerId, workflow: m.workflow,
model: m.model ?? '', providerId: normalizeProviderForMode(m.providerId, multimodelEnabled),
effort: m.effort, model:
}); normalizeProviderForMode(m.providerId, multimodelEnabled) ===
normalizeProviderForMode(m.providerId, true)
? (m.model ?? '')
: '',
effort: m.effort,
}),
multimodelEnabled
);
}) })
); );
setSyncModelsWithLead( setSyncModelsWithLead(

View file

@ -7,9 +7,12 @@ import {
buildMembersFromDrafts, buildMembersFromDrafts,
clearMemberModelOverrides, clearMemberModelOverrides,
createMemberDraftsFromInputs, createMemberDraftsFromInputs,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,
validateMemberNameInline, validateMemberNameInline,
} from '@renderer/components/team/members/MembersEditorSection'; } from '@renderer/components/team/members/MembersEditorSection';
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
@ -33,6 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive,
} from '@renderer/store/slices/teamSlice';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { import {
@ -155,6 +162,7 @@ function getProviderLabel(providerId: TeamProviderId): string {
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
const { open, onClose } = props; const { open, onClose } = props;
const { isLight } = useTheme(); const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const isLaunch = props.mode === 'launch'; const isLaunch = props.mode === 'launch';
const isSchedule = props.mode === 'schedule'; const isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null; const schedule = isSchedule ? (props.schedule ?? null) : null;
@ -261,21 +269,42 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
[membersDrafts, syncModelsWithLead] [membersDrafts, syncModelsWithLead]
); );
const selectedMemberProviders = useMemo( const selectedMemberProviders = useMemo<TeamProviderId[]>(
() => () =>
Array.from( !multimodelEnabled
new Set([ ? ['anthropic']
selectedProviderId, : Array.from(
...effectiveMemberDrafts.flatMap((member) => new Set([
member.providerId === 'codex' || member.providerId === 'gemini' selectedProviderId,
? [member.providerId] ...effectiveMemberDrafts.flatMap((member) =>
: [] member.providerId === 'codex' || member.providerId === 'gemini'
? [member.providerId]
: []
),
])
), ),
]) [effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
),
[effectiveMemberDrafts, selectedProviderId]
); );
useEffect(() => {
if (multimodelEnabled) {
return;
}
if (selectedProviderId !== 'anthropic') {
setSelectedProviderIdRaw('anthropic');
setSelectedModelRaw(getStoredTeamModel('anthropic'));
}
setMembersDrafts((prev) => {
let changed = false;
const next = prev.map((member) => {
const normalized = normalizeMemberDraftForProviderMode(member, false);
if (normalized !== member) changed = true;
return normalized;
});
return changed ? next : prev;
});
}, [multimodelEnabled, selectedProviderId]);
// Schedule store actions // Schedule store actions
const createSchedule = useStore((s) => s.createSchedule); const createSchedule = useStore((s) => s.createSchedule);
const updateSchedule = useStore((s) => s.updateSchedule); const updateSchedule = useStore((s) => s.updateSchedule);
@ -302,13 +331,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}; };
const setSelectedProviderId = (value: TeamProviderId): void => { const setSelectedProviderId = (value: TeamProviderId): void => {
setSelectedProviderIdRaw(value); const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
localStorage.setItem('team:lastSelectedProvider', value); setSelectedProviderIdRaw(normalizedValue);
if (value !== 'anthropic') { localStorage.setItem('team:lastSelectedProvider', normalizedValue);
if (normalizedValue !== 'anthropic') {
setLimitContextRaw(false); setLimitContextRaw(false);
localStorage.setItem('team:lastLimitContext', 'false'); localStorage.setItem('team:lastLimitContext', 'false');
} }
setSelectedModelRaw(getStoredTeamModel(value)); setSelectedModelRaw(getStoredTeamModel(normalizedValue));
}; };
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
@ -401,8 +431,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
promptDraft.setValue(schedule.launchConfig.prompt); promptDraft.setValue(schedule.launchConfig.prompt);
setCustomCwd(schedule.launchConfig.cwd); setCustomCwd(schedule.launchConfig.cwd);
setCwdMode('custom'); setCwdMode('custom');
setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic'); const scheduleProviderId = normalizeProviderForMode(
setSelectedModelRaw(schedule.launchConfig.model ?? ''); schedule.launchConfig.providerId,
multimodelEnabled
);
setSelectedProviderIdRaw(scheduleProviderId);
setSelectedModelRaw(
scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true)
? (schedule.launchConfig.model ?? '')
: getStoredTeamModel('anthropic')
);
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
} else { } else {
@ -417,8 +455,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setSelectedProviderIdRaw(getStoredTeamProvider()); const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider())); setSelectedProviderIdRaw(storedProviderId);
setSelectedModelRaw(getStoredTeamModel(storedProviderId));
setSelectedEffortRaw('medium'); setSelectedEffortRaw('medium');
} }
@ -442,10 +481,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
if (cancelled) return; if (cancelled) return;
const nextProviderId = const rawNextProviderId =
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
? savedRequest.providerId ? savedRequest.providerId
: 'anthropic'; : 'anthropic';
const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled);
const providerFromSaved = Boolean(savedRequest?.providerId); const providerFromSaved = Boolean(savedRequest?.providerId);
const nextMembersSource = const nextMembersSource =
members.length > 0 members.length > 0
@ -455,15 +495,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
: []; : [];
const storedEffort = localStorage.getItem('team:lastSelectedEffort'); const storedEffort = localStorage.getItem('team:lastSelectedEffort');
setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource)); setMembersDrafts(
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
)
);
setSyncModelsWithLead( setSyncModelsWithLead(
!nextMembersSource.some((member) => member.providerId || member.model || member.effort) !nextMembersSource.some((member) => member.providerId || member.model || member.effort)
); );
setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider()); setSelectedProviderIdRaw(
providerFromSaved
? nextProviderId
: normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
);
setSelectedModelRaw( setSelectedModelRaw(
typeof savedRequest?.model === 'string' typeof savedRequest?.model === 'string' &&
nextProviderId === normalizeProviderForMode(rawNextProviderId, true)
? savedRequest.model ? savedRequest.model
: getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider()) : getStoredTeamModel(
providerFromSaved
? nextProviderId
: normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
)
); );
setSelectedEffortRaw( setSelectedEffortRaw(
savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort)
@ -481,7 +534,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, isLaunch, effectiveTeamName, members]); }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Launch-only effects // Launch-only effects
@ -773,6 +826,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const provisioningError = isLaunch ? props.provisioningError : null; const provisioningError = isLaunch ? props.provisioningError : null;
const activeError = localError ?? provisioningError; const activeError = localError ?? provisioningError;
const currentProvisioning = useStore((s) =>
isLaunch && effectiveTeamName
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
: null
);
const launchInFlight = useStore((s) =>
isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Submit // Submit
@ -827,8 +888,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined, extraCliArgs: customArgs.trim() || undefined,
}); });
resetFormState();
onClose();
} else { } else {
// Schedule mode: create or update // Schedule mode: create or update
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
@ -883,7 +942,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const isDisabled = isLaunch const isDisabled = isLaunch
? isSubmitting || prepareState !== 'ready' ? isSubmitting || launchInFlight
: isSubmitting || validationErrors.length > 0; : isSubmitting || validationErrors.length > 0;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -905,7 +964,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
'Schedule automatic Claude task execution' 'Schedule automatic Claude task execution'
); );
const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; const submitLabel = isLaunch
? prepareState === 'idle' || prepareState === 'loading'
? 'Skip and Launch'
: 'Launch'
: isEditing
? 'Save Changes'
: 'Create Schedule';
const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...';
@ -1160,38 +1225,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projectsError={projectsError} projectsError={projectsError}
/> />
{isLaunch ? (
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
/>
) : null}
{/* {/*
Launch: optional settings Launch: optional settings
Schedule: prompt + execution defaults Schedule: prompt + execution defaults
@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary={launchOptionalSummary} summary={launchOptionalSummary}
> >
<div className="space-y-4"> <div className="space-y-4">
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
lockProviderModel={syncModelsWithLead}
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
softDeleteMembers
/>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="dialog-prompt" className="label-optional"> <Label htmlFor="dialog-prompt" className="label-optional">
Prompt for team lead (optional) Prompt for team lead (optional)
@ -1388,6 +1453,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div> </div>
) : null} ) : null}
{isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
<div className="pt-1">
<TeamProvisioningBanner teamName={effectiveTeamName} />
</div>
) : null}
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}> <DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */} {/* Launch-only: CLI warm-up status */}
{isLaunch ? ( {isLaunch ? (
@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</span> </span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70"> <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 launch</span>
<button
type="button"
onClick={() => setPrepareState('ready')}
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
>
Skip
</button>
</p> </p>
</div> </div>
</div> </div>
@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
disabled={isDisabled} disabled={isDisabled}
onClick={handleSubmit} onClick={handleSubmit}
> >
{isSubmitting ? ( {isSubmitting || launchInFlight ? (
<> <>
<Loader2 className="mr-1.5 size-3.5 animate-spin" /> <Loader2 className="mr-1.5 size-3.5 animate-spin" />
{submittingLabel} {submittingLabel}

View file

@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
id, id,
}) => { }) => {
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const multimodelAvailable = cliStatus?.flavor === 'free-code'; const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code';
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -180,6 +181,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0];
const ProviderIcon = activeProvider.icon; const ProviderIcon = activeProvider.icon;
const defaultModelTooltip = useMemo(() => {
if (providerId === 'anthropic') {
return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.';
}
return 'Uses the runtime default for the selected provider.';
}, [providerId]);
const isProviderSelectable = (candidateProviderId: string): boolean => const isProviderSelectable = (candidateProviderId: string): boolean =>
multimodelAvailable || candidateProviderId === 'anthropic'; multimodelAvailable || candidateProviderId === 'anthropic';
const activeProviderSelectable = isProviderSelectable(providerId); const activeProviderSelectable = isProviderSelectable(providerId);
@ -192,7 +199,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
: providerId === 'gemini' : providerId === 'gemini'
? GEMINI_MODEL_OPTIONS ? GEMINI_MODEL_OPTIONS
: ANTHROPIC_MODEL_OPTIONS; : ANTHROPIC_MODEL_OPTIONS;
if (runtimeModels.length === 0) { if (providerId === 'anthropic' || runtimeModels.length === 0) {
return [...fallback]; return [...fallback];
} }
const dynamicOptions = runtimeModels.map((model) => ({ const dynamicOptions = runtimeModels.map((model) => ({
@ -310,7 +317,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)} )}
<div <div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5" className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
> >
{modelOptions.map((opt) => ( {modelOptions.map((opt) => (
@ -342,9 +349,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" /> <Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs"> <TooltipContent side="top" className="max-w-[240px] text-xs">
Default model from Claude CLI (/model). {defaultModelTooltip.split('\n').map((line, index) => (
<br /> <React.Fragment key={line}>
Uses the runtime default for the selected provider. {index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View file

@ -49,7 +49,7 @@ export const LeadModelRow = ({
return ( return (
<div <div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]" className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[auto_1fr_auto]"
style={{ style={{
backgroundColor: isLight backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)' ? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -62,34 +62,15 @@ export const LeadModelRow = ({
style={{ backgroundColor: leadColorSet.border }} style={{ backgroundColor: leadColorSet.border }}
aria-hidden="true" aria-hidden="true"
/> />
<div className="space-y-0.5"> <div className="min-w-0">
<div className="flex h-8 items-center px-2 text-sm font-medium text-[var(--color-text)]"> <div className="flex h-8 items-center gap-3 px-2">
lead <span className="text-sm font-medium text-[var(--color-text)]">lead</span>
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
</div> </div>
</div> </div>
<div> <div className="min-w-0">
<div className="flex h-8 items-center px-2 text-xs text-[var(--color-text-secondary)]"> <div className="flex h-8 items-center justify-end px-2 text-xs text-[var(--color-text-secondary)]">
Team Lead <div className="flex min-w-0 items-center gap-2">
</div>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
<div className="flex items-center gap-2 pl-1 xl:pl-0">
<Checkbox <Checkbox
id="sync-models-with-lead" id="sync-models-with-lead"
checked={syncModelsWithTeammates} checked={syncModelsWithTeammates}
@ -97,13 +78,30 @@ export const LeadModelRow = ({
/> />
<Label <Label
htmlFor="sync-models-with-lead" htmlFor="sync-models-with-lead"
className="cursor-pointer text-xs font-normal text-text-secondary" className="cursor-pointer truncate text-xs font-normal text-text-secondary"
> >
Синхронизировать модель с тимейтами Sync model with teammates
</Label> </Label>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[220px] shrink-0 justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
</div>
{modelExpanded ? ( {modelExpanded ? (
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 md:col-span-3">
<TeamModelSelector <TeamModelSelector
@ -118,12 +116,14 @@ export const LeadModelRow = ({
onValueChange={onEffortChange} onValueChange={onEffortChange}
id="lead-effort" id="lead-effort"
/> />
<LimitContextCheckbox {providerId === 'anthropic' ? (
id="lead-limit-context" <LimitContextCheckbox
checked={limitContext} id="lead-limit-context"
onCheckedChange={onLimitContextChange} checked={limitContext}
disabled={providerId !== 'anthropic' || model === 'haiku'} onCheckedChange={onLimitContextChange}
/> disabled={model === 'haiku'}
/>
) : null}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300"> <p className="text-[11px] leading-relaxed text-sky-300">

View file

@ -9,13 +9,14 @@ import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
@ -48,6 +49,8 @@ interface MemberDraftRowProps {
lockProviderModel?: boolean; lockProviderModel?: boolean;
forceInheritedModelSettings?: boolean; forceInheritedModelSettings?: boolean;
modelLockReason?: string; modelLockReason?: string;
isRemoved?: boolean;
onRestore?: (id: string) => void;
} }
export const MemberDraftRow = ({ export const MemberDraftRow = ({
@ -76,6 +79,8 @@ export const MemberDraftRow = ({
lockProviderModel = false, lockProviderModel = false,
forceInheritedModelSettings = false, forceInheritedModelSettings = false,
modelLockReason, modelLockReason,
isRemoved = false,
onRestore,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const memberColorSet = getTeamColorSet( const memberColorSet = getTeamColorSet(
@ -154,13 +159,19 @@ export const MemberDraftRow = ({
const effectiveEffort = forceInheritedModelSettings const effectiveEffort = forceInheritedModelSettings
? inheritedEffort ? inheritedEffort
: (member.effort ?? inheritedEffort); : (member.effort ?? inheritedEffort);
const modelButtonLabel = effectiveModel?.trim() const modelButtonLabelBase = effectiveModel?.trim()
? getTeamModelLabel(effectiveModel.trim()) ? getTeamModelLabel(effectiveModel.trim())
: 'Default'; : 'Default';
const modelButtonLabel = forceInheritedModelSettings
? `${modelButtonLabelBase} (lead)`
: modelButtonLabelBase;
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: modelLockReason;
return ( return (
<div <div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]" className={`relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto] ${isRemoved ? 'opacity-55' : ''}`}
style={{ style={{
backgroundColor: isLight backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)' ? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -178,6 +189,7 @@ export const MemberDraftRow = ({
className="h-8 text-xs" className="h-8 text-xs"
value={member.name} value={member.name}
aria-label={`Member ${index + 1} name`} aria-label={`Member ${index + 1} name`}
disabled={isRemoved}
onChange={(event) => onNameChange(member.id, event.target.value)} onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name" placeholder="member-name"
style={ style={
@ -193,6 +205,7 @@ export const MemberDraftRow = ({
<div> <div>
<RoleSelect <RoleSelect
value={member.roleSelection || '__none__'} value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)} onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole} customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)} onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
@ -207,6 +220,7 @@ export const MemberDraftRow = ({
variant="outline" variant="outline"
size="sm" size="sm"
className="relative h-8 shrink-0 gap-1" className="relative h-8 shrink-0 gap-1"
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)} onClick={() => setWorkflowExpanded((prev) => !prev)}
> >
{workflowExpanded ? ( {workflowExpanded ? (
@ -221,36 +235,62 @@ export const MemberDraftRow = ({
</Button> </Button>
) : null} ) : null}
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex max-w-[190px]">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</span>
</TooltipTrigger>
{modelTooltipText ? (
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{modelTooltipText}
</TooltipContent>
) : null}
</Tooltip>
</div>
{isRemoved ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left" className="size-8 shrink-0 px-0"
disabled={lockProviderModel} aria-label={`Restore ${member.name || `member ${index + 1}`}`}
title={lockProviderModel ? modelLockReason : undefined} title="Restore member"
onClick={() => setModelExpanded((prev) => !prev)} onClick={() => onRestore?.(member.id)}
> >
{modelExpanded ? ( <RotateCcw className="size-3.5" />
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button> </Button>
</div> ) : (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200" className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
aria-label={`Remove ${member.name || `member ${index + 1}`}`} aria-label={`Remove ${member.name || `member ${index + 1}`}`}
title="Remove member" title="Remove member"
onClick={() => onRemove(member.id)} onClick={() => onRemove(member.id)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
)}
</div> </div>
{isRemoved ? (
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
) : null}
</div> </div>
{showWorkflow && onWorkflowChange && workflowExpanded ? ( {showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 md:col-span-3"> <div className="space-y-0.5 pl-3 md:col-span-3">
<label <label
htmlFor={`member-${member.id}-workflow`} htmlFor={`member-${member.id}-workflow`}
className="block text-[10px] font-medium text-[var(--color-text-muted)]" className="block text-[10px] font-medium text-[var(--color-text-muted)]"
@ -281,7 +321,7 @@ export const MemberDraftRow = ({
</div> </div>
) : null} ) : null}
{modelExpanded && ( {modelExpanded && (
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 pl-3 md:col-span-3">
<TeamModelSelector <TeamModelSelector
providerId={effectiveProviderId} providerId={effectiveProviderId}
onProviderChange={(providerId) => { onProviderChange={(providerId) => {

View file

@ -98,6 +98,7 @@ export interface MembersEditorSectionProps {
inheritModelSettingsByDefault?: boolean; inheritModelSettingsByDefault?: boolean;
forceInheritedModelSettings?: boolean; forceInheritedModelSettings?: boolean;
modelLockReason?: string; modelLockReason?: string;
softDeleteMembers?: boolean;
} }
export const MembersEditorSection = ({ export const MembersEditorSection = ({
@ -122,6 +123,7 @@ export const MembersEditorSection = ({
inheritModelSettingsByDefault = false, inheritModelSettingsByDefault = false,
forceInheritedModelSettings = false, forceInheritedModelSettings = false,
modelLockReason, modelLockReason,
softDeleteMembers = false,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
@ -215,7 +217,21 @@ export const MembersEditorSection = ({
}; };
const removeMember = (memberId: string): void => { const removeMember = (memberId: string): void => {
onChange(members.filter((c) => c.id !== memberId)); if (!softDeleteMembers) {
onChange(members.filter((c) => c.id !== memberId));
return;
}
onChange(
members.map((member) =>
member.id === memberId ? { ...member, removedAt: member.removedAt ?? Date.now() } : member
)
);
};
const restoreMember = (memberId: string): void => {
onChange(
members.map((member) => (member.id === memberId ? { ...member, removedAt: null } : member))
);
}; };
const addMember = (): void => { const addMember = (): void => {
@ -230,7 +246,9 @@ export const MembersEditorSection = ({
]); ]);
}; };
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); const activeMembers = members.filter((member) => !member.removedAt);
const removedMembers = members.filter((member) => member.removedAt);
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length; const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo( const memberColorMap = useMemo(
() => buildMemberDraftColorMap(members, existingMembers), () => buildMemberDraftColorMap(members, existingMembers),
@ -264,7 +282,7 @@ export const MembersEditorSection = ({
{!hideContent && ( {!hideContent && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
{members.map((member, index) => ( {activeMembers.map((member, index) => (
<MemberDraftRow <MemberDraftRow
key={member.id} key={member.id}
member={member} member={member}
@ -294,6 +312,47 @@ export const MembersEditorSection = ({
modelLockReason={modelLockReason} modelLockReason={modelLockReason}
/> />
))} ))}
{softDeleteMembers && removedMembers.length > 0 ? (
<div className="pt-2">
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
<div className="space-y-2">
{removedMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={activeMembers.length + index}
resolvedColor={memberColorMap.get(member.name.trim())}
nameError={null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
onRestore={restoreMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved
/>
))}
</div>
</div>
) : null}
{jsonEditorOpen && showJsonEditor ? ( {jsonEditorOpen && showJsonEditor ? (
<MembersJsonEditor <MembersJsonEditor
value={jsonText} value={jsonText}
@ -327,5 +386,7 @@ export {
createMemberDraft, createMemberDraft,
createMemberDraftsFromInputs, createMemberDraftsFromInputs,
getMemberDraftRole, getMemberDraftRole,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,
validateMemberNameInline, validateMemberNameInline,
} from './membersEditorUtils'; } from './membersEditorUtils';

View file

@ -40,6 +40,7 @@ interface TeamRosterEditorSectionProps {
onSyncModelsWithTeammatesChange: (value: boolean) => void; onSyncModelsWithTeammatesChange: (value: boolean) => void;
headerTop?: React.ReactNode; headerTop?: React.ReactNode;
headerBottom?: React.ReactNode; headerBottom?: React.ReactNode;
softDeleteMembers?: boolean;
} }
export const TeamRosterEditorSection = ({ export const TeamRosterEditorSection = ({
@ -75,6 +76,7 @@ export const TeamRosterEditorSection = ({
onSyncModelsWithTeammatesChange, onSyncModelsWithTeammatesChange,
headerTop, headerTop,
headerBottom, headerBottom,
softDeleteMembers = false,
}: TeamRosterEditorSectionProps): React.JSX.Element => { }: TeamRosterEditorSectionProps): React.JSX.Element => {
return ( return (
<MembersEditorSection <MembersEditorSection
@ -98,6 +100,7 @@ export const TeamRosterEditorSection = ({
lockProviderModel={lockProviderModel} lockProviderModel={lockProviderModel}
forceInheritedModelSettings={forceInheritedModelSettings} forceInheritedModelSettings={forceInheritedModelSettings}
modelLockReason={modelLockReason} modelLockReason={modelLockReason}
softDeleteMembers={softDeleteMembers}
headerExtra={ headerExtra={
<div className="space-y-3"> <div className="space-y-3">
{headerTop} {headerTop}

View file

@ -11,6 +11,7 @@ export interface MemberDraft {
providerId?: TeamProviderId; providerId?: TeamProviderId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
removedAt?: number | string | null;
} }
export interface MembersEditorValue { export interface MembersEditorValue {

View file

@ -36,6 +36,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
providerId: initial?.providerId, providerId: initial?.providerId,
model: initial?.model ?? '', model: initial?.model ?? '',
effort: initial?.effort, effort: initial?.effort,
removedAt: initial?.removedAt,
}; };
} }
@ -67,6 +68,7 @@ export function createMemberDraftsFromInputs(
: 'anthropic', : 'anthropic',
model: member.model ?? '', model: member.model ?? '',
effort: normalizeDraftEffort(member.effort), effort: normalizeDraftEffort(member.effort),
removedAt: member.removedAt,
}); });
}); });
} }
@ -80,6 +82,33 @@ export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
}; };
} }
export function normalizeProviderForMode(
providerId: TeamProviderId | undefined,
multimodelEnabled: boolean
): TeamProviderId {
if (multimodelEnabled && (providerId === 'codex' || providerId === 'gemini')) {
return providerId;
}
return 'anthropic';
}
export function normalizeMemberDraftForProviderMode(
member: MemberDraft,
multimodelEnabled: boolean
): MemberDraft {
if (multimodelEnabled) {
return member;
}
if (member.providerId === 'codex' || member.providerId === 'gemini') {
return {
...member,
providerId: 'anthropic',
model: '',
};
}
return member;
}
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined { function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
if (value === 'low' || value === 'medium' || value === 'high') { if (value === 'low' || value === 'medium' || value === 'high') {
return value; return value;
@ -155,6 +184,9 @@ export function getWorkflowForExport(member: MemberDraft): string | undefined {
export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] { export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] {
return members return members
.map((member) => { .map((member) => {
if (member.removedAt) {
return null;
}
const name = member.name.trim(); const name = member.name.trim();
if (!name) { if (!name) {
return null; return null;

View file

@ -85,6 +85,8 @@ export interface CliInstallationStatus {
updateAvailable: boolean; updateAvailable: boolean;
/** Whether user is logged in (claude auth status) */ /** Whether user is logged in (claude auth status) */
authLoggedIn: boolean; authLoggedIn: boolean;
/** Whether runtime authentication status is still being checked */
authStatusChecking: boolean;
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */ /** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
authMethod: string | null; authMethod: string | null;
/** Provider-level runtime status when supported by the configured runtime */ /** Provider-level runtime status when supported by the configured runtime */

View file

@ -94,6 +94,7 @@ describe('cliInstallerSlice', () => {
latestVersion: '2.1.59', latestVersion: '2.1.59',
updateAvailable: false, updateAvailable: false,
authLoggedIn: false, authLoggedIn: false,
authStatusChecking: false,
authMethod: null, authMethod: null,
providers: [], providers: [],
}; };
@ -126,6 +127,7 @@ describe('cliInstallerSlice', () => {
latestVersion: '2.1.59', latestVersion: '2.1.59',
updateAvailable: true, updateAvailable: true,
authLoggedIn: true, authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token', authMethod: 'oauth_token',
providers: [], providers: [],
}; };