feat(teams): unify provider-aware create and launch flows
This commit is contained in:
parent
bae3609561
commit
3ac46e2861
36 changed files with 2898 additions and 574 deletions
|
|
@ -89,6 +89,16 @@ function assertOptionalEffort(value: unknown): EffortLevel | undefined {
|
|||
|
||||
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId == null || payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: (() => {
|
||||
throw new HttpBadRequestError('providerId must be anthropic, codex, or gemini');
|
||||
})();
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort);
|
||||
|
|
@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
return {
|
||||
teamName,
|
||||
cwd: assertAbsoluteCwd(payload.cwd),
|
||||
providerId,
|
||||
...(prompt && {
|
||||
prompt,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
|||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
import {
|
||||
buildReplaceMembersDiff,
|
||||
buildReplaceMembersSummaryMessage,
|
||||
} from '../services/team/memberUpdateNotifications';
|
||||
|
||||
import {
|
||||
validateFromField,
|
||||
|
|
@ -534,25 +538,30 @@ async function handleGetData(
|
|||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamData;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
if (message === `Team not found: ${tn}`) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
if (meta) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
try {
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message === `Team not found: ${tn}` &&
|
||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||
) {
|
||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||
}
|
||||
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||
if (message === `Team not found: ${tn}`) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
if (meta) {
|
||||
return { success: false, error: 'TEAM_DRAFT' };
|
||||
}
|
||||
}
|
||||
logger.error(`[teams:getData] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
logger.error(`[teams:getData] ${message}`);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
setCurrentMainOp(null);
|
||||
}
|
||||
const getDataMs = Date.now() - startedAt;
|
||||
if (getDataMs >= 1500) {
|
||||
|
|
@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel {
|
|||
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
function parseOptionalMemberProviderId(
|
||||
value: unknown
|
||||
):
|
||||
| { valid: true; value: 'anthropic' | 'codex' | 'gemini' | undefined }
|
||||
| { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (value === 'anthropic' || value === 'codex' || value === 'gemini') {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member providerId must be anthropic, codex, or gemini' };
|
||||
}
|
||||
|
||||
function parseOptionalMemberEffort(
|
||||
value: unknown
|
||||
): { valid: true; value: EffortLevel | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (isValidEffort(value)) {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member effort must be low, medium, or high' };
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
request: unknown
|
||||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||||
|
|
@ -884,10 +919,22 @@ async function validateProvisioningRequest(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { valid: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { valid: false, error: providerValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { valid: false, error: 'member model must be string' };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -953,6 +1000,12 @@ async function validateProvisioningRequest(
|
|||
members,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
skipPermissions:
|
||||
|
|
@ -1089,6 +1142,16 @@ async function handleLaunchTeam(
|
|||
color: meta?.color,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: meta?.providerId === 'codex'
|
||||
? 'codex'
|
||||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
|
|
@ -1100,7 +1163,14 @@ async function handleLaunchTeam(
|
|||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })),
|
||||
members: members.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
};
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
|
|
@ -1122,6 +1192,12 @@ async function handleLaunchTeam(
|
|||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic',
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
|
|
@ -1174,9 +1250,13 @@ async function handleValidateCliArgs(
|
|||
|
||||
async function handlePrepareProvisioning(
|
||||
_event: IpcMainInvokeEvent,
|
||||
cwd: unknown
|
||||
cwd: unknown,
|
||||
providerId: unknown,
|
||||
providerIds: unknown
|
||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||
let validatedCwd: string | undefined;
|
||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
|
||||
if (cwd !== undefined) {
|
||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string' };
|
||||
|
|
@ -1186,8 +1266,32 @@ async function handlePrepareProvisioning(
|
|||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
if (providerId !== undefined) {
|
||||
if (providerId !== 'anthropic' && providerId !== 'codex' && providerId !== 'gemini') {
|
||||
return { success: false, error: 'providerId must be anthropic, codex, or gemini' };
|
||||
}
|
||||
validatedProviderId = providerId;
|
||||
}
|
||||
if (providerIds !== undefined) {
|
||||
if (!Array.isArray(providerIds)) {
|
||||
return { success: false, error: 'providerIds must be an array when provided' };
|
||||
}
|
||||
const normalized: Array<'anthropic' | 'codex' | 'gemini'> = [];
|
||||
for (const entry of providerIds) {
|
||||
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
|
||||
return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' };
|
||||
}
|
||||
if (!normalized.includes(entry)) {
|
||||
normalized.push(entry);
|
||||
}
|
||||
}
|
||||
validatedProviderIds = normalized;
|
||||
}
|
||||
return wrapTeamHandler('prepareProvisioning', () =>
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd)
|
||||
getTeamProvisioningService().prepareForProvisioning(validatedCwd, {
|
||||
providerId: validatedProviderId,
|
||||
providerIds: validatedProviderIds,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2057,10 +2161,27 @@ async function handleCreateConfig(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((member as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2286,10 +2407,13 @@ async function handleAddMember(
|
|||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role, workflow } = payload as {
|
||||
const { name, role, workflow, providerId, model } = payload as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
|
|
@ -2299,6 +2423,17 @@ async function handleAddMember(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'workflow must be a string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((payload as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
|
|
@ -2307,6 +2442,9 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
role: role,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
|
|
@ -2329,6 +2467,9 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
});
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
|
|
@ -2355,12 +2496,26 @@ async function handleReplaceMembers(
|
|||
return { success: false, error: 'members must be an array' };
|
||||
}
|
||||
const seenNames = new Set<string>();
|
||||
const members: { name: string; role?: string; workflow?: string }[] = [];
|
||||
const members: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
}[] = [];
|
||||
for (const item of payload.members) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return { success: false, error: 'member must be object' };
|
||||
}
|
||||
const m = item as { name?: unknown; role?: unknown; workflow?: unknown };
|
||||
const m = item as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(m.name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
const name = vName.value!;
|
||||
|
|
@ -2372,15 +2527,73 @@ async function handleReplaceMembers(
|
|||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(m as { providerId?: unknown }).providerId
|
||||
);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
if (m.model !== undefined && typeof m.model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
}
|
||||
const effortValidation = parseOptionalMemberEffort((m as { effort?: unknown }).effort);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name,
|
||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('replaceMembers', async () => {
|
||||
await getTeamDataService().replaceMembers(vTeam.value!, { members });
|
||||
const tn = vTeam.value!;
|
||||
const teamDataService = getTeamDataService();
|
||||
const previousMembers = (await teamDataService.getTeamData(tn)).members;
|
||||
const diff = buildReplaceMembersDiff(previousMembers, members);
|
||||
|
||||
await teamDataService.replaceMembers(tn, { members });
|
||||
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (!provisioning.isTeamAlive(tn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let leadName = 'team-lead';
|
||||
let displayName = tn;
|
||||
try {
|
||||
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
|
||||
teamDataService.getLeadMemberName(tn),
|
||||
teamDataService.getTeamDisplayName(tn),
|
||||
]);
|
||||
leadName = resolvedLeadName || 'team-lead';
|
||||
displayName = resolvedDisplayName || tn;
|
||||
} catch {
|
||||
// Best-effort: fall back to default lead and team names
|
||||
}
|
||||
|
||||
for (const addedMember of diff.added) {
|
||||
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryMessage = buildReplaceMembersSummaryMessage(diff);
|
||||
if (!summaryMessage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, summaryMessage);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about member updates in ${tn}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3088,6 +3301,7 @@ async function handleGetSavedRequest(
|
|||
color: meta.color,
|
||||
cwd: meta.cwd,
|
||||
prompt: meta.prompt,
|
||||
providerId: meta.providerId ?? 'anthropic',
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
skipPermissions: meta.skipPermissions,
|
||||
|
|
@ -3098,6 +3312,9 @@ async function handleGetSavedRequest(
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void {
|
|||
// Only report meaningful stalls
|
||||
if (maxMs < 250) return;
|
||||
|
||||
// For known IPC/main-thread operations we already emit operation-specific
|
||||
// timing diagnostics. Suppress the generic event-loop warning to avoid
|
||||
// duplicate noisy logs that do not add new debugging value.
|
||||
if (currentOp) return;
|
||||
|
||||
logger.warn(
|
||||
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
||||
(currentOp ? ` op=${currentOp}` : '')
|
||||
|
|
|
|||
|
|
@ -948,6 +948,15 @@ export class TeamDataService {
|
|||
name,
|
||||
role: request.role?.trim() || undefined,
|
||||
workflow: request.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
request.providerId === 'codex' || request.providerId === 'gemini'
|
||||
? request.providerId
|
||||
: undefined,
|
||||
model: request.model?.trim() || undefined,
|
||||
effort:
|
||||
request.effort === 'low' || request.effort === 'medium' || request.effort === 'high'
|
||||
? request.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(name),
|
||||
joinedAt: Date.now(),
|
||||
|
|
@ -977,7 +986,16 @@ export class TeamDataService {
|
|||
|
||||
async replaceMembers(
|
||||
teamName: string,
|
||||
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||
request: {
|
||||
members: {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
}[];
|
||||
}
|
||||
): Promise<void> {
|
||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||
const existingLead = existing.find(isLeadMember) ?? null;
|
||||
|
|
@ -1003,6 +1021,15 @@ export class TeamDataService {
|
|||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
color: prev?.color ?? getMemberColorByName(name),
|
||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||
|
|
@ -1957,6 +1984,16 @@ export class TeamDataService {
|
|||
return name;
|
||||
})(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
joinedAt,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
|||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
]);
|
||||
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
|
||||
|
||||
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
|
|
@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
|||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
||||
}
|
||||
|
||||
function looksLikeGeneratedAgentId(name: string): boolean {
|
||||
return GENERATED_AGENT_ID_PATTERN.test(name.trim());
|
||||
}
|
||||
|
||||
export class TeamMemberResolver {
|
||||
resolveMembers(
|
||||
config: TeamConfig,
|
||||
|
|
@ -106,13 +111,25 @@ export class TeamMemberResolver {
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
addName(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const configMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string }
|
||||
{
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
color?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
>();
|
||||
if (Array.isArray(config.members)) {
|
||||
for (const m of config.members) {
|
||||
|
|
@ -121,6 +138,9 @@ export class TeamMemberResolver {
|
|||
agentType: m.agentType,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
});
|
||||
|
|
@ -130,7 +150,16 @@ export class TeamMemberResolver {
|
|||
|
||||
const metaMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number }
|
||||
{
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
color?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
>();
|
||||
if (Array.isArray(metaMembers)) {
|
||||
for (const member of metaMembers) {
|
||||
|
|
@ -139,6 +168,9 @@ export class TeamMemberResolver {
|
|||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
color: member.color,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
|
|
@ -193,6 +225,9 @@ export class TeamMemberResolver {
|
|||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
providerId: configMember?.providerId ?? metaMember?.providerId,
|
||||
model: configMember?.model ?? metaMember?.model,
|
||||
effort: configMember?.effort ?? metaMember?.effort,
|
||||
cwd: configMember?.cwd,
|
||||
removedAt: metaMember?.removedAt,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType:
|
||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface TeamMetaFile {
|
|||
color?: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: string;
|
||||
skipPermissions?: boolean;
|
||||
|
|
@ -82,6 +83,12 @@ export class TeamMetaStore {
|
|||
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||
cwd: file.cwd.trim(),
|
||||
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
||||
providerId:
|
||||
file.providerId === 'anthropic' ||
|
||||
file.providerId === 'codex' ||
|
||||
file.providerId === 'gemini'
|
||||
? file.providerId
|
||||
: undefined,
|
||||
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||
|
|
@ -101,6 +108,7 @@ export class TeamMetaStore {
|
|||
color: data.color?.trim() || undefined,
|
||||
cwd: data.cwd.trim(),
|
||||
prompt: data.prompt?.trim() || undefined,
|
||||
providerId: data.providerId,
|
||||
model: data.model?.trim() || undefined,
|
||||
effort: data.effort?.trim() || undefined,
|
||||
skipPermissions: data.skipPermissions,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
|||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||
import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth';
|
||||
|
||||
/**
|
||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||
|
|
@ -106,6 +108,7 @@ import type {
|
|||
ToolApprovalRequest,
|
||||
ToolApprovalSettings,
|
||||
ToolCallMeta,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamProvisioning');
|
||||
|
|
@ -121,6 +124,9 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
|
|||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
||||
const PREFLIGHT_CODEX_TIMEOUT_MS = 20000;
|
||||
const PREFLIGHT_GEMINI_TIMEOUT_MS = 15000;
|
||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
|
|
@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
|
|||
'system',
|
||||
]);
|
||||
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
|
||||
const PREFLIGHT_PING_ARGS = [
|
||||
'-p',
|
||||
PREFLIGHT_PING_PROMPT,
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
] as const;
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
const PREFLIGHT_CODEX_MODEL = 'gpt-5.4-mini';
|
||||
const PREFLIGHT_GEMINI_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
function getPreflightPingModel(providerId: TeamProviderId | undefined): string {
|
||||
switch (resolveTeamProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return PREFLIGHT_CODEX_MODEL;
|
||||
case 'gemini':
|
||||
return PREFLIGHT_GEMINI_MODEL;
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'haiku';
|
||||
}
|
||||
}
|
||||
|
||||
function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||
return [
|
||||
'-p',
|
||||
PREFLIGHT_PING_PROMPT,
|
||||
'--output-format',
|
||||
'text',
|
||||
'--model',
|
||||
getPreflightPingModel(providerId),
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--no-session-persistence',
|
||||
];
|
||||
}
|
||||
|
||||
function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
||||
switch (resolveTeamProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return PREFLIGHT_CODEX_TIMEOUT_MS;
|
||||
case 'gemini':
|
||||
return PREFLIGHT_GEMINI_TIMEOUT_MS;
|
||||
case 'anthropic':
|
||||
default:
|
||||
return PREFLIGHT_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function isProbeTimeoutMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes('timeout running:') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('did not complete') ||
|
||||
lower.includes('etimedout')
|
||||
);
|
||||
}
|
||||
|
||||
function getTeamProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
type TeamsBaseLocation = 'configured' | 'default';
|
||||
|
||||
|
|
@ -257,6 +314,8 @@ interface ProvisioningRun {
|
|||
* watchdog defers to retry messages for progress.message (retries are
|
||||
* more informative than the generic "CLI not responding" stall text). */
|
||||
lastRetryAt: number;
|
||||
/** Index of the latest api_retry warning block in provisioningOutputParts. */
|
||||
apiRetryWarningIndex: number | null;
|
||||
/** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */
|
||||
apiErrorWarningEmitted: boolean;
|
||||
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
|
||||
|
|
@ -351,7 +410,12 @@ interface ProvisioningRun {
|
|||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none';
|
||||
type ProvisioningAuthSource =
|
||||
| 'anthropic_api_key'
|
||||
| 'anthropic_auth_token'
|
||||
| 'codex_runtime'
|
||||
| 'gemini_runtime'
|
||||
| 'none';
|
||||
|
||||
interface ProvisioningEnvResolution {
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
|
@ -405,6 +469,11 @@ async function ensureCwdExists(cwd: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function isMissingCwdSpawnError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes('spawn ') && lower.includes(' enoent');
|
||||
}
|
||||
|
||||
/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
|
||||
const wrapInAgentBlock = wrapAgentBlock;
|
||||
|
||||
|
|
@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
|||
return members
|
||||
.map((member) => {
|
||||
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
|
||||
const providerPart =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? ` [provider: ${member.providerId}]`
|
||||
: '';
|
||||
const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : '';
|
||||
const effortPart = member.effort ? ` [effort: ${member.effort}]` : '';
|
||||
const workflowPart = member.workflow?.trim()
|
||||
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||
: '';
|
||||
return `- ${member.name}${rolePart}${workflowPart}`;
|
||||
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -463,13 +538,21 @@ function buildMemberSpawnPrompt(
|
|||
leadName: string
|
||||
): string {
|
||||
const role = member.role?.trim() || 'team member';
|
||||
const providerLine =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? `\nProvider override for this teammate: ${member.providerId}.`
|
||||
: '';
|
||||
const modelLine = member.model?.trim()
|
||||
? `\nModel override for this teammate: ${member.model.trim()}.`
|
||||
: '';
|
||||
const effortLine = member.effort ? `\nEffort override for this teammate: ${member.effort}.` : '';
|
||||
const workflowBlock = member.workflow?.trim()
|
||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
|
||||
: '';
|
||||
const actionModeProtocol = protocols.buildActionModeProtocolText(
|
||||
protocols.MEMBER_DELEGATE_DESCRIPTION
|
||||
);
|
||||
return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock}
|
||||
return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
|
||||
|
||||
${getAgentLanguageInstruction()}
|
||||
Your FIRST action: call MCP tool member_briefing with:
|
||||
|
|
@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt(
|
|||
hasTasks: boolean
|
||||
): string {
|
||||
const role = member.role?.trim() || 'team member';
|
||||
const providerLine =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? `\n Provider override for this teammate: ${member.providerId}.`
|
||||
: '';
|
||||
const modelLine = member.model?.trim()
|
||||
? `\n Model override for this teammate: ${member.model.trim()}.`
|
||||
: '';
|
||||
const effortLine = member.effort
|
||||
? `\n Effort override for this teammate: ${member.effort}.`
|
||||
: '';
|
||||
const workflowBlock = member.workflow?.trim()
|
||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||
: '';
|
||||
|
|
@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt(
|
|||
protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION),
|
||||
' '
|
||||
);
|
||||
const providerArgLine =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? ` - provider: "${member.providerId}"\n`
|
||||
: '';
|
||||
const modelArgLine = member.model?.trim() ? ` - model: "${member.model.trim()}"\n` : '';
|
||||
const effortArgLine = member.effort ? ` - effort: "${member.effort}"\n` : '';
|
||||
return ` For "${member.name}":
|
||||
- prompt:
|
||||
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock}
|
||||
${providerArgLine}${modelArgLine}${effortArgLine} - prompt:
|
||||
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
|
||||
|
||||
${getAgentLanguageInstruction()}
|
||||
The team has been reconnected after a restart.
|
||||
|
|
@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage(
|
|||
teamName: string,
|
||||
displayName: string,
|
||||
leadName: string,
|
||||
member: Pick<TeamCreateRequest['members'][number], 'name' | 'role' | 'workflow'>
|
||||
member: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||
>
|
||||
): string {
|
||||
const roleHint =
|
||||
typeof member.role === 'string' && member.role.trim()
|
||||
|
|
@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage(
|
|||
name: member.name,
|
||||
...(member.role ? { role: member.role } : {}),
|
||||
...(member.workflow ? { workflow: member.workflow } : {}),
|
||||
...(member.providerId ? { providerId: member.providerId } : {}),
|
||||
...(member.model ? { model: member.model } : {}),
|
||||
...(member.effort ? { effort: member.effort } : {}),
|
||||
},
|
||||
displayName,
|
||||
teamName,
|
||||
leadName
|
||||
);
|
||||
const providerPart =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? `, provider="${member.providerId}"`
|
||||
: '';
|
||||
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
|
||||
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
|
||||
|
||||
return (
|
||||
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
|
||||
`Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` +
|
||||
`Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below:${workflowHint}\n\n` +
|
||||
indentMultiline(prompt, ' ')
|
||||
);
|
||||
}
|
||||
|
|
@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
|
||||
Per-member spawn instructions:
|
||||
${request.members
|
||||
.map(
|
||||
(m) => ` For “${m.name}”:
|
||||
.map((m) => {
|
||||
const providerLine =
|
||||
m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : '';
|
||||
const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : '';
|
||||
return ` For “${m.name}”:
|
||||
- name: “${m.name}”
|
||||
- prompt:
|
||||
${providerLine}${modelLine} - prompt:
|
||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
.join('\n')}`;
|
||||
})
|
||||
.join('\n\n')}`;
|
||||
|
||||
const persistentContext = buildPersistentLeadContext({
|
||||
|
|
@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp
|
|||
const cachedProbeResults = new Map<string, CachedProbeResult>();
|
||||
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
|
||||
|
||||
function createProbeCacheKey(cwd: string): string {
|
||||
return `${path.resolve(cwd)}::${getClaudeBasePath()}`;
|
||||
function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string {
|
||||
return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`;
|
||||
}
|
||||
|
||||
function isTransientProbeWarning(warning: string): boolean {
|
||||
|
|
@ -1228,6 +1342,17 @@ function isTransientProbeWarning(warning: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isBinaryProbeWarning(warning: string): boolean {
|
||||
const lower = warning.toLowerCase();
|
||||
return (
|
||||
(lower.includes('spawn ') && lower.includes(' enoent')) ||
|
||||
lower.includes('eacces') ||
|
||||
lower.includes('enoexec') ||
|
||||
lower.includes('bad cpu type in executable') ||
|
||||
lower.includes('image not found')
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingInboxRelayCandidate {
|
||||
recipient: string;
|
||||
sourceMessageId: string;
|
||||
|
|
@ -2304,8 +2429,8 @@ export class TeamProvisioningService {
|
|||
async warmup(): Promise<void> {
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
if (this.getFreshCachedProbeResult(cwd)) return;
|
||||
const result = await this.getCachedOrProbeResult(cwd);
|
||||
if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return;
|
||||
const result = await this.getCachedOrProbeResult(cwd, 'anthropic');
|
||||
if (!result) return;
|
||||
logger.info('CLI warmup completed');
|
||||
} catch (error) {
|
||||
|
|
@ -2315,32 +2440,26 @@ export class TeamProvisioningService {
|
|||
|
||||
async prepareForProvisioning(
|
||||
cwd?: string,
|
||||
opts?: { forceFresh?: boolean }
|
||||
opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
|
||||
): Promise<TeamProvisioningPrepareResult> {
|
||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||
await this.validatePrepareCwd(targetCwdForValidation);
|
||||
const providerIds = Array.from(
|
||||
new Set(
|
||||
[opts?.providerId, ...(opts?.providerIds ?? [])]
|
||||
.map((providerId) => resolveTeamProviderId(providerId))
|
||||
.filter((providerId): providerId is TeamProviderId => Boolean(providerId))
|
||||
)
|
||||
);
|
||||
if (providerIds.length === 0) {
|
||||
providerIds.push('anthropic');
|
||||
}
|
||||
|
||||
// Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
|
||||
if (opts?.forceFresh) {
|
||||
this.clearProbeCache(targetCwdForValidation);
|
||||
}
|
||||
|
||||
const cached = this.getFreshCachedProbeResult(targetCwdForValidation);
|
||||
if (cached) {
|
||||
const { warning, authSource } = cached;
|
||||
const warnings: string[] = [];
|
||||
if (warning) warnings.push(warning);
|
||||
const isAuthFailure = warning ? this.isAuthFailureWarning(warning, 'probe') : false;
|
||||
const ready = !warning || authSource !== 'none' || !isAuthFailure;
|
||||
return {
|
||||
ready,
|
||||
message: ready
|
||||
? warnings.length > 0
|
||||
? 'CLI is ready to launch (see notes)'
|
||||
: 'CLI is warmed up and ready to launch'
|
||||
: warning || 'CLI is not ready',
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
for (const providerId of providerIds) {
|
||||
this.clearProbeCache(targetCwdForValidation, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
const targetCwd = cwd?.trim() || process.cwd();
|
||||
|
|
@ -2349,45 +2468,77 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const blockingMessages: string[] = [];
|
||||
|
||||
const probeResult = await this.getCachedOrProbeResult(targetCwd);
|
||||
if (!probeResult?.claudePath) {
|
||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||
}
|
||||
|
||||
const { authSource } = probeResult;
|
||||
if (authSource === 'anthropic_api_key') {
|
||||
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
|
||||
} else if (authSource === 'anthropic_auth_token') {
|
||||
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
|
||||
}
|
||||
|
||||
if (probeResult.warning) {
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (authSource === 'none' && isAuthFailure) {
|
||||
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
|
||||
return {
|
||||
ready: false,
|
||||
message: probeResult.warning,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
for (const providerId of providerIds) {
|
||||
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
|
||||
const probeResult = cached ?? (await this.getCachedOrProbeResult(targetCwd, providerId));
|
||||
if (!probeResult?.claudePath) {
|
||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||
}
|
||||
// Preflight warnings (including timeouts) should not block provisioning.
|
||||
warnings.push(probeResult.warning);
|
||||
|
||||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const { authSource } = probeResult;
|
||||
if (authSource === 'anthropic_api_key') {
|
||||
logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`);
|
||||
} else if (authSource === 'anthropic_auth_token') {
|
||||
logger.info(
|
||||
`Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY for ${providerLabel}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!probeResult.warning) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefixedWarning =
|
||||
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (
|
||||
(authSource === 'none' ||
|
||||
authSource === 'codex_runtime' ||
|
||||
authSource === 'gemini_runtime') &&
|
||||
isAuthFailure
|
||||
) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else {
|
||||
// Preflight warnings (including timeouts) should not block provisioning.
|
||||
warnings.push(prefixedWarning);
|
||||
}
|
||||
}
|
||||
|
||||
if (blockingMessages.length > 0) {
|
||||
return {
|
||||
ready: false,
|
||||
message:
|
||||
blockingMessages.length === 1
|
||||
? blockingMessages[0]!
|
||||
: 'Some provider runtimes are not ready',
|
||||
warnings: blockingMessages.length > 1 ? blockingMessages : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
message:
|
||||
warnings.length > 0
|
||||
? 'CLI is ready to launch (see notes)'
|
||||
: 'CLI is warmed up and ready to launch',
|
||||
providerIds.length > 1
|
||||
? warnings.length > 0
|
||||
? `Validated ${providerIds.length}/${providerIds.length} provider runtimes (see notes)`
|
||||
: `Validated ${providerIds.length}/${providerIds.length} provider runtimes`
|
||||
: warnings.length > 0
|
||||
? 'CLI is ready to launch (see notes)'
|
||||
: 'CLI is warmed up and ready to launch',
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null {
|
||||
const cacheKey = createProbeCacheKey(cwd);
|
||||
private getFreshCachedProbeResult(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId | undefined
|
||||
): CachedProbeResult | null {
|
||||
const cacheKey = createProbeCacheKey(cwd, providerId);
|
||||
const cached = cachedProbeResults.get(cacheKey);
|
||||
if (!cached) return null;
|
||||
const ageMs = Date.now() - cached.cachedAtMs;
|
||||
|
|
@ -2398,8 +2549,8 @@ export class TeamProvisioningService {
|
|||
return cached;
|
||||
}
|
||||
|
||||
private clearProbeCache(cwd: string): void {
|
||||
cachedProbeResults.delete(createProbeCacheKey(cwd));
|
||||
private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void {
|
||||
cachedProbeResults.delete(createProbeCacheKey(cwd, providerId));
|
||||
}
|
||||
|
||||
private async validatePrepareCwd(cwd: string): Promise<void> {
|
||||
|
|
@ -2414,15 +2565,18 @@ export class TeamProvisioningService {
|
|||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
throw new Error(`Working directory does not exist: ${cwd}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedOrProbeResult(cwd: string): Promise<ProbeResult | null> {
|
||||
const cacheKey = createProbeCacheKey(cwd);
|
||||
const cached = this.getFreshCachedProbeResult(cwd);
|
||||
private async getCachedOrProbeResult(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId | undefined
|
||||
): Promise<ProbeResult | null> {
|
||||
const cacheKey = createProbeCacheKey(cwd, providerId);
|
||||
const cached = this.getFreshCachedProbeResult(cwd, providerId);
|
||||
if (cached) {
|
||||
return {
|
||||
claudePath: cached.claudePath,
|
||||
|
|
@ -2440,8 +2594,8 @@ export class TeamProvisioningService {
|
|||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
if (!claudePath) return null;
|
||||
|
||||
const { env, authSource } = await this.buildProvisioningEnv();
|
||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
|
||||
const { env, authSource } = await this.buildProvisioningEnv(providerId);
|
||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId);
|
||||
const result = {
|
||||
claudePath,
|
||||
authSource,
|
||||
|
|
@ -2451,7 +2605,8 @@ export class TeamProvisioningService {
|
|||
const shouldCache =
|
||||
!probe.warning ||
|
||||
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
|
||||
!isTransientProbeWarning(probe.warning));
|
||||
!isTransientProbeWarning(probe.warning) &&
|
||||
!isBinaryProbeWarning(probe.warning));
|
||||
|
||||
if (shouldCache) {
|
||||
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
|
||||
|
|
@ -2480,8 +2635,14 @@ export class TeamProvisioningService {
|
|||
lower.includes('missing api key') ||
|
||||
lower.includes('invalid api key') ||
|
||||
lower.includes('authentication failed') ||
|
||||
lower.includes('not configured for runtime use') ||
|
||||
lower.includes('set gemini_api_key') ||
|
||||
lower.includes('google adc credentials') ||
|
||||
lower.includes('google_cloud_project') ||
|
||||
lower.includes('codex provider is not authenticated') ||
|
||||
lower.includes('run `claude auth login`') ||
|
||||
lower.includes('claude auth login');
|
||||
lower.includes('claude auth login') ||
|
||||
lower.includes('claude-multimodel auth login');
|
||||
|
||||
if (hasExplicitCliAuthSignal) {
|
||||
return true;
|
||||
|
|
@ -2515,6 +2676,56 @@ export class TeamProvisioningService {
|
|||
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
private normalizeApiRetryErrorMessage(text: string): string {
|
||||
const sanitized = this.sanitizeCliSnippet(text).trim();
|
||||
if (!sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const jsonMatch = sanitized.match(/^\d{3}\s+(\{[\s\S]*\})$/);
|
||||
const jsonCandidate = jsonMatch?.[1] ?? (sanitized.startsWith('{') ? sanitized : null);
|
||||
if (jsonCandidate) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonCandidate) as {
|
||||
error?: { message?: unknown };
|
||||
message?: unknown;
|
||||
};
|
||||
const nestedMessage =
|
||||
typeof parsed.error?.message === 'string'
|
||||
? parsed.error.message
|
||||
: typeof parsed.message === 'string'
|
||||
? parsed.message
|
||||
: null;
|
||||
if (nestedMessage) {
|
||||
return this.normalizeApiRetryErrorMessage(nestedMessage);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to raw sanitized text.
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
.replace(/^gemini cli backend error:\s*/i, '')
|
||||
.replace(/^gemini api backend error:\s*/i, '')
|
||||
.replace(/^api error:\s*\d+\s*/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private isQuotaRetryMessage(text: string | undefined): boolean {
|
||||
const lower = (text ?? '').toLowerCase();
|
||||
return (
|
||||
lower.includes('quota will reset after') ||
|
||||
lower.includes('exhausted your capacity on this model') ||
|
||||
lower.includes('resource exhausted') ||
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('rate_limit')
|
||||
);
|
||||
}
|
||||
|
||||
private toMarkdownCodeSafe(text: string): string {
|
||||
return this.sanitizeCliSnippet(text).replace(/```/g, '``\\`');
|
||||
}
|
||||
|
||||
private extractApiErrorSnippet(text: string): string | null {
|
||||
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
||||
if (match?.index === undefined) return null;
|
||||
|
|
@ -3114,6 +3325,7 @@ export class TeamProvisioningService {
|
|||
stallWarningIndex: null,
|
||||
preStallMessage: null,
|
||||
lastRetryAt: 0,
|
||||
apiRetryWarningIndex: null,
|
||||
apiErrorWarningEmitted: false,
|
||||
waitingTasksSince: null,
|
||||
provisioningComplete: false,
|
||||
|
|
@ -3162,7 +3374,7 @@ export class TeamProvisioningService {
|
|||
|
||||
const prompt = buildProvisioningPrompt(request);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
|
||||
let mcpConfigPath: string;
|
||||
try {
|
||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
|
|
@ -3207,6 +3419,7 @@ export class TeamProvisioningService {
|
|||
color: request.color,
|
||||
cwd: request.cwd,
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
skipPermissions: request.skipPermissions,
|
||||
|
|
@ -3510,6 +3723,7 @@ export class TeamProvisioningService {
|
|||
teamName: request.teamName,
|
||||
members: expectedMemberSpecs,
|
||||
cwd: request.cwd,
|
||||
providerId: request.providerId,
|
||||
skipPermissions: request.skipPermissions,
|
||||
};
|
||||
|
||||
|
|
@ -3557,6 +3771,7 @@ export class TeamProvisioningService {
|
|||
stallWarningIndex: null,
|
||||
preStallMessage: null,
|
||||
lastRetryAt: 0,
|
||||
apiRetryWarningIndex: null,
|
||||
apiErrorWarningEmitted: false,
|
||||
waitingTasksSince: null,
|
||||
provisioningComplete: false,
|
||||
|
|
@ -3627,7 +3842,7 @@ export class TeamProvisioningService {
|
|||
Boolean(previousSessionId)
|
||||
);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
|
||||
let mcpConfigPath: string;
|
||||
try {
|
||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||
|
|
@ -4674,6 +4889,13 @@ export class TeamProvisioningService {
|
|||
const inp = input as Record<string, unknown>;
|
||||
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
|
||||
const memberName = typeof inp.name === 'string' ? inp.name.trim() : '';
|
||||
if (teamName && !memberName) {
|
||||
logger.warn(
|
||||
`[captureTeamSpawnEvents] Agent call for team "${run.teamName}" is missing name — ` +
|
||||
`runtime will spawn an ephemeral subagent instead of a persistent teammate`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!memberName) continue;
|
||||
if (!teamName) {
|
||||
logger.warn(
|
||||
|
|
@ -5577,21 +5799,45 @@ export class TeamProvisioningService {
|
|||
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
|
||||
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : undefined;
|
||||
const retryDelay = typeof msg.retry_delay_ms === 'number' ? msg.retry_delay_ms : undefined;
|
||||
const errorMessage =
|
||||
typeof msg.error_message === 'string' && msg.error_message.trim().length > 0
|
||||
? this.normalizeApiRetryErrorMessage(msg.error_message.trim())
|
||||
: undefined;
|
||||
const looksLikeQuotaRetry =
|
||||
errorLabel === 'rate limit' || this.isQuotaRetryMessage(errorMessage);
|
||||
|
||||
// Use CLI's own error label (e.g. "rate limit") with status code
|
||||
const statusLabel = errorLabel
|
||||
? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
|
||||
: `error ${errorStatus ?? 'unknown'}`;
|
||||
// Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
|
||||
const statusLabel = looksLikeQuotaRetry
|
||||
? 'rate limited'
|
||||
: errorLabel
|
||||
? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
|
||||
: `error ${errorStatus ?? 'unknown'}`;
|
||||
const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : '';
|
||||
const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${delayLabel}`;
|
||||
const retryText = `API retry ${attempt}/${maxRetries}: ${statusLabel}${
|
||||
errorMessage ? ` — ${errorMessage}` : ''
|
||||
}${delayLabel}`;
|
||||
|
||||
if (!run.provisioningComplete) {
|
||||
const warningText = errorMessage
|
||||
? `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n\`\`\`\n${this.toMarkdownCodeSafe(
|
||||
errorMessage
|
||||
)}\n\`\`\`\n\n${retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'}`
|
||||
: `**API retry ${attempt}/${maxRetries}: ${statusLabel}**\n\n${
|
||||
retryDelay ? `Next retry in ${Math.round(retryDelay / 1000)}s.` : 'Retrying...'
|
||||
}`;
|
||||
if (run.apiRetryWarningIndex != null) {
|
||||
run.provisioningOutputParts[run.apiRetryWarningIndex] = warningText;
|
||||
} else {
|
||||
run.apiRetryWarningIndex = run.provisioningOutputParts.length;
|
||||
run.provisioningOutputParts.push(warningText);
|
||||
}
|
||||
run.lastRetryAt = Date.now();
|
||||
run.progress = {
|
||||
...run.progress,
|
||||
updatedAt: nowIso(),
|
||||
message: retryText,
|
||||
messageSeverity: 'error' as const,
|
||||
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
||||
};
|
||||
run.onProgress(run.progress);
|
||||
}
|
||||
|
|
@ -7563,7 +7809,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
|
||||
private async buildProvisioningEnv(
|
||||
providerId: TeamProviderId | undefined = 'anthropic'
|
||||
): Promise<ProvisioningEnvResolution> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||
|
|
@ -7605,6 +7853,7 @@ export class TeamProvisioningService {
|
|||
: {}),
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
};
|
||||
applyProviderRuntimeEnv(env, providerId);
|
||||
|
||||
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
||||
if (controlApiBaseUrl) {
|
||||
|
|
@ -7631,6 +7880,14 @@ export class TeamProvisioningService {
|
|||
env.XDG_STATE_HOME = xdgStateHome;
|
||||
}
|
||||
|
||||
if (resolveTeamProviderId(providerId) === 'codex') {
|
||||
return { env, authSource: 'codex_runtime' };
|
||||
}
|
||||
|
||||
if (resolveTeamProviderId(providerId) === 'gemini') {
|
||||
return { env, authSource: 'gemini_runtime' };
|
||||
}
|
||||
|
||||
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
||||
return { env, authSource: 'anthropic_api_key' };
|
||||
|
|
@ -8300,6 +8557,15 @@ export class TeamProvisioningService {
|
|||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColorByName(member.name.trim()),
|
||||
joinedAt,
|
||||
|
|
@ -8337,14 +8603,27 @@ export class TeamProvisioningService {
|
|||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||
const workflow =
|
||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
||||
const providerId =
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined;
|
||||
const model =
|
||||
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
||||
const effort =
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined;
|
||||
const prev = byName.get(name);
|
||||
if (!prev) {
|
||||
byName.set(name, { name, role, workflow });
|
||||
byName.set(name, { name, role, workflow, providerId, model, effort });
|
||||
} else {
|
||||
byName.set(name, {
|
||||
...prev,
|
||||
role: prev.role || role,
|
||||
workflow: prev.workflow || workflow,
|
||||
providerId: prev.providerId || providerId,
|
||||
model: prev.model || model,
|
||||
effort: prev.effort || effort,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -8392,8 +8671,35 @@ export class TeamProvisioningService {
|
|||
return !inboxNameSetLower.has(match[1].toLowerCase());
|
||||
});
|
||||
if (inboxNames.length > 0) {
|
||||
const members = inboxNames.map((name) => ({ name }));
|
||||
return { members, source: 'inboxes' };
|
||||
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||
const configMembersByName = new Map(
|
||||
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
||||
);
|
||||
const members = inboxNames.map((name) => {
|
||||
const configMember = configMembersByName.get(name.toLowerCase());
|
||||
return {
|
||||
name,
|
||||
role: configMember?.role,
|
||||
workflow: configMember?.workflow,
|
||||
providerId: configMember?.providerId,
|
||||
model: configMember?.model,
|
||||
effort: configMember?.effort,
|
||||
};
|
||||
});
|
||||
const memberOverridesUsed = members.some(
|
||||
(member) => member.providerId || member.model || member.effort
|
||||
);
|
||||
return {
|
||||
members,
|
||||
source: 'inboxes',
|
||||
...(memberOverridesUsed
|
||||
? {
|
||||
warning:
|
||||
'Launch roster was recovered from inboxes and merged with config.json provider/model/effort overrides. ' +
|
||||
'Multimodel reconnect is best-effort in this fallback path.',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -8439,7 +8745,17 @@ export class TeamProvisioningService {
|
|||
configRaw: string
|
||||
): TeamCreateRequest['members'] {
|
||||
try {
|
||||
const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] };
|
||||
const parsed = JSON.parse(configRaw) as {
|
||||
members?: {
|
||||
name?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
agentType?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}[];
|
||||
};
|
||||
if (!Array.isArray(parsed.members)) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -8450,7 +8766,21 @@ export class TeamProvisioningService {
|
|||
if (!member || isLeadMember(member) || lower === 'user') continue;
|
||||
const name = rawName;
|
||||
if (!name) continue;
|
||||
byName.set(name, { name });
|
||||
byName.set(name, {
|
||||
name,
|
||||
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,
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
? member.effort
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(byName.keys());
|
||||
|
|
@ -8478,8 +8808,50 @@ export class TeamProvisioningService {
|
|||
private async probeClaudeRuntime(
|
||||
claudePath: string,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId | undefined = 'anthropic'
|
||||
): Promise<{ warning?: string }> {
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
try {
|
||||
const versionProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
['--version'],
|
||||
cwd,
|
||||
env,
|
||||
PREFLIGHT_BINARY_TIMEOUT_MS
|
||||
);
|
||||
if (versionProbe.exitCode !== 0) {
|
||||
const errorText =
|
||||
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
||||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||
return {
|
||||
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isMissingCwdSpawnError(message)) {
|
||||
return {
|
||||
warning: `Working directory does not exist: ${cwd}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
warning: `Claude CLI binary failed to start. Details: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (resolvedProviderId === 'gemini') {
|
||||
const authState = await resolveGeminiRuntimeAuth(env);
|
||||
if (authState.authenticated) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
warning:
|
||||
authState.statusMessage ??
|
||||
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
// Stage 1: verify binary works (awaited first for clearer errors)
|
||||
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
||||
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
||||
|
|
@ -8503,10 +8875,10 @@ export class TeamProvisioningService {
|
|||
try {
|
||||
pingProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
[...PREFLIGHT_PING_ARGS],
|
||||
getPreflightPingArgs(providerId),
|
||||
cwd,
|
||||
env,
|
||||
PREFLIGHT_TIMEOUT_MS,
|
||||
getPreflightTimeoutMs(providerId),
|
||||
{
|
||||
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
||||
const combined = `${stdout}\n${stderr}`.trim();
|
||||
|
|
@ -8516,7 +8888,7 @@ export class TeamProvisioningService {
|
|||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
||||
|
|
@ -8532,12 +8904,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
|
||||
const lowerOutput = combinedOutput.toLowerCase();
|
||||
const isAuthFailure =
|
||||
lowerOutput.includes('not logged in') ||
|
||||
lowerOutput.includes('please run /login') ||
|
||||
lowerOutput.includes('missing api key') ||
|
||||
lowerOutput.includes('invalid api key');
|
||||
const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
|
||||
|
||||
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
|
|
@ -8550,10 +8917,14 @@ export class TeamProvisioningService {
|
|||
|
||||
if (isAuthFailure || pingProbe.exitCode !== 0) {
|
||||
const hint = isAuthFailure
|
||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
? resolvedProviderId === 'codex'
|
||||
? 'Codex provider is not authenticated for `-p` mode. ' +
|
||||
'Run `claude-multimodel auth login --provider codex` and retry.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
}
|
||||
|
|
@ -8591,7 +8962,7 @@ export class TeamProvisioningService {
|
|||
return this.helpOutputCache;
|
||||
}
|
||||
const targetCwd = cwd ?? process.cwd();
|
||||
const probeResult = await this.getCachedOrProbeResult(targetCwd);
|
||||
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
|
||||
if (!probeResult?.claudePath) {
|
||||
throw new Error('Claude CLI not found');
|
||||
}
|
||||
|
|
|
|||
154
src/main/services/team/memberUpdateNotifications.ts
Normal file
154
src/main/services/team/memberUpdateNotifications.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
export type MemberDiffInput = {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
removedAt?: number | string | null;
|
||||
};
|
||||
|
||||
export type ReplaceMembersDiff = {
|
||||
added: Array<{
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
}>;
|
||||
removed: string[];
|
||||
updated: Array<{
|
||||
name: string;
|
||||
changes: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
function normalizeOptionalText(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function describeRoleChange(
|
||||
previousRole: string | undefined,
|
||||
nextRole: string | undefined
|
||||
): string | null {
|
||||
if (previousRole === nextRole) {
|
||||
return null;
|
||||
}
|
||||
if (previousRole && nextRole) {
|
||||
return `role changed from "${previousRole}" to "${nextRole}"`;
|
||||
}
|
||||
if (nextRole) {
|
||||
return `role set to "${nextRole}"`;
|
||||
}
|
||||
return 'role cleared';
|
||||
}
|
||||
|
||||
function describeWorkflowChange(
|
||||
previousWorkflow: string | undefined,
|
||||
nextWorkflow: string | undefined
|
||||
): string | null {
|
||||
if (previousWorkflow === nextWorkflow) {
|
||||
return null;
|
||||
}
|
||||
if (previousWorkflow && nextWorkflow) {
|
||||
return 'workflow instructions were updated';
|
||||
}
|
||||
if (nextWorkflow) {
|
||||
return 'workflow instructions were added';
|
||||
}
|
||||
return 'workflow instructions were cleared';
|
||||
}
|
||||
|
||||
export function buildReplaceMembersDiff(
|
||||
previousMembers: MemberDiffInput[],
|
||||
nextMembers: Array<{
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
}>
|
||||
): ReplaceMembersDiff {
|
||||
const previousByName = new Map(
|
||||
previousMembers
|
||||
.filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead')
|
||||
.map((member) => [
|
||||
member.name.trim().toLowerCase(),
|
||||
{
|
||||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
},
|
||||
])
|
||||
);
|
||||
const nextByName = new Map(
|
||||
nextMembers
|
||||
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead')
|
||||
.map((member) => [
|
||||
member.name.trim().toLowerCase(),
|
||||
{
|
||||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const added = Array.from(nextByName.entries())
|
||||
.filter(([name]) => !previousByName.has(name))
|
||||
.map(([, member]) => member);
|
||||
|
||||
const removed = Array.from(previousByName.entries())
|
||||
.filter(([name]) => !nextByName.has(name))
|
||||
.map(([, member]) => member.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const updated = Array.from(nextByName.entries())
|
||||
.flatMap(([name, nextMember]) => {
|
||||
const previousMember = previousByName.get(name);
|
||||
if (!previousMember) {
|
||||
return [];
|
||||
}
|
||||
const changes = [
|
||||
describeRoleChange(previousMember.role, nextMember.role),
|
||||
describeWorkflowChange(previousMember.workflow, nextMember.workflow),
|
||||
].filter((value): value is string => value !== null);
|
||||
if (changes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: nextMember.name, changes }];
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { added, removed, updated };
|
||||
}
|
||||
|
||||
export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const name of diff.removed) {
|
||||
lines.push(
|
||||
`- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const update of diff.updated) {
|
||||
lines.push(
|
||||
`- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.`
|
||||
);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'The user updated the live team roster.\n' +
|
||||
'Apply these changes to the running team now:\n' +
|
||||
lines.join('\n')
|
||||
);
|
||||
}
|
||||
|
|
@ -702,7 +702,11 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
deleteDraft: async (_teamName: string): Promise<void> => {
|
||||
throw new Error('Draft team deletion is not available in browser mode');
|
||||
},
|
||||
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
|
||||
prepareProvisioning: async (
|
||||
_cwd?: string,
|
||||
_providerId?: TeamLaunchRequest['providerId'],
|
||||
_providerIds?: TeamLaunchRequest['providerId'][]
|
||||
): Promise<TeamProvisioningPrepareResult> => {
|
||||
throw new Error('Team provisioning is not available in browser mode');
|
||||
},
|
||||
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
||||
|
|
@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
cliInstaller: CliInstallerAPI = {
|
||||
getStatus: async () => ({
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
|
|
@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
updateAvailable: false,
|
||||
authLoggedIn: false,
|
||||
authMethod: null,
|
||||
providers: [],
|
||||
}),
|
||||
install: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ function areResolvedMembersEqual(
|
|||
prevMember.agentType !== nextMember.agentType ||
|
||||
prevMember.role !== nextMember.role ||
|
||||
prevMember.workflow !== nextMember.workflow ||
|
||||
prevMember.providerId !== nextMember.providerId ||
|
||||
prevMember.model !== nextMember.model ||
|
||||
prevMember.effort !== nextMember.effort ||
|
||||
prevMember.cwd !== nextMember.cwd ||
|
||||
prevMember.gitBranch !== nextMember.gitBranch ||
|
||||
prevMember.removedAt !== nextMember.removedAt
|
||||
|
|
@ -1616,6 +1619,7 @@ export const TeamDetailView = ({
|
|||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivityByTeam[teamName]}
|
||||
launchParams={launchParams}
|
||||
onMemberClick={setSelectedMember}
|
||||
onSendMessage={(member) => {
|
||||
setSendDialogRecipient(member.name);
|
||||
|
|
@ -1976,6 +1980,7 @@ export const TeamDetailView = ({
|
|||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
|
|
@ -1998,6 +2003,9 @@ export const TeamDetailView = ({
|
|||
name: entry.name,
|
||||
role: entry.role,
|
||||
workflow: entry.workflow,
|
||||
providerId: entry.providerId,
|
||||
model: entry.model,
|
||||
effort: entry.effort,
|
||||
});
|
||||
}
|
||||
setAddMemberDialogOpen(false);
|
||||
|
|
|
|||
|
|
@ -19,11 +19,15 @@ import {
|
|||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
export interface AddMemberEntry {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
|
|
@ -116,6 +120,9 @@ export const AddMemberDialog = ({
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
buildMembersFromDrafts,
|
||||
clearMemberModelOverrides,
|
||||
createMemberDraft,
|
||||
MembersEditorSection,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -36,12 +37,19 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { LimitContextCheckbox } from './LimitContextCheckbox';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningFailureHint,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
updateProviderCheck,
|
||||
type ProvisioningProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
|
|
@ -59,10 +67,45 @@ import type {
|
|||
EffortLevel,
|
||||
Project,
|
||||
TeamCreateRequest,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
TeamProvisioningPrepareResult,
|
||||
} from '@shared/types';
|
||||
|
||||
function getStoredTeamProvider(): TeamProviderId {
|
||||
const stored = localStorage.getItem('team:lastSelectedProvider');
|
||||
return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
|
||||
}
|
||||
|
||||
function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
|
||||
if (stored === null) {
|
||||
return providerId === 'anthropic' ? 'opus' : '';
|
||||
}
|
||||
return stored === '__default__' ? '' : stored;
|
||||
}
|
||||
|
||||
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
|
||||
const normalized = normalizePath(projectPath ?? '').toLowerCase();
|
||||
return (
|
||||
normalized.includes('rendered_mcp_') ||
|
||||
normalized.includes('rendered_mcp_config') ||
|
||||
normalized.includes('/portable-mcp-live')
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamCopyData {
|
||||
teamName: string;
|
||||
description?: string;
|
||||
|
|
@ -230,6 +273,8 @@ export const CreateTeamDialog = ({
|
|||
setTeamName,
|
||||
members,
|
||||
setMembers,
|
||||
syncModelsWithLead,
|
||||
setSyncModelsWithLead,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
|
|
@ -258,6 +303,7 @@ export const CreateTeamDialog = ({
|
|||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
|
|
@ -267,11 +313,11 @@ export const CreateTeamDialog = ({
|
|||
}>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
||||
if (stored === null) return 'opus';
|
||||
return stored === '__default__' ? '' : stored;
|
||||
});
|
||||
const [selectedProviderId, setSelectedProviderIdRaw] =
|
||||
useState<TeamProviderId>(getStoredTeamProvider);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||
getStoredTeamModel(getStoredTeamProvider())
|
||||
);
|
||||
const [limitContext, setLimitContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastLimitContext') === 'true'
|
||||
);
|
||||
|
|
@ -289,6 +335,17 @@ export const CreateTeamDialog = ({
|
|||
const [worktreeName, setWorktreeNameRaw] = useState('');
|
||||
const [customArgs, setCustomArgsRaw] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const legacyTeamModel = localStorage.getItem('team:lastSelectedModel');
|
||||
if (
|
||||
legacyTeamModel != null &&
|
||||
localStorage.getItem('team:lastSelectedModel:anthropic') == null
|
||||
) {
|
||||
localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel);
|
||||
}
|
||||
localStorage.removeItem('team:lastSelectedModel');
|
||||
}, []);
|
||||
|
||||
// Re-read localStorage when advancedKey changes
|
||||
useEffect(() => {
|
||||
const storedEnabled =
|
||||
|
|
@ -301,7 +358,17 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
localStorage.setItem('team:lastSelectedModel', value);
|
||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
|
||||
};
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
setSelectedProviderIdRaw(value);
|
||||
localStorage.setItem('team:lastSelectedProvider', value);
|
||||
if (value !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
localStorage.setItem('team:lastLimitContext', 'false');
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(value));
|
||||
};
|
||||
|
||||
const setLimitContext = (value: boolean): void => {
|
||||
|
|
@ -343,6 +410,7 @@ export const CreateTeamDialog = ({
|
|||
setPrepareState('idle');
|
||||
setPrepareMessage(null);
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setConflictDismissed(false);
|
||||
};
|
||||
|
||||
|
|
@ -371,6 +439,25 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
}, [open, clearProvisioningError, dialogTeamNameKey]);
|
||||
|
||||
const effectiveMemberDrafts = useMemo(
|
||||
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
|
||||
[members, syncModelsWithLead]
|
||||
);
|
||||
|
||||
const selectedMemberProviders = useMemo(() => {
|
||||
if (soloTeam || syncModelsWithLead) {
|
||||
return [selectedProviderId];
|
||||
}
|
||||
return Array.from(
|
||||
new Set([
|
||||
selectedProviderId,
|
||||
...members.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : []
|
||||
),
|
||||
])
|
||||
);
|
||||
}, [members, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
return;
|
||||
|
|
@ -379,6 +466,7 @@ export const CreateTeamDialog = ({
|
|||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setPrepareMessage(
|
||||
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
||||
);
|
||||
|
|
@ -388,6 +476,7 @@ export const CreateTeamDialog = ({
|
|||
if (!effectiveCwd) {
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -395,26 +484,75 @@ export const CreateTeamDialog = ({
|
|||
let cancelled = false;
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Warming up CLI environment...');
|
||||
setPrepareMessage('Checking selected providers...');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
||||
|
||||
// Defer so file list fetch (triggered by project select) can run first
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
let checks = createInitialProviderChecks(selectedMemberProviders);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult =
|
||||
await api.teams.prepareProvisioning(effectiveCwd);
|
||||
for (const providerId of selectedMemberProviders) {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
details: [],
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`);
|
||||
}
|
||||
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(
|
||||
effectiveCwd,
|
||||
providerId,
|
||||
[providerId]
|
||||
);
|
||||
const detailLines = [
|
||||
...(prepResult.warnings ?? []).filter(Boolean),
|
||||
...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
|
||||
];
|
||||
if (prepResult.warnings?.length) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...prepResult.warnings.map(
|
||||
(warning) => `${getProviderLabel(providerId)}: ${warning}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!prepResult.ready) {
|
||||
anyFailure = true;
|
||||
}
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||
details: detailLines,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||
setPrepareMessage(
|
||||
anyFailure
|
||||
? 'Some selected providers need attention.'
|
||||
: anyNotes
|
||||
? 'Selected providers are ready with notes.'
|
||||
: 'Selected providers are ready.'
|
||||
);
|
||||
setPrepareWarnings(collectedWarnings);
|
||||
} catch (error) {
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
||||
);
|
||||
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||
setPrepareMessage(failureMessage);
|
||||
}
|
||||
})();
|
||||
}, 250);
|
||||
|
|
@ -423,7 +561,7 @@ export const CreateTeamDialog = ({
|
|||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, canCreate, launchTeam, effectiveCwd]);
|
||||
}, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -446,6 +584,7 @@ export const CreateTeamDialog = ({
|
|||
// display and select it.
|
||||
if (
|
||||
defaultProjectPath &&
|
||||
!isEphemeralRenderedProjectPath(defaultProjectPath) &&
|
||||
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
|
||||
) {
|
||||
const folderName =
|
||||
|
|
@ -497,9 +636,15 @@ export const CreateTeamDialog = ({
|
|||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model ?? '',
|
||||
effort: m.effort,
|
||||
});
|
||||
})
|
||||
);
|
||||
setSyncModelsWithLead(
|
||||
!initialData.members.some((member) => member.providerId || member.model || member.effort)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -559,7 +704,7 @@ export const CreateTeamDialog = ({
|
|||
if (selectedProjectPath || projects.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (defaultProjectPath) {
|
||||
if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) {
|
||||
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
|
||||
if (match) {
|
||||
setSelectedProjectPath(match.path);
|
||||
|
|
@ -569,6 +714,16 @@ export const CreateTeamDialog = ({
|
|||
setSelectedProjectPath(projects[0].path);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||
return;
|
||||
}
|
||||
if (!isEphemeralRenderedProjectPath(selectedProjectPath)) {
|
||||
return;
|
||||
}
|
||||
setSelectedProjectPath('');
|
||||
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
|
||||
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||
|
|
@ -587,8 +742,8 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
|
||||
const effectiveModel = useMemo(
|
||||
() => computeEffectiveTeamModel(selectedModel, limitContext),
|
||||
[selectedModel, limitContext]
|
||||
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||
[selectedModel, limitContext, selectedProviderId]
|
||||
);
|
||||
|
||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||
|
|
@ -601,9 +756,10 @@ export const CreateTeamDialog = ({
|
|||
teamName: sanitizedTeamName,
|
||||
description: description.trim() || undefined,
|
||||
color: teamColor || undefined,
|
||||
members: soloTeam ? [] : buildMembersFromDrafts(members),
|
||||
members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts),
|
||||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
providerId: selectedProviderId,
|
||||
model: effectiveModel,
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
limitContext,
|
||||
|
|
@ -616,9 +772,10 @@ export const CreateTeamDialog = ({
|
|||
description,
|
||||
teamColor,
|
||||
soloTeam,
|
||||
members,
|
||||
effectiveMemberDrafts,
|
||||
effectiveCwd,
|
||||
prompt,
|
||||
selectedProviderId,
|
||||
effectiveModel,
|
||||
selectedEffort,
|
||||
limitContext,
|
||||
|
|
@ -643,23 +800,11 @@ export const CreateTeamDialog = ({
|
|||
const launchOptionalSummary = useMemo(() => {
|
||||
const summary: string[] = [];
|
||||
if (prompt.trim()) summary.push('Lead prompt');
|
||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||
if (limitContext) summary.push('Limited to 200K context');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||
return summary;
|
||||
}, [
|
||||
prompt,
|
||||
selectedModel,
|
||||
selectedEffort,
|
||||
limitContext,
|
||||
skipPermissions,
|
||||
worktreeEnabled,
|
||||
worktreeName,
|
||||
customArgs,
|
||||
]);
|
||||
}, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]);
|
||||
|
||||
const teamDetailsSummary = useMemo(() => {
|
||||
const summary: string[] = [];
|
||||
|
|
@ -668,6 +813,16 @@ export const CreateTeamDialog = ({
|
|||
return summary;
|
||||
}, [description, teamColor]);
|
||||
|
||||
const handleSyncModelsWithLeadChange = useCallback(
|
||||
(checked: boolean): void => {
|
||||
setSyncModelsWithLead(checked);
|
||||
if (checked) {
|
||||
setMembers(members.map(clearMemberModelOverrides));
|
||||
}
|
||||
},
|
||||
[members, setMembers, setSyncModelsWithLead]
|
||||
);
|
||||
|
||||
const activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||
const canOpenExistingTeam =
|
||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
|
@ -822,7 +977,14 @@ export const CreateTeamDialog = ({
|
|||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-1"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
|
|
@ -836,8 +998,7 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||
in PATH, then reopen this dialog.
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<MembersEditorSection
|
||||
<TeamRosterEditorSection
|
||||
members={members}
|
||||
onChange={setMembers}
|
||||
onMembersChange={setMembers}
|
||||
fieldError={fieldErrors.members}
|
||||
validateMemberName={validateMemberNameInline}
|
||||
showWorkflow
|
||||
|
|
@ -903,35 +1064,53 @@ export const CreateTeamDialog = ({
|
|||
projectPath={effectiveCwd || null}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamMentionSuggestions}
|
||||
hideContent={soloTeam}
|
||||
headerExtra={
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="solo-team"
|
||||
checked={soloTeam}
|
||||
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="solo-team"
|
||||
className="cursor-pointer text-xs font-normal text-text-secondary"
|
||||
>
|
||||
Solo team
|
||||
</Label>
|
||||
</div>
|
||||
{soloTeam && (
|
||||
<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">
|
||||
Only the team lead (main process) will be started — no teammates will
|
||||
be spawned. Works like a regular Claude session but with access to the task
|
||||
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
||||
You can add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
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."
|
||||
hideMembersContent={soloTeam}
|
||||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
headerTop={
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="solo-team"
|
||||
checked={soloTeam}
|
||||
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="solo-team"
|
||||
className="cursor-pointer text-xs font-normal text-text-secondary"
|
||||
>
|
||||
Solo team
|
||||
</Label>
|
||||
</div>
|
||||
}
|
||||
headerBottom={
|
||||
soloTeam ? (
|
||||
<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">
|
||||
Only the team lead (main process) will be started — no teammates will be
|
||||
spawned. Works like a regular Claude session but with access to the task board
|
||||
for planning. Saves tokens by avoiding teammate coordination overhead. You can
|
||||
add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
|
|||
|
||||
<OptionalSettingsSection
|
||||
title="Optional launch settings"
|
||||
description="Prompt, model, safety, and CLI overrides live here when you need them."
|
||||
description="Prompt, safety, and CLI overrides live here when you need them."
|
||||
summary={launchOptionalSummary}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TeamModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
id="create-model"
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={selectedEffort}
|
||||
onValueChange={setSelectedEffort}
|
||||
id="create-effort"
|
||||
/>
|
||||
<LimitContextCheckbox
|
||||
id="create-limit-context"
|
||||
checked={limitContext}
|
||||
onCheckedChange={setLimitContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
<SkipPermissionsCheckbox
|
||||
id="create-skip-permissions"
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
/>
|
||||
</div>
|
||||
<SkipPermissionsCheckbox
|
||||
id="create-skip-permissions"
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
/>
|
||||
|
||||
<AdvancedCliSection
|
||||
teamName={advancedKey}
|
||||
|
|
@ -1133,27 +1294,23 @@ export const CreateTeamDialog = ({
|
|||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
<span>Pre-flight check to catch errors before launch</span>
|
||||
<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 className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Pre-flight check to catch errors before launch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'ready' ? (
|
||||
|
|
@ -1161,7 +1318,8 @@ export const CreateTeamDialog = ({
|
|||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
{prepareChecks.some((check) => check.status === 'notes') ||
|
||||
prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
|
|
@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({
|
|||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
|
|
@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!canCreate ||
|
||||
!draftLoaded ||
|
||||
isSubmitting ||
|
||||
(launchTeam && prepareState !== 'ready')
|
||||
}
|
||||
disabled={!canCreate || !draftLoaded || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
@ -1215,6 +1369,8 @@ export const CreateTeamDialog = ({
|
|||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
'Skip preflight and create'
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
createMemberDraftsFromInputs,
|
||||
MembersEditorSection,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
|
@ -43,24 +42,14 @@ interface EditTeamDialogProps {
|
|||
currentDescription: string;
|
||||
currentColor: string;
|
||||
currentMembers: ResolvedTeamMember[];
|
||||
isTeamAlive?: boolean;
|
||||
projectPath?: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||
const active = members.filter((m) => !m.removedAt);
|
||||
return active.map((m) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
return createMemberDraftsFromInputs(members);
|
||||
}
|
||||
|
||||
export const EditTeamDialog = ({
|
||||
|
|
@ -70,6 +59,7 @@ export const EditTeamDialog = ({
|
|||
currentDescription,
|
||||
currentColor,
|
||||
currentMembers,
|
||||
isTeamAlive = false,
|
||||
projectPath,
|
||||
onClose,
|
||||
onSaved,
|
||||
|
|
@ -170,11 +160,18 @@ export const EditTeamDialog = ({
|
|||
onChange={setMembers}
|
||||
validateMemberName={validateMemberNameInline}
|
||||
showWorkflow
|
||||
showJsonEditor
|
||||
showJsonEditor={!isTeamAlive}
|
||||
draftKeyPrefix={`editTeam:${teamName}`}
|
||||
projectPath={projectPath ?? null}
|
||||
lockProviderModel={isTeamAlive}
|
||||
/>
|
||||
</div>
|
||||
{isTeamAlive ? (
|
||||
<p className="text-xs text-amber-300">
|
||||
Provider and model changes are locked while the team is live. Reconnect the team to
|
||||
change them safely.
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
|
||||
<label className="label-optional mb-1 block text-xs font-medium">
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
||||
standard behavior.
|
||||
Controls how much reasoning the selected provider invests before responding. Default uses the
|
||||
provider's standard behavior for the selected model.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
buildMembersFromDrafts,
|
||||
clearMemberModelOverrides,
|
||||
createMemberDraftsFromInputs,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
|
|
@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
|||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningFailureHint,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
updateProviderCheck,
|
||||
type ProvisioningProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
|
|
@ -56,6 +74,7 @@ import type {
|
|||
Schedule,
|
||||
ScheduleLaunchConfig,
|
||||
TeamLaunchRequest,
|
||||
TeamProviderId,
|
||||
TeamProvisioningPrepareResult,
|
||||
UpdateSchedulePatch,
|
||||
} from '@shared/types';
|
||||
|
|
@ -104,6 +123,31 @@ function getLocalTimezone(): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getStoredTeamProvider(): TeamProviderId {
|
||||
const stored = localStorage.getItem('team:lastSelectedProvider');
|
||||
return stored === 'codex' || stored === 'gemini' ? stored : 'anthropic';
|
||||
}
|
||||
|
||||
function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
|
||||
if (stored === null) {
|
||||
return providerId === 'anthropic' ? 'opus' : '';
|
||||
}
|
||||
return stored === '__default__' ? '' : stored;
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
|
@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
||||
if (stored === null) return 'opus';
|
||||
return stored === '__default__' ? '' : stored;
|
||||
});
|
||||
const [selectedProviderId, setSelectedProviderIdRaw] =
|
||||
useState<TeamProviderId>(getStoredTeamProvider);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||
getStoredTeamModel(getStoredTeamProvider())
|
||||
);
|
||||
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
||||
const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
|
||||
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
);
|
||||
|
|
@ -182,7 +228,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const members = isLaunch ? props.members : storeMembers;
|
||||
|
||||
// Advanced CLI section state (with localStorage persistence)
|
||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||
|
|
@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [warmUpMinutes, setWarmUpMinutes] = useState(15);
|
||||
const [maxTurns, setMaxTurns] = useState(50);
|
||||
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
|
||||
const effectiveMemberDrafts = useMemo(
|
||||
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
|
||||
[membersDrafts, syncModelsWithLead]
|
||||
);
|
||||
const selectedMemberProviders = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set([
|
||||
selectedProviderId,
|
||||
...effectiveMemberDrafts.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? [member.providerId]
|
||||
: []
|
||||
),
|
||||
])
|
||||
),
|
||||
[effectiveMemberDrafts, selectedProviderId]
|
||||
);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
|
|
@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value);
|
||||
};
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
setSelectedProviderIdRaw(value);
|
||||
localStorage.setItem('team:lastSelectedProvider', value);
|
||||
if (value !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
localStorage.setItem('team:lastLimitContext', 'false');
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(value));
|
||||
};
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
localStorage.setItem('team:lastSelectedModel', value);
|
||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
|
||||
};
|
||||
|
||||
const setLimitContext = (value: boolean): void => {
|
||||
|
|
@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const legacyTeamModel = localStorage.getItem('team:lastSelectedModel');
|
||||
if (
|
||||
legacyTeamModel != null &&
|
||||
localStorage.getItem('team:lastSelectedModel:anthropic') == null
|
||||
) {
|
||||
localStorage.setItem('team:lastSelectedModel:anthropic', legacyTeamModel);
|
||||
}
|
||||
localStorage.removeItem('team:lastSelectedModel');
|
||||
|
||||
for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) {
|
||||
const schedKey = `schedule:${suffix}`;
|
||||
const teamKey = `team:${suffix}`;
|
||||
const teamKey =
|
||||
suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`;
|
||||
const schedVal = localStorage.getItem(schedKey);
|
||||
if (schedVal != null && localStorage.getItem(teamKey) == null) {
|
||||
localStorage.setItem(teamKey, schedVal);
|
||||
|
|
@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setPrepareState('idle');
|
||||
setPrepareMessage(null);
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setClearContext(false);
|
||||
setConflictDismissed(false);
|
||||
setMembersDrafts([]);
|
||||
setSyncModelsWithLead(false);
|
||||
chipDraft.clearChipDraft();
|
||||
// Schedule fields
|
||||
setSelectedTeamName('');
|
||||
|
|
@ -311,6 +401,7 @@ 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 ?? '');
|
||||
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
|
||||
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
|
||||
|
|
@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setSelectedModelRaw('opus');
|
||||
setSelectedProviderIdRaw(getStoredTeamProvider());
|
||||
setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
|
||||
setSelectedEffortRaw('medium');
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isSchedule, schedule?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isLaunch) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let savedRequest = null;
|
||||
try {
|
||||
savedRequest = effectiveTeamName
|
||||
? await api.teams.getSavedRequest(effectiveTeamName)
|
||||
: null;
|
||||
} catch {
|
||||
savedRequest = null;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const nextProviderId =
|
||||
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
||||
? savedRequest.providerId
|
||||
: 'anthropic';
|
||||
const providerFromSaved = Boolean(savedRequest?.providerId);
|
||||
const nextMembersSource =
|
||||
members.length > 0
|
||||
? members
|
||||
: savedRequest?.members && savedRequest.members.length > 0
|
||||
? savedRequest.members
|
||||
: [];
|
||||
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
||||
|
||||
setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource));
|
||||
setSyncModelsWithLead(
|
||||
!nextMembersSource.some((member) => member.providerId || member.model || member.effort)
|
||||
);
|
||||
setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider());
|
||||
setSelectedModelRaw(
|
||||
typeof savedRequest?.model === 'string'
|
||||
? savedRequest.model
|
||||
: getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider())
|
||||
);
|
||||
setSelectedEffortRaw(
|
||||
savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort)
|
||||
);
|
||||
setLimitContextRaw(
|
||||
savedRequest?.limitContext === true ||
|
||||
localStorage.getItem('team:lastLimitContext') === 'true'
|
||||
);
|
||||
setSkipPermissionsRaw(
|
||||
savedRequest?.skipPermissions ??
|
||||
localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isLaunch, effectiveTeamName, members]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch-only effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setPrepareMessage(
|
||||
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
||||
);
|
||||
|
|
@ -364,6 +513,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (!effectiveCwd) {
|
||||
setPrepareState('idle');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks([]);
|
||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
let cancelled = false;
|
||||
const requestSeq = ++prepareRequestSeqRef.current;
|
||||
setPrepareState('loading');
|
||||
setPrepareMessage('Warming up CLI environment...');
|
||||
setPrepareMessage('Checking selected providers...');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
||||
|
||||
void (async () => {
|
||||
let checks = createInitialProviderChecks(selectedMemberProviders);
|
||||
let anyFailure = false;
|
||||
let anyNotes = false;
|
||||
const collectedWarnings: string[] = [];
|
||||
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult =
|
||||
await api.teams.prepareProvisioning(effectiveCwd);
|
||||
for (const providerId of selectedMemberProviders) {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
details: [],
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
setPrepareMessage(`Checking ${getProviderLabel(providerId)} runtime...`);
|
||||
}
|
||||
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(
|
||||
effectiveCwd,
|
||||
providerId,
|
||||
[providerId]
|
||||
);
|
||||
const detailLines = [
|
||||
...(prepResult.warnings ?? []).filter(Boolean),
|
||||
...(!prepResult.ready && prepResult.message ? [prepResult.message] : []),
|
||||
];
|
||||
if (prepResult.warnings?.length) {
|
||||
anyNotes = true;
|
||||
collectedWarnings.push(
|
||||
...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
|
||||
);
|
||||
}
|
||||
if (!prepResult.ready) {
|
||||
anyFailure = true;
|
||||
}
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||
details: detailLines,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
setPrepareChecks(checks);
|
||||
}
|
||||
}
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||
setPrepareMessage(
|
||||
anyFailure
|
||||
? 'Some selected providers need attention.'
|
||||
: anyNotes
|
||||
? 'Selected providers are ready with notes.'
|
||||
: 'Selected providers are ready.'
|
||||
);
|
||||
setPrepareWarnings(collectedWarnings);
|
||||
} catch (error) {
|
||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||
const failureMessage =
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
||||
);
|
||||
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||
setPrepareMessage(failureMessage);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isLaunch, effectiveCwd]);
|
||||
}, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared effects: projects
|
||||
|
|
@ -490,19 +687,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// Mention suggestions (shared — from props in launch, from store in schedule)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const members = isLaunch ? props.members : storeMembers;
|
||||
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
|
||||
const memberColorMap = useMemo(
|
||||
() => buildMemberDraftColorMap(membersDrafts, members),
|
||||
[membersDrafts, members]
|
||||
);
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||
color: colorMap.get(m.name),
|
||||
})),
|
||||
[members, colorMap]
|
||||
() => buildMemberDraftSuggestions(membersDrafts, memberColorMap),
|
||||
[memberColorMap, membersDrafts]
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
args.push('--verbose', '--setting-sources', 'user,project,local');
|
||||
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
|
||||
if (skipPermissions) args.push('--dangerously-skip-permissions');
|
||||
const model = computeEffectiveTeamModel(selectedModel, limitContext);
|
||||
const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId);
|
||||
if (model) args.push('--model', model);
|
||||
if (selectedEffort) args.push('--effort', selectedEffort);
|
||||
if (!clearContext) args.push('--resume', '<previous>');
|
||||
return args;
|
||||
}, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]);
|
||||
}, [
|
||||
isLaunch,
|
||||
skipPermissions,
|
||||
selectedModel,
|
||||
limitContext,
|
||||
selectedEffort,
|
||||
clearContext,
|
||||
selectedProviderId,
|
||||
]);
|
||||
|
||||
const launchOptionalSummary = useMemo(() => {
|
||||
if (!isLaunch) return [];
|
||||
|
||||
const summary: string[] = [];
|
||||
if (promptDraft.value.trim()) summary.push('Lead prompt');
|
||||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||
if (limitContext) summary.push('Limited to 200K context');
|
||||
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (clearContext) summary.push('Fresh session');
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
|
|
@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
isLaunch,
|
||||
promptDraft.value,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
selectedEffort,
|
||||
limitContext,
|
||||
skipPermissions,
|
||||
|
|
@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setLocalError('Select working directory (cwd)');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isLaunch &&
|
||||
membersDrafts.some(
|
||||
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
|
||||
)
|
||||
) {
|
||||
setLocalError('Fix member names before launch');
|
||||
return;
|
||||
}
|
||||
if (isLaunch) {
|
||||
const activeNames = membersDrafts
|
||||
.map((member) => member.name.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (new Set(activeNames).size !== activeNames.length) {
|
||||
setLocalError('Member names must be unique before launch');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLocalError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (isLaunch) {
|
||||
await api.teams.replaceMembers(effectiveTeamName, {
|
||||
members: buildMembersFromDrafts(effectiveMemberDrafts),
|
||||
});
|
||||
await props.onLaunch({
|
||||
teamName: effectiveTeamName,
|
||||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
model: computeEffectiveTeamModel(selectedModel, limitContext),
|
||||
providerId: selectedProviderId,
|
||||
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
limitContext,
|
||||
clearContext: clearContext || undefined,
|
||||
|
|
@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const launchConfig: ScheduleLaunchConfig = {
|
||||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim(),
|
||||
providerId: selectedProviderId,
|
||||
model: selectedModel || undefined,
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
skipPermissions,
|
||||
|
|
@ -748,12 +974,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
Claude CLI is not installed — launch is blocked
|
||||
CLI environment is not available — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
{!shouldHideProvisioningProviderStatusList(prepareChecks, prepareMessage) ? (
|
||||
<ProvisioningProviderStatusList
|
||||
checks={prepareChecks}
|
||||
className="mt-1"
|
||||
suppressDetailsMatching={prepareMessage}
|
||||
/>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p
|
||||
|
|
@ -768,18 +1001,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
) : null}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Install Claude CLI from the Dashboard, then reopen this dialog.
|
||||
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
|
||||
prepareChecks.some((check) =>
|
||||
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
|
||||
) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -922,6 +1160,38 @@ 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
|
||||
|
|
@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<TeamModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
id="dialog-model"
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={selectedEffort}
|
||||
onValueChange={setSelectedEffort}
|
||||
id="dialog-effort"
|
||||
/>
|
||||
<LimitContextCheckbox
|
||||
id="launch-limit-context"
|
||||
checked={limitContext}
|
||||
onCheckedChange={setLimitContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
<SkipPermissionsCheckbox
|
||||
id="dialog-skip-permissions"
|
||||
checked={skipPermissions}
|
||||
|
|
@ -1061,6 +1315,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
<div>
|
||||
<TeamModelSelector
|
||||
providerId={selectedProviderId}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
id="dialog-model"
|
||||
|
|
@ -1137,27 +1393,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
{isLaunch ? (
|
||||
<div className="min-w-0">
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
<span>Pre-flight check to catch errors before launch</span>
|
||||
<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 className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<div>
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
<span>Pre-flight check to catch errors before launch</span>
|
||||
<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>
|
||||
</div>
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'ready' ? (
|
||||
|
|
@ -1165,7 +1424,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
{prepareChecks.some((check) => check.status === 'notes') ||
|
||||
prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
|
|
@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-1" />
|
||||
{prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,258 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
|
||||
export interface ProvisioningProviderCheck {
|
||||
providerId: TeamProviderId;
|
||||
status: ProvisioningProviderCheckStatus;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
export function createInitialProviderChecks(
|
||||
providerIds: TeamProviderId[]
|
||||
): ProvisioningProviderCheck[] {
|
||||
return providerIds.map((providerId) => ({
|
||||
providerId,
|
||||
status: 'pending',
|
||||
details: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateProviderCheck(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
providerId: TeamProviderId,
|
||||
patch: Partial<ProvisioningProviderCheck>
|
||||
): ProvisioningProviderCheck[] {
|
||||
return checks.map((check) =>
|
||||
check.providerId === providerId
|
||||
? {
|
||||
...check,
|
||||
...patch,
|
||||
}
|
||||
: check
|
||||
);
|
||||
}
|
||||
|
||||
export function failIncompleteProviderChecks(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
detail: string
|
||||
): ProvisioningProviderCheck[] {
|
||||
return checks.map((check) =>
|
||||
check.status === 'ready' || check.status === 'notes' || check.status === 'failed'
|
||||
? check
|
||||
: {
|
||||
...check,
|
||||
status: 'failed',
|
||||
details: check.details.length > 0 ? check.details : [detail],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return 'checking...';
|
||||
case 'ready':
|
||||
return 'OK';
|
||||
case 'notes':
|
||||
return 'OK (notes)';
|
||||
case 'failed':
|
||||
return 'ERR';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'queued';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeDetail(detail: string, status: ProvisioningProviderCheckStatus): string | null {
|
||||
const lower = detail.toLowerCase();
|
||||
|
||||
if (lower.includes('spawn ') && lower.includes(' enoent')) {
|
||||
return 'CLI binary missing';
|
||||
}
|
||||
if (lower.includes('working directory does not exist:')) {
|
||||
return 'Working directory missing';
|
||||
}
|
||||
if (
|
||||
lower.includes('eacces') ||
|
||||
lower.includes('enoexec') ||
|
||||
lower.includes('bad cpu type in executable') ||
|
||||
lower.includes('image not found')
|
||||
) {
|
||||
return 'CLI binary could not be started';
|
||||
}
|
||||
if (lower.includes('preflight check for `claude -p` did not complete')) {
|
||||
return 'CLI preflight did not complete';
|
||||
}
|
||||
if (lower.includes('not authenticated') || lower.includes('not logged in')) {
|
||||
return 'Authentication required';
|
||||
}
|
||||
if (lower.includes('provider is not configured for runtime use')) {
|
||||
return 'Runtime provider is not configured';
|
||||
}
|
||||
if (lower.includes('claude cli binary failed to start')) {
|
||||
return 'CLI binary could not be started';
|
||||
}
|
||||
if (lower.includes('claude cli preflight check failed')) {
|
||||
return 'CLI preflight failed';
|
||||
}
|
||||
|
||||
if (status === 'notes') {
|
||||
return 'Ready with notes';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'Needs attention';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDisplayStatusText(check: ProvisioningProviderCheck): string {
|
||||
const summary = check.details.find(Boolean)
|
||||
? summarizeDetail(check.details[0]!, check.status)
|
||||
: null;
|
||||
return summary ?? getStatusLabel(check.status);
|
||||
}
|
||||
|
||||
export function shouldHideProvisioningProviderStatusList(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
const normalizedMessage = (message ?? '').trim().toLowerCase();
|
||||
if (!normalizedMessage || checks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return checks.every((check) => {
|
||||
if (check.status !== 'failed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = getDisplayStatusText(check).toLowerCase();
|
||||
const visibleDetails = check.details.filter(
|
||||
(detail) => detail.trim().toLowerCase() !== normalizedMessage
|
||||
);
|
||||
|
||||
return summary === 'working directory missing' && visibleDetails.length === 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusColor(status: ProvisioningProviderCheckStatus): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'text-emerald-400';
|
||||
case 'notes':
|
||||
return 'text-sky-300';
|
||||
case 'failed':
|
||||
return 'text-red-300';
|
||||
case 'checking':
|
||||
return 'text-[var(--color-text-secondary)]';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'text-[var(--color-text-muted)]';
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: ProvisioningProviderCheckStatus }): React.JSX.Element {
|
||||
if (status === 'checking') {
|
||||
return <Loader2 className="size-3 animate-spin" />;
|
||||
}
|
||||
if (status === 'ready') {
|
||||
return <CheckCircle2 className="size-3" />;
|
||||
}
|
||||
if (status === 'notes' || status === 'failed') {
|
||||
return <AlertTriangle className="size-3" />;
|
||||
}
|
||||
return <span className="inline-block size-1.5 rounded-full bg-current opacity-60" />;
|
||||
}
|
||||
|
||||
export function ProvisioningProviderStatusList({
|
||||
checks,
|
||||
className = '',
|
||||
suppressDetailsMatching,
|
||||
}: {
|
||||
checks: ProvisioningProviderCheck[];
|
||||
className?: string;
|
||||
suppressDetailsMatching?: string | null;
|
||||
}): React.JSX.Element | null {
|
||||
if (checks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-1 pl-5 ${className}`.trim()}>
|
||||
{checks.map((check) => {
|
||||
const visibleDetails = check.details.filter(
|
||||
(detail) => detail.trim() !== (suppressDetailsMatching ?? '').trim()
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={check.providerId}>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[11px] ${getStatusColor(check.status)}`}
|
||||
>
|
||||
<StatusIcon status={check.status} />
|
||||
<span>
|
||||
{getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)}
|
||||
</span>
|
||||
</div>
|
||||
{visibleDetails.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-4">
|
||||
{visibleDetails.map((detail) => (
|
||||
<p key={detail} className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{detail}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getProvisioningFailureHint(
|
||||
message: string | null | undefined,
|
||||
checks: ProvisioningProviderCheck[]
|
||||
): string {
|
||||
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
|
||||
if (combined.includes('working directory does not exist:')) {
|
||||
return 'Choose an existing working directory, then reopen this dialog.';
|
||||
}
|
||||
if (combined.includes('not authenticated') || combined.includes('not logged in')) {
|
||||
return 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
|
||||
}
|
||||
if (combined.includes('provider is not configured for runtime use')) {
|
||||
return 'Configure the selected provider runtime, then reopen this dialog.';
|
||||
}
|
||||
if (
|
||||
combined.includes('spawn ') ||
|
||||
combined.includes(' enoent') ||
|
||||
combined.includes('eacces') ||
|
||||
combined.includes('enoexec') ||
|
||||
combined.includes('bad cpu type in executable') ||
|
||||
combined.includes('image not found')
|
||||
) {
|
||||
return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
|
||||
}
|
||||
|
||||
return 'Resolve the issue above, then reopen this dialog.';
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Check, ChevronDown, Info } from 'lucide-react';
|
||||
|
||||
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
||||
|
|
@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
/** Google Gemini — official sparkle/star mark (Simple Icons) */
|
||||
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Local — server rack icon */
|
||||
const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="3" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="3" y="14" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="7" cy="6.5" r="1" fill="currentColor" />
|
||||
<circle cx="7" cy="17.5" r="1" fill="currentColor" />
|
||||
<line
|
||||
x1="10.5"
|
||||
y1="6.5"
|
||||
x2="17.5"
|
||||
y2="6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="10.5"
|
||||
y1="17.5"
|
||||
x2="17.5"
|
||||
y2="17.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M12 2.25c.62 3.9 1.6 6.57 3.18 8.15 1.58 1.58 4.25 2.56 8.15 3.18-3.9.62-6.57 1.6-8.15 3.18-1.58 1.58-2.56 4.25-3.18 8.15-.62-3.9-1.6-6.57-3.18-8.15-1.58-1.58-4.25-2.56-8.15-3.18 3.9-.62 6.57-1.6 8.15-3.18C10.4 8.82 11.38 6.15 12 2.25Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
|
@ -72,22 +44,88 @@ interface ProviderDef {
|
|||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
|
||||
{ id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
|
||||
{ id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
|
||||
{ id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
|
||||
{ id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
|
||||
{ id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
|
||||
];
|
||||
|
||||
const ACTIVE_PROVIDER = PROVIDERS[0];
|
||||
|
||||
// --- Model options (Anthropic only for now) ---
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
const ANTHROPIC_MODEL_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.6' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
] as const;
|
||||
|
||||
const CODEX_MODEL_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
] as const;
|
||||
|
||||
const GEMINI_MODEL_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||
] as const;
|
||||
|
||||
const MODEL_LABEL_OVERRIDES: Record<string, string> = {
|
||||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||||
'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)',
|
||||
'claude-opus-4-6': 'Opus 4.6',
|
||||
'claude-opus-4-6[1m]': 'Opus 4.6 (1M)',
|
||||
'claude-haiku-4-5-20251001': 'Haiku 4.5',
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||||
'gpt-5.3-codex-spark': 'GPT-5.3 Spark',
|
||||
'gpt-5.2-codex': 'GPT-5.2 Codex',
|
||||
'gpt-5.2': 'GPT-5.2',
|
||||
'gpt-5.1-codex-mini': 'GPT-5.1 Mini',
|
||||
'gpt-5.1-codex-max': 'GPT-5.1 Max',
|
||||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
|
||||
};
|
||||
|
||||
export function getTeamModelLabel(model: string): string {
|
||||
return MODEL_LABEL_OVERRIDES[model] ?? model;
|
||||
}
|
||||
|
||||
export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string {
|
||||
switch (providerId) {
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'anthropic':
|
||||
default:
|
||||
return 'Anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
export function getTeamEffortLabel(effort: string): string {
|
||||
const trimmed = effort.trim();
|
||||
if (!trimmed) return 'Default';
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
|
||||
}
|
||||
|
||||
export function formatTeamModelSummary(
|
||||
providerId: 'anthropic' | 'codex' | 'gemini',
|
||||
model: string,
|
||||
effort?: string
|
||||
): string {
|
||||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
|
||||
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
|
||||
return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the effective model string for team provisioning.
|
||||
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
|
||||
|
|
@ -96,25 +134,33 @@ const MODEL_OPTIONS = [
|
|||
*/
|
||||
export function computeEffectiveTeamModel(
|
||||
selectedModel: string,
|
||||
limitContext: boolean
|
||||
limitContext: boolean,
|
||||
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
|
||||
): string | undefined {
|
||||
const base = selectedModel || undefined;
|
||||
if (providerId !== 'anthropic') return base;
|
||||
if (limitContext) return base;
|
||||
if (base === 'haiku') return base;
|
||||
return base ? `${base}[1m]` : 'opus[1m]';
|
||||
}
|
||||
|
||||
export interface TeamModelSelectorProps {
|
||||
providerId: 'anthropic' | 'codex' | 'gemini';
|
||||
onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||
providerId,
|
||||
onProviderChange,
|
||||
value,
|
||||
onValueChange,
|
||||
id,
|
||||
}) => {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const multimodelAvailable = cliStatus?.flavor === 'free-code';
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const ProviderIcon = ACTIVE_PROVIDER.icon;
|
||||
const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0];
|
||||
const ProviderIcon = activeProvider.icon;
|
||||
const isProviderSelectable = (candidateProviderId: string): boolean =>
|
||||
multimodelAvailable || candidateProviderId === 'anthropic';
|
||||
const activeProviderSelectable = isProviderSelectable(providerId);
|
||||
const runtimeModels =
|
||||
cliStatus?.providers.find((provider) => provider.providerId === providerId)?.models ?? [];
|
||||
const modelOptions = useMemo(() => {
|
||||
const fallback =
|
||||
providerId === 'codex'
|
||||
? CODEX_MODEL_OPTIONS
|
||||
: providerId === 'gemini'
|
||||
? GEMINI_MODEL_OPTIONS
|
||||
: ANTHROPIC_MODEL_OPTIONS;
|
||||
if (runtimeModels.length === 0) {
|
||||
return [...fallback];
|
||||
}
|
||||
const dynamicOptions = runtimeModels.map((model) => ({
|
||||
value: model,
|
||||
label: getTeamModelLabel(model),
|
||||
}));
|
||||
return [{ value: '', label: 'Default' }, ...dynamicOptions];
|
||||
}, [providerId, runtimeModels]);
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Model (optional)
|
||||
</Label>
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{/* Provider button */}
|
||||
<div ref={containerRef} className="relative space-y-2">
|
||||
<div className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
|
||||
'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors',
|
||||
dropdownOpen
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
}}
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<ProviderIcon className="size-3.5" />
|
||||
<span>{ACTIVE_PROVIDER.label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon className="size-3.5" />
|
||||
<span>{activeProvider.label}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 transition-transform duration-200',
|
||||
|
|
@ -163,31 +235,116 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
/>
|
||||
</button>
|
||||
|
||||
{/* Model pills */}
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
{/* Provider dropdown */}
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
}}
|
||||
>
|
||||
{PROVIDERS.map((provider, index) => {
|
||||
const Icon = provider.icon;
|
||||
const isActive = provider.id === activeProvider.id;
|
||||
const isFirst = index === 0;
|
||||
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
||||
|
||||
return (
|
||||
<React.Fragment key={provider.id}>
|
||||
{prevWasActive && !isFirst && (
|
||||
<div
|
||||
className="mx-2 my-1 border-t"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={provider.comingSoon || !isProviderSelectable(provider.id)}
|
||||
onClick={() => {
|
||||
if (!provider.comingSoon && isProviderSelectable(provider.id)) {
|
||||
onProviderChange(provider.id as 'anthropic' | 'codex' | 'gemini');
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
|
||||
isActive && 'bg-indigo-500/10 text-indigo-400',
|
||||
(provider.comingSoon || !isProviderSelectable(provider.id)) &&
|
||||
'cursor-not-allowed opacity-40',
|
||||
!isActive &&
|
||||
!provider.comingSoon &&
|
||||
isProviderSelectable(provider.id) &&
|
||||
'hover:bg-white/5'
|
||||
)}
|
||||
style={
|
||||
!isActive && !provider.comingSoon && isProviderSelectable(provider.id)
|
||||
? { color: 'var(--color-text-secondary)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">{provider.label}</span>
|
||||
{provider.comingSoon && (
|
||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
{!provider.comingSoon && !isProviderSelectable(provider.id) && (
|
||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
Multimodel off
|
||||
</span>
|
||||
)}
|
||||
{isActive && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!multimodelAvailable && (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Codex and Gemini require Multimodel mode.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{modelOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-center text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]',
|
||||
!activeProviderSelectable && 'cursor-not-allowed opacity-45'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
style={{
|
||||
borderColor: value === opt.value ? 'var(--color-border-emphasis)' : 'transparent',
|
||||
}}
|
||||
disabled={!activeProviderSelectable}
|
||||
onClick={() => {
|
||||
if (!activeProviderSelectable) return;
|
||||
onValueChange(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
<span className="leading-tight">{opt.label}</span>
|
||||
{opt.value === '' && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Info className="size-3 opacity-40 transition-opacity hover:opacity-70" />
|
||||
<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 />
|
||||
Currently Sonnet 4.6, but may change with CLI updates.
|
||||
Uses the runtime default for the selected provider.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -195,64 +352,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Provider dropdown */}
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 z-50 mb-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
}}
|
||||
>
|
||||
{PROVIDERS.map((provider, index) => {
|
||||
const Icon = provider.icon;
|
||||
const isActive = provider.id === ACTIVE_PROVIDER.id;
|
||||
const isFirst = index === 0;
|
||||
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
||||
|
||||
return (
|
||||
<React.Fragment key={provider.id}>
|
||||
{prevWasActive && !isFirst && (
|
||||
<div
|
||||
className="mx-2 my-1 border-t"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={provider.comingSoon}
|
||||
onClick={() => {
|
||||
if (!provider.comingSoon) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
|
||||
isActive && 'bg-indigo-500/10 text-indigo-400',
|
||||
provider.comingSoon && 'cursor-not-allowed opacity-40',
|
||||
!isActive && !provider.comingSoon && 'hover:bg-white/5'
|
||||
)}
|
||||
style={
|
||||
!isActive && !provider.comingSoon
|
||||
? { color: 'var(--color-text-secondary)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">{provider.label}</span>
|
||||
{provider.comingSoon && (
|
||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
{isActive && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
138
src/renderer/components/team/members/LeadModelRow.tsx
Normal file
138
src/renderer/components/team/members/LeadModelRow.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
import {
|
||||
getTeamModelLabel,
|
||||
TeamModelSelector,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface LeadModelRowProps {
|
||||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const LeadModelRow = ({
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
const leadColorSet = getTeamColorSet(getMemberColorByName('lead'));
|
||||
const modelButtonLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
|
||||
style={{
|
||||
backgroundColor: isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||
: 'var(--color-surface-raised)',
|
||||
boxShadow: isLight ? '0 1px 2px rgba(15, 23, 42, 0.06)' : '0 1px 2px rgba(0, 0, 0, 0.28)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-1 rounded-l-md"
|
||||
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>
|
||||
</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">
|
||||
<Checkbox
|
||||
id="sync-models-with-lead"
|
||||
checked={syncModelsWithTeammates}
|
||||
onCheckedChange={(checked) => onSyncModelsWithTeammatesChange(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-models-with-lead"
|
||||
className="cursor-pointer text-xs font-normal text-text-secondary"
|
||||
>
|
||||
Синхронизировать модель с тимейтами
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{modelExpanded ? (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<TeamModelSelector
|
||||
providerId={providerId}
|
||||
onProviderChange={onProviderChange}
|
||||
value={model}
|
||||
onValueChange={onModelChange}
|
||||
id="lead-model"
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={effort ?? ''}
|
||||
onValueChange={onEffortChange}
|
||||
id="lead-effort"
|
||||
/>
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
checked={limitContext}
|
||||
onCheckedChange={onLimitContextChange}
|
||||
disabled={providerId !== 'anthropic' || model === 'haiku'}
|
||||
/>
|
||||
<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">
|
||||
These settings control the team lead and act as the default runtime for teammates that
|
||||
do not have their own override.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -26,6 +26,7 @@ import type {
|
|||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
runtimeSummary?: string;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -46,6 +47,7 @@ interface MemberCardProps {
|
|||
export const MemberCard = ({
|
||||
member,
|
||||
memberColor,
|
||||
runtimeSummary,
|
||||
taskCounts,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -131,42 +133,49 @@ export const MemberCard = ({
|
|||
aria-label={presenceLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||
{displayMemberName(member.name)}
|
||||
</span>
|
||||
{member.gitBranch ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<GitBranch size={10} />
|
||||
{member.gitBranch}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||
{displayMemberName(member.name)}
|
||||
</span>
|
||||
) : null}
|
||||
{currentTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={currentTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="working on"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
) : null}
|
||||
{reviewTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="reviewing"
|
||||
onOpenTask={onOpenReviewTask}
|
||||
/>
|
||||
) : null}
|
||||
{!activityTask && isAwaitingReply ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: colors.border }}
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
awaiting reply
|
||||
{member.gitBranch ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<GitBranch size={10} />
|
||||
{member.gitBranch}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{currentTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={currentTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="working on"
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
) : null}
|
||||
{reviewTask ? (
|
||||
<CurrentTaskIndicator
|
||||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="reviewing"
|
||||
onOpenTask={onOpenReviewTask}
|
||||
/>
|
||||
) : null}
|
||||
{!activityTask && isAwaitingReply ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: colors.border }}
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
awaiting reply
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{runtimeSummary ? (
|
||||
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{runtimeSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import {
|
||||
getTeamModelLabel,
|
||||
TeamModelSelector,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
|
|
@ -16,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
|
|||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface MemberDraftRowProps {
|
||||
member: MemberDraft;
|
||||
|
|
@ -29,11 +34,20 @@ interface MemberDraftRowProps {
|
|||
showWorkflow?: boolean;
|
||||
onWorkflowChange?: (id: string, workflow: string) => void;
|
||||
onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => void;
|
||||
onProviderChange: (id: string, providerId: TeamProviderId) => void;
|
||||
onModelChange: (id: string, model: string) => void;
|
||||
onEffortChange: (id: string, effort: string) => void;
|
||||
inheritedProviderId?: TeamProviderId;
|
||||
inheritedModel?: string;
|
||||
inheritedEffort?: EffortLevel;
|
||||
draftKeyPrefix?: string;
|
||||
projectPath?: string | null;
|
||||
mentionSuggestions?: MentionSuggestion[];
|
||||
taskSuggestions?: MentionSuggestion[];
|
||||
teamSuggestions?: MentionSuggestion[];
|
||||
lockProviderModel?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
}
|
||||
|
||||
export const MemberDraftRow = ({
|
||||
|
|
@ -48,11 +62,20 @@ export const MemberDraftRow = ({
|
|||
showWorkflow = false,
|
||||
onWorkflowChange,
|
||||
onWorkflowChipsChange,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
inheritedProviderId = 'anthropic',
|
||||
inheritedModel = '',
|
||||
inheritedEffort,
|
||||
draftKeyPrefix,
|
||||
projectPath,
|
||||
mentionSuggestions = [],
|
||||
taskSuggestions,
|
||||
teamSuggestions,
|
||||
lockProviderModel = false,
|
||||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const memberColorSet = getTeamColorSet(
|
||||
|
|
@ -122,10 +145,22 @@ export const MemberDraftRow = ({
|
|||
const suggestionsExcludingSelf = mentionSuggestions.filter(
|
||||
(s) => s.name.toLowerCase() !== member.name.trim().toLowerCase()
|
||||
);
|
||||
const effectiveProviderId = forceInheritedModelSettings
|
||||
? inheritedProviderId
|
||||
: (member.providerId ?? inheritedProviderId);
|
||||
const effectiveModel = forceInheritedModelSettings
|
||||
? inheritedModel
|
||||
: (member.model ?? inheritedModel);
|
||||
const effectiveEffort = forceInheritedModelSettings
|
||||
? inheritedEffort
|
||||
: (member.effort ?? inheritedEffort);
|
||||
const modelButtonLabel = effectiveModel?.trim()
|
||||
? getTeamModelLabel(effectiveModel.trim())
|
||||
: 'Default';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_220px_auto]"
|
||||
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
|
||||
style={{
|
||||
backgroundColor: isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||
|
|
@ -165,48 +200,54 @@ export const MemberDraftRow = ({
|
|||
inputClassName="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{showWorkflow && onWorkflowChange ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
{showWorkflow && onWorkflowChange ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative h-8 shrink-0 gap-1"
|
||||
onClick={() => setWorkflowExpanded((prev) => !prev)}
|
||||
>
|
||||
{workflowExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Workflow
|
||||
{!workflowExpanded && workflowDraft.value.trim() ? (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
<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"
|
||||
disabled={lockProviderModel}
|
||||
title={lockProviderModel ? modelLockReason : undefined}
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
>
|
||||
{modelExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
<span className="truncate">Model: {modelButtonLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative h-8 shrink-0 gap-1"
|
||||
onClick={() => setWorkflowExpanded((prev) => !prev)}
|
||||
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)}
|
||||
>
|
||||
{workflowExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Workflow
|
||||
{!workflowExpanded && workflowDraft.value.trim() ? (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-blue-500" />
|
||||
) : null}
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1"
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
>
|
||||
{modelExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Model
|
||||
</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>
|
||||
</div>
|
||||
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||
<div className="space-y-0.5 md:col-span-3">
|
||||
|
|
@ -241,14 +282,38 @@ export const MemberDraftRow = ({
|
|||
) : null}
|
||||
{modelExpanded && (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<div className="pointer-events-none opacity-40">
|
||||
<TeamModelSelector value="" onValueChange={() => {}} />
|
||||
</div>
|
||||
<TeamModelSelector
|
||||
providerId={effectiveProviderId}
|
||||
onProviderChange={(providerId) => {
|
||||
if (lockProviderModel) return;
|
||||
onProviderChange(member.id, providerId);
|
||||
}}
|
||||
value={effectiveModel ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (lockProviderModel) return;
|
||||
onModelChange(member.id, value);
|
||||
}}
|
||||
id={`member-${member.id}-model`}
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={effectiveEffort ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (lockProviderModel) return;
|
||||
onEffortChange(member.id, value);
|
||||
}}
|
||||
id={`member-${member.id}-effort`}
|
||||
/>
|
||||
{lockProviderModel && (
|
||||
<p className="text-[11px] text-amber-300">
|
||||
{modelLockReason ??
|
||||
'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
|
||||
</p>
|
||||
)}
|
||||
<div 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">
|
||||
Claude Code doesn't support per-member model selection yet — all teammates
|
||||
inherit the team launch model. We plan to solve this via a local proxy.
|
||||
If this teammate uses a different provider than the lead, they will be started in a
|
||||
separate process automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getTeamEffortLabel,
|
||||
getTeamModelLabel,
|
||||
getTeamProviderLabel,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
|
|
@ -27,6 +33,7 @@ interface MemberListProps {
|
|||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
launchParams?: TeamLaunchParams;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
|
|
@ -42,6 +49,7 @@ export const MemberList = ({
|
|||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
launchParams,
|
||||
onMemberClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -77,6 +85,45 @@ export const MemberList = ({
|
|||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(member: ResolvedTeamMember): string | undefined => {
|
||||
const hasMemberOverride = Boolean(member.providerId || member.model || member.effort);
|
||||
if (!hasMemberOverride && launchParams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultProvider = launchParams?.providerId ?? 'anthropic';
|
||||
const memberProvider = member.providerId ?? defaultProvider;
|
||||
const defaultModel = launchParams?.model?.trim() || '';
|
||||
const memberModel = member.model?.trim() || '';
|
||||
const defaultEffort = launchParams?.effort;
|
||||
const memberEffort = member.effort;
|
||||
|
||||
const showProvider =
|
||||
!launchParams || Boolean(member.providerId && memberProvider !== defaultProvider);
|
||||
const showModel = !launchParams
|
||||
? Boolean(memberModel)
|
||||
: Boolean(memberModel && memberModel !== defaultModel);
|
||||
const showEffort = !launchParams
|
||||
? Boolean(memberEffort)
|
||||
: Boolean(memberEffort && memberEffort !== defaultEffort);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (showProvider) {
|
||||
parts.push(getTeamProviderLabel(memberProvider));
|
||||
}
|
||||
if (showModel) {
|
||||
parts.push(getTeamModelLabel(memberModel));
|
||||
}
|
||||
if (showEffort && memberEffort) {
|
||||
parts.push(getTeamEffortLabel(memberEffort));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
},
|
||||
[launchParams]
|
||||
);
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
|
|
@ -111,6 +158,7 @@ export const MemberList = ({
|
|||
reviewTask={isRemoved ? null : reviewTask}
|
||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
function membersToJsonText(drafts: MemberDraft[]): string {
|
||||
const arr = drafts
|
||||
|
|
@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
|||
if (role) obj.role = role;
|
||||
const workflow = getWorkflowForExport(d);
|
||||
if (workflow) obj.workflow = workflow;
|
||||
if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId;
|
||||
if (d.model?.trim()) obj.model = d.model.trim();
|
||||
if (d.effort) obj.effort = d.effort;
|
||||
return obj;
|
||||
});
|
||||
return JSON.stringify(arr, null, 2);
|
||||
|
|
@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||
const providerId: TeamProviderId =
|
||||
item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic';
|
||||
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
||||
const effort: EffortLevel | undefined =
|
||||
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
||||
? item.effort
|
||||
: undefined;
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
|
|
@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: workflow || undefined,
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -74,6 +88,16 @@ export interface MembersEditorSectionProps {
|
|||
hideContent?: boolean;
|
||||
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
||||
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
||||
/** Default provider to use for newly added member rows. */
|
||||
defaultProviderId?: TeamProviderId;
|
||||
/** When true, provider/model controls stay read-only for existing rows. */
|
||||
lockProviderModel?: boolean;
|
||||
inheritedProviderId?: TeamProviderId;
|
||||
inheritedModel?: string;
|
||||
inheritedEffort?: EffortLevel;
|
||||
inheritModelSettingsByDefault?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
|
|
@ -90,6 +114,14 @@ export const MembersEditorSection = ({
|
|||
headerExtra,
|
||||
hideContent = false,
|
||||
existingMembers,
|
||||
defaultProviderId = 'anthropic',
|
||||
lockProviderModel = false,
|
||||
inheritedProviderId,
|
||||
inheritedModel,
|
||||
inheritedEffort,
|
||||
inheritModelSettingsByDefault = false,
|
||||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
|
|
@ -150,13 +182,52 @@ export const MembersEditorSection = ({
|
|||
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
|
||||
};
|
||||
|
||||
const updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => {
|
||||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId
|
||||
? {
|
||||
...c,
|
||||
providerId,
|
||||
model: c.providerId === providerId ? c.model : '',
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateMemberModel = (memberId: string, model: string): void => {
|
||||
onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c)));
|
||||
};
|
||||
|
||||
const updateMemberEffort = (memberId: string, effort: string): void => {
|
||||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId
|
||||
? {
|
||||
...c,
|
||||
effort:
|
||||
effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const removeMember = (memberId: string): void => {
|
||||
onChange(members.filter((c) => c.id !== memberId));
|
||||
};
|
||||
|
||||
const addMember = (): void => {
|
||||
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
|
||||
onChange([...members, createMemberDraft({ name: suggestedName })]);
|
||||
onChange([
|
||||
...members,
|
||||
createMemberDraft(
|
||||
inheritModelSettingsByDefault
|
||||
? { name: suggestedName }
|
||||
: { name: suggestedName, providerId: defaultProviderId }
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
|
|
@ -207,11 +278,20 @@ export const MembersEditorSection = ({
|
|||
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={lockProviderModel}
|
||||
modelLockReason={modelLockReason}
|
||||
/>
|
||||
))}
|
||||
{jsonEditorOpen && showJsonEditor ? (
|
||||
|
|
@ -243,7 +323,9 @@ export {
|
|||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
buildMembersFromDrafts,
|
||||
clearMemberModelOverrides,
|
||||
createMemberDraft,
|
||||
createMemberDraftsFromInputs,
|
||||
getMemberDraftRole,
|
||||
validateMemberNameInline,
|
||||
} from './membersEditorUtils';
|
||||
|
|
|
|||
121
src/renderer/components/team/members/TeamRosterEditorSection.tsx
Normal file
121
src/renderer/components/team/members/TeamRosterEditorSection.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
|
||||
import { MembersEditorSection } from './MembersEditorSection';
|
||||
import { LeadModelRow } from './LeadModelRow';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface TeamRosterEditorSectionProps {
|
||||
members: MemberDraft[];
|
||||
onMembersChange: (members: MemberDraft[]) => void;
|
||||
fieldError?: string;
|
||||
validateMemberName?: (name: string) => string | null;
|
||||
showWorkflow?: boolean;
|
||||
showJsonEditor?: boolean;
|
||||
draftKeyPrefix?: string;
|
||||
projectPath?: string | null;
|
||||
taskSuggestions?: MentionSuggestion[];
|
||||
teamSuggestions?: MentionSuggestion[];
|
||||
hideMembersContent?: boolean;
|
||||
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
||||
defaultProviderId?: TeamProviderId;
|
||||
inheritedProviderId: TeamProviderId;
|
||||
inheritedModel: string;
|
||||
inheritedEffort?: EffortLevel;
|
||||
inheritModelSettingsByDefault?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
lockProviderModel?: boolean;
|
||||
modelLockReason?: string;
|
||||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
headerTop?: React.ReactNode;
|
||||
headerBottom?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
members,
|
||||
onMembersChange,
|
||||
fieldError,
|
||||
validateMemberName,
|
||||
showWorkflow = false,
|
||||
showJsonEditor = true,
|
||||
draftKeyPrefix,
|
||||
projectPath,
|
||||
taskSuggestions,
|
||||
teamSuggestions,
|
||||
hideMembersContent = false,
|
||||
existingMembers,
|
||||
defaultProviderId = 'anthropic',
|
||||
inheritedProviderId,
|
||||
inheritedModel,
|
||||
inheritedEffort,
|
||||
inheritModelSettingsByDefault = false,
|
||||
forceInheritedModelSettings = false,
|
||||
lockProviderModel = false,
|
||||
modelLockReason,
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
headerTop,
|
||||
headerBottom,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<MembersEditorSection
|
||||
members={members}
|
||||
onChange={onMembersChange}
|
||||
fieldError={fieldError}
|
||||
validateMemberName={validateMemberName}
|
||||
showWorkflow={showWorkflow}
|
||||
showJsonEditor={showJsonEditor}
|
||||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
hideContent={hideMembersContent}
|
||||
existingMembers={existingMembers}
|
||||
defaultProviderId={defaultProviderId}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
inheritModelSettingsByDefault={inheritModelSettingsByDefault}
|
||||
lockProviderModel={lockProviderModel}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
modelLockReason={modelLockReason}
|
||||
headerExtra={
|
||||
<div className="space-y-3">
|
||||
{headerTop}
|
||||
<LeadModelRow
|
||||
providerId={providerId}
|
||||
model={model}
|
||||
effort={effort}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={onProviderChange}
|
||||
onModelChange={onModelChange}
|
||||
onEffortChange={onEffortChange}
|
||||
onLimitContextChange={onLimitContextChange}
|
||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
/>
|
||||
{headerBottom}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
export interface MemberDraft {
|
||||
id: string;
|
||||
|
|
@ -7,6 +8,9 @@ export interface MemberDraft {
|
|||
customRole: string;
|
||||
workflow?: string;
|
||||
workflowChips?: InlineChip[];
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
export interface MembersEditorValue {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { TeamProvisioningMemberInput } from '@shared/types';
|
||||
import type { EffortLevel, TeamProvisioningMemberInput, TeamProviderId } from '@shared/types';
|
||||
|
||||
function isValidMemberName(name: string): boolean {
|
||||
if (name.length < 1 || name.length > 128) return false;
|
||||
|
|
@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
roleSelection: initial?.roleSelection ?? '',
|
||||
customRole: initial?.customRole ?? '',
|
||||
workflow: initial?.workflow,
|
||||
providerId: initial?.providerId,
|
||||
model: initial?.model ?? '',
|
||||
effort: initial?.effort,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemberDraftsFromInputs(
|
||||
members: readonly {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
): MemberDraft[] {
|
||||
return members
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => {
|
||||
const role = typeof member.role === 'string' ? member.role.trim() : '';
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
name: member.name,
|
||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: member.workflow,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: 'anthropic',
|
||||
model: member.model ?? '',
|
||||
effort: normalizeDraftEffort(member.effort),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
|
||||
return {
|
||||
...member,
|
||||
providerId: undefined,
|
||||
model: '',
|
||||
effort: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
|
||||
if (value === 'low' || value === 'medium' || value === 'high') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface ExistingMemberColorInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
|
|
@ -113,6 +164,21 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
const result: TeamProvisioningMemberInput = { name, role };
|
||||
const workflow = getWorkflowForExport(member);
|
||||
if (workflow) result.workflow = workflow;
|
||||
const providerId: TeamProviderId =
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: 'anthropic';
|
||||
if (providerId !== 'anthropic') {
|
||||
result.providerId = providerId;
|
||||
}
|
||||
const model = member.model?.trim();
|
||||
if (model) {
|
||||
result.model = model;
|
||||
}
|
||||
const effort = normalizeDraftEffort(member.effort);
|
||||
if (effort) {
|
||||
result.effort = effort;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult {
|
|||
setTeamName: (v: string) => void;
|
||||
members: MemberDraft[];
|
||||
setMembers: (v: MemberDraft[]) => void;
|
||||
syncModelsWithLead: boolean;
|
||||
setSyncModelsWithLead: (v: boolean) => void;
|
||||
cwdMode: 'project' | 'custom';
|
||||
setCwdMode: (v: 'project' | 'custom') => void;
|
||||
selectedProjectPath: string;
|
||||
|
|
@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
|
||||
return members.map(({ id, name, roleSelection, customRole, workflow }) => ({
|
||||
id,
|
||||
name,
|
||||
roleSelection,
|
||||
customRole,
|
||||
workflow,
|
||||
}));
|
||||
return members.map(
|
||||
({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
|
||||
id,
|
||||
name,
|
||||
roleSelection,
|
||||
customRole,
|
||||
workflow,
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
|
||||
|
|
@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
|
|||
roleSelection: m.roleSelection,
|
||||
customRole: m.customRole,
|
||||
workflow: m.workflow,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
// ── State ──────────────────────────────────────────────────────────────
|
||||
const [teamName, setTeamNameState] = useState('');
|
||||
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
||||
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
|
||||
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
|
||||
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
|
||||
const [customCwd, setCustomCwdState] = useState('');
|
||||
|
|
@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
// ── Refs (latest values for debounced callbacks) ───────────────────────
|
||||
const teamNameRef = useRef('');
|
||||
const membersRef = useRef<MemberDraft[]>([]);
|
||||
const syncModelsWithLeadRef = useRef(true);
|
||||
const cwdModeRef = useRef<'project' | 'custom'>('project');
|
||||
const selectedProjectPathRef = useRef('');
|
||||
const customCwdRef = useRef('');
|
||||
|
|
@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
version: 1,
|
||||
teamName: teamNameRef.current,
|
||||
members: serializeMembers(membersRef.current),
|
||||
syncModelsWithLead: syncModelsWithLeadRef.current,
|
||||
cwdMode: cwdModeRef.current,
|
||||
selectedProjectPath: selectedProjectPathRef.current,
|
||||
customCwd: customCwdRef.current,
|
||||
|
|
@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
|
||||
teamNameRef.current = snap.teamName;
|
||||
membersRef.current = deserialized;
|
||||
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
|
||||
cwdModeRef.current = snap.cwdMode;
|
||||
selectedProjectPathRef.current = snap.selectedProjectPath;
|
||||
customCwdRef.current = snap.customCwd;
|
||||
|
|
@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
|
||||
setTeamNameState(snap.teamName);
|
||||
setMembersState(deserialized);
|
||||
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
|
||||
setCwdModeState(snap.cwdMode);
|
||||
setSelectedProjectPathState(snap.selectedProjectPath);
|
||||
setCustomCwdState(snap.customCwd);
|
||||
|
|
@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
[scheduleSave]
|
||||
);
|
||||
|
||||
const setSyncModelsWithLead = useCallback(
|
||||
(v: boolean) => {
|
||||
userTouchedRef.current = true;
|
||||
syncModelsWithLeadRef.current = v;
|
||||
setSyncModelsWithLeadState(v);
|
||||
scheduleSave();
|
||||
},
|
||||
[scheduleSave]
|
||||
);
|
||||
|
||||
const setCwdMode = useCallback(
|
||||
(v: 'project' | 'custom') => {
|
||||
userTouchedRef.current = true;
|
||||
|
|
@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
|
||||
teamNameRef.current = '';
|
||||
membersRef.current = [];
|
||||
syncModelsWithLeadRef.current = true;
|
||||
cwdModeRef.current = 'project';
|
||||
selectedProjectPathRef.current = '';
|
||||
customCwdRef.current = '';
|
||||
|
|
@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
|
||||
setTeamNameState('');
|
||||
setMembersState([]);
|
||||
setSyncModelsWithLeadState(true);
|
||||
setCwdModeState('project');
|
||||
setSelectedProjectPathState('');
|
||||
setCustomCwdState('');
|
||||
|
|
@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
setTeamName,
|
||||
members,
|
||||
setMembers,
|
||||
syncModelsWithLead,
|
||||
setSyncModelsWithLead,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@ export interface SerializedMemberDraft {
|
|||
roleSelection: string;
|
||||
customRole: string;
|
||||
workflow?: string;
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string;
|
||||
effort?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface CreateTeamDraftSnapshot {
|
||||
version: number;
|
||||
teamName: string;
|
||||
members: SerializedMemberDraft[];
|
||||
syncModelsWithLead?: boolean;
|
||||
cwdMode: 'project' | 'custom';
|
||||
selectedProjectPath: string;
|
||||
customCwd: string;
|
||||
|
|
@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
|
|||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.roleSelection === 'string' &&
|
||||
typeof obj.customRole === 'string'
|
||||
typeof obj.customRole === 'string' &&
|
||||
(obj.providerId === undefined ||
|
||||
obj.providerId === 'anthropic' ||
|
||||
obj.providerId === 'codex' ||
|
||||
obj.providerId === 'gemini') &&
|
||||
(obj.model === undefined || typeof obj.model === 'string') &&
|
||||
(obj.effort === undefined ||
|
||||
obj.effort === 'low' ||
|
||||
obj.effort === 'medium' ||
|
||||
obj.effort === 'high')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +83,7 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
|
|||
typeof obj.teamName === 'string' &&
|
||||
Array.isArray(obj.members) &&
|
||||
obj.members.every(isValidMember) &&
|
||||
(obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
|
||||
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
|
||||
typeof obj.selectedProjectPath === 'string' &&
|
||||
typeof obj.customCwd === 'string' &&
|
||||
|
|
@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
|
|||
version: SNAPSHOT_VERSION,
|
||||
teamName: '',
|
||||
members: [],
|
||||
syncModelsWithLead: true,
|
||||
cwdMode: 'project',
|
||||
selectedProjectPath: '',
|
||||
customCwd: '',
|
||||
|
|
|
|||
|
|
@ -559,6 +559,7 @@ export interface GlobalTaskDetailState {
|
|||
|
||||
/** Per-team launch parameters shown in the header badge. */
|
||||
export interface TeamLaunchParams {
|
||||
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||
model?: string; // 'opus' | 'sonnet' | 'haiku'
|
||||
effort?: EffortLevel;
|
||||
limitContext?: boolean;
|
||||
|
|
@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
limitContext: request.limitContext ?? false,
|
||||
|
|
@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
limitContext: request.limitContext ?? false,
|
||||
|
|
|
|||
|
|
@ -427,7 +427,11 @@ export interface TeamsAPI {
|
|||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
|
||||
deleteDraft: (teamName: string) => Promise<void>;
|
||||
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
|
||||
prepareProvisioning: (
|
||||
cwd?: string,
|
||||
providerId?: TeamLaunchRequest['providerId'],
|
||||
providerIds?: TeamLaunchRequest['providerId'][]
|
||||
) => Promise<TeamProvisioningPrepareResult>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||
cancelProvisioning: (runId: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Repository Pattern abstraction allows swapping storage backend (JSON → sql.js/Drizzle).
|
||||
*/
|
||||
|
||||
import type { EffortLevel } from './team';
|
||||
import type { EffortLevel, TeamProviderId } from './team';
|
||||
|
||||
// =============================================================================
|
||||
// Schedule Status Types
|
||||
|
|
@ -49,6 +49,7 @@ export interface Schedule {
|
|||
export interface ScheduleLaunchConfig {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
skipPermissions?: boolean;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ export interface TeamMember {
|
|||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
color?: string;
|
||||
joinedAt?: number;
|
||||
cwd?: string;
|
||||
|
|
@ -471,6 +474,9 @@ export interface ResolvedTeamMember {
|
|||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
|
|
@ -503,11 +509,13 @@ export interface TeamData {
|
|||
}
|
||||
|
||||
export type EffortLevel = 'low' | 'medium' | 'high';
|
||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
|
||||
export interface TeamLaunchRequest {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||
|
|
@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput {
|
|||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
export interface TeamCreateRequest {
|
||||
|
|
@ -643,6 +654,7 @@ export interface TeamCreateRequest {
|
|||
members: TeamProvisioningMemberInput[];
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||
|
|
@ -780,6 +792,9 @@ export interface AddMemberRequest {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
export interface RemoveMemberRequest {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([
|
|||
'--mcp-config',
|
||||
'--disallowedTools',
|
||||
'--verbose',
|
||||
'--model',
|
||||
'--effort',
|
||||
'--resume',
|
||||
'--permission-mode',
|
||||
'--permission-prompt-tool',
|
||||
'--dangerously-skip-permissions',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -91,6 +91,28 @@ describe('TeamMemberResolver', () => {
|
|||
expect(names).not.toContain('dream-team.team-lead');
|
||||
});
|
||||
|
||||
it('ignores leaked generated agent ids from inbox file names', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
name: 'Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
|
||||
};
|
||||
const metaMembers: TeamConfig['members'] = [
|
||||
{ name: 'alice', agentType: 'general-purpose' },
|
||||
{ name: 'bob', agentType: 'general-purpose' },
|
||||
];
|
||||
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
expect(names).toContain('bob');
|
||||
expect(names).toContain('team-lead');
|
||||
expect(names).not.toContain('a3975f80d37fbcea1');
|
||||
expect(names).not.toContain('a68a8f6a643e59bfd');
|
||||
});
|
||||
|
||||
it('keeps dotted names when they are explicitly configured members', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB);
|
||||
});
|
||||
|
||||
it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult');
|
||||
getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => {
|
||||
if (providerId === 'codex') {
|
||||
return {
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'none',
|
||||
warning: 'Not logged in to Codex runtime',
|
||||
};
|
||||
}
|
||||
return {
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'oauth_token',
|
||||
};
|
||||
});
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
forceFresh: true,
|
||||
providerId: 'anthropic',
|
||||
providerIds: ['codex', 'anthropic'],
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.message).toBe('Codex: Not logged in to Codex runtime');
|
||||
expect(getCachedOrProbeResult).toHaveBeenCalledTimes(2);
|
||||
expect(getCachedOrProbeResult.mock.calls.map((call) => call[1])).toEqual([
|
||||
'anthropic',
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
|||
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['alice-2']);
|
||||
});
|
||||
|
||||
it('inbox fallback merges provider/model overrides from config for multimodel reconnect', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
{ listInboxNames: vi.fn(async () => ['bob']) } as never,
|
||||
{ getMembers: vi.fn(async () => []) } as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
const configRaw = JSON.stringify({
|
||||
name: 't',
|
||||
members: [{ name: 'bob', role: 'reviewer', provider: 'codex', model: 'gpt-5.4' }],
|
||||
});
|
||||
|
||||
const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw);
|
||||
expect(result.source).toBe('inboxes');
|
||||
expect(result.members).toEqual([
|
||||
{ name: 'bob', role: 'reviewer', workflow: undefined, providerId: 'codex', model: 'gpt-5.4' },
|
||||
]);
|
||||
expect(result.warning).toContain('best-effort');
|
||||
});
|
||||
|
||||
it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => {
|
||||
const svc = new TeamProvisioningService(
|
||||
{} as never,
|
||||
|
|
|
|||
Loading…
Reference in a new issue