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 {
|
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
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 prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||||
const model = assertOptionalString(payload.model, 'model');
|
const model = assertOptionalString(payload.model, 'model');
|
||||||
const effort = assertOptionalEffort(payload.effort);
|
const effort = assertOptionalEffort(payload.effort);
|
||||||
|
|
@ -100,6 +110,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
cwd: assertAbsoluteCwd(payload.cwd),
|
cwd: assertAbsoluteCwd(payload.cwd),
|
||||||
|
providerId,
|
||||||
...(prompt && {
|
...(prompt && {
|
||||||
prompt,
|
prompt,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
import {
|
||||||
|
buildReplaceMembersDiff,
|
||||||
|
buildReplaceMembersSummaryMessage,
|
||||||
|
} from '../services/team/memberUpdateNotifications';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
|
|
@ -534,25 +538,30 @@ async function handleGetData(
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamData;
|
let data: TeamData;
|
||||||
|
setCurrentMainOp('team:getData');
|
||||||
try {
|
try {
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
try {
|
||||||
} catch (error) {
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
} catch (error) {
|
||||||
if (
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
message === `Team not found: ${tn}` &&
|
if (
|
||||||
getTeamProvisioningService().hasProvisioningRun(tn)
|
message === `Team not found: ${tn}` &&
|
||||||
) {
|
getTeamProvisioningService().hasProvisioningRun(tn)
|
||||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
) {
|
||||||
}
|
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' };
|
|
||||||
}
|
}
|
||||||
|
// 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}`);
|
} finally {
|
||||||
return { success: false, error: message };
|
setCurrentMainOp(null);
|
||||||
}
|
}
|
||||||
const getDataMs = Date.now() - startedAt;
|
const getDataMs = Date.now() - startedAt;
|
||||||
if (getDataMs >= 1500) {
|
if (getDataMs >= 1500) {
|
||||||
|
|
@ -833,6 +842,32 @@ function isValidEffort(value: unknown): value is EffortLevel {
|
||||||
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
|
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(
|
async function validateProvisioningRequest(
|
||||||
request: unknown
|
request: unknown
|
||||||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||||||
|
|
@ -884,10 +919,22 @@ async function validateProvisioningRequest(
|
||||||
if (workflow !== undefined && typeof workflow !== 'string') {
|
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||||
return { valid: false, error: 'member workflow must be 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({
|
members.push({
|
||||||
name: memberName,
|
name: memberName,
|
||||||
role: typeof role === 'string' ? role.trim() : undefined,
|
role: typeof role === 'string' ? role.trim() : undefined,
|
||||||
workflow: typeof workflow === 'string' ? workflow.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,
|
members,
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
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,
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||||
skipPermissions:
|
skipPermissions:
|
||||||
|
|
@ -1089,6 +1142,16 @@ async function handleLaunchTeam(
|
||||||
color: meta?.color,
|
color: meta?.color,
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
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,
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||||
|
|
@ -1100,7 +1163,14 @@ async function handleLaunchTeam(
|
||||||
typeof payload.extraCliArgs === 'string'
|
typeof payload.extraCliArgs === 'string'
|
||||||
? payload.extraCliArgs.trim() || undefined
|
? payload.extraCliArgs.trim() || undefined
|
||||||
: 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', () =>
|
return wrapTeamHandler('create', () =>
|
||||||
|
|
@ -1122,6 +1192,12 @@ async function handleLaunchTeam(
|
||||||
teamName: validatedTeamName.value!,
|
teamName: validatedTeamName.value!,
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
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,
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||||
clearContext: payload.clearContext === true ? true : undefined,
|
clearContext: payload.clearContext === true ? true : undefined,
|
||||||
|
|
@ -1174,9 +1250,13 @@ async function handleValidateCliArgs(
|
||||||
|
|
||||||
async function handlePrepareProvisioning(
|
async function handlePrepareProvisioning(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
cwd: unknown
|
cwd: unknown,
|
||||||
|
providerId: unknown,
|
||||||
|
providerIds: unknown
|
||||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||||
let validatedCwd: string | undefined;
|
let validatedCwd: string | undefined;
|
||||||
|
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||||
|
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
|
||||||
if (cwd !== undefined) {
|
if (cwd !== undefined) {
|
||||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||||
return { success: false, error: 'cwd must be a non-empty string' };
|
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' };
|
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', () =>
|
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') {
|
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||||
return { success: false, error: 'member workflow must be 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({
|
members.push({
|
||||||
name: memberName,
|
name: memberName,
|
||||||
role: typeof role === 'string' ? role.trim() : undefined,
|
role: typeof role === 'string' ? role.trim() : undefined,
|
||||||
workflow: typeof workflow === 'string' ? workflow.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') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return { success: false, error: 'Invalid payload' };
|
return { success: false, error: 'Invalid payload' };
|
||||||
}
|
}
|
||||||
const { name, role, workflow } = payload as {
|
const { name, role, workflow, providerId, model } = payload as {
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
role?: unknown;
|
role?: unknown;
|
||||||
workflow?: unknown;
|
workflow?: unknown;
|
||||||
|
providerId?: unknown;
|
||||||
|
model?: unknown;
|
||||||
|
effort?: unknown;
|
||||||
};
|
};
|
||||||
const vName = validateTeammateName(name);
|
const vName = validateTeammateName(name);
|
||||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member 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') {
|
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||||
return { success: false, error: 'workflow must be a 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 () => {
|
return wrapTeamHandler('addMember', async () => {
|
||||||
const tn = vTeam.value!;
|
const tn = vTeam.value!;
|
||||||
|
|
@ -2307,6 +2442,9 @@ async function handleAddMember(
|
||||||
name: memberName,
|
name: memberName,
|
||||||
role: role,
|
role: role,
|
||||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
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
|
// If team is alive, notify the lead to spawn the new teammate
|
||||||
|
|
@ -2329,6 +2467,9 @@ async function handleAddMember(
|
||||||
name: memberName,
|
name: memberName,
|
||||||
...(typeof role === 'string' ? { role } : {}),
|
...(typeof role === 'string' ? { role } : {}),
|
||||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||||
|
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||||
|
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||||
|
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||||
|
|
@ -2355,12 +2496,26 @@ async function handleReplaceMembers(
|
||||||
return { success: false, error: 'members must be an array' };
|
return { success: false, error: 'members must be an array' };
|
||||||
}
|
}
|
||||||
const seenNames = new Set<string>();
|
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) {
|
for (const item of payload.members) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
return { success: false, error: 'member must be 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);
|
const vName = validateTeammateName(m.name);
|
||||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||||
const name = vName.value!;
|
const name = vName.value!;
|
||||||
|
|
@ -2372,15 +2527,73 @@ async function handleReplaceMembers(
|
||||||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||||||
return { success: false, error: 'member workflow must be 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({
|
members.push({
|
||||||
name,
|
name,
|
||||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||||
workflow: typeof m.workflow === 'string' ? m.workflow.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 () => {
|
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,
|
color: meta.color,
|
||||||
cwd: meta.cwd,
|
cwd: meta.cwd,
|
||||||
prompt: meta.prompt,
|
prompt: meta.prompt,
|
||||||
|
providerId: meta.providerId ?? 'anthropic',
|
||||||
model: meta.model,
|
model: meta.model,
|
||||||
effort: meta.effort as TeamCreateRequest['effort'],
|
effort: meta.effort as TeamCreateRequest['effort'],
|
||||||
skipPermissions: meta.skipPermissions,
|
skipPermissions: meta.skipPermissions,
|
||||||
|
|
@ -3098,6 +3312,9 @@ async function handleGetSavedRequest(
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
workflow: m.workflow,
|
workflow: m.workflow,
|
||||||
|
providerId: m.providerId,
|
||||||
|
model: m.model,
|
||||||
|
effort: m.effort,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ export function startEventLoopLagMonitor(): void {
|
||||||
// Only report meaningful stalls
|
// Only report meaningful stalls
|
||||||
if (maxMs < 250) return;
|
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(
|
logger.warn(
|
||||||
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
||||||
(currentOp ? ` op=${currentOp}` : '')
|
(currentOp ? ` op=${currentOp}` : '')
|
||||||
|
|
|
||||||
|
|
@ -948,6 +948,15 @@ export class TeamDataService {
|
||||||
name,
|
name,
|
||||||
role: request.role?.trim() || undefined,
|
role: request.role?.trim() || undefined,
|
||||||
workflow: request.workflow?.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',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(name),
|
color: getMemberColorByName(name),
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
|
|
@ -977,7 +986,16 @@ export class TeamDataService {
|
||||||
|
|
||||||
async replaceMembers(
|
async replaceMembers(
|
||||||
teamName: string,
|
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> {
|
): Promise<void> {
|
||||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||||
const existingLead = existing.find(isLeadMember) ?? null;
|
const existingLead = existing.find(isLeadMember) ?? null;
|
||||||
|
|
@ -1003,6 +1021,15 @@ export class TeamDataService {
|
||||||
name,
|
name,
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.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',
|
agentType: prev?.agentType ?? 'general-purpose',
|
||||||
color: prev?.color ?? getMemberColorByName(name),
|
color: prev?.color ?? getMemberColorByName(name),
|
||||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||||
|
|
@ -1957,6 +1984,16 @@ export class TeamDataService {
|
||||||
return name;
|
return name;
|
||||||
})(),
|
})(),
|
||||||
role: member.role?.trim() || undefined,
|
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',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(member.name.trim()),
|
color: getMemberColorByName(member.name.trim()),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||||
'cross_team_list_targets',
|
'cross_team_list_targets',
|
||||||
'cross_team_get_outbox',
|
'cross_team_get_outbox',
|
||||||
]);
|
]);
|
||||||
|
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
|
||||||
|
|
||||||
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
@ -51,6 +52,10 @@ function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
||||||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
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 {
|
export class TeamMemberResolver {
|
||||||
resolveMembers(
|
resolveMembers(
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
|
|
@ -106,13 +111,25 @@ export class TeamMemberResolver {
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
addName(trimmed);
|
addName(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const configMemberMap = new Map<
|
const configMemberMap = new Map<
|
||||||
string,
|
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)) {
|
if (Array.isArray(config.members)) {
|
||||||
for (const m of config.members) {
|
for (const m of config.members) {
|
||||||
|
|
@ -121,6 +138,9 @@ export class TeamMemberResolver {
|
||||||
agentType: m.agentType,
|
agentType: m.agentType,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
workflow: m.workflow,
|
workflow: m.workflow,
|
||||||
|
providerId: m.providerId,
|
||||||
|
model: m.model,
|
||||||
|
effort: m.effort,
|
||||||
color: m.color,
|
color: m.color,
|
||||||
cwd: m.cwd,
|
cwd: m.cwd,
|
||||||
});
|
});
|
||||||
|
|
@ -130,7 +150,16 @@ export class TeamMemberResolver {
|
||||||
|
|
||||||
const metaMemberMap = new Map<
|
const metaMemberMap = new Map<
|
||||||
string,
|
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)) {
|
if (Array.isArray(metaMembers)) {
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
|
|
@ -139,6 +168,9 @@ export class TeamMemberResolver {
|
||||||
agentType: member.agentType,
|
agentType: member.agentType,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
workflow: member.workflow,
|
workflow: member.workflow,
|
||||||
|
providerId: member.providerId,
|
||||||
|
model: member.model,
|
||||||
|
effort: member.effort,
|
||||||
color: member.color,
|
color: member.color,
|
||||||
removedAt: member.removedAt,
|
removedAt: member.removedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -193,6 +225,9 @@ export class TeamMemberResolver {
|
||||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||||
role: configMember?.role ?? metaMember?.role,
|
role: configMember?.role ?? metaMember?.role,
|
||||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||||
|
providerId: configMember?.providerId ?? metaMember?.providerId,
|
||||||
|
model: configMember?.model ?? metaMember?.model,
|
||||||
|
effort: configMember?.effort ?? metaMember?.effort,
|
||||||
cwd: configMember?.cwd,
|
cwd: configMember?.cwd,
|
||||||
removedAt: metaMember?.removedAt,
|
removedAt: metaMember?.removedAt,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||||
workflow: typeof member.workflow === 'string' ? member.workflow.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:
|
agentType:
|
||||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface TeamMetaFile {
|
||||||
color?: string;
|
color?: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
skipPermissions?: boolean;
|
skipPermissions?: boolean;
|
||||||
|
|
@ -82,6 +83,12 @@ export class TeamMetaStore {
|
||||||
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
color: typeof file.color === 'string' ? file.color.trim() || undefined : undefined,
|
||||||
cwd: file.cwd.trim(),
|
cwd: file.cwd.trim(),
|
||||||
prompt: typeof file.prompt === 'string' ? file.prompt.trim() || undefined : undefined,
|
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,
|
model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined,
|
||||||
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined,
|
||||||
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined,
|
||||||
|
|
@ -101,6 +108,7 @@ export class TeamMetaStore {
|
||||||
color: data.color?.trim() || undefined,
|
color: data.color?.trim() || undefined,
|
||||||
cwd: data.cwd.trim(),
|
cwd: data.cwd.trim(),
|
||||||
prompt: data.prompt?.trim() || undefined,
|
prompt: data.prompt?.trim() || undefined,
|
||||||
|
providerId: data.providerId,
|
||||||
model: data.model?.trim() || undefined,
|
model: data.model?.trim() || undefined,
|
||||||
effort: data.effort?.trim() || undefined,
|
effort: data.effort?.trim() || undefined,
|
||||||
skipPermissions: data.skipPermissions,
|
skipPermissions: data.skipPermissions,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
import { applyProviderRuntimeEnv, resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||||
|
import { resolveGeminiRuntimeAuth } from '../runtime/geminiRuntimeAuth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||||
|
|
@ -106,6 +108,7 @@ import type {
|
||||||
ToolApprovalRequest,
|
ToolApprovalRequest,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
ToolCallMeta,
|
ToolCallMeta,
|
||||||
|
TeamProviderId,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
|
|
@ -121,6 +124,9 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
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_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
const FS_MONITOR_POLL_MS = 2000;
|
const FS_MONITOR_POLL_MS = 2000;
|
||||||
|
|
@ -143,18 +149,69 @@ const HANDLED_STREAM_JSON_TYPES = new Set([
|
||||||
'system',
|
'system',
|
||||||
]);
|
]);
|
||||||
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
|
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_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';
|
type TeamsBaseLocation = 'configured' | 'default';
|
||||||
|
|
||||||
|
|
@ -257,6 +314,8 @@ interface ProvisioningRun {
|
||||||
* watchdog defers to retry messages for progress.message (retries are
|
* watchdog defers to retry messages for progress.message (retries are
|
||||||
* more informative than the generic "CLI not responding" stall text). */
|
* more informative than the generic "CLI not responding" stall text). */
|
||||||
lastRetryAt: number;
|
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. */
|
/** True after emitApiErrorWarning() fires once — prevents duplicate warnings and pre-complete false positives. */
|
||||||
apiErrorWarningEmitted: boolean;
|
apiErrorWarningEmitted: boolean;
|
||||||
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
|
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
|
||||||
|
|
@ -351,7 +410,12 @@ interface ProvisioningRun {
|
||||||
|
|
||||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
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 {
|
interface ProvisioningEnvResolution {
|
||||||
env: NodeJS.ProcessEnv;
|
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. */
|
/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */
|
||||||
const wrapInAgentBlock = wrapAgentBlock;
|
const wrapInAgentBlock = wrapAgentBlock;
|
||||||
|
|
||||||
|
|
@ -426,10 +495,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||||
return members
|
return members
|
||||||
.map((member) => {
|
.map((member) => {
|
||||||
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
|
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()
|
const workflowPart = member.workflow?.trim()
|
||||||
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
|
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||||
: '';
|
: '';
|
||||||
return `- ${member.name}${rolePart}${workflowPart}`;
|
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -463,13 +538,21 @@ function buildMemberSpawnPrompt(
|
||||||
leadName: string
|
leadName: string
|
||||||
): string {
|
): string {
|
||||||
const role = member.role?.trim() || 'team member';
|
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()
|
const workflowBlock = member.workflow?.trim()
|
||||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
|
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
|
||||||
: '';
|
: '';
|
||||||
const actionModeProtocol = protocols.buildActionModeProtocolText(
|
const actionModeProtocol = protocols.buildActionModeProtocolText(
|
||||||
protocols.MEMBER_DELEGATE_DESCRIPTION
|
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()}
|
${getAgentLanguageInstruction()}
|
||||||
Your FIRST action: call MCP tool member_briefing with:
|
Your FIRST action: call MCP tool member_briefing with:
|
||||||
|
|
@ -499,6 +582,16 @@ function buildReconnectMemberSpawnPrompt(
|
||||||
hasTasks: boolean
|
hasTasks: boolean
|
||||||
): string {
|
): string {
|
||||||
const role = member.role?.trim() || 'team member';
|
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()
|
const workflowBlock = member.workflow?.trim()
|
||||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
|
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -506,9 +599,15 @@ function buildReconnectMemberSpawnPrompt(
|
||||||
protocols.buildActionModeProtocolText(protocols.MEMBER_DELEGATE_DESCRIPTION),
|
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}":
|
return ` For "${member.name}":
|
||||||
- prompt:
|
${providerArgLine}${modelArgLine}${effortArgLine} - prompt:
|
||||||
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock}
|
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}
|
||||||
|
|
||||||
${getAgentLanguageInstruction()}
|
${getAgentLanguageInstruction()}
|
||||||
The team has been reconnected after a restart.
|
The team has been reconnected after a restart.
|
||||||
|
|
@ -546,7 +645,10 @@ export function buildAddMemberSpawnMessage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
member: Pick<TeamCreateRequest['members'][number], 'name' | 'role' | 'workflow'>
|
member: Pick<
|
||||||
|
TeamCreateRequest['members'][number],
|
||||||
|
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||||
|
>
|
||||||
): string {
|
): string {
|
||||||
const roleHint =
|
const roleHint =
|
||||||
typeof member.role === 'string' && member.role.trim()
|
typeof member.role === 'string' && member.role.trim()
|
||||||
|
|
@ -562,15 +664,24 @@ export function buildAddMemberSpawnMessage(
|
||||||
name: member.name,
|
name: member.name,
|
||||||
...(member.role ? { role: member.role } : {}),
|
...(member.role ? { role: member.role } : {}),
|
||||||
...(member.workflow ? { workflow: member.workflow } : {}),
|
...(member.workflow ? { workflow: member.workflow } : {}),
|
||||||
|
...(member.providerId ? { providerId: member.providerId } : {}),
|
||||||
|
...(member.model ? { model: member.model } : {}),
|
||||||
|
...(member.effort ? { effort: member.effort } : {}),
|
||||||
},
|
},
|
||||||
displayName,
|
displayName,
|
||||||
teamName,
|
teamName,
|
||||||
leadName
|
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 (
|
return (
|
||||||
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
|
`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, ' ')
|
indentMultiline(prompt, ' ')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -926,15 +1037,18 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
|
|
||||||
Per-member spawn instructions:
|
Per-member spawn instructions:
|
||||||
${request.members
|
${request.members
|
||||||
.map(
|
.map((m) => {
|
||||||
(m) => ` For “${m.name}”:
|
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}”
|
- name: “${m.name}”
|
||||||
- prompt:
|
${providerLine}${modelLine} - prompt:
|
||||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => ` ${line}`)
|
.map((line) => ` ${line}`)
|
||||||
.join('\n')}`
|
.join('\n')}`;
|
||||||
)
|
})
|
||||||
.join('\n\n')}`;
|
.join('\n\n')}`;
|
||||||
|
|
||||||
const persistentContext = buildPersistentLeadContext({
|
const persistentContext = buildPersistentLeadContext({
|
||||||
|
|
@ -1212,8 +1326,8 @@ type AuthWarningSource = 'probe' | 'stdout' | 'stderr' | 'assistant' | 'pre-comp
|
||||||
const cachedProbeResults = new Map<string, CachedProbeResult>();
|
const cachedProbeResults = new Map<string, CachedProbeResult>();
|
||||||
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
|
const probeInFlightByKey = new Map<string, Promise<ProbeResult | null>>();
|
||||||
|
|
||||||
function createProbeCacheKey(cwd: string): string {
|
function createProbeCacheKey(cwd: string, providerId: TeamProviderId | undefined): string {
|
||||||
return `${path.resolve(cwd)}::${getClaudeBasePath()}`;
|
return `${path.resolve(cwd)}::${getClaudeBasePath()}::${resolveTeamProviderId(providerId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransientProbeWarning(warning: string): boolean {
|
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 {
|
interface PendingInboxRelayCandidate {
|
||||||
recipient: string;
|
recipient: string;
|
||||||
sourceMessageId: string;
|
sourceMessageId: string;
|
||||||
|
|
@ -2304,8 +2429,8 @@ export class TeamProvisioningService {
|
||||||
async warmup(): Promise<void> {
|
async warmup(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
if (this.getFreshCachedProbeResult(cwd)) return;
|
if (this.getFreshCachedProbeResult(cwd, 'anthropic')) return;
|
||||||
const result = await this.getCachedOrProbeResult(cwd);
|
const result = await this.getCachedOrProbeResult(cwd, 'anthropic');
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
logger.info('CLI warmup completed');
|
logger.info('CLI warmup completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2315,32 +2440,26 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
async prepareForProvisioning(
|
async prepareForProvisioning(
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
opts?: { forceFresh?: boolean }
|
opts?: { forceFresh?: boolean; providerId?: TeamProviderId; providerIds?: TeamProviderId[] }
|
||||||
): Promise<TeamProvisioningPrepareResult> {
|
): Promise<TeamProvisioningPrepareResult> {
|
||||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||||
await this.validatePrepareCwd(targetCwdForValidation);
|
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
|
// Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache
|
||||||
if (opts?.forceFresh) {
|
if (opts?.forceFresh) {
|
||||||
this.clearProbeCache(targetCwdForValidation);
|
for (const providerId of providerIds) {
|
||||||
}
|
this.clearProbeCache(targetCwdForValidation, providerId);
|
||||||
|
}
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetCwd = cwd?.trim() || process.cwd();
|
const targetCwd = cwd?.trim() || process.cwd();
|
||||||
|
|
@ -2349,45 +2468,77 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const blockingMessages: string[] = [];
|
||||||
|
|
||||||
const probeResult = await this.getCachedOrProbeResult(targetCwd);
|
for (const providerId of providerIds) {
|
||||||
if (!probeResult?.claudePath) {
|
const cached = this.getFreshCachedProbeResult(targetCwdForValidation, providerId);
|
||||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
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');
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// 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 {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
message:
|
message:
|
||||||
warnings.length > 0
|
providerIds.length > 1
|
||||||
? 'CLI is ready to launch (see notes)'
|
? warnings.length > 0
|
||||||
: 'CLI is warmed up and ready to launch',
|
? `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,
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFreshCachedProbeResult(cwd: string): CachedProbeResult | null {
|
private getFreshCachedProbeResult(
|
||||||
const cacheKey = createProbeCacheKey(cwd);
|
cwd: string,
|
||||||
|
providerId: TeamProviderId | undefined
|
||||||
|
): CachedProbeResult | null {
|
||||||
|
const cacheKey = createProbeCacheKey(cwd, providerId);
|
||||||
const cached = cachedProbeResults.get(cacheKey);
|
const cached = cachedProbeResults.get(cacheKey);
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
const ageMs = Date.now() - cached.cachedAtMs;
|
const ageMs = Date.now() - cached.cachedAtMs;
|
||||||
|
|
@ -2398,8 +2549,8 @@ export class TeamProvisioningService {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearProbeCache(cwd: string): void {
|
private clearProbeCache(cwd: string, providerId: TeamProviderId | undefined): void {
|
||||||
cachedProbeResults.delete(createProbeCacheKey(cwd));
|
cachedProbeResults.delete(createProbeCacheKey(cwd, providerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validatePrepareCwd(cwd: string): Promise<void> {
|
private async validatePrepareCwd(cwd: string): Promise<void> {
|
||||||
|
|
@ -2414,15 +2565,18 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
return;
|
throw new Error(`Working directory does not exist: ${cwd}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCachedOrProbeResult(cwd: string): Promise<ProbeResult | null> {
|
private async getCachedOrProbeResult(
|
||||||
const cacheKey = createProbeCacheKey(cwd);
|
cwd: string,
|
||||||
const cached = this.getFreshCachedProbeResult(cwd);
|
providerId: TeamProviderId | undefined
|
||||||
|
): Promise<ProbeResult | null> {
|
||||||
|
const cacheKey = createProbeCacheKey(cwd, providerId);
|
||||||
|
const cached = this.getFreshCachedProbeResult(cwd, providerId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return {
|
return {
|
||||||
claudePath: cached.claudePath,
|
claudePath: cached.claudePath,
|
||||||
|
|
@ -2440,8 +2594,8 @@ export class TeamProvisioningService {
|
||||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!claudePath) return null;
|
if (!claudePath) return null;
|
||||||
|
|
||||||
const { env, authSource } = await this.buildProvisioningEnv();
|
const { env, authSource } = await this.buildProvisioningEnv(providerId);
|
||||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
|
const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId);
|
||||||
const result = {
|
const result = {
|
||||||
claudePath,
|
claudePath,
|
||||||
authSource,
|
authSource,
|
||||||
|
|
@ -2451,7 +2605,8 @@ export class TeamProvisioningService {
|
||||||
const shouldCache =
|
const shouldCache =
|
||||||
!probe.warning ||
|
!probe.warning ||
|
||||||
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
|
(!this.isAuthFailureWarning(probe.warning, 'probe') &&
|
||||||
!isTransientProbeWarning(probe.warning));
|
!isTransientProbeWarning(probe.warning) &&
|
||||||
|
!isBinaryProbeWarning(probe.warning));
|
||||||
|
|
||||||
if (shouldCache) {
|
if (shouldCache) {
|
||||||
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
|
cachedProbeResults.set(cacheKey, { cacheKey, ...result, cachedAtMs: Date.now() });
|
||||||
|
|
@ -2480,8 +2635,14 @@ export class TeamProvisioningService {
|
||||||
lower.includes('missing api key') ||
|
lower.includes('missing api key') ||
|
||||||
lower.includes('invalid api key') ||
|
lower.includes('invalid api key') ||
|
||||||
lower.includes('authentication failed') ||
|
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('run `claude auth login`') ||
|
||||||
lower.includes('claude auth login');
|
lower.includes('claude auth login') ||
|
||||||
|
lower.includes('claude-multimodel auth login');
|
||||||
|
|
||||||
if (hasExplicitCliAuthSignal) {
|
if (hasExplicitCliAuthSignal) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -2515,6 +2676,56 @@ export class TeamProvisioningService {
|
||||||
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
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 {
|
private extractApiErrorSnippet(text: string): string | null {
|
||||||
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
||||||
if (match?.index === undefined) return null;
|
if (match?.index === undefined) return null;
|
||||||
|
|
@ -3114,6 +3325,7 @@ export class TeamProvisioningService {
|
||||||
stallWarningIndex: null,
|
stallWarningIndex: null,
|
||||||
preStallMessage: null,
|
preStallMessage: null,
|
||||||
lastRetryAt: 0,
|
lastRetryAt: 0,
|
||||||
|
apiRetryWarningIndex: null,
|
||||||
apiErrorWarningEmitted: false,
|
apiErrorWarningEmitted: false,
|
||||||
waitingTasksSince: null,
|
waitingTasksSince: null,
|
||||||
provisioningComplete: false,
|
provisioningComplete: false,
|
||||||
|
|
@ -3162,7 +3374,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const prompt = buildProvisioningPrompt(request);
|
const prompt = buildProvisioningPrompt(request);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
try {
|
try {
|
||||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||||
|
|
@ -3207,6 +3419,7 @@ export class TeamProvisioningService {
|
||||||
color: request.color,
|
color: request.color,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
prompt: request.prompt,
|
prompt: request.prompt,
|
||||||
|
providerId: request.providerId,
|
||||||
model: request.model,
|
model: request.model,
|
||||||
effort: request.effort,
|
effort: request.effort,
|
||||||
skipPermissions: request.skipPermissions,
|
skipPermissions: request.skipPermissions,
|
||||||
|
|
@ -3510,6 +3723,7 @@ export class TeamProvisioningService {
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
members: expectedMemberSpecs,
|
members: expectedMemberSpecs,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
providerId: request.providerId,
|
||||||
skipPermissions: request.skipPermissions,
|
skipPermissions: request.skipPermissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3557,6 +3771,7 @@ export class TeamProvisioningService {
|
||||||
stallWarningIndex: null,
|
stallWarningIndex: null,
|
||||||
preStallMessage: null,
|
preStallMessage: null,
|
||||||
lastRetryAt: 0,
|
lastRetryAt: 0,
|
||||||
|
apiRetryWarningIndex: null,
|
||||||
apiErrorWarningEmitted: false,
|
apiErrorWarningEmitted: false,
|
||||||
waitingTasksSince: null,
|
waitingTasksSince: null,
|
||||||
provisioningComplete: false,
|
provisioningComplete: false,
|
||||||
|
|
@ -3627,7 +3842,7 @@ export class TeamProvisioningService {
|
||||||
Boolean(previousSessionId)
|
Boolean(previousSessionId)
|
||||||
);
|
);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId);
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
try {
|
try {
|
||||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||||
|
|
@ -4674,6 +4889,13 @@ export class TeamProvisioningService {
|
||||||
const inp = input as Record<string, unknown>;
|
const inp = input as Record<string, unknown>;
|
||||||
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
|
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
|
||||||
const memberName = typeof inp.name === 'string' ? inp.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 (!memberName) continue;
|
||||||
if (!teamName) {
|
if (!teamName) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -5577,21 +5799,45 @@ export class TeamProvisioningService {
|
||||||
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
|
const errorStatus = typeof msg.error_status === 'number' ? msg.error_status : undefined;
|
||||||
const errorLabel = typeof msg.error === 'string' ? msg.error.replace(/_/g, ' ') : 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 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
|
// Use a human label for known quota/rate-limit retries instead of a misleading 500 bucket.
|
||||||
const statusLabel = errorLabel
|
const statusLabel = looksLikeQuotaRetry
|
||||||
? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
|
? 'rate limited'
|
||||||
: `error ${errorStatus ?? 'unknown'}`;
|
: errorLabel
|
||||||
|
? `${errorLabel}${errorStatus ? ` (${errorStatus})` : ''}`
|
||||||
|
: `error ${errorStatus ?? 'unknown'}`;
|
||||||
const delayLabel = retryDelay ? ` — next retry in ${Math.round(retryDelay / 1000)}s` : '';
|
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) {
|
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.lastRetryAt = Date.now();
|
||||||
run.progress = {
|
run.progress = {
|
||||||
...run.progress,
|
...run.progress,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
message: retryText,
|
message: retryText,
|
||||||
messageSeverity: 'error' as const,
|
messageSeverity: 'error' as const,
|
||||||
|
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
||||||
};
|
};
|
||||||
run.onProgress(run.progress);
|
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();
|
const shellEnv = await resolveInteractiveShellEnv();
|
||||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
// 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',
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
};
|
};
|
||||||
|
applyProviderRuntimeEnv(env, providerId);
|
||||||
|
|
||||||
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
||||||
if (controlApiBaseUrl) {
|
if (controlApiBaseUrl) {
|
||||||
|
|
@ -7631,6 +7880,14 @@ export class TeamProvisioningService {
|
||||||
env.XDG_STATE_HOME = xdgStateHome;
|
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
|
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
||||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
||||||
return { env, authSource: 'anthropic_api_key' };
|
return { env, authSource: 'anthropic_api_key' };
|
||||||
|
|
@ -8300,6 +8557,15 @@ export class TeamProvisioningService {
|
||||||
name: member.name.trim(),
|
name: member.name.trim(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.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',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColorByName(member.name.trim()),
|
color: getMemberColorByName(member.name.trim()),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
|
|
@ -8337,14 +8603,27 @@ export class TeamProvisioningService {
|
||||||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||||
const workflow =
|
const workflow =
|
||||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
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);
|
const prev = byName.get(name);
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
byName.set(name, { name, role, workflow });
|
byName.set(name, { name, role, workflow, providerId, model, effort });
|
||||||
} else {
|
} else {
|
||||||
byName.set(name, {
|
byName.set(name, {
|
||||||
...prev,
|
...prev,
|
||||||
role: prev.role || role,
|
role: prev.role || role,
|
||||||
workflow: prev.workflow || workflow,
|
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());
|
return !inboxNameSetLower.has(match[1].toLowerCase());
|
||||||
});
|
});
|
||||||
if (inboxNames.length > 0) {
|
if (inboxNames.length > 0) {
|
||||||
const members = inboxNames.map((name) => ({ name }));
|
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
|
||||||
return { members, source: 'inboxes' };
|
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) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -8439,7 +8745,17 @@ export class TeamProvisioningService {
|
||||||
configRaw: string
|
configRaw: string
|
||||||
): TeamCreateRequest['members'] {
|
): TeamCreateRequest['members'] {
|
||||||
try {
|
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)) {
|
if (!Array.isArray(parsed.members)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -8450,7 +8766,21 @@ export class TeamProvisioningService {
|
||||||
if (!member || isLeadMember(member) || lower === 'user') continue;
|
if (!member || isLeadMember(member) || lower === 'user') continue;
|
||||||
const name = rawName;
|
const name = rawName;
|
||||||
if (!name) continue;
|
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.
|
// Defense: ignore CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||||
const allNames = Array.from(byName.keys());
|
const allNames = Array.from(byName.keys());
|
||||||
|
|
@ -8478,8 +8808,50 @@ export class TeamProvisioningService {
|
||||||
private async probeClaudeRuntime(
|
private async probeClaudeRuntime(
|
||||||
claudePath: string,
|
claudePath: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
env: NodeJS.ProcessEnv
|
env: NodeJS.ProcessEnv,
|
||||||
|
providerId: TeamProviderId | undefined = 'anthropic'
|
||||||
): Promise<{ warning?: string }> {
|
): 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)
|
// Stage 1: verify binary works (awaited first for clearer errors)
|
||||||
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
// Important: keep this sequential with Stage 2 to avoid auth/credential-store races
|
||||||
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
// when multiple `claude` processes start simultaneously (most visible on Windows).
|
||||||
|
|
@ -8503,10 +8875,10 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
pingProbe = await this.spawnProbe(
|
pingProbe = await this.spawnProbe(
|
||||||
claudePath,
|
claudePath,
|
||||||
[...PREFLIGHT_PING_ARGS],
|
getPreflightPingArgs(providerId),
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
PREFLIGHT_TIMEOUT_MS,
|
getPreflightTimeoutMs(providerId),
|
||||||
{
|
{
|
||||||
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
||||||
const combined = `${stdout}\n${stderr}`.trim();
|
const combined = `${stdout}\n${stderr}`.trim();
|
||||||
|
|
@ -8516,7 +8888,7 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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(
|
logger.warn(
|
||||||
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
||||||
|
|
@ -8532,12 +8904,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
|
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
|
||||||
const lowerOutput = combinedOutput.toLowerCase();
|
const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
|
||||||
const isAuthFailure =
|
|
||||||
lowerOutput.includes('not logged in') ||
|
|
||||||
lowerOutput.includes('please run /login') ||
|
|
||||||
lowerOutput.includes('missing api key') ||
|
|
||||||
lowerOutput.includes('invalid api key');
|
|
||||||
|
|
||||||
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -8550,10 +8917,14 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (isAuthFailure || pingProbe.exitCode !== 0) {
|
if (isAuthFailure || pingProbe.exitCode !== 0) {
|
||||||
const hint = isAuthFailure
|
const hint = isAuthFailure
|
||||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
? resolvedProviderId === 'codex'
|
||||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
? 'Codex provider is not authenticated for `-p` mode. ' +
|
||||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
'Run `claude-multimodel auth login --provider codex` and retry.' +
|
||||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
(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'}).`;
|
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||||
return { warning: hint };
|
return { warning: hint };
|
||||||
}
|
}
|
||||||
|
|
@ -8591,7 +8962,7 @@ export class TeamProvisioningService {
|
||||||
return this.helpOutputCache;
|
return this.helpOutputCache;
|
||||||
}
|
}
|
||||||
const targetCwd = cwd ?? process.cwd();
|
const targetCwd = cwd ?? process.cwd();
|
||||||
const probeResult = await this.getCachedOrProbeResult(targetCwd);
|
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
|
||||||
if (!probeResult?.claudePath) {
|
if (!probeResult?.claudePath) {
|
||||||
throw new Error('Claude CLI not found');
|
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> => {
|
deleteDraft: async (_teamName: string): Promise<void> => {
|
||||||
throw new Error('Draft team deletion is not available in browser mode');
|
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');
|
throw new Error('Team provisioning is not available in browser mode');
|
||||||
},
|
},
|
||||||
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
|
||||||
|
|
@ -1057,6 +1061,11 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
|
|
||||||
cliInstaller: CliInstallerAPI = {
|
cliInstaller: CliInstallerAPI = {
|
||||||
getStatus: async () => ({
|
getStatus: async () => ({
|
||||||
|
flavor: 'claude',
|
||||||
|
displayName: 'Claude CLI',
|
||||||
|
supportsSelfUpdate: true,
|
||||||
|
showVersionDetails: true,
|
||||||
|
showBinaryPath: true,
|
||||||
installed: false,
|
installed: false,
|
||||||
installedVersion: null,
|
installedVersion: null,
|
||||||
binaryPath: null,
|
binaryPath: null,
|
||||||
|
|
@ -1064,6 +1073,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
authLoggedIn: false,
|
authLoggedIn: false,
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
|
providers: [],
|
||||||
}),
|
}),
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,9 @@ function areResolvedMembersEqual(
|
||||||
prevMember.agentType !== nextMember.agentType ||
|
prevMember.agentType !== nextMember.agentType ||
|
||||||
prevMember.role !== nextMember.role ||
|
prevMember.role !== nextMember.role ||
|
||||||
prevMember.workflow !== nextMember.workflow ||
|
prevMember.workflow !== nextMember.workflow ||
|
||||||
|
prevMember.providerId !== nextMember.providerId ||
|
||||||
|
prevMember.model !== nextMember.model ||
|
||||||
|
prevMember.effort !== nextMember.effort ||
|
||||||
prevMember.cwd !== nextMember.cwd ||
|
prevMember.cwd !== nextMember.cwd ||
|
||||||
prevMember.gitBranch !== nextMember.gitBranch ||
|
prevMember.gitBranch !== nextMember.gitBranch ||
|
||||||
prevMember.removedAt !== nextMember.removedAt
|
prevMember.removedAt !== nextMember.removedAt
|
||||||
|
|
@ -1616,6 +1619,7 @@ export const TeamDetailView = ({
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivityByTeam[teamName]}
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
|
launchParams={launchParams}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onSendMessage={(member) => {
|
onSendMessage={(member) => {
|
||||||
setSendDialogRecipient(member.name);
|
setSendDialogRecipient(member.name);
|
||||||
|
|
@ -1976,6 +1980,7 @@ export const TeamDetailView = ({
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
currentColor={data.config.color ?? ''}
|
||||||
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
currentMembers={data.members.filter((m) => !isLeadMember(m))}
|
||||||
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
|
|
@ -1998,6 +2003,9 @@ export const TeamDetailView = ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
workflow: entry.workflow,
|
workflow: entry.workflow,
|
||||||
|
providerId: entry.providerId,
|
||||||
|
model: entry.model,
|
||||||
|
effort: entry.effort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setAddMemberDialogOpen(false);
|
setAddMemberDialogOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,15 @@ import {
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
export interface AddMemberEntry {
|
export interface AddMemberEntry {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddMemberDialogProps {
|
interface AddMemberDialogProps {
|
||||||
|
|
@ -116,6 +120,9 @@ export const AddMemberDialog = ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
workflow: m.workflow,
|
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 { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
buildMemberDraftColorMap,
|
buildMemberDraftColorMap,
|
||||||
buildMemberDraftSuggestions,
|
buildMemberDraftSuggestions,
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
|
clearMemberModelOverrides,
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
MembersEditorSection,
|
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection';
|
||||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
|
@ -36,12 +37,19 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
|
||||||
import { LimitContextCheckbox } from './LimitContextCheckbox';
|
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import {
|
||||||
|
createInitialProviderChecks,
|
||||||
|
failIncompleteProviderChecks,
|
||||||
|
getProvisioningFailureHint,
|
||||||
|
ProvisioningProviderStatusList,
|
||||||
|
shouldHideProvisioningProviderStatusList,
|
||||||
|
updateProviderCheck,
|
||||||
|
type ProvisioningProviderCheck,
|
||||||
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel } from './TeamModelSelector';
|
||||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
|
|
@ -59,10 +67,45 @@ import type {
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
Project,
|
Project,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
TeamProviderId,
|
||||||
TeamProvisioningMemberInput,
|
TeamProvisioningMemberInput,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
} from '@shared/types';
|
} 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 {
|
export interface TeamCopyData {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -230,6 +273,8 @@ export const CreateTeamDialog = ({
|
||||||
setTeamName,
|
setTeamName,
|
||||||
members,
|
members,
|
||||||
setMembers,
|
setMembers,
|
||||||
|
syncModelsWithLead,
|
||||||
|
setSyncModelsWithLead,
|
||||||
cwdMode,
|
cwdMode,
|
||||||
setCwdMode,
|
setCwdMode,
|
||||||
selectedProjectPath,
|
selectedProjectPath,
|
||||||
|
|
@ -258,6 +303,7 @@ export const CreateTeamDialog = ({
|
||||||
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
|
@ -267,11 +313,11 @@ export const CreateTeamDialog = ({
|
||||||
}>({});
|
}>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
const [selectedProviderId, setSelectedProviderIdRaw] =
|
||||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
useState<TeamProviderId>(getStoredTeamProvider);
|
||||||
if (stored === null) return 'opus';
|
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||||
return stored === '__default__' ? '' : stored;
|
getStoredTeamModel(getStoredTeamProvider())
|
||||||
});
|
);
|
||||||
const [limitContext, setLimitContextRaw] = useState(
|
const [limitContext, setLimitContextRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastLimitContext') === 'true'
|
() => localStorage.getItem('team:lastLimitContext') === 'true'
|
||||||
);
|
);
|
||||||
|
|
@ -289,6 +335,17 @@ export const CreateTeamDialog = ({
|
||||||
const [worktreeName, setWorktreeNameRaw] = useState('');
|
const [worktreeName, setWorktreeNameRaw] = useState('');
|
||||||
const [customArgs, setCustomArgsRaw] = 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
|
// Re-read localStorage when advancedKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedEnabled =
|
const storedEnabled =
|
||||||
|
|
@ -301,7 +358,17 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
const setSelectedModel = (value: string): void => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
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 => {
|
const setLimitContext = (value: boolean): void => {
|
||||||
|
|
@ -343,6 +410,7 @@ export const CreateTeamDialog = ({
|
||||||
setPrepareState('idle');
|
setPrepareState('idle');
|
||||||
setPrepareMessage(null);
|
setPrepareMessage(null);
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setConflictDismissed(false);
|
setConflictDismissed(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -371,6 +439,25 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}, [open, clearProvisioningError, dialogTeamNameKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -379,6 +466,7 @@ export const CreateTeamDialog = ({
|
||||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setPrepareMessage(
|
setPrepareMessage(
|
||||||
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
||||||
);
|
);
|
||||||
|
|
@ -388,6 +476,7 @@ export const CreateTeamDialog = ({
|
||||||
if (!effectiveCwd) {
|
if (!effectiveCwd) {
|
||||||
setPrepareState('idle');
|
setPrepareState('idle');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -395,26 +484,75 @@ export const CreateTeamDialog = ({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const requestSeq = ++prepareRequestSeqRef.current;
|
const requestSeq = ++prepareRequestSeqRef.current;
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Warming up CLI environment...');
|
setPrepareMessage('Checking selected providers...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
||||||
|
|
||||||
// Defer so file list fetch (triggered by project select) can run first
|
// Defer so file list fetch (triggered by project select) can run first
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
let checks = createInitialProviderChecks(selectedMemberProviders);
|
||||||
|
let anyFailure = false;
|
||||||
|
let anyNotes = false;
|
||||||
|
const collectedWarnings: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepResult: TeamProvisioningPrepareResult =
|
for (const providerId of selectedMemberProviders) {
|
||||||
await api.teams.prepareProvisioning(effectiveCwd);
|
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;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||||
setPrepareMessage(prepResult.message);
|
setPrepareMessage(
|
||||||
setPrepareWarnings(prepResult.warnings ?? []);
|
anyFailure
|
||||||
|
? 'Some selected providers need attention.'
|
||||||
|
: anyNotes
|
||||||
|
? 'Selected providers are ready with notes.'
|
||||||
|
: 'Selected providers are ready.'
|
||||||
|
);
|
||||||
|
setPrepareWarnings(collectedWarnings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
|
const failureMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareMessage(
|
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
setPrepareMessage(failureMessage);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
@ -423,7 +561,7 @@ export const CreateTeamDialog = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [open, canCreate, launchTeam, effectiveCwd]);
|
}, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -446,6 +584,7 @@ export const CreateTeamDialog = ({
|
||||||
// display and select it.
|
// display and select it.
|
||||||
if (
|
if (
|
||||||
defaultProjectPath &&
|
defaultProjectPath &&
|
||||||
|
!isEphemeralRenderedProjectPath(defaultProjectPath) &&
|
||||||
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
|
!nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath)
|
||||||
) {
|
) {
|
||||||
const folderName =
|
const folderName =
|
||||||
|
|
@ -497,9 +636,15 @@ export const CreateTeamDialog = ({
|
||||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||||
customRole: isCustom ? m.role : '',
|
customRole: isCustom ? m.role : '',
|
||||||
workflow: m.workflow,
|
workflow: m.workflow,
|
||||||
|
providerId: m.providerId,
|
||||||
|
model: m.model ?? '',
|
||||||
|
effort: m.effort,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setSyncModelsWithLead(
|
||||||
|
!initialData.members.some((member) => member.providerId || member.model || member.effort)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -559,7 +704,7 @@ export const CreateTeamDialog = ({
|
||||||
if (selectedProjectPath || projects.length === 0) {
|
if (selectedProjectPath || projects.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (defaultProjectPath) {
|
if (defaultProjectPath && !isEphemeralRenderedProjectPath(defaultProjectPath)) {
|
||||||
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
|
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
|
||||||
if (match) {
|
if (match) {
|
||||||
setSelectedProjectPath(match.path);
|
setSelectedProjectPath(match.path);
|
||||||
|
|
@ -569,6 +714,16 @@ export const CreateTeamDialog = ({
|
||||||
setSelectedProjectPath(projects[0].path);
|
setSelectedProjectPath(projects[0].path);
|
||||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || cwdMode !== 'project' || !selectedProjectPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEphemeralRenderedProjectPath(selectedProjectPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedProjectPath('');
|
||||||
|
}, [open, cwdMode, selectedProjectPath, setSelectedProjectPath]);
|
||||||
|
|
||||||
useFileListCacheWarmer(effectiveCwd || null);
|
useFileListCacheWarmer(effectiveCwd || null);
|
||||||
|
|
||||||
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||||
|
|
@ -587,8 +742,8 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveModel = useMemo(
|
const effectiveModel = useMemo(
|
||||||
() => computeEffectiveTeamModel(selectedModel, limitContext),
|
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||||
[selectedModel, limitContext]
|
[selectedModel, limitContext, selectedProviderId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
|
|
@ -601,9 +756,10 @@ export const CreateTeamDialog = ({
|
||||||
teamName: sanitizedTeamName,
|
teamName: sanitizedTeamName,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
color: teamColor || undefined,
|
color: teamColor || undefined,
|
||||||
members: soloTeam ? [] : buildMembersFromDrafts(members),
|
members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts),
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: prompt.trim() || undefined,
|
prompt: prompt.trim() || undefined,
|
||||||
|
providerId: selectedProviderId,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
limitContext,
|
limitContext,
|
||||||
|
|
@ -616,9 +772,10 @@ export const CreateTeamDialog = ({
|
||||||
description,
|
description,
|
||||||
teamColor,
|
teamColor,
|
||||||
soloTeam,
|
soloTeam,
|
||||||
members,
|
effectiveMemberDrafts,
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
prompt,
|
prompt,
|
||||||
|
selectedProviderId,
|
||||||
effectiveModel,
|
effectiveModel,
|
||||||
selectedEffort,
|
selectedEffort,
|
||||||
limitContext,
|
limitContext,
|
||||||
|
|
@ -643,23 +800,11 @@ export const CreateTeamDialog = ({
|
||||||
const launchOptionalSummary = useMemo(() => {
|
const launchOptionalSummary = useMemo(() => {
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
if (prompt.trim()) summary.push('Lead prompt');
|
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 (skipPermissions) summary.push('Auto-approve tools');
|
||||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||||
return summary;
|
return summary;
|
||||||
}, [
|
}, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]);
|
||||||
prompt,
|
|
||||||
selectedModel,
|
|
||||||
selectedEffort,
|
|
||||||
limitContext,
|
|
||||||
skipPermissions,
|
|
||||||
worktreeEnabled,
|
|
||||||
worktreeName,
|
|
||||||
customArgs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const teamDetailsSummary = useMemo(() => {
|
const teamDetailsSummary = useMemo(() => {
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
|
|
@ -668,6 +813,16 @@ export const CreateTeamDialog = ({
|
||||||
return summary;
|
return summary;
|
||||||
}, [description, teamColor]);
|
}, [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 activeError = localError ?? provisioningErrorsByTeam[request.teamName] ?? null;
|
||||||
const canOpenExistingTeam =
|
const canOpenExistingTeam =
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||||
|
|
@ -822,7 +977,14 @@ export const CreateTeamDialog = ({
|
||||||
<p className="text-red-300/80">
|
<p className="text-red-300/80">
|
||||||
{prepareMessage ?? 'Failed to prepare environment'}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
</p>
|
</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">
|
<div className="space-y-0.5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p
|
<p
|
||||||
|
|
@ -836,8 +998,7 @@ export const CreateTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||||
in PATH, then reopen this dialog.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -892,9 +1053,9 @@ export const CreateTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<MembersEditorSection
|
<TeamRosterEditorSection
|
||||||
members={members}
|
members={members}
|
||||||
onChange={setMembers}
|
onMembersChange={setMembers}
|
||||||
fieldError={fieldErrors.members}
|
fieldError={fieldErrors.members}
|
||||||
validateMemberName={validateMemberNameInline}
|
validateMemberName={validateMemberNameInline}
|
||||||
showWorkflow
|
showWorkflow
|
||||||
|
|
@ -903,35 +1064,53 @@ export const CreateTeamDialog = ({
|
||||||
projectPath={effectiveCwd || null}
|
projectPath={effectiveCwd || null}
|
||||||
taskSuggestions={taskSuggestions}
|
taskSuggestions={taskSuggestions}
|
||||||
teamSuggestions={teamMentionSuggestions}
|
teamSuggestions={teamMentionSuggestions}
|
||||||
hideContent={soloTeam}
|
defaultProviderId={selectedProviderId}
|
||||||
headerExtra={
|
inheritedProviderId={selectedProviderId}
|
||||||
<div className="space-y-2">
|
inheritedModel={selectedModel}
|
||||||
<div className="flex items-center gap-2">
|
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
|
||||||
<Checkbox
|
inheritModelSettingsByDefault
|
||||||
id="solo-team"
|
lockProviderModel={syncModelsWithLead}
|
||||||
checked={soloTeam}
|
forceInheritedModelSettings={syncModelsWithLead}
|
||||||
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
|
||||||
/>
|
hideMembersContent={soloTeam}
|
||||||
<Label
|
providerId={selectedProviderId}
|
||||||
htmlFor="solo-team"
|
model={selectedModel}
|
||||||
className="cursor-pointer text-xs font-normal text-text-secondary"
|
effort={(selectedEffort as EffortLevel) || undefined}
|
||||||
>
|
limitContext={limitContext}
|
||||||
Solo team
|
onProviderChange={setSelectedProviderId}
|
||||||
</Label>
|
onModelChange={setSelectedModel}
|
||||||
</div>
|
onEffortChange={setSelectedEffort}
|
||||||
{soloTeam && (
|
onLimitContextChange={setLimitContext}
|
||||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
headerTop={
|
||||||
Only the team lead (main process) will be started — no teammates will
|
<div className="flex items-center gap-2">
|
||||||
be spawned. Works like a regular Claude session but with access to the task
|
<Checkbox
|
||||||
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
id="solo-team"
|
||||||
You can add members later from the team settings.
|
checked={soloTeam}
|
||||||
</p>
|
onCheckedChange={(checked) => setSoloTeam(checked === true)}
|
||||||
</div>
|
/>
|
||||||
)}
|
<Label
|
||||||
|
htmlFor="solo-team"
|
||||||
|
className="cursor-pointer text-xs font-normal text-text-secondary"
|
||||||
|
>
|
||||||
|
Solo team
|
||||||
|
</Label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -984,7 +1163,7 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
<OptionalSettingsSection
|
<OptionalSettingsSection
|
||||||
title="Optional launch settings"
|
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}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -1017,29 +1196,11 @@ export const CreateTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<SkipPermissionsCheckbox
|
||||||
<TeamModelSelector
|
id="create-skip-permissions"
|
||||||
value={selectedModel}
|
checked={skipPermissions}
|
||||||
onValueChange={setSelectedModel}
|
onCheckedChange={setSkipPermissions}
|
||||||
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>
|
|
||||||
|
|
||||||
<AdvancedCliSection
|
<AdvancedCliSection
|
||||||
teamName={advancedKey}
|
teamName={advancedKey}
|
||||||
|
|
@ -1133,27 +1294,23 @@ export const CreateTeamDialog = ({
|
||||||
<DialogFooter className="pt-4 sm:justify-between">
|
<DialogFooter className="pt-4 sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
{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 className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<div>
|
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
<span>
|
<div>
|
||||||
{prepareMessage ??
|
<span>
|
||||||
(prepareState === 'idle'
|
{prepareMessage ??
|
||||||
? 'Warming up CLI environment...'
|
(prepareState === 'idle'
|
||||||
: 'Preparing environment...')}
|
? 'Warming up CLI environment...'
|
||||||
</span>
|
: 'Preparing environment...')}
|
||||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
</span>
|
||||||
<span>Pre-flight check to catch errors before launch</span>
|
<p className="mt-0.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||||
<button
|
Pre-flight check to catch errors before launch
|
||||||
type="button"
|
</p>
|
||||||
onClick={() => setPrepareState('ready')}
|
</div>
|
||||||
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}
|
) : null}
|
||||||
|
|
||||||
{canCreate && launchTeam && prepareState === 'ready' ? (
|
{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">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{prepareWarnings.length > 0
|
{prepareChecks.some((check) => check.status === 'notes') ||
|
||||||
|
prepareWarnings.length > 0
|
||||||
? 'CLI environment ready (with notes)'
|
? 'CLI environment ready (with notes)'
|
||||||
: 'CLI environment ready'}
|
: 'CLI environment ready'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1171,7 +1329,8 @@ export const CreateTeamDialog = ({
|
||||||
{prepareMessage}
|
{prepareMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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">
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p key={warning} className="text-[11px] text-sky-300">
|
<p key={warning} className="text-[11px] text-sky-300">
|
||||||
|
|
@ -1202,12 +1361,7 @@ export const CreateTeamDialog = ({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
disabled={!canCreate || !draftLoaded || isSubmitting}
|
||||||
!canCreate ||
|
|
||||||
!draftLoaded ||
|
|
||||||
isSubmitting ||
|
|
||||||
(launchTeam && prepareState !== 'ready')
|
|
||||||
}
|
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
|
@ -1215,6 +1369,8 @@ export const CreateTeamDialog = ({
|
||||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
|
) : launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||||
|
'Skip preflight and create'
|
||||||
) : (
|
) : (
|
||||||
'Create'
|
'Create'
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import {
|
import {
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
createMemberDraft,
|
createMemberDraftsFromInputs,
|
||||||
MembersEditorSection,
|
MembersEditorSection,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -43,24 +42,14 @@ interface EditTeamDialogProps {
|
||||||
currentDescription: string;
|
currentDescription: string;
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
currentMembers: ResolvedTeamMember[];
|
currentMembers: ResolvedTeamMember[];
|
||||||
|
isTeamAlive?: boolean;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||||
const active = members.filter((m) => !m.removedAt);
|
return createMemberDraftsFromInputs(members);
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditTeamDialog = ({
|
export const EditTeamDialog = ({
|
||||||
|
|
@ -70,6 +59,7 @@ export const EditTeamDialog = ({
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentColor,
|
currentColor,
|
||||||
currentMembers,
|
currentMembers,
|
||||||
|
isTeamAlive = false,
|
||||||
projectPath,
|
projectPath,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -170,11 +160,18 @@ export const EditTeamDialog = ({
|
||||||
onChange={setMembers}
|
onChange={setMembers}
|
||||||
validateMemberName={validateMemberNameInline}
|
validateMemberName={validateMemberNameInline}
|
||||||
showWorkflow
|
showWorkflow
|
||||||
showJsonEditor
|
showJsonEditor={!isTeamAlive}
|
||||||
draftKeyPrefix={`editTeam:${teamName}`}
|
draftKeyPrefix={`editTeam:${teamName}`}
|
||||||
projectPath={projectPath ?? null}
|
projectPath={projectPath ?? null}
|
||||||
|
lockProviderModel={isTeamAlive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
|
{/* 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">
|
<label className="label-optional mb-1 block text-xs font-medium">
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||||
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
Controls how much reasoning the selected provider invests before responding. Default uses the
|
||||||
standard behavior.
|
provider's standard behavior for the selected model.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
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 { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
|
@ -21,10 +29,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
|
|
@ -43,10 +51,20 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput';
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
|
import {
|
||||||
|
createInitialProviderChecks,
|
||||||
|
failIncompleteProviderChecks,
|
||||||
|
getProvisioningFailureHint,
|
||||||
|
ProvisioningProviderStatusList,
|
||||||
|
shouldHideProvisioningProviderStatusList,
|
||||||
|
updateProviderCheck,
|
||||||
|
type ProvisioningProviderCheck,
|
||||||
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
||||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
|
|
@ -56,6 +74,7 @@ import type {
|
||||||
Schedule,
|
Schedule,
|
||||||
ScheduleLaunchConfig,
|
ScheduleLaunchConfig,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
|
TeamProviderId,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
} from '@shared/types';
|
} 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
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -157,11 +201,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
const [selectedProviderId, setSelectedProviderIdRaw] =
|
||||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
useState<TeamProviderId>(getStoredTeamProvider);
|
||||||
if (stored === null) return 'opus';
|
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||||
return stored === '__default__' ? '' : stored;
|
getStoredTeamModel(getStoredTeamProvider())
|
||||||
});
|
);
|
||||||
|
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
||||||
|
const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
|
||||||
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
() => 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 [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
||||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
|
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||||
|
const members = isLaunch ? props.members : storeMembers;
|
||||||
|
|
||||||
// Advanced CLI section state (with localStorage persistence)
|
// Advanced CLI section state (with localStorage persistence)
|
||||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||||
|
|
@ -208,6 +257,24 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [warmUpMinutes, setWarmUpMinutes] = useState(15);
|
const [warmUpMinutes, setWarmUpMinutes] = useState(15);
|
||||||
const [maxTurns, setMaxTurns] = useState(50);
|
const [maxTurns, setMaxTurns] = useState(50);
|
||||||
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
|
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
|
// Schedule store actions
|
||||||
const createSchedule = useStore((s) => s.createSchedule);
|
const createSchedule = useStore((s) => s.createSchedule);
|
||||||
|
|
@ -234,9 +301,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
localStorage.setItem(`team:lastCustomArgs:${effectiveTeamName}`, value);
|
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 => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
setSelectedModelRaw(value);
|
||||||
localStorage.setItem('team:lastSelectedModel', value);
|
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLimitContext = (value: boolean): void => {
|
const setLimitContext = (value: boolean): void => {
|
||||||
|
|
@ -259,9 +336,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
useEffect(() => {
|
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']) {
|
for (const suffix of ['lastSelectedModel', 'lastSelectedEffort']) {
|
||||||
const schedKey = `schedule:${suffix}`;
|
const schedKey = `schedule:${suffix}`;
|
||||||
const teamKey = `team:${suffix}`;
|
const teamKey =
|
||||||
|
suffix === 'lastSelectedModel' ? 'team:lastSelectedModel:anthropic' : `team:${suffix}`;
|
||||||
const schedVal = localStorage.getItem(schedKey);
|
const schedVal = localStorage.getItem(schedKey);
|
||||||
if (schedVal != null && localStorage.getItem(teamKey) == null) {
|
if (schedVal != null && localStorage.getItem(teamKey) == null) {
|
||||||
localStorage.setItem(teamKey, schedVal);
|
localStorage.setItem(teamKey, schedVal);
|
||||||
|
|
@ -280,11 +367,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setPrepareState('idle');
|
setPrepareState('idle');
|
||||||
setPrepareMessage(null);
|
setPrepareMessage(null);
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setCwdMode('project');
|
setCwdMode('project');
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setClearContext(false);
|
setClearContext(false);
|
||||||
setConflictDismissed(false);
|
setConflictDismissed(false);
|
||||||
|
setMembersDrafts([]);
|
||||||
|
setSyncModelsWithLead(false);
|
||||||
chipDraft.clearChipDraft();
|
chipDraft.clearChipDraft();
|
||||||
// Schedule fields
|
// Schedule fields
|
||||||
setSelectedTeamName('');
|
setSelectedTeamName('');
|
||||||
|
|
@ -311,6 +401,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
promptDraft.setValue(schedule.launchConfig.prompt);
|
promptDraft.setValue(schedule.launchConfig.prompt);
|
||||||
setCustomCwd(schedule.launchConfig.cwd);
|
setCustomCwd(schedule.launchConfig.cwd);
|
||||||
setCwdMode('custom');
|
setCwdMode('custom');
|
||||||
|
setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic');
|
||||||
setSelectedModelRaw(schedule.launchConfig.model ?? '');
|
setSelectedModelRaw(schedule.launchConfig.model ?? '');
|
||||||
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
|
setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false);
|
||||||
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
|
setSelectedEffortRaw(schedule.launchConfig.effort ?? '');
|
||||||
|
|
@ -326,7 +417,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setCwdMode('project');
|
setCwdMode('project');
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setSelectedModelRaw('opus');
|
setSelectedProviderIdRaw(getStoredTeamProvider());
|
||||||
|
setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider()));
|
||||||
setSelectedEffortRaw('medium');
|
setSelectedEffortRaw('medium');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +427,62 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, isSchedule, schedule?.id]);
|
}, [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
|
// Launch-only effects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -355,6 +503,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
if (typeof api.teams.prepareProvisioning !== 'function') {
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setPrepareMessage(
|
setPrepareMessage(
|
||||||
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
'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) {
|
if (!effectiveCwd) {
|
||||||
setPrepareState('idle');
|
setPrepareState('idle');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks([]);
|
||||||
setPrepareMessage('Select a working directory to validate the launch environment.');
|
setPrepareMessage('Select a working directory to validate the launch environment.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -371,31 +521,78 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const requestSeq = ++prepareRequestSeqRef.current;
|
const requestSeq = ++prepareRequestSeqRef.current;
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Warming up CLI environment...');
|
setPrepareMessage('Checking selected providers...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
setPrepareChecks(createInitialProviderChecks(selectedMemberProviders));
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
let checks = createInitialProviderChecks(selectedMemberProviders);
|
||||||
|
let anyFailure = false;
|
||||||
|
let anyNotes = false;
|
||||||
|
const collectedWarnings: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepResult: TeamProvisioningPrepareResult =
|
for (const providerId of selectedMemberProviders) {
|
||||||
await api.teams.prepareProvisioning(effectiveCwd);
|
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;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
setPrepareState(anyFailure ? 'failed' : 'ready');
|
||||||
setPrepareMessage(prepResult.message);
|
setPrepareMessage(
|
||||||
setPrepareWarnings(prepResult.warnings ?? []);
|
anyFailure
|
||||||
|
? 'Some selected providers need attention.'
|
||||||
|
: anyNotes
|
||||||
|
? 'Selected providers are ready with notes.'
|
||||||
|
: 'Selected providers are ready.'
|
||||||
|
);
|
||||||
|
setPrepareWarnings(collectedWarnings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
|
const failureMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment';
|
||||||
setPrepareState('failed');
|
setPrepareState('failed');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareMessage(
|
setPrepareChecks(failIncompleteProviderChecks(checks, failureMessage));
|
||||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
setPrepareMessage(failureMessage);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, isLaunch, effectiveCwd]);
|
}, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared effects: projects
|
// 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)
|
// Mention suggestions (shared — from props in launch, from store in schedule)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
const { suggestions: taskSuggestions } = useTaskSuggestions(null);
|
||||||
const members = isLaunch ? props.members : storeMembers;
|
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
|
||||||
|
const memberColorMap = useMemo(
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
() => buildMemberDraftColorMap(membersDrafts, members),
|
||||||
|
[membersDrafts, members]
|
||||||
|
);
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() => buildMemberDraftSuggestions(membersDrafts, memberColorMap),
|
||||||
members.map((m) => ({
|
[memberColorMap, membersDrafts]
|
||||||
id: m.name,
|
|
||||||
name: m.name,
|
|
||||||
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
|
||||||
color: colorMap.get(m.name),
|
|
||||||
})),
|
|
||||||
[members, colorMap]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -516,21 +709,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
args.push('--verbose', '--setting-sources', 'user,project,local');
|
args.push('--verbose', '--setting-sources', 'user,project,local');
|
||||||
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
|
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
|
||||||
if (skipPermissions) args.push('--dangerously-skip-permissions');
|
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 (model) args.push('--model', model);
|
||||||
if (selectedEffort) args.push('--effort', selectedEffort);
|
if (selectedEffort) args.push('--effort', selectedEffort);
|
||||||
if (!clearContext) args.push('--resume', '<previous>');
|
if (!clearContext) args.push('--resume', '<previous>');
|
||||||
return args;
|
return args;
|
||||||
}, [isLaunch, skipPermissions, selectedModel, limitContext, selectedEffort, clearContext]);
|
}, [
|
||||||
|
isLaunch,
|
||||||
|
skipPermissions,
|
||||||
|
selectedModel,
|
||||||
|
limitContext,
|
||||||
|
selectedEffort,
|
||||||
|
clearContext,
|
||||||
|
selectedProviderId,
|
||||||
|
]);
|
||||||
|
|
||||||
const launchOptionalSummary = useMemo(() => {
|
const launchOptionalSummary = useMemo(() => {
|
||||||
if (!isLaunch) return [];
|
if (!isLaunch) return [];
|
||||||
|
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
if (promptDraft.value.trim()) summary.push('Lead prompt');
|
if (promptDraft.value.trim()) summary.push('Lead prompt');
|
||||||
|
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
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 (skipPermissions) summary.push('Auto-approve tools');
|
||||||
if (clearContext) summary.push('Fresh session');
|
if (clearContext) summary.push('Fresh session');
|
||||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||||
|
|
@ -540,6 +742,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
isLaunch,
|
isLaunch,
|
||||||
promptDraft.value,
|
promptDraft.value,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
selectedProviderId,
|
||||||
selectedEffort,
|
selectedEffort,
|
||||||
limitContext,
|
limitContext,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
|
|
@ -584,17 +787,39 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setLocalError('Select working directory (cwd)');
|
setLocalError('Select working directory (cwd)');
|
||||||
return;
|
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);
|
setLocalError(null);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (isLaunch) {
|
if (isLaunch) {
|
||||||
|
await api.teams.replaceMembers(effectiveTeamName, {
|
||||||
|
members: buildMembersFromDrafts(effectiveMemberDrafts),
|
||||||
|
});
|
||||||
await props.onLaunch({
|
await props.onLaunch({
|
||||||
teamName: effectiveTeamName,
|
teamName: effectiveTeamName,
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
prompt: promptDraft.value.trim() || undefined,
|
||||||
model: computeEffectiveTeamModel(selectedModel, limitContext),
|
providerId: selectedProviderId,
|
||||||
|
model: computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId),
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
limitContext,
|
limitContext,
|
||||||
clearContext: clearContext || undefined,
|
clearContext: clearContext || undefined,
|
||||||
|
|
@ -610,6 +835,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const launchConfig: ScheduleLaunchConfig = {
|
const launchConfig: ScheduleLaunchConfig = {
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: promptDraft.value.trim(),
|
prompt: promptDraft.value.trim(),
|
||||||
|
providerId: selectedProviderId,
|
||||||
model: selectedModel || undefined,
|
model: selectedModel || undefined,
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
skipPermissions,
|
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" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<p className="font-medium text-red-300">
|
<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>
|
||||||
<p className="text-red-300/80">
|
<p className="text-red-300/80">
|
||||||
{prepareMessage ?? 'Failed to prepare environment'}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
</p>
|
</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">
|
<div className="space-y-0.5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p
|
<p
|
||||||
|
|
@ -768,18 +1001,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Install Claude CLI from the Dashboard, then reopen this dialog.
|
{getProvisioningFailureHint(prepareMessage, prepareChecks)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{(prepareMessage ?? '').toLowerCase().includes('spawn ') ||
|
||||||
type="button"
|
prepareChecks.some((check) =>
|
||||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
check.details.some((detail) => detail.toLowerCase().includes('spawn '))
|
||||||
onClick={() => {
|
) ? (
|
||||||
onClose();
|
<button
|
||||||
openDashboard();
|
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={() => {
|
||||||
Go to Dashboard
|
onClose();
|
||||||
</button>
|
openDashboard();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -922,6 +1160,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
projectsError={projectsError}
|
projectsError={projectsError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isLaunch ? (
|
||||||
|
<TeamRosterEditorSection
|
||||||
|
members={membersDrafts}
|
||||||
|
onMembersChange={setMembersDrafts}
|
||||||
|
validateMemberName={validateMemberNameInline}
|
||||||
|
showWorkflow
|
||||||
|
showJsonEditor
|
||||||
|
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
|
||||||
|
projectPath={effectiveCwd || null}
|
||||||
|
taskSuggestions={taskSuggestions}
|
||||||
|
teamSuggestions={teamMentionSuggestions}
|
||||||
|
existingMembers={members}
|
||||||
|
defaultProviderId={selectedProviderId}
|
||||||
|
inheritedProviderId={selectedProviderId}
|
||||||
|
inheritedModel={selectedModel}
|
||||||
|
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
|
||||||
|
inheritModelSettingsByDefault
|
||||||
|
forceInheritedModelSettings={syncModelsWithLead}
|
||||||
|
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
|
||||||
|
providerId={selectedProviderId}
|
||||||
|
model={selectedModel}
|
||||||
|
effort={(selectedEffort as EffortLevel) || undefined}
|
||||||
|
limitContext={limitContext}
|
||||||
|
onProviderChange={setSelectedProviderId}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
onEffortChange={setSelectedEffort}
|
||||||
|
onLimitContextChange={setLimitContext}
|
||||||
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
Launch: optional settings
|
Launch: optional settings
|
||||||
Schedule: prompt + execution defaults
|
Schedule: prompt + execution defaults
|
||||||
|
|
@ -959,22 +1229,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<SkipPermissionsCheckbox
|
||||||
id="dialog-skip-permissions"
|
id="dialog-skip-permissions"
|
||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
|
|
@ -1061,6 +1315,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TeamModelSelector
|
<TeamModelSelector
|
||||||
|
providerId={selectedProviderId}
|
||||||
|
onProviderChange={setSelectedProviderId}
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onValueChange={setSelectedModel}
|
onValueChange={setSelectedModel}
|
||||||
id="dialog-model"
|
id="dialog-model"
|
||||||
|
|
@ -1137,27 +1393,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
{isLaunch ? (
|
{isLaunch ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
{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 className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<div>
|
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
<span>
|
<div>
|
||||||
{prepareMessage ??
|
<span>
|
||||||
(prepareState === 'idle'
|
{prepareMessage ??
|
||||||
? 'Warming up CLI environment...'
|
(prepareState === 'idle'
|
||||||
: 'Preparing environment...')}
|
? 'Warming up CLI environment...'
|
||||||
</span>
|
: 'Preparing environment...')}
|
||||||
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
</span>
|
||||||
<span>Pre-flight check to catch errors before launch</span>
|
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||||
<button
|
<span>Pre-flight check to catch errors before launch</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setPrepareState('ready')}
|
type="button"
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
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>
|
Skip
|
||||||
</p>
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ProvisioningProviderStatusList checks={prepareChecks} className="mt-2" />
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{prepareState === 'ready' ? (
|
{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">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{prepareWarnings.length > 0
|
{prepareChecks.some((check) => check.status === 'notes') ||
|
||||||
|
prepareWarnings.length > 0
|
||||||
? 'CLI environment ready (with notes)'
|
? 'CLI environment ready (with notes)'
|
||||||
: 'CLI environment ready'}
|
: 'CLI environment ready'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1175,7 +1435,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
{prepareMessage}
|
{prepareMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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">
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p key={warning} className="text-[11px] text-sky-300">
|
<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 { Label } from '@renderer/components/ui/label';
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { Check, ChevronDown, Info } from 'lucide-react';
|
import { Check, ChevronDown, Info } from 'lucide-react';
|
||||||
|
|
||||||
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
||||||
|
|
@ -26,38 +27,9 @@ const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Google Gemini — official sparkle/star mark (Simple Icons) */
|
const GoogleGeminiIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" className={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" />
|
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 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"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -72,22 +44,88 @@ interface ProviderDef {
|
||||||
|
|
||||||
const PROVIDERS: ProviderDef[] = [
|
const PROVIDERS: ProviderDef[] = [
|
||||||
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
|
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
|
||||||
{ id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
|
{ id: 'codex', label: 'Codex', icon: OpenAIIcon, comingSoon: false },
|
||||||
{ id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
|
{ id: 'gemini', label: 'Gemini', icon: GoogleGeminiIcon, comingSoon: false },
|
||||||
{ id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACTIVE_PROVIDER = PROVIDERS[0];
|
const ANTHROPIC_MODEL_OPTIONS = [
|
||||||
|
|
||||||
// --- Model options (Anthropic only for now) ---
|
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
|
||||||
{ value: '', label: 'Default' },
|
{ value: '', label: 'Default' },
|
||||||
{ value: 'opus', label: 'Opus 4.6' },
|
{ value: 'opus', label: 'Opus 4.6' },
|
||||||
{ value: 'sonnet', label: 'Sonnet 4.6' },
|
{ value: 'sonnet', label: 'Sonnet 4.6' },
|
||||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||||
] as const;
|
] 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.
|
* Computes the effective model string for team provisioning.
|
||||||
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
|
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
|
||||||
|
|
@ -96,25 +134,33 @@ const MODEL_OPTIONS = [
|
||||||
*/
|
*/
|
||||||
export function computeEffectiveTeamModel(
|
export function computeEffectiveTeamModel(
|
||||||
selectedModel: string,
|
selectedModel: string,
|
||||||
limitContext: boolean
|
limitContext: boolean,
|
||||||
|
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const base = selectedModel || undefined;
|
const base = selectedModel || undefined;
|
||||||
|
if (providerId !== 'anthropic') return base;
|
||||||
if (limitContext) return base;
|
if (limitContext) return base;
|
||||||
if (base === 'haiku') return base;
|
if (base === 'haiku') return base;
|
||||||
return base ? `${base}[1m]` : 'opus[1m]';
|
return base ? `${base}[1m]` : 'opus[1m]';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamModelSelectorProps {
|
export interface TeamModelSelectorProps {
|
||||||
|
providerId: 'anthropic' | 'codex' | 'gemini';
|
||||||
|
onProviderChange: (providerId: 'anthropic' | 'codex' | 'gemini') => void;
|
||||||
value: string;
|
value: string;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
|
providerId,
|
||||||
|
onProviderChange,
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
id,
|
id,
|
||||||
}) => {
|
}) => {
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const multimodelAvailable = cliStatus?.flavor === 'free-code';
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -132,29 +178,55 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [dropdownOpen]);
|
}, [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 (
|
return (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||||
Model (optional)
|
Model (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<div ref={containerRef} className="relative inline-block">
|
<div ref={containerRef} className="relative space-y-2">
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
<div className="relative inline-flex">
|
||||||
{/* Provider button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
|
'flex min-w-[170px] items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs font-medium transition-colors',
|
||||||
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
|
|
||||||
dropdownOpen
|
dropdownOpen
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||||
: 'text-[var(--color-text-secondary)] hover: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)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
>
|
>
|
||||||
<ProviderIcon className="size-3.5" />
|
<span className="flex items-center gap-2">
|
||||||
<span>{ACTIVE_PROVIDER.label}</span>
|
<ProviderIcon className="size-3.5" />
|
||||||
|
<span>{activeProvider.label}</span>
|
||||||
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3 transition-transform duration-200',
|
'size-3 transition-transform duration-200',
|
||||||
|
|
@ -163,31 +235,116 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Model pills */}
|
{/* Provider dropdown */}
|
||||||
{MODEL_OPTIONS.map((opt) => (
|
{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
|
<button
|
||||||
key={opt.value || '__default__'}
|
key={opt.value || '__default__'}
|
||||||
type="button"
|
type="button"
|
||||||
id={opt.value === value ? id : undefined}
|
id={opt.value === value ? id : undefined}
|
||||||
className={cn(
|
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
|
value === opt.value
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
? '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 === '' && (
|
{opt.value === '' && (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<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>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||||
Default model from Claude CLI (/model).
|
Default model from Claude CLI (/model).
|
||||||
<br />
|
<br />
|
||||||
Currently Sonnet 4.6, but may change with CLI updates.
|
Uses the runtime default for the selected provider.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -195,64 +352,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</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 {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
|
runtimeSummary?: string;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -46,6 +47,7 @@ interface MemberCardProps {
|
||||||
export const MemberCard = ({
|
export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
|
runtimeSummary,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -131,42 +133,49 @@ export const MemberCard = ({
|
||||||
aria-label={presenceLabel}
|
aria-label={presenceLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
|
||||||
{displayMemberName(member.name)}
|
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||||
</span>
|
{displayMemberName(member.name)}
|
||||||
{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>
|
</span>
|
||||||
) : null}
|
{member.gitBranch ? (
|
||||||
{currentTask ? (
|
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<CurrentTaskIndicator
|
<GitBranch size={10} />
|
||||||
task={currentTask}
|
{member.gitBranch}
|
||||||
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>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
|
@ -16,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
interface MemberDraftRowProps {
|
interface MemberDraftRowProps {
|
||||||
member: MemberDraft;
|
member: MemberDraft;
|
||||||
|
|
@ -29,11 +34,20 @@ interface MemberDraftRowProps {
|
||||||
showWorkflow?: boolean;
|
showWorkflow?: boolean;
|
||||||
onWorkflowChange?: (id: string, workflow: string) => void;
|
onWorkflowChange?: (id: string, workflow: string) => void;
|
||||||
onWorkflowChipsChange?: (id: string, chips: InlineChip[]) => 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;
|
draftKeyPrefix?: string;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
mentionSuggestions?: MentionSuggestion[];
|
mentionSuggestions?: MentionSuggestion[];
|
||||||
taskSuggestions?: MentionSuggestion[];
|
taskSuggestions?: MentionSuggestion[];
|
||||||
teamSuggestions?: MentionSuggestion[];
|
teamSuggestions?: MentionSuggestion[];
|
||||||
|
lockProviderModel?: boolean;
|
||||||
|
forceInheritedModelSettings?: boolean;
|
||||||
|
modelLockReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
|
|
@ -48,11 +62,20 @@ export const MemberDraftRow = ({
|
||||||
showWorkflow = false,
|
showWorkflow = false,
|
||||||
onWorkflowChange,
|
onWorkflowChange,
|
||||||
onWorkflowChipsChange,
|
onWorkflowChipsChange,
|
||||||
|
onProviderChange,
|
||||||
|
onModelChange,
|
||||||
|
onEffortChange,
|
||||||
|
inheritedProviderId = 'anthropic',
|
||||||
|
inheritedModel = '',
|
||||||
|
inheritedEffort,
|
||||||
draftKeyPrefix,
|
draftKeyPrefix,
|
||||||
projectPath,
|
projectPath,
|
||||||
mentionSuggestions = [],
|
mentionSuggestions = [],
|
||||||
taskSuggestions,
|
taskSuggestions,
|
||||||
teamSuggestions,
|
teamSuggestions,
|
||||||
|
lockProviderModel = false,
|
||||||
|
forceInheritedModelSettings = false,
|
||||||
|
modelLockReason,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
const memberColorSet = getTeamColorSet(
|
||||||
|
|
@ -122,10 +145,22 @@ export const MemberDraftRow = ({
|
||||||
const suggestionsExcludingSelf = mentionSuggestions.filter(
|
const suggestionsExcludingSelf = mentionSuggestions.filter(
|
||||||
(s) => s.name.toLowerCase() !== member.name.trim().toLowerCase()
|
(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 (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: isLight
|
backgroundColor: isLight
|
||||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||||
|
|
@ -165,48 +200,54 @@ export const MemberDraftRow = ({
|
||||||
inputClassName="h-8 text-xs"
|
inputClassName="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="space-y-1">
|
||||||
{showWorkflow && onWorkflowChange ? (
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="relative h-8 shrink-0 gap-1"
|
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||||
onClick={() => setWorkflowExpanded((prev) => !prev)}
|
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
|
||||||
|
title="Remove member"
|
||||||
|
onClick={() => onRemove(member.id)}
|
||||||
>
|
>
|
||||||
{workflowExpanded ? (
|
<Trash2 className="size-3.5" />
|
||||||
<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>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
<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 ? (
|
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||||
<div className="space-y-0.5 md:col-span-3">
|
<div className="space-y-0.5 md:col-span-3">
|
||||||
|
|
@ -241,14 +282,38 @@ export const MemberDraftRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
{modelExpanded && (
|
{modelExpanded && (
|
||||||
<div className="space-y-2 md:col-span-3">
|
<div className="space-y-2 md:col-span-3">
|
||||||
<div className="pointer-events-none opacity-40">
|
<TeamModelSelector
|
||||||
<TeamModelSelector value="" onValueChange={() => {}} />
|
providerId={effectiveProviderId}
|
||||||
</div>
|
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">
|
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||||
Claude Code doesn't support per-member model selection yet — all teammates
|
If this teammate uses a different provider than the lead, they will be started in a
|
||||||
inherit the team launch model. We plan to solve this via a local proxy.
|
separate process automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
|
@ -27,6 +33,7 @@ interface MemberListProps {
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
launchParams?: TeamLaunchParams;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
|
|
@ -42,6 +49,7 @@ export const MemberList = ({
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
launchParams,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -77,6 +85,45 @@ export const MemberList = ({
|
||||||
const removedMembers = members.filter((m) => m.removedAt);
|
const removedMembers = members.filter((m) => m.removedAt);
|
||||||
const colorMap = buildMemberColorMap(members);
|
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) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
<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}
|
reviewTask={isRemoved ? null : reviewTask}
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
|
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
function membersToJsonText(drafts: MemberDraft[]): string {
|
function membersToJsonText(drafts: MemberDraft[]): string {
|
||||||
const arr = drafts
|
const arr = drafts
|
||||||
|
|
@ -30,6 +31,9 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
||||||
if (role) obj.role = role;
|
if (role) obj.role = role;
|
||||||
const workflow = getWorkflowForExport(d);
|
const workflow = getWorkflowForExport(d);
|
||||||
if (workflow) obj.workflow = workflow;
|
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 obj;
|
||||||
});
|
});
|
||||||
return JSON.stringify(arr, null, 2);
|
return JSON.stringify(arr, null, 2);
|
||||||
|
|
@ -42,6 +46,13 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
||||||
const name = typeof item.name === 'string' ? item.name : '';
|
const name = typeof item.name === 'string' ? item.name : '';
|
||||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||||
const workflow = typeof item.workflow === 'string' ? item.workflow.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 presetRoles: readonly string[] = PRESET_ROLES;
|
||||||
const isPreset = presetRoles.includes(role);
|
const isPreset = presetRoles.includes(role);
|
||||||
return createMemberDraft({
|
return createMemberDraft({
|
||||||
|
|
@ -49,6 +60,9 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
||||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||||
customRole: role && !isPreset ? role : '',
|
customRole: role && !isPreset ? role : '',
|
||||||
workflow: workflow || undefined,
|
workflow: workflow || undefined,
|
||||||
|
providerId,
|
||||||
|
model,
|
||||||
|
effort,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +88,16 @@ export interface MembersEditorSectionProps {
|
||||||
hideContent?: boolean;
|
hideContent?: boolean;
|
||||||
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
/** 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 }[];
|
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 = ({
|
export const MembersEditorSection = ({
|
||||||
|
|
@ -90,6 +114,14 @@ export const MembersEditorSection = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
hideContent = false,
|
hideContent = false,
|
||||||
existingMembers,
|
existingMembers,
|
||||||
|
defaultProviderId = 'anthropic',
|
||||||
|
lockProviderModel = false,
|
||||||
|
inheritedProviderId,
|
||||||
|
inheritedModel,
|
||||||
|
inheritedEffort,
|
||||||
|
inheritModelSettingsByDefault = false,
|
||||||
|
forceInheritedModelSettings = false,
|
||||||
|
modelLockReason,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
|
@ -150,13 +182,52 @@ export const MembersEditorSection = ({
|
||||||
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
|
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 => {
|
const removeMember = (memberId: string): void => {
|
||||||
onChange(members.filter((c) => c.id !== memberId));
|
onChange(members.filter((c) => c.id !== memberId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMember = (): void => {
|
const addMember = (): void => {
|
||||||
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
|
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);
|
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
@ -207,11 +278,20 @@ export const MembersEditorSection = ({
|
||||||
showWorkflow={showWorkflow}
|
showWorkflow={showWorkflow}
|
||||||
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
||||||
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
||||||
|
onProviderChange={updateMemberProvider}
|
||||||
|
onModelChange={updateMemberModel}
|
||||||
|
onEffortChange={updateMemberEffort}
|
||||||
|
inheritedProviderId={inheritedProviderId}
|
||||||
|
inheritedModel={inheritedModel}
|
||||||
|
inheritedEffort={inheritedEffort}
|
||||||
|
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||||
draftKeyPrefix={draftKeyPrefix}
|
draftKeyPrefix={draftKeyPrefix}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
mentionSuggestions={mentionSuggestions}
|
mentionSuggestions={mentionSuggestions}
|
||||||
taskSuggestions={taskSuggestions}
|
taskSuggestions={taskSuggestions}
|
||||||
teamSuggestions={teamSuggestions}
|
teamSuggestions={teamSuggestions}
|
||||||
|
lockProviderModel={lockProviderModel}
|
||||||
|
modelLockReason={modelLockReason}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{jsonEditorOpen && showJsonEditor ? (
|
{jsonEditorOpen && showJsonEditor ? (
|
||||||
|
|
@ -243,7 +323,9 @@ export {
|
||||||
buildMemberDraftColorMap,
|
buildMemberDraftColorMap,
|
||||||
buildMemberDraftSuggestions,
|
buildMemberDraftSuggestions,
|
||||||
buildMembersFromDrafts,
|
buildMembersFromDrafts,
|
||||||
|
clearMemberModelOverrides,
|
||||||
createMemberDraft,
|
createMemberDraft,
|
||||||
|
createMemberDraftsFromInputs,
|
||||||
getMemberDraftRole,
|
getMemberDraftRole,
|
||||||
validateMemberNameInline,
|
validateMemberNameInline,
|
||||||
} from './membersEditorUtils';
|
} 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 { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
export interface MemberDraft {
|
export interface MemberDraft {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -7,6 +8,9 @@ export interface MemberDraft {
|
||||||
customRole: string;
|
customRole: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
workflowChips?: InlineChip[];
|
workflowChips?: InlineChip[];
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MembersEditorValue {
|
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 { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
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 {
|
function isValidMemberName(name: string): boolean {
|
||||||
if (name.length < 1 || name.length > 128) return false;
|
if (name.length < 1 || name.length > 128) return false;
|
||||||
|
|
@ -33,9 +33,60 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
roleSelection: initial?.roleSelection ?? '',
|
roleSelection: initial?.roleSelection ?? '',
|
||||||
customRole: initial?.customRole ?? '',
|
customRole: initial?.customRole ?? '',
|
||||||
workflow: initial?.workflow,
|
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 {
|
interface ExistingMemberColorInput {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
|
@ -113,6 +164,21 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
||||||
const result: TeamProvisioningMemberInput = { name, role };
|
const result: TeamProvisioningMemberInput = { name, role };
|
||||||
const workflow = getWorkflowForExport(member);
|
const workflow = getWorkflowForExport(member);
|
||||||
if (workflow) result.workflow = workflow;
|
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;
|
return result;
|
||||||
})
|
})
|
||||||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export interface UseCreateTeamDraftResult {
|
||||||
setTeamName: (v: string) => void;
|
setTeamName: (v: string) => void;
|
||||||
members: MemberDraft[];
|
members: MemberDraft[];
|
||||||
setMembers: (v: MemberDraft[]) => void;
|
setMembers: (v: MemberDraft[]) => void;
|
||||||
|
syncModelsWithLead: boolean;
|
||||||
|
setSyncModelsWithLead: (v: boolean) => void;
|
||||||
cwdMode: 'project' | 'custom';
|
cwdMode: 'project' | 'custom';
|
||||||
setCwdMode: (v: 'project' | 'custom') => void;
|
setCwdMode: (v: 'project' | 'custom') => void;
|
||||||
selectedProjectPath: string;
|
selectedProjectPath: string;
|
||||||
|
|
@ -63,13 +65,18 @@ const DEBOUNCE_MS = 400;
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
|
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
|
||||||
return members.map(({ id, name, roleSelection, customRole, workflow }) => ({
|
return members.map(
|
||||||
id,
|
({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
|
||||||
name,
|
id,
|
||||||
roleSelection,
|
name,
|
||||||
customRole,
|
roleSelection,
|
||||||
workflow,
|
customRole,
|
||||||
}));
|
workflow,
|
||||||
|
providerId,
|
||||||
|
model,
|
||||||
|
effort,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
|
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
|
||||||
|
|
@ -80,6 +87,9 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
|
||||||
roleSelection: m.roleSelection,
|
roleSelection: m.roleSelection,
|
||||||
customRole: m.customRole,
|
customRole: m.customRole,
|
||||||
workflow: m.workflow,
|
workflow: m.workflow,
|
||||||
|
providerId: m.providerId,
|
||||||
|
model: m.model,
|
||||||
|
effort: m.effort,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +102,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
// ── State ──────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────
|
||||||
const [teamName, setTeamNameState] = useState('');
|
const [teamName, setTeamNameState] = useState('');
|
||||||
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
||||||
|
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
|
||||||
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
|
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
|
||||||
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
|
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
|
||||||
const [customCwd, setCustomCwdState] = useState('');
|
const [customCwd, setCustomCwdState] = useState('');
|
||||||
|
|
@ -103,6 +114,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
// ── Refs (latest values for debounced callbacks) ───────────────────────
|
// ── Refs (latest values for debounced callbacks) ───────────────────────
|
||||||
const teamNameRef = useRef('');
|
const teamNameRef = useRef('');
|
||||||
const membersRef = useRef<MemberDraft[]>([]);
|
const membersRef = useRef<MemberDraft[]>([]);
|
||||||
|
const syncModelsWithLeadRef = useRef(true);
|
||||||
const cwdModeRef = useRef<'project' | 'custom'>('project');
|
const cwdModeRef = useRef<'project' | 'custom'>('project');
|
||||||
const selectedProjectPathRef = useRef('');
|
const selectedProjectPathRef = useRef('');
|
||||||
const customCwdRef = useRef('');
|
const customCwdRef = useRef('');
|
||||||
|
|
@ -128,6 +140,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
version: 1,
|
version: 1,
|
||||||
teamName: teamNameRef.current,
|
teamName: teamNameRef.current,
|
||||||
members: serializeMembers(membersRef.current),
|
members: serializeMembers(membersRef.current),
|
||||||
|
syncModelsWithLead: syncModelsWithLeadRef.current,
|
||||||
cwdMode: cwdModeRef.current,
|
cwdMode: cwdModeRef.current,
|
||||||
selectedProjectPath: selectedProjectPathRef.current,
|
selectedProjectPath: selectedProjectPathRef.current,
|
||||||
customCwd: customCwdRef.current,
|
customCwd: customCwdRef.current,
|
||||||
|
|
@ -187,6 +200,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
|
|
||||||
teamNameRef.current = snap.teamName;
|
teamNameRef.current = snap.teamName;
|
||||||
membersRef.current = deserialized;
|
membersRef.current = deserialized;
|
||||||
|
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
|
||||||
cwdModeRef.current = snap.cwdMode;
|
cwdModeRef.current = snap.cwdMode;
|
||||||
selectedProjectPathRef.current = snap.selectedProjectPath;
|
selectedProjectPathRef.current = snap.selectedProjectPath;
|
||||||
customCwdRef.current = snap.customCwd;
|
customCwdRef.current = snap.customCwd;
|
||||||
|
|
@ -196,6 +210,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
|
|
||||||
setTeamNameState(snap.teamName);
|
setTeamNameState(snap.teamName);
|
||||||
setMembersState(deserialized);
|
setMembersState(deserialized);
|
||||||
|
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
|
||||||
setCwdModeState(snap.cwdMode);
|
setCwdModeState(snap.cwdMode);
|
||||||
setSelectedProjectPathState(snap.selectedProjectPath);
|
setSelectedProjectPathState(snap.selectedProjectPath);
|
||||||
setCustomCwdState(snap.customCwd);
|
setCustomCwdState(snap.customCwd);
|
||||||
|
|
@ -260,6 +275,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
[scheduleSave]
|
[scheduleSave]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setSyncModelsWithLead = useCallback(
|
||||||
|
(v: boolean) => {
|
||||||
|
userTouchedRef.current = true;
|
||||||
|
syncModelsWithLeadRef.current = v;
|
||||||
|
setSyncModelsWithLeadState(v);
|
||||||
|
scheduleSave();
|
||||||
|
},
|
||||||
|
[scheduleSave]
|
||||||
|
);
|
||||||
|
|
||||||
const setCwdMode = useCallback(
|
const setCwdMode = useCallback(
|
||||||
(v: 'project' | 'custom') => {
|
(v: 'project' | 'custom') => {
|
||||||
userTouchedRef.current = true;
|
userTouchedRef.current = true;
|
||||||
|
|
@ -334,6 +359,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
|
|
||||||
teamNameRef.current = '';
|
teamNameRef.current = '';
|
||||||
membersRef.current = [];
|
membersRef.current = [];
|
||||||
|
syncModelsWithLeadRef.current = true;
|
||||||
cwdModeRef.current = 'project';
|
cwdModeRef.current = 'project';
|
||||||
selectedProjectPathRef.current = '';
|
selectedProjectPathRef.current = '';
|
||||||
customCwdRef.current = '';
|
customCwdRef.current = '';
|
||||||
|
|
@ -343,6 +369,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
|
|
||||||
setTeamNameState('');
|
setTeamNameState('');
|
||||||
setMembersState([]);
|
setMembersState([]);
|
||||||
|
setSyncModelsWithLeadState(true);
|
||||||
setCwdModeState('project');
|
setCwdModeState('project');
|
||||||
setSelectedProjectPathState('');
|
setSelectedProjectPathState('');
|
||||||
setCustomCwdState('');
|
setCustomCwdState('');
|
||||||
|
|
@ -358,6 +385,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||||
setTeamName,
|
setTeamName,
|
||||||
members,
|
members,
|
||||||
setMembers,
|
setMembers,
|
||||||
|
syncModelsWithLead,
|
||||||
|
setSyncModelsWithLead,
|
||||||
cwdMode,
|
cwdMode,
|
||||||
setCwdMode,
|
setCwdMode,
|
||||||
selectedProjectPath,
|
selectedProjectPath,
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,16 @@ export interface SerializedMemberDraft {
|
||||||
roleSelection: string;
|
roleSelection: string;
|
||||||
customRole: string;
|
customRole: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
|
model?: string;
|
||||||
|
effort?: 'low' | 'medium' | 'high';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTeamDraftSnapshot {
|
export interface CreateTeamDraftSnapshot {
|
||||||
version: number;
|
version: number;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
members: SerializedMemberDraft[];
|
members: SerializedMemberDraft[];
|
||||||
|
syncModelsWithLead?: boolean;
|
||||||
cwdMode: 'project' | 'custom';
|
cwdMode: 'project' | 'custom';
|
||||||
selectedProjectPath: string;
|
selectedProjectPath: string;
|
||||||
customCwd: string;
|
customCwd: string;
|
||||||
|
|
@ -57,7 +61,16 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
|
||||||
typeof obj.id === 'string' &&
|
typeof obj.id === 'string' &&
|
||||||
typeof obj.name === 'string' &&
|
typeof obj.name === 'string' &&
|
||||||
typeof obj.roleSelection === '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' &&
|
typeof obj.teamName === 'string' &&
|
||||||
Array.isArray(obj.members) &&
|
Array.isArray(obj.members) &&
|
||||||
obj.members.every(isValidMember) &&
|
obj.members.every(isValidMember) &&
|
||||||
|
(obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
|
||||||
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
|
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
|
||||||
typeof obj.selectedProjectPath === 'string' &&
|
typeof obj.selectedProjectPath === 'string' &&
|
||||||
typeof obj.customCwd === 'string' &&
|
typeof obj.customCwd === 'string' &&
|
||||||
|
|
@ -154,6 +168,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
|
||||||
version: SNAPSHOT_VERSION,
|
version: SNAPSHOT_VERSION,
|
||||||
teamName: '',
|
teamName: '',
|
||||||
members: [],
|
members: [],
|
||||||
|
syncModelsWithLead: true,
|
||||||
cwdMode: 'project',
|
cwdMode: 'project',
|
||||||
selectedProjectPath: '',
|
selectedProjectPath: '',
|
||||||
customCwd: '',
|
customCwd: '',
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,7 @@ export interface GlobalTaskDetailState {
|
||||||
|
|
||||||
/** Per-team launch parameters shown in the header badge. */
|
/** Per-team launch parameters shown in the header badge. */
|
||||||
export interface TeamLaunchParams {
|
export interface TeamLaunchParams {
|
||||||
|
providerId?: 'anthropic' | 'codex' | 'gemini';
|
||||||
model?: string; // 'opus' | 'sonnet' | 'haiku'
|
model?: string; // 'opus' | 'sonnet' | 'haiku'
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
limitContext?: boolean;
|
limitContext?: boolean;
|
||||||
|
|
@ -1949,6 +1950,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Persist per-team launch params (model, effort, limit context)
|
// Persist per-team launch params (model, effort, limit context)
|
||||||
const baseModel = extractBaseModel(request.model);
|
const baseModel = extractBaseModel(request.model);
|
||||||
const params: TeamLaunchParams = {
|
const params: TeamLaunchParams = {
|
||||||
|
providerId: request.providerId ?? 'anthropic',
|
||||||
model: baseModel || 'default',
|
model: baseModel || 'default',
|
||||||
effort: request.effort,
|
effort: request.effort,
|
||||||
limitContext: request.limitContext ?? false,
|
limitContext: request.limitContext ?? false,
|
||||||
|
|
@ -2125,6 +2127,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Persist per-team launch params (model, effort, limit context)
|
// Persist per-team launch params (model, effort, limit context)
|
||||||
const baseModel = extractBaseModel(request.model);
|
const baseModel = extractBaseModel(request.model);
|
||||||
const params: TeamLaunchParams = {
|
const params: TeamLaunchParams = {
|
||||||
|
providerId: request.providerId ?? 'anthropic',
|
||||||
model: baseModel || 'default',
|
model: baseModel || 'default',
|
||||||
effort: request.effort,
|
effort: request.effort,
|
||||||
limitContext: request.limitContext ?? false,
|
limitContext: request.limitContext ?? false,
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,11 @@ export interface TeamsAPI {
|
||||||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||||
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
|
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
|
||||||
deleteDraft: (teamName: string) => Promise<void>;
|
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>;
|
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
cancelProvisioning: (runId: string) => Promise<void>;
|
cancelProvisioning: (runId: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Repository Pattern abstraction allows swapping storage backend (JSON → sql.js/Drizzle).
|
* 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
|
// Schedule Status Types
|
||||||
|
|
@ -49,6 +49,7 @@ export interface Schedule {
|
||||||
export interface ScheduleLaunchConfig {
|
export interface ScheduleLaunchConfig {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
skipPermissions?: boolean;
|
skipPermissions?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ export interface TeamMember {
|
||||||
role?: string;
|
role?: string;
|
||||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
color?: string;
|
color?: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|
@ -471,6 +474,9 @@ export interface ResolvedTeamMember {
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Set only when member's git branch differs from the lead's branch. */
|
/** Set only when member's git branch differs from the lead's branch. */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
|
|
@ -503,11 +509,13 @@ export interface TeamData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EffortLevel = 'low' | 'medium' | 'high';
|
export type EffortLevel = 'low' | 'medium' | 'high';
|
||||||
|
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
|
|
||||||
export interface TeamLaunchRequest {
|
export interface TeamLaunchRequest {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||||
|
|
@ -633,6 +641,9 @@ export interface TeamProvisioningMemberInput {
|
||||||
role?: string;
|
role?: string;
|
||||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCreateRequest {
|
export interface TeamCreateRequest {
|
||||||
|
|
@ -643,6 +654,7 @@ export interface TeamCreateRequest {
|
||||||
members: TeamProvisioningMemberInput[];
|
members: TeamProvisioningMemberInput[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||||
|
|
@ -780,6 +792,9 @@ export interface AddMemberRequest {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveMemberRequest {
|
export interface RemoveMemberRequest {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ export const PROTECTED_CLI_FLAGS = new Set([
|
||||||
'--mcp-config',
|
'--mcp-config',
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'--verbose',
|
'--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');
|
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', () => {
|
it('keeps dotted names when they are explicitly configured members', () => {
|
||||||
const resolver = new TeamMemberResolver();
|
const resolver = new TeamMemberResolver();
|
||||||
const config: TeamConfig = {
|
const config: TeamConfig = {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,38 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(probeSpy.mock.calls[1]?.[1]).toBe(cwdB);
|
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 () => {
|
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({
|
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']);
|
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 () => {
|
it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => {
|
||||||
const svc = new TeamProvisioningService(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue