feat: improve team launch model and status UX
This commit is contained in:
parent
759cae2669
commit
074b614469
20 changed files with 786 additions and 220 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'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'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>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue