merge(team): integrate teammate worktree isolation ui
This commit is contained in:
commit
708e1c3bf2
29 changed files with 952 additions and 63 deletions
|
|
@ -1274,6 +1274,10 @@ async function validateProvisioningRequest(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { valid: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const isolation = (member as { isolation?: unknown }).isolation;
|
||||
if (isolation !== undefined && isolation !== 'worktree') {
|
||||
return { valid: false, error: 'member isolation must be "worktree" when provided' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
|
|
@ -1295,6 +1299,7 @@ async function validateProvisioningRequest(
|
|||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
@ -1572,6 +1577,7 @@ async function handleLaunchTeam(
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
|
|
@ -2760,6 +2766,10 @@ async function handleCreateConfig(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
const isolation = (member as { isolation?: unknown }).isolation;
|
||||
if (isolation !== undefined && isolation !== 'worktree') {
|
||||
return { success: false, error: 'member isolation must be "worktree" when provided' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(member as { providerId?: unknown }).providerId
|
||||
);
|
||||
|
|
@ -2781,6 +2791,7 @@ async function handleCreateConfig(
|
|||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
@ -3189,10 +3200,11 @@ async function handleAddMember(
|
|||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role, workflow, providerId, model } = payload as {
|
||||
const { name, role, workflow, isolation, providerId, model } = payload as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
isolation?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
|
|
@ -3205,6 +3217,9 @@ async function handleAddMember(
|
|||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'workflow must be a string' };
|
||||
}
|
||||
if (isolation !== undefined && isolation !== 'worktree') {
|
||||
return { success: false, error: 'isolation must be "worktree" when provided' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
|
|
@ -3227,6 +3242,7 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
role: role,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
@ -3252,6 +3268,7 @@ async function handleAddMember(
|
|||
name: memberName,
|
||||
...(typeof role === 'string' ? { role } : {}),
|
||||
...(typeof workflow === 'string' ? { workflow } : {}),
|
||||
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
|
||||
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
|
||||
...(effortValidation.value ? { effort: effortValidation.value } : {}),
|
||||
|
|
@ -3285,6 +3302,7 @@ async function handleReplaceMembers(
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -3297,6 +3315,7 @@ async function handleReplaceMembers(
|
|||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
isolation?: unknown;
|
||||
providerId?: unknown;
|
||||
model?: unknown;
|
||||
effort?: unknown;
|
||||
|
|
@ -3312,6 +3331,9 @@ async function handleReplaceMembers(
|
|||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
if (m.isolation !== undefined && m.isolation !== 'worktree') {
|
||||
return { success: false, error: 'member isolation must be "worktree" when provided' };
|
||||
}
|
||||
const providerValidation = parseOptionalMemberProviderId(
|
||||
(m as { providerId?: unknown }).providerId
|
||||
);
|
||||
|
|
@ -3332,6 +3354,7 @@ async function handleReplaceMembers(
|
|||
name,
|
||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||||
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ interface LedgerEvent {
|
|||
operation: 'create' | 'modify' | 'delete';
|
||||
confidence: LedgerConfidence;
|
||||
workspaceRoot: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
baseWorkspaceRoot?: string;
|
||||
dirtyLeaderWarning?: string;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
timestamp: string;
|
||||
|
|
@ -192,6 +196,10 @@ interface LedgerSummaryScopeV2 {
|
|||
confidenceBreakdown?: TaskChangeScope['confidenceBreakdown'];
|
||||
visibleFileCount: number;
|
||||
contributors: LedgerSummaryContributorV2[];
|
||||
worktreePaths?: string[];
|
||||
worktreeBranches?: string[];
|
||||
baseWorkspaceRoots?: string[];
|
||||
dirtyLeaderWarnings?: string[];
|
||||
}
|
||||
|
||||
interface LedgerSummaryFileV2 {
|
||||
|
|
@ -217,6 +225,10 @@ interface LedgerSummaryFileV2 {
|
|||
contentAvailability: 'full-text' | 'hash-only' | 'metadata-only';
|
||||
reviewability: 'full-text' | 'partial-text' | 'metadata-only';
|
||||
relation?: LedgerChangeRelation;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
baseWorkspaceRoot?: string;
|
||||
dirtyLeaderWarning?: string;
|
||||
primaryActorKey?: string;
|
||||
agentIds: string[];
|
||||
memberNames?: string[];
|
||||
|
|
@ -785,7 +797,11 @@ export class TaskChangeLedgerReader {
|
|||
|
||||
if (params.bundle) {
|
||||
files = params.bundle.files.map((file) => {
|
||||
const groupKey = this.groupKeyForFileSummary(file.filePath, file.relation);
|
||||
const groupKey = this.groupKeyForFileSummary(
|
||||
file.filePath,
|
||||
file.relation,
|
||||
file.worktreePath
|
||||
);
|
||||
const entry = groupedSnippets.get(groupKey);
|
||||
return {
|
||||
...this.mapV2SummaryFile(file, params.projectPath),
|
||||
|
|
@ -968,13 +984,17 @@ export class TaskChangeLedgerReader {
|
|||
...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}),
|
||||
...(file.memberNames ? { memberNames: file.memberNames } : {}),
|
||||
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
|
||||
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
|
||||
...(file.worktreeBranch ? { worktreeBranch: file.worktreeBranch } : {}),
|
||||
...(file.baseWorkspaceRoot ? { baseWorkspaceRoot: file.baseWorkspaceRoot } : {}),
|
||||
...(file.dirtyLeaderWarning ? { dirtyLeaderWarning: file.dirtyLeaderWarning } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string {
|
||||
if (file.relation) {
|
||||
return `${file.relation.kind}:${normalizePathForComparison(file.relation.oldPath)}->${normalizePathForComparison(file.relation.newPath)}`;
|
||||
return this.relationChangeKey(file.relation, file.worktreePath);
|
||||
}
|
||||
const slashNormalized = file.changeKey.replace(/\\/g, '/');
|
||||
const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized);
|
||||
|
|
@ -1015,6 +1035,10 @@ export class TaskChangeLedgerReader {
|
|||
...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}),
|
||||
...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}),
|
||||
...(scope.contributors ? { contributors: scope.contributors } : {}),
|
||||
...(scope.worktreePaths ? { worktreePaths: scope.worktreePaths } : {}),
|
||||
...(scope.worktreeBranches ? { worktreeBranches: scope.worktreeBranches } : {}),
|
||||
...(scope.baseWorkspaceRoots ? { baseWorkspaceRoots: scope.baseWorkspaceRoots } : {}),
|
||||
...(scope.dirtyLeaderWarnings ? { dirtyLeaderWarnings: scope.dirtyLeaderWarnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1076,6 +1100,10 @@ export class TaskChangeLedgerReader {
|
|||
executionSeq: event.executionSeq,
|
||||
linesAdded: event.linesAdded,
|
||||
linesRemoved: event.linesRemoved,
|
||||
worktreePath: event.worktreePath,
|
||||
worktreeBranch: event.worktreeBranch,
|
||||
baseWorkspaceRoot: event.baseWorkspaceRoot,
|
||||
dirtyLeaderWarning: event.dirtyLeaderWarning,
|
||||
textAvailability:
|
||||
beforeContent !== null && afterContent !== null
|
||||
? 'full-text'
|
||||
|
|
@ -1169,6 +1197,7 @@ export class TaskChangeLedgerReader {
|
|||
linesRemoved += removed;
|
||||
}
|
||||
const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets);
|
||||
const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger;
|
||||
files.push({
|
||||
filePath: displayPath,
|
||||
relativePath: this.relativePath(displayPath, projectPath),
|
||||
|
|
@ -1181,7 +1210,7 @@ export class TaskChangeLedgerReader {
|
|||
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create'
|
||||
),
|
||||
changeKey: relation
|
||||
? `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`
|
||||
? this.relationChangeKey(relation, worktreeLedger?.worktreePath)
|
||||
: `path:${normalizePathForComparison(displayPath)}`,
|
||||
diffStatKnown: true,
|
||||
ledgerSummary: {
|
||||
|
|
@ -1189,6 +1218,16 @@ export class TaskChangeLedgerReader {
|
|||
latestOperation:
|
||||
entry.snippets[entry.snippets.length - 1]?.ledger?.operation ??
|
||||
(entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'),
|
||||
...(worktreeLedger?.worktreePath ? { worktreePath: worktreeLedger.worktreePath } : {}),
|
||||
...(worktreeLedger?.worktreeBranch
|
||||
? { worktreeBranch: worktreeLedger.worktreeBranch }
|
||||
: {}),
|
||||
...(worktreeLedger?.baseWorkspaceRoot
|
||||
? { baseWorkspaceRoot: worktreeLedger.baseWorkspaceRoot }
|
||||
: {}),
|
||||
...(worktreeLedger?.dirtyLeaderWarning
|
||||
? { dirtyLeaderWarning: worktreeLedger.dirtyLeaderWarning }
|
||||
: {}),
|
||||
},
|
||||
timeline: this.buildTimeline(displayPath, entry.snippets),
|
||||
});
|
||||
|
|
@ -1206,6 +1245,22 @@ export class TaskChangeLedgerReader {
|
|||
): TaskChangeScope {
|
||||
const primaryMemberName = events.find((event) => event.memberName)?.memberName;
|
||||
const primaryAgentId = events.find((event) => event.agentId)?.agentId;
|
||||
const worktreePaths = [
|
||||
...new Set(events.flatMap((event) => (event.worktreePath ? [event.worktreePath] : []))),
|
||||
].sort();
|
||||
const worktreeBranches = [
|
||||
...new Set(events.flatMap((event) => (event.worktreeBranch ? [event.worktreeBranch] : []))),
|
||||
].sort();
|
||||
const baseWorkspaceRoots = [
|
||||
...new Set(
|
||||
events.flatMap((event) => (event.baseWorkspaceRoot ? [event.baseWorkspaceRoot] : []))
|
||||
),
|
||||
].sort();
|
||||
const dirtyLeaderWarnings = [
|
||||
...new Set(
|
||||
events.flatMap((event) => (event.dirtyLeaderWarning ? [event.dirtyLeaderWarning] : []))
|
||||
),
|
||||
].sort();
|
||||
return {
|
||||
taskId,
|
||||
memberName: primaryMemberName ?? primaryAgentId ?? '',
|
||||
|
|
@ -1240,6 +1295,10 @@ export class TaskChangeLedgerReader {
|
|||
},
|
||||
}
|
||||
: {}),
|
||||
...(worktreePaths.length > 0 ? { worktreePaths } : {}),
|
||||
...(worktreeBranches.length > 0 ? { worktreeBranches } : {}),
|
||||
...(baseWorkspaceRoots.length > 0 ? { baseWorkspaceRoots } : {}),
|
||||
...(dirtyLeaderWarnings.length > 0 ? { dirtyLeaderWarnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1310,16 +1369,31 @@ export class TaskChangeLedgerReader {
|
|||
}
|
||||
|
||||
private groupKeyForSnippet(snippet: SnippetDiff): string {
|
||||
return this.groupKeyForFileSummary(snippet.filePath, snippet.ledger?.relation);
|
||||
return this.groupKeyForFileSummary(
|
||||
snippet.filePath,
|
||||
snippet.ledger?.relation,
|
||||
snippet.ledger?.worktreePath
|
||||
);
|
||||
}
|
||||
|
||||
private groupKeyForFileSummary(filePath: string, relation?: LedgerChangeRelation): string {
|
||||
private groupKeyForFileSummary(
|
||||
filePath: string,
|
||||
relation?: LedgerChangeRelation,
|
||||
worktreePath?: string
|
||||
): string {
|
||||
if (relation) {
|
||||
return `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`;
|
||||
return this.relationChangeKey(relation, worktreePath);
|
||||
}
|
||||
return `path:${normalizePathForComparison(filePath)}`;
|
||||
}
|
||||
|
||||
private relationChangeKey(relation: LedgerChangeRelation, worktreePath?: string): string {
|
||||
const pathPart = `${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`;
|
||||
return worktreePath
|
||||
? `${relation.kind}:${normalizePathForComparison(worktreePath)}:${pathPart}`
|
||||
: `${relation.kind}:${pathPart}`;
|
||||
}
|
||||
|
||||
private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
|
||||
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1216,6 +1216,7 @@ export class TeamDataService {
|
|||
name: configMember.name.trim(),
|
||||
role: configMember.role,
|
||||
workflow: configMember.workflow,
|
||||
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
agentType: configMember.agentType ?? 'general-purpose',
|
||||
color: configMember.color,
|
||||
joinedAt: configMember.joinedAt ?? Date.now(),
|
||||
|
|
@ -1277,6 +1278,7 @@ export class TeamDataService {
|
|||
name,
|
||||
role: request.role?.trim() || undefined,
|
||||
workflow: request.workflow?.trim() || undefined,
|
||||
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId:
|
||||
request.providerId === 'codex' || request.providerId === 'gemini'
|
||||
? request.providerId
|
||||
|
|
@ -1316,6 +1318,7 @@ export class TeamDataService {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
|
|
@ -1358,6 +1361,7 @@ export class TeamDataService {
|
|||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
@ -2435,6 +2439,7 @@ export class TeamDataService {
|
|||
})(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export class TeamMemberResolver {
|
|||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
|
|
@ -147,6 +148,7 @@ export class TeamMemberResolver {
|
|||
agentType: configMember.agentType,
|
||||
role: configMember.role,
|
||||
workflow: configMember.workflow,
|
||||
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId,
|
||||
model: configMember.model,
|
||||
effort: configMember.effort,
|
||||
|
|
@ -164,6 +166,7 @@ export class TeamMemberResolver {
|
|||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: TeamMember['effort'];
|
||||
|
|
@ -179,6 +182,7 @@ export class TeamMemberResolver {
|
|||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
|
|
@ -232,6 +236,7 @@ export class TeamMemberResolver {
|
|||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
isolation: configMember?.isolation ?? metaMember?.isolation,
|
||||
providerId: configMember?.providerId ?? metaMember?.providerId,
|
||||
model: configMember?.model ?? metaMember?.model,
|
||||
effort: configMember?.effort ?? metaMember?.effort,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
|
|||
|
|
@ -1235,7 +1235,7 @@ interface PendingMemberRestartContext {
|
|||
requestedAt: string;
|
||||
desired: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||
'name' | 'role' | 'workflow' | 'isolation' | 'providerId' | 'model' | 'effort'
|
||||
>;
|
||||
}
|
||||
|
||||
|
|
@ -1682,10 +1682,11 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
|||
: '';
|
||||
const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : '';
|
||||
const effortPart = member.effort ? ` [effort: ${member.effort}]` : '';
|
||||
const isolationPart = member.isolation === 'worktree' ? ' [isolation: worktree]' : '';
|
||||
const workflowPart = member.workflow?.trim()
|
||||
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||
: '';
|
||||
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
|
||||
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${isolationPart}${workflowPart}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -2032,13 +2033,29 @@ ${indentMultiline(buildMemberReviewFlowReminder(), ' ')}
|
|||
- If you have no tasks, wait for new assignments.`;
|
||||
}
|
||||
|
||||
function buildAgentToolArgsSuffix(
|
||||
member: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'providerId' | 'model' | 'effort' | 'isolation'
|
||||
>
|
||||
): string {
|
||||
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 isolationPart = member.isolation === 'worktree' ? ', isolation="worktree"' : '';
|
||||
return `${providerPart}${modelPart}${effortPart}${isolationPart}`;
|
||||
}
|
||||
|
||||
export function buildAddMemberSpawnMessage(
|
||||
teamName: string,
|
||||
displayName: string,
|
||||
leadName: string,
|
||||
member: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
|
||||
>
|
||||
): string {
|
||||
const roleHint =
|
||||
|
|
@ -2063,16 +2080,11 @@ export function buildAddMemberSpawnMessage(
|
|||
teamName,
|
||||
leadName
|
||||
);
|
||||
const providerPart =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? `, provider="${member.providerId}"`
|
||||
: '';
|
||||
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
|
||||
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
|
||||
const agentArgs = buildAgentToolArgsSuffix(member);
|
||||
|
||||
return (
|
||||
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
|
||||
`Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, 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"${agentArgs}, and the exact prompt below:${workflowHint}\n\n` +
|
||||
indentMultiline(prompt, ' ')
|
||||
);
|
||||
}
|
||||
|
|
@ -2083,7 +2095,7 @@ export function buildRestartMemberSpawnMessage(
|
|||
leadName: string,
|
||||
member: Pick<
|
||||
TeamCreateRequest['members'][number],
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
|
||||
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
|
||||
>
|
||||
): string {
|
||||
const roleHint =
|
||||
|
|
@ -2108,16 +2120,11 @@ export function buildRestartMemberSpawnMessage(
|
|||
teamName,
|
||||
leadName
|
||||
);
|
||||
const providerPart =
|
||||
member.providerId && member.providerId !== 'anthropic'
|
||||
? `, provider="${member.providerId}"`
|
||||
: '';
|
||||
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
|
||||
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
|
||||
const agentArgs = buildAgentToolArgsSuffix(member);
|
||||
|
||||
return (
|
||||
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
|
||||
`Please respawn 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. ` +
|
||||
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below. ` +
|
||||
`This is a restart of an existing persistent teammate, not a new teammate. ` +
|
||||
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
|
||||
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
|
||||
|
|
@ -2132,6 +2139,7 @@ interface RuntimeBootstrapMemberSpec {
|
|||
model?: string;
|
||||
provider?: TeamProviderId;
|
||||
effort?: EffortLevel;
|
||||
isolation?: 'worktree';
|
||||
agentType?: string;
|
||||
description?: string;
|
||||
useSplitPane?: boolean;
|
||||
|
|
@ -2208,6 +2216,7 @@ function buildDeterministicCreateBootstrapSpec(
|
|||
...(member.model?.trim() ? { model: member.model.trim() } : {}),
|
||||
...(member.providerId ? { provider: member.providerId } : {}),
|
||||
...(member.effort ? { effort: member.effort } : {}),
|
||||
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
||||
})),
|
||||
launch: {
|
||||
|
|
@ -2255,6 +2264,7 @@ function buildDeterministicLaunchBootstrapSpec(
|
|||
...(member.model?.trim() ? { model: member.model.trim() } : {}),
|
||||
...(member.providerId ? { provider: member.providerId } : {}),
|
||||
...(member.effort ? { effort: member.effort } : {}),
|
||||
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(member.role?.trim() ? { role: member.role.trim() } : {}),
|
||||
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
|
||||
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
||||
|
|
@ -5962,6 +5972,7 @@ export class TeamProvisioningService {
|
|||
name: configuredMember.name,
|
||||
role: configuredMember.role,
|
||||
workflow: configuredMember.workflow,
|
||||
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: configuredMember.providerId,
|
||||
model: configuredMember.model,
|
||||
effort: configuredMember.effort,
|
||||
|
|
@ -5977,6 +5988,7 @@ export class TeamProvisioningService {
|
|||
name: configuredMember.name,
|
||||
role: configuredMember.role,
|
||||
workflow: configuredMember.workflow,
|
||||
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: configuredMember.providerId,
|
||||
model: configuredMember.model,
|
||||
effort: configuredMember.effort,
|
||||
|
|
@ -7708,6 +7720,7 @@ export class TeamProvisioningService {
|
|||
name: m.name.trim(),
|
||||
role: m.role?.trim() || undefined,
|
||||
workflow: m.workflow?.trim() || undefined,
|
||||
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||
model: m.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(m.effort) ? m.effort : undefined,
|
||||
|
|
@ -7869,6 +7882,7 @@ export class TeamProvisioningService {
|
|||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
|
|
@ -7982,6 +7996,7 @@ export class TeamProvisioningService {
|
|||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: 'opencode',
|
||||
model: member.model ?? input.request.model,
|
||||
effort: member.effort ?? input.request.effort,
|
||||
|
|
@ -8081,6 +8096,7 @@ export class TeamProvisioningService {
|
|||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
|
|
@ -8671,6 +8687,7 @@ export class TeamProvisioningService {
|
|||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
@ -10104,6 +10121,7 @@ export class TeamProvisioningService {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -10128,6 +10146,10 @@ export class TeamProvisioningService {
|
|||
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
|
||||
const workflow =
|
||||
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
|
||||
const isolation =
|
||||
metaMember?.isolation === 'worktree' || configuredMember?.isolation === 'worktree'
|
||||
? 'worktree'
|
||||
: undefined;
|
||||
const providerId =
|
||||
normalizeTeamMemberProviderId(metaMember?.providerId) ??
|
||||
normalizeTeamMemberProviderId(configuredMember?.providerId);
|
||||
|
|
@ -10145,6 +10167,7 @@ export class TeamProvisioningService {
|
|||
name,
|
||||
...(role ? { role } : {}),
|
||||
...(workflow ? { workflow } : {}),
|
||||
...(isolation ? { isolation } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
|
|
@ -15425,6 +15448,7 @@ export class TeamProvisioningService {
|
|||
name: member.name.trim(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
@ -15467,18 +15491,20 @@ export class TeamProvisioningService {
|
|||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||
const workflow =
|
||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
||||
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model =
|
||||
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||
const prev = byName.get(name);
|
||||
if (!prev) {
|
||||
byName.set(name, { name, role, workflow, providerId, model, effort });
|
||||
byName.set(name, { name, role, workflow, isolation, providerId, model, effort });
|
||||
} else {
|
||||
byName.set(name, {
|
||||
...prev,
|
||||
role: prev.role || role,
|
||||
workflow: prev.workflow || workflow,
|
||||
isolation: prev.isolation || isolation,
|
||||
providerId: prev.providerId || providerId,
|
||||
model: prev.model || model,
|
||||
effort: prev.effort || effort,
|
||||
|
|
@ -15539,13 +15565,14 @@ export class TeamProvisioningService {
|
|||
name,
|
||||
role: configMember?.role,
|
||||
workflow: configMember?.workflow,
|
||||
isolation: configMember?.isolation,
|
||||
providerId: configMember?.providerId,
|
||||
model: configMember?.model,
|
||||
effort: configMember?.effort,
|
||||
};
|
||||
});
|
||||
const memberOverridesUsed = members.some(
|
||||
(member) => member.providerId || member.model || member.effort
|
||||
(member) => member.providerId || member.model || member.effort || member.isolation
|
||||
);
|
||||
return {
|
||||
members,
|
||||
|
|
@ -15608,6 +15635,7 @@ export class TeamProvisioningService {
|
|||
name?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: string;
|
||||
agentType?: string;
|
||||
providerId?: string;
|
||||
provider?: string;
|
||||
|
|
@ -15630,6 +15658,7 @@ export class TeamProvisioningService {
|
|||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow:
|
||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
export interface MemberDiffInput {
|
||||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
removedAt?: number | string | null;
|
||||
}
|
||||
|
||||
|
|
@ -14,8 +16,10 @@ export interface ReplaceMembersDiff {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}[];
|
||||
removed: string[];
|
||||
updated: {
|
||||
|
|
@ -67,8 +71,10 @@ export function buildReplaceMembersDiff(
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
}[]
|
||||
): ReplaceMembersDiff {
|
||||
const previousByName = new Map(
|
||||
|
|
@ -80,8 +86,10 @@ export function buildReplaceMembersDiff(
|
|||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
effort: member.effort,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
|
@ -94,8 +102,10 @@ export function buildReplaceMembersDiff(
|
|||
name: member.name.trim(),
|
||||
role: normalizeOptionalText(member.role),
|
||||
workflow: normalizeOptionalText(member.workflow),
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: member.providerId,
|
||||
model: normalizeOptionalText(member.model),
|
||||
effort: member.effort,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
|
@ -118,6 +128,11 @@ export function buildReplaceMembersDiff(
|
|||
const changes = [
|
||||
describeRoleChange(previousMember.role, nextMember.role),
|
||||
describeWorkflowChange(previousMember.workflow, nextMember.workflow),
|
||||
previousMember.isolation !== nextMember.isolation
|
||||
? nextMember.isolation === 'worktree'
|
||||
? 'worktree isolation enabled'
|
||||
: 'worktree isolation disabled'
|
||||
: null,
|
||||
].filter((value): value is string => value !== null);
|
||||
if (changes.length === 0) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface TeamRuntimeMemberSpec {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId: TeamRuntimeProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
|
|||
|
|
@ -2839,6 +2839,7 @@ export const TeamDetailView = ({
|
|||
name: entry.name,
|
||||
role: entry.role,
|
||||
workflow: entry.workflow,
|
||||
isolation: entry.isolation,
|
||||
providerId: entry.providerId,
|
||||
model: entry.model,
|
||||
effort: entry.effort,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getNextSuggestedMemberName } from '@renderer/components/team/members/memberNameSets';
|
||||
import {
|
||||
|
|
@ -25,6 +25,7 @@ export interface AddMemberEntry {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -41,14 +42,36 @@ interface AddMemberDialogProps {
|
|||
/** Project path for @file mentions in workflow field. */
|
||||
projectPath?: string | null;
|
||||
/** Existing team members with their colors — used so new drafts get the next available color */
|
||||
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
||||
existingMembers?: readonly {
|
||||
name: string;
|
||||
color?: string;
|
||||
isolation?: 'worktree';
|
||||
removedAt?: number | string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
const DIALOG_WIDTH = 'w-[720px]';
|
||||
|
||||
function buildInitialDrafts(existingNames: string[]): MemberDraft[] {
|
||||
function deriveExistingWorktreeDefault(
|
||||
existingMembers: AddMemberDialogProps['existingMembers']
|
||||
): boolean {
|
||||
const activeTeammates =
|
||||
existingMembers?.filter(
|
||||
(member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead'
|
||||
) ?? [];
|
||||
return (
|
||||
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
|
||||
);
|
||||
}
|
||||
|
||||
function buildInitialDrafts(existingNames: string[], worktreeDefault = false): MemberDraft[] {
|
||||
const suggestedName = getNextSuggestedMemberName(existingNames);
|
||||
return [createMemberDraft({ name: suggestedName })];
|
||||
return [
|
||||
createMemberDraft({
|
||||
name: suggestedName,
|
||||
isolation: worktreeDefault ? 'worktree' : undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const AddMemberDialog = ({
|
||||
|
|
@ -61,8 +84,13 @@ export const AddMemberDialog = ({
|
|||
projectPath,
|
||||
existingMembers,
|
||||
}: AddMemberDialogProps): React.JSX.Element => {
|
||||
const [members, setMembers] = useState<MemberDraft[]>(() => buildInitialDrafts(existingNames));
|
||||
const existingWorktreeDefault = deriveExistingWorktreeDefault(existingMembers);
|
||||
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(existingWorktreeDefault);
|
||||
const [members, setMembers] = useState<MemberDraft[]>(() =>
|
||||
buildInitialDrafts(existingNames, existingWorktreeDefault)
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wasOpenRef = useRef(open);
|
||||
|
||||
// Combine existing names + names already in the draft list for duplicate validation
|
||||
const allNames = useMemo(() => {
|
||||
|
|
@ -120,6 +148,7 @@ export const AddMemberDialog = ({
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
|
|
@ -129,24 +158,21 @@ export const AddMemberDialog = ({
|
|||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
if (!nextOpen) {
|
||||
setMembers(buildInitialDrafts(existingNames));
|
||||
setMembers(buildInitialDrafts(existingNames, teammateWorktreeDefault));
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Re-initialize drafts when the dialog opens with fresh suggested name
|
||||
// (existingNames may have changed since last close)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMembers((prev) => {
|
||||
const allEmpty = prev.every((m) => !m.name.trim());
|
||||
if (prev.length === 0 || allEmpty) {
|
||||
return buildInitialDrafts(existingNames);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [open, existingNames]);
|
||||
const wasOpen = wasOpenRef.current;
|
||||
if (open && !wasOpen) {
|
||||
setTeammateWorktreeDefault(existingWorktreeDefault);
|
||||
setMembers(buildInitialDrafts(existingNames, existingWorktreeDefault));
|
||||
setError(null);
|
||||
}
|
||||
wasOpenRef.current = open;
|
||||
}, [existingNames, existingWorktreeDefault, open]);
|
||||
|
||||
const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length;
|
||||
|
||||
|
|
@ -169,6 +195,9 @@ export const AddMemberDialog = ({
|
|||
draftKeyPrefix={`addMember:${teamName}`}
|
||||
projectPath={projectPath}
|
||||
existingMembers={existingMembers}
|
||||
showWorktreeIsolationControls
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -367,6 +367,8 @@ export const CreateTeamDialog = ({
|
|||
setMembers,
|
||||
syncModelsWithLead,
|
||||
setSyncModelsWithLead,
|
||||
teammateWorktreeDefault,
|
||||
setTeammateWorktreeDefault,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
|
|
@ -925,6 +927,7 @@ export const CreateTeamDialog = ({
|
|||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||
model: m.model ?? '',
|
||||
effort: m.effort,
|
||||
|
|
@ -933,6 +936,10 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
})
|
||||
);
|
||||
setTeammateWorktreeDefault(
|
||||
initialData.members.length > 0 &&
|
||||
initialData.members.every((member) => member.isolation === 'worktree')
|
||||
);
|
||||
setSyncModelsWithLead(
|
||||
!initialData.members.some((member) => member.providerId || member.model || member.effort)
|
||||
);
|
||||
|
|
@ -1609,6 +1616,9 @@ export const CreateTeamDialog = ({
|
|||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
showWorktreeIsolationControls={!soloTeam}
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ function membersToDrafts(members: ResolvedTeamMember[]) {
|
|||
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
||||
}
|
||||
|
||||
function deriveTeammateWorktreeDefault(members: readonly ResolvedTeamMember[]): boolean {
|
||||
const activeTeammates = filterEditableMemberInputs(members).filter((member) => !member.removedAt);
|
||||
return (
|
||||
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
|
||||
);
|
||||
}
|
||||
|
||||
function useEditTeamErrorReset(
|
||||
setError: (value: string | null) => void,
|
||||
setSaveOutcomeError: (value: string | null) => void
|
||||
|
|
@ -146,6 +153,9 @@ export const EditTeamDialog = ({
|
|||
const [description, setDescription] = useState(currentDescription);
|
||||
const [color, setColor] = useState(currentColor);
|
||||
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(() =>
|
||||
deriveTeammateWorktreeDefault(currentMembers)
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
|
||||
|
|
@ -187,6 +197,7 @@ export const EditTeamDialog = ({
|
|||
setDescription(currentDescription);
|
||||
setColor(currentColor);
|
||||
setMembers(membersToDrafts(currentMembers));
|
||||
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(currentMembers));
|
||||
setError(null);
|
||||
setSaveOutcomeError(null);
|
||||
setMembersPendingRestartRetry({});
|
||||
|
|
@ -293,7 +304,7 @@ export const EditTeamDialog = ({
|
|||
members.map((member) => [
|
||||
member.id,
|
||||
restartNames.has(member.name.trim().toLowerCase())
|
||||
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
|
||||
? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, or effort changes.'
|
||||
: null,
|
||||
])
|
||||
);
|
||||
|
|
@ -380,6 +391,7 @@ export const EditTeamDialog = ({
|
|||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
isolation: member.isolation,
|
||||
})) as ResolvedTeamMember[],
|
||||
});
|
||||
|
||||
|
|
@ -558,6 +570,9 @@ export const EditTeamDialog = ({
|
|||
}
|
||||
existingMembers={currentMembers}
|
||||
existingMemberColorMap={effectiveResolvedMemberColorMap}
|
||||
showWorktreeIsolationControls
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
lockProviderModel={false}
|
||||
lockExistingMemberIdentity={isTeamAlive}
|
||||
identityLockReason={undefined}
|
||||
|
|
@ -588,7 +603,7 @@ export const EditTeamDialog = ({
|
|||
<p className="text-xs text-amber-300">
|
||||
Saving will restart{' '}
|
||||
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
|
||||
apply role, workflow, provider, model, or effort changes:{' '}
|
||||
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
|
||||
{effectiveMembersToRestart.join(', ')}.
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,21 @@ function resolveResolvedMemberRuntime(
|
|||
};
|
||||
}
|
||||
|
||||
function deriveTeammateWorktreeDefault(
|
||||
members: readonly {
|
||||
name: string;
|
||||
isolation?: 'worktree';
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
): boolean {
|
||||
const activeTeammates = members.filter(
|
||||
(member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead'
|
||||
);
|
||||
return (
|
||||
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
|
@ -359,6 +374,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
getStoredTeamModel(getStoredTeamProvider())
|
||||
);
|
||||
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
||||
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
|
||||
const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
|
||||
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
|
|
@ -755,6 +771,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
|
||||
)
|
||||
);
|
||||
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource));
|
||||
setSyncModelsWithLead(
|
||||
!editableMembersSource.some((member) => member.providerId || member.model || member.effort)
|
||||
);
|
||||
|
|
@ -1100,19 +1117,35 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (
|
||||
previousProvider === currentProviderId &&
|
||||
previousModel === currentModel &&
|
||||
(previousEffort ?? '') === (currentEffort ?? '')
|
||||
(previousEffort ?? '') === (currentEffort ?? '') &&
|
||||
(previousMember.isolation ?? '') === (member.isolation ?? '')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtimeMessage =
|
||||
previousProvider !== currentProviderId ||
|
||||
previousModel !== currentModel ||
|
||||
(previousEffort ?? '') !== (currentEffort ?? '')
|
||||
? `${formatTeamModelSummary(
|
||||
currentProviderId,
|
||||
currentModel,
|
||||
currentEffort
|
||||
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`
|
||||
: null;
|
||||
const isolationMessage =
|
||||
previousMember.isolation !== member.isolation
|
||||
? `${member.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'} instead of ${
|
||||
previousMember.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'
|
||||
}`
|
||||
: null;
|
||||
|
||||
notes.push({
|
||||
key: `member:${name.toLowerCase()}`,
|
||||
memberName: name,
|
||||
message: `${formatTeamModelSummary(
|
||||
currentProviderId,
|
||||
currentModel,
|
||||
currentEffort
|
||||
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`,
|
||||
message: [runtimeMessage, isolationMessage]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join('; '),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1497,6 +1530,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
const summary: string[] = [];
|
||||
if (promptDraft.value.trim()) summary.push('Lead prompt');
|
||||
const worktreeMemberCount = effectiveMemberDrafts.filter(
|
||||
(member) => !member.removedAt && member.isolation === 'worktree'
|
||||
).length;
|
||||
if (worktreeMemberCount > 0) {
|
||||
summary.push(
|
||||
`${worktreeMemberCount} teammate worktree${worktreeMemberCount === 1 ? '' : 's'}`
|
||||
);
|
||||
}
|
||||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||
|
|
@ -1513,6 +1554,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return summary;
|
||||
}, [
|
||||
isLaunchMode,
|
||||
effectiveMemberDrafts,
|
||||
promptDraft.value,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
|
|
@ -2166,6 +2208,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
showWorktreeIsolationControls
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
function normalizeRestartSensitiveMemberContract(member: {
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
|
|
@ -21,13 +22,15 @@ function normalizeRestartSensitiveMemberContract(member: {
|
|||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
isolation?: 'worktree';
|
||||
} {
|
||||
const role = member.role?.trim() || undefined;
|
||||
const workflow = member.workflow?.trim() || undefined;
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
const model = member.model?.trim() || undefined;
|
||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||
return { role, workflow, providerId, model, effort };
|
||||
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
|
||||
return { role, workflow, providerId, model, effort, isolation };
|
||||
}
|
||||
|
||||
export function getMemberRuntimeContractKey(member: {
|
||||
|
|
@ -36,6 +39,7 @@ export function getMemberRuntimeContractKey(member: {
|
|||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
isolation?: string;
|
||||
}): string {
|
||||
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
|
||||
}
|
||||
|
|
@ -68,7 +72,8 @@ export function getMembersRequiringRuntimeRestart(params: {
|
|||
previousRuntime.workflow !== nextRuntime.workflow ||
|
||||
previousRuntime.providerId !== nextRuntime.providerId ||
|
||||
previousRuntime.model !== nextRuntime.model ||
|
||||
previousRuntime.effort !== nextRuntime.effort
|
||||
previousRuntime.effort !== nextRuntime.effort ||
|
||||
previousRuntime.isolation !== nextRuntime.isolation
|
||||
) {
|
||||
membersToRestart.push(previousMember.name);
|
||||
}
|
||||
|
|
@ -124,6 +129,7 @@ function normalizeEditableMemberSnapshot(member: {
|
|||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
isolation?: string;
|
||||
removedAt?: number | string | null;
|
||||
}): {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
|
@ -20,7 +22,15 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
Info,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -65,6 +75,8 @@ interface MemberDraftRowProps {
|
|||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
|
||||
lockedModelAction?: {
|
||||
label: string;
|
||||
description?: string;
|
||||
|
|
@ -111,6 +123,8 @@ export const MemberDraftRow = ({
|
|||
warningText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
showWorktreeIsolationControls = false,
|
||||
onWorktreeIsolationChange,
|
||||
lockedModelAction,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -327,6 +341,41 @@ export const MemberDraftRow = ({
|
|||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
|
||||
isRemoved && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`member-${member.id}-worktree-isolation`}
|
||||
checked={member.isolation === 'worktree'}
|
||||
disabled={isRemoved}
|
||||
onCheckedChange={(checked) =>
|
||||
onWorktreeIsolationChange?.(member.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`member-${member.id}-worktree-isolation`}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-1.5 text-xs font-normal',
|
||||
isRemoved && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span>Worktree</span>
|
||||
</Label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
Run this teammate in a separate git worktree. Apply/reject changes targets that
|
||||
worktree, not the lead workspace.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{hideActionButton ? null : isRemoved ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { GitBranch, Plus } from 'lucide-react';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
|||
if (role) obj.role = role;
|
||||
const workflow = getWorkflowForExport(d);
|
||||
if (workflow) obj.workflow = workflow;
|
||||
if (d.isolation === 'worktree') obj.isolation = 'worktree';
|
||||
if (d.providerId) obj.providerId = d.providerId;
|
||||
if (d.model?.trim()) obj.model = d.model.trim();
|
||||
if (d.effort) obj.effort = d.effort;
|
||||
|
|
@ -49,6 +51,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||
const isolation = item.isolation === 'worktree' ? 'worktree' : undefined;
|
||||
const providerId = normalizeOptionalTeamProviderId(item.providerId);
|
||||
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
||||
const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
|
||||
|
|
@ -61,6 +64,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: workflow || undefined,
|
||||
isolation,
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
|
|
@ -111,6 +115,9 @@ export interface MembersEditorSectionProps {
|
|||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
disableAddMember?: boolean;
|
||||
addMemberLockReason?: string;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
teammateWorktreeDefault?: boolean;
|
||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
|
|
@ -145,6 +152,9 @@ export const MembersEditorSection = ({
|
|||
memberModelIssueById,
|
||||
disableAddMember = false,
|
||||
addMemberLockReason,
|
||||
showWorktreeIsolationControls = false,
|
||||
teammateWorktreeDefault = false,
|
||||
onTeammateWorktreeDefaultChange,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
|
|
@ -236,6 +246,23 @@ export const MembersEditorSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
|
||||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
|
||||
onTeammateWorktreeDefaultChange?.(enabled);
|
||||
onChange(
|
||||
members.map((member) =>
|
||||
member.removedAt ? member : { ...member, isolation: enabled ? 'worktree' : undefined }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const removeMember = (memberId: string): void => {
|
||||
if (!softDeleteMembers) {
|
||||
onChange(members.filter((c) => c.id !== memberId));
|
||||
|
|
@ -260,8 +287,15 @@ export const MembersEditorSection = ({
|
|||
...members,
|
||||
createMemberDraft(
|
||||
inheritModelSettingsByDefault
|
||||
? { name: suggestedName }
|
||||
: { name: suggestedName, providerId: defaultProviderId }
|
||||
? {
|
||||
name: suggestedName,
|
||||
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
|
||||
}
|
||||
: {
|
||||
name: suggestedName,
|
||||
providerId: defaultProviderId,
|
||||
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
|
||||
}
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
|
@ -274,6 +308,11 @@ export const MembersEditorSection = ({
|
|||
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
|
||||
[members, existingMembers, existingMemberColorMap]
|
||||
);
|
||||
const worktreeDefaultControlId = useMemo(
|
||||
() =>
|
||||
`teammate-worktree-default-${(draftKeyPrefix ?? 'default').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
|
||||
[draftKeyPrefix]
|
||||
);
|
||||
|
||||
const mentionSuggestions = useMemo(
|
||||
() => buildMemberDraftSuggestions(members, memberColorMap),
|
||||
|
|
@ -308,6 +347,22 @@ export const MembersEditorSection = ({
|
|||
{headerExtra}
|
||||
{!hideContent && (
|
||||
<>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2">
|
||||
<Checkbox
|
||||
id={worktreeDefaultControlId}
|
||||
checked={teammateWorktreeDefault}
|
||||
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={worktreeDefaultControlId}
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Run teammates in separate worktrees</span>
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
{disableAddMember && addMemberLockReason ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||
) : null}
|
||||
|
|
@ -330,6 +385,8 @@ export const MembersEditorSection = ({
|
|||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
|
|
@ -374,6 +431,8 @@ export const MembersEditorSection = ({
|
|||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ interface TeamRosterEditorSectionProps {
|
|||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
teammateWorktreeDefault?: boolean;
|
||||
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
|
|
@ -95,6 +98,9 @@ export const TeamRosterEditorSection = ({
|
|||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
memberModelIssueById,
|
||||
showWorktreeIsolationControls = false,
|
||||
teammateWorktreeDefault = false,
|
||||
onTeammateWorktreeDefaultChange,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<MembersEditorSection
|
||||
|
|
@ -122,6 +128,9 @@ export const TeamRosterEditorSection = ({
|
|||
softDeleteMembers={softDeleteMembers}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
|
||||
headerExtra={
|
||||
<div className="space-y-3">
|
||||
{headerTop}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface MemberDraft {
|
|||
customRole: string;
|
||||
workflow?: string;
|
||||
workflowChips?: InlineChip[];
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
roleSelection: initial?.roleSelection ?? '',
|
||||
customRole: initial?.customRole ?? '',
|
||||
workflow: initial?.workflow,
|
||||
isolation: initial?.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId,
|
||||
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
|
||||
effort: initial?.effort,
|
||||
|
|
@ -48,6 +49,7 @@ export function createMemberDraftsFromInputs(
|
|||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
isolation?: 'worktree';
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
): MemberDraft[] {
|
||||
|
|
@ -63,6 +65,7 @@ export function createMemberDraftsFromInputs(
|
|||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model ?? '',
|
||||
effort: normalizeDraftEffort(member.effort),
|
||||
|
|
@ -237,6 +240,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
const result: TeamProvisioningMemberInput = { name, role };
|
||||
const workflow = getWorkflowForExport(member);
|
||||
if (workflow) result.workflow = workflow;
|
||||
if (member.isolation === 'worktree') result.isolation = 'worktree';
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
if (providerId) {
|
||||
result.providerId = providerId;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, FilePlus, GitBranch, Loader2, Save, Undo2 } from 'lucide-react';
|
||||
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
|
@ -179,6 +179,28 @@ export const FileSectionHeader = ({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{file.ledgerSummary?.worktreePath && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] text-blue-300">
|
||||
<GitBranch className="size-3" />
|
||||
WORKTREE
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-text">
|
||||
{file.ledgerSummary.worktreeBranch ?? 'Isolated worktree'}
|
||||
</div>
|
||||
<div className="break-all text-text-muted">{file.ledgerSummary.worktreePath}</div>
|
||||
{file.ledgerSummary.dirtyLeaderWarning && (
|
||||
<div className="text-amber-300">{file.ledgerSummary.dirtyLeaderWarning}</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{fileDecision && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface UseCreateTeamDraftResult {
|
|||
setMembers: (v: MemberDraft[]) => void;
|
||||
syncModelsWithLead: boolean;
|
||||
setSyncModelsWithLead: (v: boolean) => void;
|
||||
teammateWorktreeDefault: boolean;
|
||||
setTeammateWorktreeDefault: (v: boolean) => void;
|
||||
cwdMode: 'project' | 'custom';
|
||||
setCwdMode: (v: 'project' | 'custom') => void;
|
||||
selectedProjectPath: string;
|
||||
|
|
@ -66,12 +68,13 @@ const DEBOUNCE_MS = 400;
|
|||
|
||||
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
|
||||
return members.map(
|
||||
({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
|
||||
({ id, name, roleSelection, customRole, workflow, isolation, providerId, model, effort }) => ({
|
||||
id,
|
||||
name,
|
||||
roleSelection,
|
||||
customRole,
|
||||
workflow,
|
||||
isolation,
|
||||
providerId,
|
||||
model,
|
||||
effort,
|
||||
|
|
@ -87,6 +90,7 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
|
|||
roleSelection: m.roleSelection,
|
||||
customRole: m.customRole,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
|
|
@ -103,6 +107,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
const [teamName, setTeamNameState] = useState('');
|
||||
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
||||
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
|
||||
const [teammateWorktreeDefault, setTeammateWorktreeDefaultState] = useState(false);
|
||||
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
|
||||
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
|
||||
const [customCwd, setCustomCwdState] = useState('');
|
||||
|
|
@ -115,6 +120,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
const teamNameRef = useRef('');
|
||||
const membersRef = useRef<MemberDraft[]>([]);
|
||||
const syncModelsWithLeadRef = useRef(true);
|
||||
const teammateWorktreeDefaultRef = useRef(false);
|
||||
const cwdModeRef = useRef<'project' | 'custom'>('project');
|
||||
const selectedProjectPathRef = useRef('');
|
||||
const customCwdRef = useRef('');
|
||||
|
|
@ -141,6 +147,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
teamName: teamNameRef.current,
|
||||
members: serializeMembers(membersRef.current),
|
||||
syncModelsWithLead: syncModelsWithLeadRef.current,
|
||||
teammateWorktreeDefault: teammateWorktreeDefaultRef.current,
|
||||
cwdMode: cwdModeRef.current,
|
||||
selectedProjectPath: selectedProjectPathRef.current,
|
||||
customCwd: customCwdRef.current,
|
||||
|
|
@ -201,6 +208,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
teamNameRef.current = snap.teamName;
|
||||
membersRef.current = deserialized;
|
||||
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
|
||||
teammateWorktreeDefaultRef.current = snap.teammateWorktreeDefault === true;
|
||||
cwdModeRef.current = snap.cwdMode;
|
||||
selectedProjectPathRef.current = snap.selectedProjectPath;
|
||||
customCwdRef.current = snap.customCwd;
|
||||
|
|
@ -211,6 +219,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
setTeamNameState(snap.teamName);
|
||||
setMembersState(deserialized);
|
||||
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
|
||||
setTeammateWorktreeDefaultState(snap.teammateWorktreeDefault === true);
|
||||
setCwdModeState(snap.cwdMode);
|
||||
setSelectedProjectPathState(snap.selectedProjectPath);
|
||||
setCustomCwdState(snap.customCwd);
|
||||
|
|
@ -285,6 +294,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
[scheduleSave]
|
||||
);
|
||||
|
||||
const setTeammateWorktreeDefault = useCallback(
|
||||
(v: boolean) => {
|
||||
userTouchedRef.current = true;
|
||||
teammateWorktreeDefaultRef.current = v;
|
||||
setTeammateWorktreeDefaultState(v);
|
||||
scheduleSave();
|
||||
},
|
||||
[scheduleSave]
|
||||
);
|
||||
|
||||
const setCwdMode = useCallback(
|
||||
(v: 'project' | 'custom') => {
|
||||
userTouchedRef.current = true;
|
||||
|
|
@ -360,6 +379,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
teamNameRef.current = '';
|
||||
membersRef.current = [];
|
||||
syncModelsWithLeadRef.current = true;
|
||||
teammateWorktreeDefaultRef.current = false;
|
||||
cwdModeRef.current = 'project';
|
||||
selectedProjectPathRef.current = '';
|
||||
customCwdRef.current = '';
|
||||
|
|
@ -370,6 +390,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
setTeamNameState('');
|
||||
setMembersState([]);
|
||||
setSyncModelsWithLeadState(true);
|
||||
setTeammateWorktreeDefaultState(false);
|
||||
setCwdModeState('project');
|
||||
setSelectedProjectPathState('');
|
||||
setCustomCwdState('');
|
||||
|
|
@ -387,6 +408,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|||
setMembers,
|
||||
syncModelsWithLead,
|
||||
setSyncModelsWithLead,
|
||||
teammateWorktreeDefault,
|
||||
setTeammateWorktreeDefault,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface SerializedMemberDraft {
|
|||
roleSelection: string;
|
||||
customRole: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -42,6 +43,7 @@ export interface CreateTeamDraftSnapshot {
|
|||
teamName: string;
|
||||
members: SerializedMemberDraft[];
|
||||
syncModelsWithLead?: boolean;
|
||||
teammateWorktreeDefault?: boolean;
|
||||
cwdMode: 'project' | 'custom';
|
||||
selectedProjectPath: string;
|
||||
customCwd: string;
|
||||
|
|
@ -69,6 +71,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
|
|||
typeof obj.name === 'string' &&
|
||||
typeof obj.roleSelection === 'string' &&
|
||||
typeof obj.customRole === 'string' &&
|
||||
(obj.isolation === undefined || obj.isolation === 'worktree') &&
|
||||
(obj.providerId === undefined || isTeamProviderId(obj.providerId)) &&
|
||||
(obj.model === undefined || typeof obj.model === 'string') &&
|
||||
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
|
||||
|
|
@ -85,6 +88,8 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
|
|||
Array.isArray(obj.members) &&
|
||||
obj.members.every(isValidMember) &&
|
||||
(obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
|
||||
(obj.teammateWorktreeDefault === undefined ||
|
||||
typeof obj.teammateWorktreeDefault === 'boolean') &&
|
||||
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
|
||||
typeof obj.selectedProjectPath === 'string' &&
|
||||
typeof obj.customCwd === 'string' &&
|
||||
|
|
@ -170,6 +175,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
|
|||
teamName: '',
|
||||
members: [],
|
||||
syncModelsWithLead: true,
|
||||
teammateWorktreeDefault: false,
|
||||
cwdMode: 'project',
|
||||
selectedProjectPath: '',
|
||||
customCwd: '',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ export interface SnippetDiff {
|
|||
linesAdded?: number;
|
||||
linesRemoved?: number;
|
||||
textAvailability?: 'patch-text' | 'full-text' | 'unavailable';
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
baseWorkspaceRoot?: string;
|
||||
dirtyLeaderWarning?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +100,10 @@ export interface FileChangeSummary {
|
|||
agentIds?: string[];
|
||||
memberNames?: string[];
|
||||
executionSeqRange?: { start: number; end: number };
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
baseWorkspaceRoot?: string;
|
||||
dirtyLeaderWarning?: string;
|
||||
};
|
||||
/** Edit timeline for this file (Phase 4) */
|
||||
timeline?: FileEditTimeline;
|
||||
|
|
@ -261,6 +269,10 @@ export interface TaskChangeScope {
|
|||
firstTimestamp: string;
|
||||
lastTimestamp: string;
|
||||
}>;
|
||||
worktreePaths?: string[];
|
||||
worktreeBranches?: string[];
|
||||
baseWorkspaceRoots?: string[];
|
||||
dirtyLeaderWarnings?: string[];
|
||||
}
|
||||
|
||||
/** Результат парсинга всех границ задач из JSONL файла */
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface TeamMember {
|
|||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
/** Opt-in runtime isolation for persistent teammates. Omitted means shared workspace. */
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -714,6 +716,7 @@ export interface ResolvedTeamMember {
|
|||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -762,6 +765,7 @@ export interface TeamMemberSnapshot {
|
|||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -1065,6 +1069,8 @@ export interface TeamProvisioningMemberInput {
|
|||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
/** Opt-in: run this teammate in its own git worktree. */
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
@ -1228,6 +1234,7 @@ export interface AddMemberRequest {
|
|||
name: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
|
|
|
|||
|
|
@ -254,6 +254,288 @@ describe('TaskChangeLedgerReader', () => {
|
|||
expect(result?.files[0]?.linesRemoved).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves v2 worktree metadata from centralized ledger summaries', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
source: 'task-change-ledger',
|
||||
bundleKind: 'summary',
|
||||
taskId: TASK_ID,
|
||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||
journalStamp: {},
|
||||
integrity: 'ok',
|
||||
eventCount: 1,
|
||||
noticeCount: 0,
|
||||
scope: {
|
||||
confidence: { tier: 1, label: 'high', reason: 'bundle' },
|
||||
memberName: 'alice',
|
||||
agentIds: ['alice@team'],
|
||||
startTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
endTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolUseIds: ['tool-1'],
|
||||
toolUseCount: 1,
|
||||
phaseSet: ['work'],
|
||||
visibleFileCount: 1,
|
||||
contributors: [],
|
||||
worktreePaths: ['/repo/.claude/worktrees/team-atlas-alice-12345678'],
|
||||
worktreeBranches: ['worktree-team-atlas-alice-12345678'],
|
||||
baseWorkspaceRoots: ['/repo'],
|
||||
dirtyLeaderWarnings: ['Leader workspace had uncommitted changes.'],
|
||||
},
|
||||
files: [
|
||||
{
|
||||
changeKey: 'path:/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
|
||||
filePath: '/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
|
||||
relativePath: 'src/a.ts',
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
diffStatKnown: true,
|
||||
eventCount: 1,
|
||||
firstTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
latestOperation: 'modify',
|
||||
createdInTask: false,
|
||||
deletedInTask: false,
|
||||
latestBeforeHash: null,
|
||||
latestAfterHash: null,
|
||||
contentAvailability: 'full-text',
|
||||
reviewability: 'full-text',
|
||||
agentIds: ['alice@team'],
|
||||
worktreePath: '/repo/.claude/worktrees/team-atlas-alice-12345678',
|
||||
worktreeBranch: 'worktree-team-atlas-alice-12345678',
|
||||
baseWorkspaceRoot: '/repo',
|
||||
dirtyLeaderWarning: 'Leader workspace had uncommitted changes.',
|
||||
},
|
||||
],
|
||||
totalLinesAdded: 1,
|
||||
totalLinesRemoved: 0,
|
||||
diffStatCompleteness: 'complete',
|
||||
totalFiles: 1,
|
||||
confidence: 'high',
|
||||
warningCount: 0,
|
||||
warnings: [],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const result = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result?.scope.worktreePaths).toEqual([
|
||||
'/repo/.claude/worktrees/team-atlas-alice-12345678',
|
||||
]);
|
||||
expect(result?.files[0]?.ledgerSummary?.worktreePath).toBe(
|
||||
'/repo/.claude/worktrees/team-atlas-alice-12345678'
|
||||
);
|
||||
expect(result?.files[0]?.ledgerSummary?.worktreeBranch).toBe(
|
||||
'worktree-team-atlas-alice-12345678'
|
||||
);
|
||||
expect(result?.files[0]?.filePath).toBe(
|
||||
'/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps identical relative rename relations isolated by worktree path', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
source: 'task-change-ledger',
|
||||
bundleKind: 'summary',
|
||||
taskId: TASK_ID,
|
||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||
journalStamp: {},
|
||||
integrity: 'ok',
|
||||
eventCount: 2,
|
||||
noticeCount: 0,
|
||||
scope: {
|
||||
confidence: { tier: 1, label: 'high', reason: 'bundle' },
|
||||
memberName: 'alice',
|
||||
agentIds: ['alice@team', 'bob@team'],
|
||||
startTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
endTimestamp: '2026-03-01T10:01:00.000Z',
|
||||
toolUseIds: ['tool-1', 'tool-2'],
|
||||
toolUseCount: 2,
|
||||
phaseSet: ['work'],
|
||||
visibleFileCount: 2,
|
||||
contributors: [],
|
||||
worktreePaths: ['/repo/.claude/worktrees/team-a', '/repo/.claude/worktrees/team-b'],
|
||||
},
|
||||
files: [
|
||||
{
|
||||
changeKey: 'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
|
||||
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
|
||||
relativePath: 'src/new.ts',
|
||||
displayPath: 'src/new.ts',
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
diffStatKnown: true,
|
||||
eventCount: 1,
|
||||
firstTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
latestOperation: 'modify',
|
||||
createdInTask: false,
|
||||
deletedInTask: false,
|
||||
latestBeforeHash: null,
|
||||
latestAfterHash: null,
|
||||
contentAvailability: 'full-text',
|
||||
reviewability: 'full-text',
|
||||
agentIds: ['alice@team'],
|
||||
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
|
||||
worktreePath: '/repo/.claude/worktrees/team-a',
|
||||
},
|
||||
{
|
||||
changeKey: 'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
|
||||
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
|
||||
relativePath: 'src/new.ts',
|
||||
displayPath: 'src/new.ts',
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
diffStatKnown: true,
|
||||
eventCount: 1,
|
||||
firstTimestamp: '2026-03-01T10:01:00.000Z',
|
||||
lastTimestamp: '2026-03-01T10:01:00.000Z',
|
||||
latestOperation: 'modify',
|
||||
createdInTask: false,
|
||||
deletedInTask: false,
|
||||
latestBeforeHash: null,
|
||||
latestAfterHash: null,
|
||||
contentAvailability: 'full-text',
|
||||
reviewability: 'full-text',
|
||||
agentIds: ['bob@team'],
|
||||
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
|
||||
worktreePath: '/repo/.claude/worktrees/team-b',
|
||||
},
|
||||
],
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
diffStatCompleteness: 'complete',
|
||||
totalFiles: 2,
|
||||
confidence: 'high',
|
||||
warningCount: 0,
|
||||
warnings: [],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const result = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result?.files).toHaveLength(2);
|
||||
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
|
||||
new Set([
|
||||
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
|
||||
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps worktree relation keys isolated when rebuilding directly from journal events', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
|
||||
await mkdir(eventsDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
|
||||
[
|
||||
{
|
||||
schemaVersion: 1,
|
||||
eventId: 'event-a',
|
||||
taskId: TASK_ID,
|
||||
taskRef: TASK_ID,
|
||||
taskRefKind: 'canonical',
|
||||
phase: 'work',
|
||||
executionSeq: 1,
|
||||
sessionId: 'session-a',
|
||||
agentId: 'alice@team',
|
||||
toolUseId: 'tool-a',
|
||||
source: 'shell_snapshot',
|
||||
operation: 'modify',
|
||||
confidence: 'high',
|
||||
workspaceRoot: '/repo/.claude/worktrees/team-a',
|
||||
worktreePath: '/repo/.claude/worktrees/team-a',
|
||||
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
|
||||
relativePath: 'src/new.ts',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolStatus: 'succeeded',
|
||||
before: null,
|
||||
after: null,
|
||||
oldString: 'export const a = 1;\n',
|
||||
newString: 'export const a = 2;\n',
|
||||
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
|
||||
linesAdded: 1,
|
||||
linesRemoved: 1,
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
eventId: 'event-b',
|
||||
taskId: TASK_ID,
|
||||
taskRef: TASK_ID,
|
||||
taskRefKind: 'canonical',
|
||||
phase: 'work',
|
||||
executionSeq: 2,
|
||||
sessionId: 'session-b',
|
||||
agentId: 'bob@team',
|
||||
toolUseId: 'tool-b',
|
||||
source: 'shell_snapshot',
|
||||
operation: 'modify',
|
||||
confidence: 'high',
|
||||
workspaceRoot: '/repo/.claude/worktrees/team-b',
|
||||
worktreePath: '/repo/.claude/worktrees/team-b',
|
||||
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
|
||||
relativePath: 'src/new.ts',
|
||||
timestamp: '2026-03-01T10:01:00.000Z',
|
||||
toolStatus: 'succeeded',
|
||||
before: null,
|
||||
after: null,
|
||||
oldString: 'export const b = 1;\n',
|
||||
newString: 'export const b = 2;\n',
|
||||
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
|
||||
linesAdded: 1,
|
||||
linesRemoved: 1,
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const result = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result?.files).toHaveLength(2);
|
||||
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
|
||||
new Set([
|
||||
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
|
||||
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to journal summary when bundle and freshness describe different generations', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||
|
|
|
|||
|
|
@ -588,6 +588,48 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('persists teammate worktree isolation in replaceMembers', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => []),
|
||||
writeMembers,
|
||||
} as never;
|
||||
|
||||
const service = new TeamDataService(
|
||||
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
|
||||
{ getTasks: vi.fn(async () => []) } as never,
|
||||
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
);
|
||||
|
||||
await service.replaceMembers('runtime-team', {
|
||||
members: [
|
||||
{ name: 'alice', role: 'Developer', isolation: 'worktree' },
|
||||
{ name: 'bob', role: 'Reviewer' },
|
||||
],
|
||||
});
|
||||
|
||||
const [, writtenMembers] = writeMembers.mock.calls[0] as unknown as [
|
||||
string,
|
||||
Array<{
|
||||
name: string;
|
||||
isolation?: 'worktree';
|
||||
}>,
|
||||
];
|
||||
expect(writtenMembers.find((member) => member.name === 'alice')).toMatchObject({
|
||||
isolation: 'worktree',
|
||||
});
|
||||
expect(writtenMembers.find((member) => member.name === 'bob')?.isolation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry over agentId from a previously removed member with the same name', async () => {
|
||||
const writeMembers = vi.fn(async () => {});
|
||||
const membersMetaStore = {
|
||||
|
|
|
|||
|
|
@ -332,6 +332,42 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('createTeam bootstrap spec includes worktree isolation only for selected teammates', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
const { child } = createFakeChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async () => false);
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName: 'worktree-mixed-team',
|
||||
cwd: process.cwd(),
|
||||
members: [
|
||||
{ name: 'alice', role: 'developer', isolation: 'worktree' },
|
||||
{ name: 'bob', role: 'reviewer' },
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const bootstrapSpec = extractBootstrapSpec();
|
||||
expect(bootstrapSpec.members?.[0]).toEqual(
|
||||
expect.objectContaining({ name: 'alice', isolation: 'worktree' })
|
||||
);
|
||||
expect(bootstrapSpec.members?.[1]).toEqual(expect.objectContaining({ name: 'bob' }));
|
||||
expect(bootstrapSpec.members?.[1]).not.toHaveProperty('isolation');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('forwards codex provider launch overrides into createTeam runtime args', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
|
||||
const { child } = createFakeChild();
|
||||
|
|
@ -452,6 +488,24 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
);
|
||||
});
|
||||
|
||||
it('add and restart teammate prompts include worktree isolation only when selected', () => {
|
||||
const addMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
|
||||
name: 'alice',
|
||||
isolation: 'worktree',
|
||||
});
|
||||
const normalAddMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
|
||||
name: 'bob',
|
||||
});
|
||||
const restartMessage = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
|
||||
name: 'alice',
|
||||
isolation: 'worktree',
|
||||
});
|
||||
|
||||
expect(addMessage).toContain('isolation="worktree"');
|
||||
expect(restartMessage).toContain('isolation="worktree"');
|
||||
expect(normalAddMessage).not.toContain('isolation="worktree"');
|
||||
});
|
||||
|
||||
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||
const { child } = createFakeChild();
|
||||
|
|
@ -746,7 +800,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex', isolation: 'worktree' }],
|
||||
source: 'config-fallback',
|
||||
warning: undefined,
|
||||
}));
|
||||
|
|
@ -810,7 +864,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||
members: [{ name: 'alice', role: 'developer', providerId: 'codex', isolation: 'worktree' }],
|
||||
source: 'config-fallback',
|
||||
warning: undefined,
|
||||
}));
|
||||
|
|
@ -832,6 +886,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'])
|
||||
);
|
||||
expect(extractBootstrapSpec().members?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
provider: 'codex',
|
||||
isolation: 'worktree',
|
||||
})
|
||||
);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,6 +112,36 @@ describe('getMembersRequiringRuntimeRestart', () => {
|
|||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns existing teammates whose worktree isolation changed', () => {
|
||||
const result = getMembersRequiringRuntimeRestart({
|
||||
previousMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
isolation: undefined,
|
||||
} as any,
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
isolation: 'worktree',
|
||||
} as any,
|
||||
],
|
||||
nextMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
isolation: 'worktree',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
|
||||
const result = getLiveRosterIdentityChanges({
|
||||
previousMembers: [
|
||||
|
|
|
|||
|
|
@ -92,6 +92,29 @@ describe('members editor editable input filtering', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('preserves worktree isolation when importing and exporting member drafts', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'developer',
|
||||
isolation: 'worktree',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
agentType: 'reviewer',
|
||||
},
|
||||
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>>)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts);
|
||||
|
||||
expect(drafts[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
|
||||
expect(exported[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
|
||||
expect(exported[1]).toMatchObject({ name: 'bob' });
|
||||
expect(exported[1]).not.toHaveProperty('isolation');
|
||||
});
|
||||
|
||||
it('reuses existing member colors for matching draft names', () => {
|
||||
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
|
||||
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue