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,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: true,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
|
|
@ -497,6 +498,7 @@ export class CliInstallerService {
|
|||
if (binaryPath) {
|
||||
r.installed = true;
|
||||
r.binaryPath = binaryPath;
|
||||
r.authStatusChecking = true;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) });
|
||||
|
||||
try {
|
||||
|
|
@ -522,6 +524,7 @@ export class CliInstallerService {
|
|||
]);
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
r.authStatusChecking = false;
|
||||
if (r.supportsSelfUpdate) {
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
|
|
@ -541,6 +544,7 @@ export class CliInstallerService {
|
|||
diag: CliInstallerStatusRunDiag
|
||||
): Promise<void> {
|
||||
if (result.flavor === 'free-code') {
|
||||
result.authStatusChecking = true;
|
||||
try {
|
||||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
|
|
@ -556,10 +560,12 @@ export class CliInstallerService {
|
|||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
diag.authLastError = msg;
|
||||
result.authStatusChecking = false;
|
||||
logger.warn(`Provider status check failed for claude-multimodel: ${msg}`);
|
||||
}
|
||||
return;
|
||||
|
|
@ -578,6 +584,7 @@ export class CliInstallerService {
|
|||
const auth = parseClaudeAuthStatusStdout(authStdout);
|
||||
result.authLoggedIn = auth.loggedIn === true;
|
||||
result.authMethod = auth.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
diag.authLastError = null;
|
||||
logger.info(
|
||||
`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}`
|
||||
);
|
||||
result.authLoggedIn = false;
|
||||
result.authStatusChecking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -621,6 +629,7 @@ export class CliInstallerService {
|
|||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
result.authStatusChecking = false;
|
||||
diag.authTimedOut = hitAuthTimeout;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ function getSourceServerEntry(): string {
|
|||
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> {
|
||||
try {
|
||||
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`);
|
||||
}
|
||||
|
||||
// 2. Dev mode — prefer source for hot changes
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
checked.push(sourceEntry);
|
||||
if (await pathExists(sourceEntry)) {
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Dev mode — built dist
|
||||
// 2. Dev mode — prefer built dist for reliable direct execution
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
checked.push(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(
|
||||
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -134,15 +135,26 @@ export class TeamMemberResolver {
|
|||
if (Array.isArray(config.members)) {
|
||||
for (const m of config.members) {
|
||||
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(), {
|
||||
agentType: m.agentType,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
agentType: configMember.agentType,
|
||||
role: configMember.role,
|
||||
workflow: configMember.workflow,
|
||||
providerId,
|
||||
model: configMember.model,
|
||||
effort: configMember.effort,
|
||||
color: configMember.color,
|
||||
cwd: configMember.cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ interface ProvisioningRun {
|
|||
onProgress: (progress: TeamProvisioningProgress) => void;
|
||||
expectedMembers: string[];
|
||||
request: TeamCreateRequest;
|
||||
effectiveMembers: TeamCreateRequest['members'];
|
||||
lastLogProgressAt: number;
|
||||
/** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */
|
||||
lastDataReceivedAt: number;
|
||||
|
|
@ -491,6 +492,45 @@ function formatWorkflowBlock(workflow: string, indent: string): string {
|
|||
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 {
|
||||
return members
|
||||
.map((member) => {
|
||||
|
|
@ -983,17 +1023,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
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 userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
||||
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 isSolo = request.members.length === 0;
|
||||
const isSolo = effectiveMembers.length === 0;
|
||||
|
||||
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` +
|
||||
|
|
@ -1036,14 +1079,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.
|
||||
|
||||
Per-member spawn instructions:
|
||||
${request.members
|
||||
${effectiveMembers
|
||||
.map((m) => {
|
||||
const providerLine =
|
||||
m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : '';
|
||||
const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : '';
|
||||
const effortLine = m.effort ? ` - effort: “${m.effort}”\n` : '';
|
||||
return ` For “${m.name}”:
|
||||
- name: “${m.name}”
|
||||
${providerLine}${modelLine} - prompt:
|
||||
${providerLine}${modelLine}${effortLine} - prompt:
|
||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
|
|
@ -1055,7 +1099,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
|||
teamName: request.teamName,
|
||||
leadName,
|
||||
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.
|
||||
|
|
@ -3051,6 +3095,14 @@ export class TeamProvisioningService {
|
|||
// Respawn with saved context — CLI handles its own auth refresh.
|
||||
let child: ReturnType<typeof spawn>;
|
||||
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, {
|
||||
cwd: ctx.cwd,
|
||||
env: { ...ctx.env },
|
||||
|
|
@ -3292,6 +3344,11 @@ export class TeamProvisioningService {
|
|||
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 startedAt = nowIso();
|
||||
const run: ProvisioningRun = {
|
||||
|
|
@ -3318,6 +3375,7 @@ export class TeamProvisioningService {
|
|||
onProgress,
|
||||
expectedMembers: request.members.map((member) => member.name),
|
||||
request,
|
||||
effectiveMembers: effectiveMemberSpecs,
|
||||
lastLogProgressAt: 0,
|
||||
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
|
||||
lastStdoutReceivedAt: 0,
|
||||
|
|
@ -3372,13 +3430,14 @@ export class TeamProvisioningService {
|
|||
this.provisioningRunByTeam.set(request.teamName, runId);
|
||||
run.onProgress(run.progress);
|
||||
|
||||
const prompt = buildProvisioningPrompt(request);
|
||||
const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
|
||||
let mcpConfigPath: string;
|
||||
try {
|
||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
run.mcpConfigPath = mcpConfigPath;
|
||||
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
|
|
@ -3718,12 +3777,20 @@ export class TeamProvisioningService {
|
|||
const runId = randomUUID();
|
||||
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
|
||||
const syntheticRequest: TeamCreateRequest = {
|
||||
teamName: request.teamName,
|
||||
members: expectedMemberSpecs,
|
||||
cwd: request.cwd,
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
skipPermissions: request.skipPermissions,
|
||||
};
|
||||
|
||||
|
|
@ -3764,6 +3831,7 @@ export class TeamProvisioningService {
|
|||
onProgress,
|
||||
expectedMembers,
|
||||
request: syntheticRequest,
|
||||
effectiveMembers: effectiveMemberSpecs,
|
||||
lastLogProgressAt: 0,
|
||||
lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites)
|
||||
lastStdoutReceivedAt: 0,
|
||||
|
|
@ -3837,7 +3905,7 @@ export class TeamProvisioningService {
|
|||
|
||||
const prompt = buildLaunchPrompt(
|
||||
request,
|
||||
expectedMemberSpecs,
|
||||
effectiveMemberSpecs,
|
||||
existingTasks,
|
||||
Boolean(previousSessionId)
|
||||
);
|
||||
|
|
@ -3847,6 +3915,7 @@ export class TeamProvisioningService {
|
|||
try {
|
||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
run.mcpConfigPath = mcpConfigPath;
|
||||
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath);
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.provisioningRunByTeam.delete(request.teamName);
|
||||
|
|
@ -6862,7 +6931,13 @@ export class TeamProvisioningService {
|
|||
run.teamName,
|
||||
run.request.cwd,
|
||||
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);
|
||||
|
||||
|
|
@ -6996,7 +7071,13 @@ export class TeamProvisioningService {
|
|||
run.teamName,
|
||||
run.request.cwd,
|
||||
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.
|
||||
|
|
@ -7789,7 +7870,13 @@ export class TeamProvisioningService {
|
|||
run.teamName,
|
||||
run.request.cwd,
|
||||
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
|
||||
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.
|
||||
* Combines session history append and projectPath update to avoid
|
||||
|
|
@ -7972,7 +8149,13 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
projectPath: string,
|
||||
detectedSessionId: string | null,
|
||||
color?: string
|
||||
color?: string,
|
||||
launchState?: {
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamCreateRequest['effort'];
|
||||
members?: TeamCreateRequest['members'];
|
||||
}
|
||||
): Promise<void> {
|
||||
const MAX_SESSION_HISTORY = 5000;
|
||||
const MAX_PROJECT_PATH_HISTORY = 500;
|
||||
|
|
@ -8049,6 +8232,8 @@ export class TeamProvisioningService {
|
|||
: pathHistory;
|
||||
}
|
||||
|
||||
this.applyEffectiveLaunchStateToConfig(config, launchState);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -8751,6 +8936,7 @@ export class TeamProvisioningService {
|
|||
role?: string;
|
||||
workflow?: string;
|
||||
agentType?: string;
|
||||
providerId?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
|
|
@ -8771,10 +8957,7 @@ export class TeamProvisioningService {
|
|||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow:
|
||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId:
|
||||
member.provider === 'codex' || member.provider === 'gemini'
|
||||
? member.provider
|
||||
: undefined,
|
||||
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
|
|
@ -8985,6 +9168,56 @@ export class TeamProvisioningService {
|
|||
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(
|
||||
claudePath: string,
|
||||
args: string[],
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -125,8 +125,10 @@ const SLOW_CHECK_DELAY_MS = 5_000;
|
|||
|
||||
const CliCheckingSpinner = ({
|
||||
styles,
|
||||
label,
|
||||
}: {
|
||||
styles: { border: string; bg: string };
|
||||
label: string;
|
||||
}): React.JSX.Element => {
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
|
||||
|
|
@ -146,7 +148,7 @@ const CliCheckingSpinner = ({
|
|||
/>
|
||||
<div>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Checking AI Providers...
|
||||
{label}
|
||||
</span>
|
||||
{showHint && (
|
||||
<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`;
|
||||
}
|
||||
|
||||
if (cliStatus.authStatusChecking) {
|
||||
return 'Checking authentication...';
|
||||
}
|
||||
|
||||
if (cliStatus.authLoggedIn) {
|
||||
return 'Authenticated';
|
||||
}
|
||||
|
|
@ -316,6 +322,18 @@ function formatRuntimeAuthSummary(
|
|||
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 {
|
||||
const providers: CliProviderStatus[] = [
|
||||
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
|
||||
|
|
@ -349,6 +367,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
|
|||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: true,
|
||||
authMethod: null,
|
||||
providers,
|
||||
};
|
||||
|
|
@ -643,6 +662,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
|
|
@ -695,6 +719,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'completed') return 'success';
|
||||
if (installerState !== 'idle') return 'info';
|
||||
if (!cliStatus) return 'loading';
|
||||
if (isCheckingMultimodelStatus(cliStatus)) return 'info';
|
||||
if (cliStatus.authStatusChecking) return 'info';
|
||||
if (!cliStatus.installed) return 'error';
|
||||
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
|
||||
if (cliStatus.updateAvailable) return 'info';
|
||||
|
|
@ -785,7 +811,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
// 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 ────────────────────────────────────────────────────────
|
||||
|
|
@ -937,8 +968,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
|
||||
// Installed but not logged in — yellow warning banner
|
||||
if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) {
|
||||
if (isVerifyingAuth) {
|
||||
if (
|
||||
cliStatus.installed &&
|
||||
cliStatus.flavor !== 'free-code' &&
|
||||
(cliStatus.authStatusChecking || isVerifyingAuth)
|
||||
) {
|
||||
if (cliStatus.authStatusChecking || isVerifyingAuth) {
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
cliStatus.installed &&
|
||||
cliStatus.flavor !== 'free-code' &&
|
||||
!cliStatus.authStatusChecking &&
|
||||
!cliStatus.authLoggedIn
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
|
|||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: true,
|
||||
authMethod: null,
|
||||
providers: providers.map((provider) => ({
|
||||
...provider,
|
||||
|
|
@ -223,6 +224,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
|
|
@ -261,7 +267,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking AI Providers...
|
||||
{multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1136,30 +1136,35 @@ export const TeamDetailView = ({
|
|||
const teamSummary = teams.find((t) => t.teamName === teamName);
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-full items-center justify-center p-6">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
||||
<p className="mt-2 text-xs text-text-secondary">
|
||||
This is a draft team — <strong>{teamSummary?.displayName || teamName}</strong> has
|
||||
been configured with {teamSummary?.memberCount ?? 0} member
|
||||
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI
|
||||
yet. Click Launch to select a model and start the team.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => setLaunchDialogOpen(true)}
|
||||
>
|
||||
Launch
|
||||
</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"
|
||||
onClick={() => {
|
||||
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="size-full overflow-auto p-6">
|
||||
<div ref={provisioningBannerRef}>
|
||||
<TeamProvisioningBanner teamName={teamName} />
|
||||
</div>
|
||||
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center">
|
||||
<div className="max-w-md text-center">
|
||||
<p className="text-sm font-medium text-text">Team not launched yet</p>
|
||||
<p className="mt-2 text-xs text-text-secondary">
|
||||
This is a draft team — <strong>{teamSummary?.displayName || teamName}</strong> has
|
||||
been configured with {teamSummary?.memberCount ?? 0} member
|
||||
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI
|
||||
yet. Click Launch to select a model and start the team.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => setLaunchDialogOpen(true)}
|
||||
>
|
||||
Launch
|
||||
</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"
|
||||
onClick={() => {
|
||||
void api.teams.deleteDraft(teamName).catch(() => {});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -592,14 +592,14 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
setLaunchingTeamName(request.teamName);
|
||||
try {
|
||||
await launchTeam(request);
|
||||
openTeamTab(request.teamName, request.cwd);
|
||||
} catch (err) {
|
||||
console.error('Failed to launch team:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLaunchingTeamName(null);
|
||||
}
|
||||
},
|
||||
[launchTeam, openTeamTab]
|
||||
[launchTeam]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
buildMembersFromDrafts,
|
||||
clearMemberModelOverrides,
|
||||
createMemberDraft,
|
||||
normalizeMemberDraftForProviderMode,
|
||||
normalizeProviderForMode,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
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 { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -266,6 +269,7 @@ export const CreateTeamDialog = ({
|
|||
onOpenTeam,
|
||||
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
|
||||
// ── Persisted draft state (survives tab navigation) ──────────────────
|
||||
const {
|
||||
|
|
@ -362,13 +366,14 @@ export const CreateTeamDialog = ({
|
|||
};
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
setSelectedProviderIdRaw(value);
|
||||
localStorage.setItem('team:lastSelectedProvider', value);
|
||||
if (value !== 'anthropic') {
|
||||
const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
||||
if (normalizedValue !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
localStorage.setItem('team:lastLimitContext', 'false');
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(value));
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
};
|
||||
|
||||
const setLimitContext = (value: boolean): void => {
|
||||
|
|
@ -444,7 +449,10 @@ export const CreateTeamDialog = ({
|
|||
[members, syncModelsWithLead]
|
||||
);
|
||||
|
||||
const selectedMemberProviders = useMemo(() => {
|
||||
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
|
||||
if (!multimodelEnabled) {
|
||||
return ['anthropic'];
|
||||
}
|
||||
if (soloTeam || syncModelsWithLead) {
|
||||
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(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
|
|
@ -631,15 +654,22 @@ export const CreateTeamDialog = ({
|
|||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = m.role != null && presetRoles.includes(m.role);
|
||||
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
|
||||
return createMemberDraft({
|
||||
name: m.name,
|
||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model ?? '',
|
||||
effort: m.effort,
|
||||
});
|
||||
return normalizeMemberDraftForProviderMode(
|
||||
createMemberDraft({
|
||||
name: m.name,
|
||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
providerId: normalizeProviderForMode(m.providerId, multimodelEnabled),
|
||||
model:
|
||||
normalizeProviderForMode(m.providerId, multimodelEnabled) ===
|
||||
normalizeProviderForMode(m.providerId, true)
|
||||
? (m.model ?? '')
|
||||
: '',
|
||||
effort: m.effort,
|
||||
}),
|
||||
multimodelEnabled
|
||||
);
|
||||
})
|
||||
);
|
||||
setSyncModelsWithLead(
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import {
|
|||
buildMembersFromDrafts,
|
||||
clearMemberModelOverrides,
|
||||
createMemberDraftsFromInputs,
|
||||
normalizeMemberDraftForProviderMode,
|
||||
normalizeProviderForMode,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -33,6 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
|
|
@ -155,6 +162,7 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
|||
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
|
||||
const { open, onClose } = props;
|
||||
const { isLight } = useTheme();
|
||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
const isLaunch = props.mode === 'launch';
|
||||
const isSchedule = props.mode === 'schedule';
|
||||
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||
|
|
@ -261,21 +269,42 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
|
||||
[membersDrafts, syncModelsWithLead]
|
||||
);
|
||||
const selectedMemberProviders = useMemo(
|
||||
const selectedMemberProviders = useMemo<TeamProviderId[]>(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set([
|
||||
selectedProviderId,
|
||||
...effectiveMemberDrafts.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? [member.providerId]
|
||||
: []
|
||||
!multimodelEnabled
|
||||
? ['anthropic']
|
||||
: Array.from(
|
||||
new Set([
|
||||
selectedProviderId,
|
||||
...effectiveMemberDrafts.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? [member.providerId]
|
||||
: []
|
||||
),
|
||||
])
|
||||
),
|
||||
])
|
||||
),
|
||||
[effectiveMemberDrafts, selectedProviderId]
|
||||
[effectiveMemberDrafts, multimodelEnabled, 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
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
const updateSchedule = useStore((s) => s.updateSchedule);
|
||||
|
|
@ -302,13 +331,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
};
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
setSelectedProviderIdRaw(value);
|
||||
localStorage.setItem('team:lastSelectedProvider', value);
|
||||
if (value !== 'anthropic') {
|
||||
const normalizedValue = normalizeProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
||||
if (normalizedValue !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
localStorage.setItem('team:lastLimitContext', 'false');
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(value));
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
};
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
|
|
@ -401,8 +431,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
promptDraft.setValue(schedule.launchConfig.prompt);
|
||||
setCustomCwd(schedule.launchConfig.cwd);
|
||||
setCwdMode('custom');
|
||||
setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic');
|
||||
setSelectedModelRaw(schedule.launchConfig.model ?? '');
|
||||
const scheduleProviderId = normalizeProviderForMode(
|
||||
schedule.launchConfig.providerId,
|
||||
multimodelEnabled
|
||||
);
|
||||
setSelectedProviderIdRaw(scheduleProviderId);
|
||||
setSelectedModelRaw(
|
||||
scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true)
|
||||
? (schedule.launchConfig.model ?? '')
|
||||
: getStoredTeamModel('anthropic')
|
||||
);
|
||||
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
|
||||
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
|
||||
} else {
|
||||
|
|
@ -417,8 +455,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setSelectedProviderIdRaw(getStoredTeamProvider());
|
||||
setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
|
||||
const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled);
|
||||
setSelectedProviderIdRaw(storedProviderId);
|
||||
setSelectedModelRaw(getStoredTeamModel(storedProviderId));
|
||||
setSelectedEffortRaw('medium');
|
||||
}
|
||||
|
||||
|
|
@ -442,10 +481,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const nextProviderId =
|
||||
const rawNextProviderId =
|
||||
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
||||
? savedRequest.providerId
|
||||
: 'anthropic';
|
||||
const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled);
|
||||
const providerFromSaved = Boolean(savedRequest?.providerId);
|
||||
const nextMembersSource =
|
||||
members.length > 0
|
||||
|
|
@ -455,15 +495,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
: [];
|
||||
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
||||
|
||||
setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource));
|
||||
setMembersDrafts(
|
||||
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
|
||||
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
|
||||
)
|
||||
);
|
||||
setSyncModelsWithLead(
|
||||
!nextMembersSource.some((member) => member.providerId || member.model || member.effort)
|
||||
);
|
||||
setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider());
|
||||
setSelectedProviderIdRaw(
|
||||
providerFromSaved
|
||||
? nextProviderId
|
||||
: normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
);
|
||||
setSelectedModelRaw(
|
||||
typeof savedRequest?.model === 'string'
|
||||
typeof savedRequest?.model === 'string' &&
|
||||
nextProviderId === normalizeProviderForMode(rawNextProviderId, true)
|
||||
? savedRequest.model
|
||||
: getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider())
|
||||
: getStoredTeamModel(
|
||||
providerFromSaved
|
||||
? nextProviderId
|
||||
: normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
)
|
||||
);
|
||||
setSelectedEffortRaw(
|
||||
savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort)
|
||||
|
|
@ -481,7 +534,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isLaunch, effectiveTeamName, members]);
|
||||
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch-only effects
|
||||
|
|
@ -773,6 +826,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
const provisioningError = isLaunch ? props.provisioningError : null;
|
||||
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
|
||||
|
|
@ -827,8 +888,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||
extraCliArgs: customArgs.trim() || undefined,
|
||||
});
|
||||
resetFormState();
|
||||
onClose();
|
||||
} else {
|
||||
// Schedule mode: create or update
|
||||
const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined;
|
||||
|
|
@ -883,7 +942,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isDisabled = isLaunch
|
||||
? isSubmitting || prepareState !== 'ready'
|
||||
? isSubmitting || launchInFlight
|
||||
: isSubmitting || validationErrors.length > 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -905,7 +964,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
'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...';
|
||||
|
||||
|
|
@ -1160,38 +1225,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
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
|
||||
Schedule: prompt + execution defaults
|
||||
|
|
@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary={launchOptionalSummary}
|
||||
>
|
||||
<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">
|
||||
<Label htmlFor="dialog-prompt" className="label-optional">
|
||||
Prompt for team lead (optional)
|
||||
|
|
@ -1388,6 +1453,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
|
||||
<div className="pt-1">
|
||||
<TeamProvisioningBanner teamName={effectiveTeamName} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
|
||||
{/* Launch-only: CLI warm-up status */}
|
||||
{isLaunch ? (
|
||||
|
|
@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</span>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
<span>Pre-flight check to catch errors before launch</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
disabled={isDisabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{isSubmitting || launchInFlight ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
{submittingLabel}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
id,
|
||||
}) => {
|
||||
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 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 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 =>
|
||||
multimodelAvailable || candidateProviderId === 'anthropic';
|
||||
const activeProviderSelectable = isProviderSelectable(providerId);
|
||||
|
|
@ -192,7 +199,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
: providerId === 'gemini'
|
||||
? GEMINI_MODEL_OPTIONS
|
||||
: ANTHROPIC_MODEL_OPTIONS;
|
||||
if (runtimeModels.length === 0) {
|
||||
if (providerId === 'anthropic' || runtimeModels.length === 0) {
|
||||
return [...fallback];
|
||||
}
|
||||
const dynamicOptions = runtimeModels.map((model) => ({
|
||||
|
|
@ -310,7 +317,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
)}
|
||||
|
||||
<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))' }}
|
||||
>
|
||||
{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" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
Default model from Claude CLI (/model).
|
||||
<br />
|
||||
Uses the runtime default for the selected provider.
|
||||
{defaultModelTooltip.split('\n').map((line, index) => (
|
||||
<React.Fragment key={line}>
|
||||
{index > 0 ? <br /> : null}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const LeadModelRow = ({
|
|||
|
||||
return (
|
||||
<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={{
|
||||
backgroundColor: isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||
|
|
@ -62,34 +62,15 @@ export const LeadModelRow = ({
|
|||
style={{ backgroundColor: leadColorSet.border }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex h-8 items-center px-2 text-sm font-medium text-[var(--color-text)]">
|
||||
lead
|
||||
<div className="min-w-0">
|
||||
<div className="flex h-8 items-center gap-3 px-2">
|
||||
<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 className="flex h-8 items-center px-2 text-xs text-[var(--color-text-secondary)]">
|
||||
Team Lead
|
||||
</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">
|
||||
<div className="min-w-0">
|
||||
<div className="flex h-8 items-center justify-end px-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
id="sync-models-with-lead"
|
||||
checked={syncModelsWithTeammates}
|
||||
|
|
@ -97,13 +78,30 @@ export const LeadModelRow = ({
|
|||
/>
|
||||
<Label
|
||||
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>
|
||||
</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 ? (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<TeamModelSelector
|
||||
|
|
@ -118,12 +116,14 @@ export const LeadModelRow = ({
|
|||
onValueChange={onEffortChange}
|
||||
id="lead-effort"
|
||||
/>
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
checked={limitContext}
|
||||
onCheckedChange={onLimitContextChange}
|
||||
disabled={providerId !== 'anthropic' || model === 'haiku'}
|
||||
/>
|
||||
{providerId === 'anthropic' ? (
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
checked={limitContext}
|
||||
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">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<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 { Input } from '@renderer/components/ui/input';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
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 { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -48,6 +49,8 @@ interface MemberDraftRowProps {
|
|||
lockProviderModel?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
isRemoved?: boolean;
|
||||
onRestore?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const MemberDraftRow = ({
|
||||
|
|
@ -76,6 +79,8 @@ export const MemberDraftRow = ({
|
|||
lockProviderModel = false,
|
||||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
isRemoved = false,
|
||||
onRestore,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const memberColorSet = getTeamColorSet(
|
||||
|
|
@ -154,13 +159,19 @@ export const MemberDraftRow = ({
|
|||
const effectiveEffort = forceInheritedModelSettings
|
||||
? inheritedEffort
|
||||
: (member.effort ?? inheritedEffort);
|
||||
const modelButtonLabel = effectiveModel?.trim()
|
||||
const modelButtonLabelBase = effectiveModel?.trim()
|
||||
? getTeamModelLabel(effectiveModel.trim())
|
||||
: '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 (
|
||||
<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={{
|
||||
backgroundColor: isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||
|
|
@ -178,6 +189,7 @@ export const MemberDraftRow = ({
|
|||
className="h-8 text-xs"
|
||||
value={member.name}
|
||||
aria-label={`Member ${index + 1} name`}
|
||||
disabled={isRemoved}
|
||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
||||
placeholder="member-name"
|
||||
style={
|
||||
|
|
@ -193,6 +205,7 @@ export const MemberDraftRow = ({
|
|||
<div>
|
||||
<RoleSelect
|
||||
value={member.roleSelection || '__none__'}
|
||||
disabled={isRemoved}
|
||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||
customRole={member.customRole}
|
||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||
|
|
@ -207,6 +220,7 @@ export const MemberDraftRow = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="relative h-8 shrink-0 gap-1"
|
||||
disabled={isRemoved}
|
||||
onClick={() => setWorkflowExpanded((prev) => !prev)}
|
||||
>
|
||||
{workflowExpanded ? (
|
||||
|
|
@ -221,36 +235,62 @@ export const MemberDraftRow = ({
|
|||
</Button>
|
||||
) : null}
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
|
||||
disabled={lockProviderModel}
|
||||
title={lockProviderModel ? modelLockReason : undefined}
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
className="size-8 shrink-0 px-0"
|
||||
aria-label={`Restore ${member.name || `member ${index + 1}`}`}
|
||||
title="Restore member"
|
||||
onClick={() => onRestore?.(member.id)}
|
||||
>
|
||||
{modelExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
<span className="truncate">Model: {modelButtonLabel}</span>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
|
||||
title="Remove member"
|
||||
onClick={() => onRemove(member.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
|
||||
title="Remove member"
|
||||
onClick={() => onRemove(member.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isRemoved ? (
|
||||
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
|
||||
) : null}
|
||||
</div>
|
||||
{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
|
||||
htmlFor={`member-${member.id}-workflow`}
|
||||
className="block text-[10px] font-medium text-[var(--color-text-muted)]"
|
||||
|
|
@ -281,7 +321,7 @@ export const MemberDraftRow = ({
|
|||
</div>
|
||||
) : null}
|
||||
{modelExpanded && (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<div className="space-y-2 pl-3 md:col-span-3">
|
||||
<TeamModelSelector
|
||||
providerId={effectiveProviderId}
|
||||
onProviderChange={(providerId) => {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export interface MembersEditorSectionProps {
|
|||
inheritModelSettingsByDefault?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
softDeleteMembers?: boolean;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
|
|
@ -122,6 +123,7 @@ export const MembersEditorSection = ({
|
|||
inheritModelSettingsByDefault = false,
|
||||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
softDeleteMembers = false,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
|
|
@ -215,7 +217,21 @@ export const MembersEditorSection = ({
|
|||
};
|
||||
|
||||
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 => {
|
||||
|
|
@ -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 memberColorMap = useMemo(
|
||||
() => buildMemberDraftColorMap(members, existingMembers),
|
||||
|
|
@ -264,7 +282,7 @@ export const MembersEditorSection = ({
|
|||
{!hideContent && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{members.map((member, index) => (
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
|
|
@ -294,6 +312,47 @@ export const MembersEditorSection = ({
|
|||
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 ? (
|
||||
<MembersJsonEditor
|
||||
value={jsonText}
|
||||
|
|
@ -327,5 +386,7 @@ export {
|
|||
createMemberDraft,
|
||||
createMemberDraftsFromInputs,
|
||||
getMemberDraftRole,
|
||||
normalizeMemberDraftForProviderMode,
|
||||
normalizeProviderForMode,
|
||||
validateMemberNameInline,
|
||||
} from './membersEditorUtils';
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface TeamRosterEditorSectionProps {
|
|||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
headerTop?: React.ReactNode;
|
||||
headerBottom?: React.ReactNode;
|
||||
softDeleteMembers?: boolean;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
|
|
@ -75,6 +76,7 @@ export const TeamRosterEditorSection = ({
|
|||
onSyncModelsWithTeammatesChange,
|
||||
headerTop,
|
||||
headerBottom,
|
||||
softDeleteMembers = false,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<MembersEditorSection
|
||||
|
|
@ -98,6 +100,7 @@ export const TeamRosterEditorSection = ({
|
|||
lockProviderModel={lockProviderModel}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
modelLockReason={modelLockReason}
|
||||
softDeleteMembers={softDeleteMembers}
|
||||
headerExtra={
|
||||
<div className="space-y-3">
|
||||
{headerTop}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface MemberDraft {
|
|||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
removedAt?: number | string | null;
|
||||
}
|
||||
|
||||
export interface MembersEditorValue {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
providerId: initial?.providerId,
|
||||
model: initial?.model ?? '',
|
||||
effort: initial?.effort,
|
||||
removedAt: initial?.removedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +68,7 @@ export function createMemberDraftsFromInputs(
|
|||
: 'anthropic',
|
||||
model: member.model ?? '',
|
||||
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 {
|
||||
if (value === 'low' || value === 'medium' || value === 'high') {
|
||||
return value;
|
||||
|
|
@ -155,6 +184,9 @@ export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
|||
export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] {
|
||||
return members
|
||||
.map((member) => {
|
||||
if (member.removedAt) {
|
||||
return null;
|
||||
}
|
||||
const name = member.name.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export interface CliInstallationStatus {
|
|||
updateAvailable: boolean;
|
||||
/** Whether user is logged in (claude auth status) */
|
||||
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 */
|
||||
authMethod: string | null;
|
||||
/** Provider-level runtime status when supported by the configured runtime */
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ describe('cliInstallerSlice', () => {
|
|||
latestVersion: '2.1.59',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: false,
|
||||
authMethod: null,
|
||||
providers: [],
|
||||
};
|
||||
|
|
@ -126,6 +127,7 @@ describe('cliInstallerSlice', () => {
|
|||
latestVersion: '2.1.59',
|
||||
updateAvailable: true,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token',
|
||||
providers: [],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue