diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index e1b551d4..a086ae25 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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, diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 7f2cda58..e5986773 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -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; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index d736bb37..962052fd 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 3f4ab861..45ee41ba 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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, diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 7a316a15..2a5fbe58 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0322c65a..2de62192 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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, diff --git a/src/main/services/team/memberUpdateNotifications.ts b/src/main/services/team/memberUpdateNotifications.ts index eb0e6e1f..2fc386fd 100644 --- a/src/main/services/team/memberUpdateNotifications.ts +++ b/src/main/services/team/memberUpdateNotifications.ts @@ -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 []; diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index e2a13b4c..f4d1a846 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -15,6 +15,7 @@ export interface TeamRuntimeMemberSpec { name: string; role?: string; workflow?: string; + isolation?: 'worktree'; providerId: TeamRuntimeProviderId; model?: string; effort?: EffortLevel; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 8e2e031f..5c5d99db 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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, diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 26eb4187..6f28f45f 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -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(() => buildInitialDrafts(existingNames)); + const existingWorktreeDefault = deriveExistingWorktreeDefault(existingMembers); + const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(existingWorktreeDefault); + const [members, setMembers] = useState(() => + buildInitialDrafts(existingNames, existingWorktreeDefault) + ); const [error, setError] = useState(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} /> diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e71b8d20..2c8a45f3 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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} diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 9eaa1c4c..2c6ebf9c 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -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(null); const [saveOutcomeError, setSaveOutcomeError] = useState(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 = ({

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(', ')}.

) : null} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 210aa8a4..b855f5ef 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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([]); + 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} diff --git a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts index 2b271814..668e93a0 100644 --- a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts +++ b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts @@ -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; diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 51b67cfe..c75b1784 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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} + {showWorktreeIsolationControls ? ( + + +
+ + onWorktreeIsolationChange?.(member.id, checked === true) + } + /> + +
+
+ + Run this teammate in a separate git worktree. Apply/reject changes targets that + worktree, not the lead workspace. + +
+ ) : null} {hideActionButton ? null : isRemoved ? (