From 074b614469d5ca71e56fd781b93218773b74a1bd Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 2 Apr 2026 15:15:32 +0300 Subject: [PATCH] feat: improve team launch model and status UX --- .../infrastructure/CliInstallerService.ts | 9 + .../services/team/TeamMcpConfigBuilder.ts | 34 ++- src/main/services/team/TeamMemberResolver.ts | 28 +- .../services/team/TeamProvisioningService.ts | 265 ++++++++++++++++-- src/renderer/api/httpClient.ts | 1 + .../components/dashboard/CliStatusBanner.tsx | 51 +++- .../settings/sections/CliStatusSection.tsx | 8 +- .../components/team/TeamDetailView.tsx | 53 ++-- src/renderer/components/team/TeamListView.tsx | 4 +- .../team/dialogs/CreateTeamDialog.tsx | 60 +++- .../team/dialogs/LaunchTeamDialog.tsx | 202 ++++++++----- .../team/dialogs/TeamModelSelector.tsx | 22 +- .../components/team/members/LeadModelRow.tsx | 70 ++--- .../team/members/MemberDraftRow.tsx | 92 ++++-- .../team/members/MembersEditorSection.tsx | 67 ++++- .../team/members/TeamRosterEditorSection.tsx | 3 + .../team/members/membersEditorTypes.ts | 1 + .../team/members/membersEditorUtils.ts | 32 +++ src/shared/types/cliInstaller.ts | 2 + test/renderer/store/cliInstallerSlice.test.ts | 2 + 20 files changed, 786 insertions(+), 220 deletions(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 70c9cfe2..0310ee44 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -433,6 +433,7 @@ export class CliInstallerService { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: true, authMethod: null, providers, }; @@ -497,6 +498,7 @@ export class CliInstallerService { if (binaryPath) { r.installed = true; r.binaryPath = binaryPath; + r.authStatusChecking = true; this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(r) }); try { @@ -522,6 +524,7 @@ export class CliInstallerService { ]); } else { // No binary — still check latest version for "install" prompt + r.authStatusChecking = false; if (r.supportsSelfUpdate) { await this.fetchLatestVersion(r); } @@ -541,6 +544,7 @@ export class CliInstallerService { diag: CliInstallerStatusRunDiag ): Promise { if (result.flavor === 'free-code') { + result.authStatusChecking = true; try { const providers = await this.multimodelBridgeService.getProviderStatuses( binaryPath, @@ -556,10 +560,12 @@ export class CliInstallerService { result.authLoggedIn = providers.some((provider) => provider.authenticated); result.authMethod = providers.find((provider) => provider.authenticated)?.authMethod ?? null; + result.authStatusChecking = false; this.sendProgress({ type: 'status', status: cloneCliInstallationStatus(result) }); } catch (error) { const msg = getErrorMessage(error); diag.authLastError = msg; + result.authStatusChecking = false; logger.warn(`Provider status check failed for claude-multimodel: ${msg}`); } return; @@ -578,6 +584,7 @@ export class CliInstallerService { const auth = parseClaudeAuthStatusStdout(authStdout); result.authLoggedIn = auth.loggedIn === true; result.authMethod = auth.authMethod ?? null; + result.authStatusChecking = false; diag.authLastError = null; logger.info( `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + @@ -598,6 +605,7 @@ export class CliInstallerService { `Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}` ); result.authLoggedIn = false; + result.authStatusChecking = false; } } } @@ -621,6 +629,7 @@ export class CliInstallerService { if (timer) { clearTimeout(timer); } + result.authStatusChecking = false; diag.authTimedOut = hitAuthTimeout; } } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 2b96a824..0a15034d 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -69,6 +69,13 @@ function getSourceServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } +function getWorkspaceTsxBinCandidates(): string[] { + return [ + path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'), + path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'), + ]; +} + async function pathExists(targetPath: string): Promise { try { await fs.promises.access(targetPath, fs.constants.F_OK); @@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise { logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); } - // 2. Dev mode — prefer source for hot changes - const sourceEntry = getSourceServerEntry(); - checked.push(sourceEntry); - if (await pathExists(sourceEntry)) { - return { - command: 'pnpm', - args: ['--dir', getWorkspaceMcpServerDir(), 'exec', 'tsx', sourceEntry], - }; - } - - // 3. Dev mode — built dist + // 2. Dev mode — prefer built dist for reliable direct execution const builtEntry = getBuiltServerEntry(); checked.push(builtEntry); if (await pathExists(builtEntry)) { @@ -231,6 +228,21 @@ async function resolveMcpLaunchSpec(): Promise { }; } + // 3. Dev mode fallback — run source directly through a local tsx binary + const sourceEntry = getSourceServerEntry(); + checked.push(sourceEntry); + if (await pathExists(sourceEntry)) { + for (const tsxBin of getWorkspaceTsxBinCandidates()) { + checked.push(tsxBin); + if (await pathExists(tsxBin)) { + return { + command: tsxBin, + args: [sourceEntry], + }; + } + } + } + throw new Error( `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` ); diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 92832d0e..d2295643 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -8,6 +8,7 @@ import type { MemberStatus, ResolvedTeamMember, TeamConfig, + TeamMember, TeamTaskWithKanban, } from '@shared/types'; @@ -134,15 +135,26 @@ export class TeamMemberResolver { if (Array.isArray(config.members)) { for (const m of config.members) { if (typeof m?.name === 'string' && m.name.trim() !== '') { + const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' }; + const providerId = + configMember.providerId === 'anthropic' || + configMember.providerId === 'codex' || + configMember.providerId === 'gemini' + ? configMember.providerId + : configMember.provider === 'anthropic' || + configMember.provider === 'codex' || + configMember.provider === 'gemini' + ? configMember.provider + : undefined; configMemberMap.set(m.name.trim(), { - agentType: m.agentType, - role: m.role, - workflow: m.workflow, - providerId: m.providerId, - model: m.model, - effort: m.effort, - color: m.color, - cwd: m.cwd, + agentType: configMember.agentType, + role: configMember.role, + workflow: configMember.workflow, + providerId, + model: configMember.model, + effort: configMember.effort, + color: configMember.color, + cwd: configMember.cwd, }); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9841a248..6cb62dc6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -295,6 +295,7 @@ interface ProvisioningRun { onProgress: (progress: TeamProvisioningProgress) => void; expectedMembers: string[]; request: TeamCreateRequest; + effectiveMembers: TeamCreateRequest['members']; lastLogProgressAt: number; /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ lastDataReceivedAt: number; @@ -491,6 +492,45 @@ function formatWorkflowBlock(workflow: string, indent: string): string { return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; } +type TeamMemberInput = TeamCreateRequest['members'][number]; + +function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | undefined { + return providerId === 'codex' || providerId === 'gemini' || providerId === 'anthropic' + ? providerId + : undefined; +} + +function buildEffectiveTeamMemberSpec( + member: TeamMemberInput, + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamMemberInput { + const memberProviderId = normalizeTeamMemberProviderId(member.providerId); + const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); + const model = member.model?.trim() || defaults.model?.trim() || undefined; + + return { + ...member, + providerId: memberProviderId ?? defaultProviderId ?? 'anthropic', + model, + effort: member.effort ?? defaults.effort, + }; +} + +function buildEffectiveTeamMemberSpecs( + members: TeamCreateRequest['members'], + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + } +): TeamCreateRequest['members'] { + return members.map((member) => buildEffectiveTeamMemberSpec(member, defaults)); +} + function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { @@ -983,17 +1023,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; } -function buildProvisioningPrompt(request: TeamCreateRequest): string { +function buildProvisioningPrompt( + request: TeamCreateRequest, + effectiveMembers: TeamCreateRequest['members'] +): string { const displayName = request.displayName?.trim() || request.teamName; const userPromptBlock = request.prompt?.trim() ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` : ''; const leadName = - request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; + effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; const projectName = path.basename(request.cwd); - const isSolo = request.members.length === 0; + const isSolo = effectiveMembers.length === 0; const step3Block = isSolo ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` + @@ -1036,14 +1079,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn. Per-member spawn instructions: -${request.members +${effectiveMembers .map((m) => { const providerLine = m.providerId && m.providerId !== 'anthropic' ? ` - provider: “${m.providerId}”\n` : ''; const modelLine = m.model?.trim() ? ` - model: “${m.model.trim()}”\n` : ''; + const effortLine = m.effort ? ` - effort: “${m.effort}”\n` : ''; return ` For “${m.name}”: - name: “${m.name}” -${providerLine}${modelLine} - prompt: +${providerLine}${modelLine}${effortLine} - prompt: ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) .split('\n') .map((line) => ` ${line}`) @@ -1055,7 +1099,7 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName) teamName: request.teamName, leadName, isSolo, - members: request.members, + members: effectiveMembers, }); return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it. @@ -3051,6 +3095,14 @@ export class TeamProvisioningService { // Respawn with saved context — CLI handles its own auth refresh. let child: ReturnType; try { + if (mcpFlagIdx !== -1 && mcpFlagIdx + 1 < ctx.args.length) { + await this.validateAgentTeamsMcpRuntime( + ctx.claudePath, + ctx.cwd, + ctx.env, + ctx.args[mcpFlagIdx + 1]! + ); + } child = spawnCli(ctx.claudePath, ctx.args, { cwd: ctx.cwd, env: { ...ctx.env }, @@ -3292,6 +3344,11 @@ export class TeamProvisioningService { throw new Error('Claude CLI not found; install it or provide a valid path'); } + const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); const runId = randomUUID(); const startedAt = nowIso(); const run: ProvisioningRun = { @@ -3318,6 +3375,7 @@ export class TeamProvisioningService { onProgress, expectedMembers: request.members.map((member) => member.name), request, + effectiveMembers: effectiveMemberSpecs, lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -3372,13 +3430,14 @@ export class TeamProvisioningService { this.provisioningRunByTeam.set(request.teamName, runId); run.onProgress(run.progress); - const prompt = buildProvisioningPrompt(request); + const prompt = buildProvisioningPrompt(request, effectiveMemberSpecs); let child: ReturnType; const { env: shellEnv } = await this.buildProvisioningEnv(request.providerId); let mcpConfigPath: string; try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -3718,12 +3777,20 @@ export class TeamProvisioningService { const runId = randomUUID(); const startedAt = nowIso(); + const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }); + // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, providerId: request.providerId, + model: request.model, + effort: request.effort, skipPermissions: request.skipPermissions, }; @@ -3764,6 +3831,7 @@ export class TeamProvisioningService { onProgress, expectedMembers, request: syntheticRequest, + effectiveMembers: effectiveMemberSpecs, lastLogProgressAt: 0, lastDataReceivedAt: 0, // intentionally 0 — real reset happens after spawn (see startStallWatchdog call sites) lastStdoutReceivedAt: 0, @@ -3837,7 +3905,7 @@ export class TeamProvisioningService { const prompt = buildLaunchPrompt( request, - expectedMemberSpecs, + effectiveMemberSpecs, existingTasks, Boolean(previousSessionId) ); @@ -3847,6 +3915,7 @@ export class TeamProvisioningService { try { mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath); } catch (error) { this.runs.delete(runId); this.provisioningRunByTeam.delete(request.teamName); @@ -6862,7 +6931,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); await this.cleanupPrelaunchBackup(run.teamName); @@ -6996,7 +7071,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); // Clean up team.meta.json — provisioning succeeded, config.json is now authoritative. @@ -7789,7 +7870,13 @@ export class TeamProvisioningService { run.teamName, run.request.cwd, run.detectedSessionId, - run.request.color + run.request.color, + { + providerId: run.request.providerId, + model: run.request.model, + effort: run.request.effort, + members: run.effectiveMembers, + } ); // Process was killed by timeout — mark as disconnected, not ready const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', { @@ -7963,6 +8050,96 @@ export class TeamProvisioningService { } } + private applyEffectiveLaunchStateToConfig( + config: Record, + launchState?: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + members?: TeamCreateRequest['members']; + } + ): void { + if (!launchState || !Array.isArray(config.members)) { + return; + } + + const effectiveLeadProviderId = + normalizeTeamMemberProviderId(launchState.providerId) ?? 'anthropic'; + const effectiveLeadModel = launchState.model?.trim() || undefined; + const effectiveLeadEffort = + launchState.effort === 'low' || + launchState.effort === 'medium' || + launchState.effort === 'high' + ? launchState.effort + : undefined; + + const membersByName = new Map( + (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const) + ); + + config.members = (config.members as Record[]).map((member) => { + if (!member || typeof member !== 'object') { + return member; + } + + const rawName = typeof member.name === 'string' ? member.name.trim() : ''; + const nextMember = { ...member }; + + const assignRuntimeState = (state: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + }): void => { + const providerId = normalizeTeamMemberProviderId(state.providerId); + if (providerId) { + nextMember.provider = providerId; + nextMember.providerId = providerId; + } else { + delete nextMember.provider; + delete nextMember.providerId; + } + + const model = state.model?.trim() || undefined; + if (model) { + nextMember.model = model; + } else { + delete nextMember.model; + } + + const effort = + state.effort === 'low' || state.effort === 'medium' || state.effort === 'high' + ? state.effort + : undefined; + if (effort) { + nextMember.effort = effort; + } else { + delete nextMember.effort; + } + }; + + if (isLeadMember(nextMember) || rawName.toLowerCase() === 'team-lead') { + assignRuntimeState({ + providerId: effectiveLeadProviderId, + model: effectiveLeadModel, + effort: effectiveLeadEffort, + }); + return nextMember; + } + + const effectiveMember = membersByName.get(rawName.toLowerCase()); + if (!effectiveMember) { + return nextMember; + } + + assignRuntimeState({ + providerId: effectiveMember.providerId, + model: effectiveMember.model, + effort: effectiveMember.effort, + }); + return nextMember; + }); + } + /** * Single atomic read-mutate-write for post-launch config updates. * Combines session history append and projectPath update to avoid @@ -7972,7 +8149,13 @@ export class TeamProvisioningService { teamName: string, projectPath: string, detectedSessionId: string | null, - color?: string + color?: string, + launchState?: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + members?: TeamCreateRequest['members']; + } ): Promise { const MAX_SESSION_HISTORY = 5000; const MAX_PROJECT_PATH_HISTORY = 500; @@ -8049,6 +8232,8 @@ export class TeamProvisioningService { : pathHistory; } + this.applyEffectiveLaunchStateToConfig(config, launchState); + await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); } catch (error) { logger.warn( @@ -8751,6 +8936,7 @@ export class TeamProvisioningService { role?: string; workflow?: string; agentType?: string; + providerId?: string; provider?: string; model?: string; effort?: string; @@ -8771,10 +8957,7 @@ export class TeamProvisioningService { role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, - providerId: - member.provider === 'codex' || member.provider === 'gemini' - ? member.provider - : undefined, + providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, effort: member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' @@ -8985,6 +9168,56 @@ export class TeamProvisioningService { return output; } + private buildAgentTeamsMcpValidationError(output: string): string { + const detail = this.normalizeApiRetryErrorMessage(output) || output.trim(); + if (!detail) { + return ( + 'agent-teams MCP loaded config but did not expose member_briefing. ' + + 'The leader would start without required team MCP tools.' + ); + } + return ( + 'agent-teams MCP loaded config but did not expose member_briefing. ' + `Details: ${detail}` + ); + } + + private async validateAgentTeamsMcpRuntime( + claudePath: string, + cwd: string, + env: NodeJS.ProcessEnv, + mcpConfigPath: string + ): Promise { + const result = await this.spawnProbe( + claudePath, + [ + '--setting-sources', + 'user,project,local', + '--mcp-config', + mcpConfigPath, + '--', + 'mcp', + 'get', + 'agent-teams', + ], + cwd, + env, + VERIFY_TIMEOUT_MS + ); + + const combinedOutput = buildCombinedLogs(result.stdout, result.stderr).trim(); + if (result.exitCode !== 0) { + throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + } + + const normalizedOutput = combinedOutput.toLowerCase(); + if ( + !normalizedOutput.includes('status: ✓ connected') && + !normalizedOutput.includes('status: connected') + ) { + throw new Error(this.buildAgentTeamsMcpValidationError(combinedOutput)); + } + } + private async spawnProbe( claudePath: string, args: string[], diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0c74c587..5b14ca3c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: false, authMethod: null, providers: [], }), diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 23ee9564..fa2256ac 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -125,8 +125,10 @@ const SLOW_CHECK_DELAY_MS = 5_000; const CliCheckingSpinner = ({ styles, + label, }: { styles: { border: string; bg: string }; + label: string; }): React.JSX.Element => { const [showHint, setShowHint] = useState(false); @@ -146,7 +148,7 @@ const CliCheckingSpinner = ({ />
- Checking AI Providers... + {label} {showHint && (

@@ -309,6 +311,10 @@ function formatRuntimeAuthSummary( return `Providers: ${connected}/${denominator} connected`; } + if (cliStatus.authStatusChecking) { + return 'Checking authentication...'; + } + if (cliStatus.authLoggedIn) { return 'Authenticated'; } @@ -316,6 +322,18 @@ function formatRuntimeAuthSummary( return null; } +function isCheckingMultimodelStatus( + cliStatus: NonNullable['cliStatus']> +): boolean { + return ( + cliStatus.flavor === 'free-code' && + cliStatus.providers.length > 0 && + cliStatus.providers.every( + (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated + ) + ); +} + function createLoadingMultimodelStatus(): CliInstallationStatus { const providers: CliProviderStatus[] = [ { providerId: 'anthropic' as const, displayName: 'Anthropic' }, @@ -349,6 +367,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus { latestVersion: null, updateAvailable: false, authLoggedIn: false, + authStatusChecking: true, authMethod: null, providers, }; @@ -643,6 +662,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { async (enabled: boolean) => { setIsSwitchingFlavor(true); try { + useStore.setState({ + cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatusLoading: true, + cliStatusError: null, + }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); await fetchCliStatus(); @@ -695,6 +719,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'completed') return 'success'; if (installerState !== 'idle') return 'info'; if (!cliStatus) return 'loading'; + if (isCheckingMultimodelStatus(cliStatus)) return 'info'; + if (cliStatus.authStatusChecking) return 'info'; if (!cliStatus.installed) return 'error'; if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.updateAvailable) return 'info'; @@ -785,7 +811,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } // Claude-only mode: keep the generic loading spinner. - return ; + return ( + + ); } // ── Downloading ──────────────────────────────────────────────────────── @@ -937,8 +968,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } // Installed but not logged in — yellow warning banner - if (cliStatus.installed && cliStatus.flavor !== 'free-code' && !cliStatus.authLoggedIn) { - if (isVerifyingAuth) { + if ( + cliStatus.installed && + cliStatus.flavor !== 'free-code' && + (cliStatus.authStatusChecking || isVerifyingAuth) + ) { + if (cliStatus.authStatusChecking || isVerifyingAuth) { return (

{
); } + } + + if ( + cliStatus.installed && + cliStatus.flavor !== 'free-code' && + !cliStatus.authStatusChecking && + !cliStatus.authLoggedIn + ) { return ( <>
({ ...provider, @@ -223,6 +224,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { async (enabled: boolean) => { setIsSwitchingFlavor(true); try { + useStore.setState({ + cliStatus: enabled ? createLoadingMultimodelStatus() : null, + cliStatusLoading: true, + cliStatusError: null, + }); await updateConfig('general', { multimodelEnabled: enabled }); await invalidateCliStatus(); await fetchCliStatus(); @@ -261,7 +267,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { style={{ color: 'var(--color-text-muted)' }} > - Checking AI Providers... + {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
)} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 89369941..7579e4d6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1136,30 +1136,35 @@ export const TeamDetailView = ({ const teamSummary = teams.find((t) => t.teamName === teamName); return ( <> -
-
-

Team not launched yet

-

- This is a draft team — {teamSummary?.displayName || teamName} has - been configured with {teamSummary?.memberCount ?? 0} member - {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI - yet. Click Launch to select a model and start the team. -

-
- - +
+
+ +
+
+
+

Team not launched yet

+

+ This is a draft team — {teamSummary?.displayName || teamName} has + been configured with {teamSummary?.memberCount ?? 0} member + {teamSummary?.memberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI + yet. Click Launch to select a model and start the team. +

+
+ + +
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 4f10d048..b7d14a65 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -592,14 +592,14 @@ export const TeamListView = (): React.JSX.Element => { setLaunchingTeamName(request.teamName); try { await launchTeam(request); - openTeamTab(request.teamName, request.cwd); } catch (err) { console.error('Failed to launch team:', err); + throw err; } finally { setLaunchingTeamName(null); } }, - [launchTeam, openTeamTab] + [launchTeam] ); useEffect(() => { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index a6689fb0..faf44f48 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -7,6 +7,8 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraft, + normalizeMemberDraftForProviderMode, + normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; @@ -33,6 +35,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; @@ -266,6 +269,7 @@ export const CreateTeamDialog = ({ onOpenTeam, }: CreateTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); // ── Persisted draft state (survives tab navigation) ────────────────── const { @@ -362,13 +366,14 @@ export const CreateTeamDialog = ({ }; const setSelectedProviderId = (value: TeamProviderId): void => { - setSelectedProviderIdRaw(value); - localStorage.setItem('team:lastSelectedProvider', value); - if (value !== 'anthropic') { + const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + setSelectedProviderIdRaw(normalizedValue); + localStorage.setItem('team:lastSelectedProvider', normalizedValue); + if (normalizedValue !== 'anthropic') { setLimitContextRaw(false); localStorage.setItem('team:lastLimitContext', 'false'); } - setSelectedModelRaw(getStoredTeamModel(value)); + setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; const setLimitContext = (value: boolean): void => { @@ -444,7 +449,10 @@ export const CreateTeamDialog = ({ [members, syncModelsWithLead] ); - const selectedMemberProviders = useMemo(() => { + const selectedMemberProviders = useMemo(() => { + if (!multimodelEnabled) { + return ['anthropic']; + } if (soloTeam || syncModelsWithLead) { return [selectedProviderId]; } @@ -456,7 +464,22 @@ export const CreateTeamDialog = ({ ), ]) ); - }, [members, selectedProviderId, soloTeam, syncModelsWithLead]); + }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); + + useEffect(() => { + if (multimodelEnabled) { + return; + } + if (selectedProviderId !== 'anthropic') { + setSelectedProviderIdRaw('anthropic'); + setSelectedModelRaw(getStoredTeamModel('anthropic')); + } + const nextMembers = members.map((member) => normalizeMemberDraftForProviderMode(member, false)); + const changed = nextMembers.some((member, index) => member !== members[index]); + if (changed) { + setMembers(nextMembers); + } + }, [members, multimodelEnabled, selectedProviderId, setMembers]); useEffect(() => { if (!open || !canCreate || !launchTeam) { @@ -631,15 +654,22 @@ export const CreateTeamDialog = ({ const presetRoles: readonly string[] = PRESET_ROLES; const isPreset = m.role != null && presetRoles.includes(m.role); const isCustom = m.role != null && m.role.length > 0 && !isPreset; - return createMemberDraft({ - name: m.name, - roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), - customRole: isCustom ? m.role : '', - workflow: m.workflow, - providerId: m.providerId, - model: m.model ?? '', - effort: m.effort, - }); + return normalizeMemberDraftForProviderMode( + createMemberDraft({ + name: m.name, + roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), + customRole: isCustom ? m.role : '', + workflow: m.workflow, + providerId: normalizeProviderForMode(m.providerId, multimodelEnabled), + model: + normalizeProviderForMode(m.providerId, multimodelEnabled) === + normalizeProviderForMode(m.providerId, true) + ? (m.model ?? '') + : '', + effort: m.effort, + }), + multimodelEnabled + ); }) ); setSyncModelsWithLead( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index ed22b46b..978b1658 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -7,9 +7,12 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraftsFromInputs, + normalizeMemberDraftForProviderMode, + normalizeProviderForMode, validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { TeamRosterEditorSection } from '@renderer/components/team/members/TeamRosterEditorSection'; +import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -33,6 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { + getCurrentProvisioningProgressForTeam, + isTeamProvisioningActive, +} from '@renderer/store/slices/teamSlice'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { @@ -155,6 +162,7 @@ function getProviderLabel(providerId: TeamProviderId): string { export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { const { open, onClose } = props; const { isLight } = useTheme(); + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const isLaunch = props.mode === 'launch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? (props.schedule ?? null) : null; @@ -261,21 +269,42 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), [membersDrafts, syncModelsWithLead] ); - const selectedMemberProviders = useMemo( + const selectedMemberProviders = useMemo( () => - Array.from( - new Set([ - selectedProviderId, - ...effectiveMemberDrafts.flatMap((member) => - member.providerId === 'codex' || member.providerId === 'gemini' - ? [member.providerId] - : [] + !multimodelEnabled + ? ['anthropic'] + : Array.from( + new Set([ + selectedProviderId, + ...effectiveMemberDrafts.flatMap((member) => + member.providerId === 'codex' || member.providerId === 'gemini' + ? [member.providerId] + : [] + ), + ]) ), - ]) - ), - [effectiveMemberDrafts, selectedProviderId] + [effectiveMemberDrafts, multimodelEnabled, selectedProviderId] ); + useEffect(() => { + if (multimodelEnabled) { + return; + } + if (selectedProviderId !== 'anthropic') { + setSelectedProviderIdRaw('anthropic'); + setSelectedModelRaw(getStoredTeamModel('anthropic')); + } + setMembersDrafts((prev) => { + let changed = false; + const next = prev.map((member) => { + const normalized = normalizeMemberDraftForProviderMode(member, false); + if (normalized !== member) changed = true; + return normalized; + }); + return changed ? next : prev; + }); + }, [multimodelEnabled, selectedProviderId]); + // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); const updateSchedule = useStore((s) => s.updateSchedule); @@ -302,13 +331,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedProviderId = (value: TeamProviderId): void => { - setSelectedProviderIdRaw(value); - localStorage.setItem('team:lastSelectedProvider', value); - if (value !== 'anthropic') { + const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + setSelectedProviderIdRaw(normalizedValue); + localStorage.setItem('team:lastSelectedProvider', normalizedValue); + if (normalizedValue !== 'anthropic') { setLimitContextRaw(false); localStorage.setItem('team:lastLimitContext', 'false'); } - setSelectedModelRaw(getStoredTeamModel(value)); + setSelectedModelRaw(getStoredTeamModel(normalizedValue)); }; const setSelectedModel = (value: string): void => { @@ -401,8 +431,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); - setSelectedProviderIdRaw(schedule.launchConfig.providerId ?? 'anthropic'); - setSelectedModelRaw(schedule.launchConfig.model ?? ''); + const scheduleProviderId = normalizeProviderForMode( + schedule.launchConfig.providerId, + multimodelEnabled + ); + setSelectedProviderIdRaw(scheduleProviderId); + setSelectedModelRaw( + scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) + ? (schedule.launchConfig.model ?? '') + : getStoredTeamModel('anthropic') + ); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); } else { @@ -417,8 +455,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setSelectedProviderIdRaw(getStoredTeamProvider()); - setSelectedModelRaw(getStoredTeamModel(getStoredTeamProvider())); + const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); + setSelectedProviderIdRaw(storedProviderId); + setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); } @@ -442,10 +481,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen } if (cancelled) return; - const nextProviderId = + const rawNextProviderId = savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini' ? savedRequest.providerId : 'anthropic'; + const nextProviderId = normalizeProviderForMode(rawNextProviderId, multimodelEnabled); const providerFromSaved = Boolean(savedRequest?.providerId); const nextMembersSource = members.length > 0 @@ -455,15 +495,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen : []; const storedEffort = localStorage.getItem('team:lastSelectedEffort'); - setMembersDrafts(createMemberDraftsFromInputs(nextMembersSource)); + setMembersDrafts( + createMemberDraftsFromInputs(nextMembersSource).map((member) => + normalizeMemberDraftForProviderMode(member, multimodelEnabled) + ) + ); setSyncModelsWithLead( !nextMembersSource.some((member) => member.providerId || member.model || member.effort) ); - setSelectedProviderIdRaw(providerFromSaved ? nextProviderId : getStoredTeamProvider()); + setSelectedProviderIdRaw( + providerFromSaved + ? nextProviderId + : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ); setSelectedModelRaw( - typeof savedRequest?.model === 'string' + typeof savedRequest?.model === 'string' && + nextProviderId === normalizeProviderForMode(rawNextProviderId, true) ? savedRequest.model - : getStoredTeamModel(providerFromSaved ? nextProviderId : getStoredTeamProvider()) + : getStoredTeamModel( + providerFromSaved + ? nextProviderId + : normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ) ); setSelectedEffortRaw( savedRequest?.effort ?? (storedEffort === null ? 'medium' : storedEffort) @@ -481,7 +534,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, isLaunch, effectiveTeamName, members]); + }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]); // --------------------------------------------------------------------------- // Launch-only effects @@ -773,6 +826,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const provisioningError = isLaunch ? props.provisioningError : null; const activeError = localError ?? provisioningError; + const currentProvisioning = useStore((s) => + isLaunch && effectiveTeamName + ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) + : null + ); + const launchInFlight = useStore((s) => + isLaunch && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false + ); // --------------------------------------------------------------------------- // Submit @@ -827,8 +888,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, extraCliArgs: customArgs.trim() || undefined, }); - resetFormState(); - onClose(); } else { // Schedule mode: create or update const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; @@ -883,7 +942,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const isDisabled = isLaunch - ? isSubmitting || prepareState !== 'ready' + ? isSubmitting || launchInFlight : isSubmitting || validationErrors.length > 0; // --------------------------------------------------------------------------- @@ -905,7 +964,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen 'Schedule automatic Claude task execution' ); - const submitLabel = isLaunch ? 'Launch' : isEditing ? 'Save Changes' : 'Create Schedule'; + const submitLabel = isLaunch + ? prepareState === 'idle' || prepareState === 'loading' + ? 'Skip and Launch' + : 'Launch' + : isEditing + ? 'Save Changes' + : 'Create Schedule'; const submittingLabel = isLaunch ? 'Launching...' : isEditing ? 'Saving...' : 'Creating...'; @@ -1160,38 +1225,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projectsError={projectsError} /> - {isLaunch ? ( - - ) : null} - {/* ═══════════════════════════════════════════════════════════════════ Launch: optional settings Schedule: prompt + execution defaults @@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen summary={launchOptionalSummary} >
+ +
) : null} + {isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? ( +
+ +
+ ) : null} + {/* Launch-only: CLI warm-up status */} {isLaunch ? ( @@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen

Pre-flight check to catch errors before launch -

@@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen disabled={isDisabled} onClick={handleSubmit} > - {isSubmitting ? ( + {isSubmitting || launchInFlight ? ( <> {submittingLabel} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index c1003bcb..654c4385 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC = ({ id, }) => { const cliStatus = useStore((s) => s.cliStatus); - const multimodelAvailable = cliStatus?.flavor === 'free-code'; + const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const multimodelAvailable = multimodelEnabled || cliStatus?.flavor === 'free-code'; const [dropdownOpen, setDropdownOpen] = useState(false); const containerRef = useRef(null); @@ -180,6 +181,12 @@ export const TeamModelSelector: React.FC = ({ const activeProvider = PROVIDERS.find((provider) => provider.id === providerId) ?? PROVIDERS[0]; const ProviderIcon = activeProvider.icon; + const defaultModelTooltip = useMemo(() => { + if (providerId === 'anthropic') { + return 'Default model from Claude CLI (/model).\nUses the runtime default for the selected provider.'; + } + return 'Uses the runtime default for the selected provider.'; + }, [providerId]); const isProviderSelectable = (candidateProviderId: string): boolean => multimodelAvailable || candidateProviderId === 'anthropic'; const activeProviderSelectable = isProviderSelectable(providerId); @@ -192,7 +199,7 @@ export const TeamModelSelector: React.FC = ({ : providerId === 'gemini' ? GEMINI_MODEL_OPTIONS : ANTHROPIC_MODEL_OPTIONS; - if (runtimeModels.length === 0) { + if (providerId === 'anthropic' || runtimeModels.length === 0) { return [...fallback]; } const dynamicOptions = runtimeModels.map((model) => ({ @@ -310,7 +317,7 @@ export const TeamModelSelector: React.FC = ({ )}
{modelOptions.map((opt) => ( @@ -342,9 +349,12 @@ export const TeamModelSelector: React.FC = ({ - Default model from Claude CLI (/model). -
- Uses the runtime default for the selected provider. + {defaultModelTooltip.split('\n').map((line, index) => ( + + {index > 0 ?
: null} + {line} +
+ ))}
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index 3321f7cc..2bc85aea 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -49,7 +49,7 @@ export const LeadModelRow = ({ return (