feat: improve team launch model and status UX

This commit is contained in:
iliya 2026-04-02 15:15:32 +03:00
parent 759cae2669
commit 074b614469
20 changed files with 786 additions and 220 deletions

View file

@ -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<void> {
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;
}
}

View file

@ -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<boolean> {
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
@ -211,17 +218,7 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
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<McpLaunchSpec> {
};
}
// 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')}`
);

View file

@ -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,
});
}
}

View file

@ -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<typeof spawn>;
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<typeof spawn>;
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<string, unknown>,
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<string, unknown>[]).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<void> {
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<void> {
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[],

View file

@ -1072,6 +1072,7 @@ export class HttpAPIClient implements ElectronAPI {
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: false,
authMethod: null,
providers: [],
}),

View file

@ -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 = ({
/>
<div>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Checking AI Providers...
{label}
</span>
{showHint && (
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.7 }}>
@ -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<ReturnType<typeof useCliInstaller>['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 <CliCheckingSpinner styles={styles} />;
return (
<CliCheckingSpinner
styles={styles}
label={multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
/>
);
}
// ── 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 (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 p-4"
@ -954,6 +989,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</div>
);
}
}
if (
cliStatus.installed &&
cliStatus.flavor !== 'free-code' &&
!cliStatus.authStatusChecking &&
!cliStatus.authLoggedIn
) {
return (
<>
<div

View file

@ -131,6 +131,7 @@ function createLoadingMultimodelStatus(): CliInstallationStatus {
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: true,
authMethod: null,
providers: providers.map((provider) => ({
...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)' }}
>
<Loader2 className="size-4 animate-spin" />
Checking AI Providers...
{multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
</div>
)}

View file

@ -1136,30 +1136,35 @@ export const TeamDetailView = ({
const teamSummary = teams.find((t) => t.teamName === teamName);
return (
<>
<div className="flex size-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="text-sm font-medium text-text">Team not launched yet</p>
<p className="mt-2 text-xs text-text-secondary">
This is a draft team <strong>{teamSummary?.displayName || teamName}</strong> has
been configured with {teamSummary?.memberCount ?? 0} member
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI
yet. Click Launch to select a model and start the team.
</p>
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => setLaunchDialogOpen(true)}
>
Launch
</button>
<button
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
onClick={() => {
void api.teams.deleteDraft(teamName).catch(() => {});
}}
>
Delete
</button>
<div className="size-full overflow-auto p-6">
<div ref={provisioningBannerRef}>
<TeamProvisioningBanner teamName={teamName} />
</div>
<div className="flex min-h-[calc(100vh-12rem)] items-center justify-center">
<div className="max-w-md text-center">
<p className="text-sm font-medium text-text">Team not launched yet</p>
<p className="mt-2 text-xs text-text-secondary">
This is a draft team <strong>{teamSummary?.displayName || teamName}</strong> has
been configured with {teamSummary?.memberCount ?? 0} member
{teamSummary?.memberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI
yet. Click Launch to select a model and start the team.
</p>
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500"
onClick={() => setLaunchDialogOpen(true)}
>
Launch
</button>
<button
className="rounded-md bg-surface-raised px-4 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:text-text"
onClick={() => {
void api.teams.deleteDraft(teamName).catch(() => {});
}}
>
Delete
</button>
</div>
</div>
</div>
</div>

View file

@ -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(() => {

View file

@ -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<TeamProviderId[]>(() => {
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(

View file

@ -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<TeamProviderId[]>(
() =>
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 ? (
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
/>
) : null}
{/*
Launch: optional settings
Schedule: prompt + execution defaults
@ -1203,6 +1236,38 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
summary={launchOptionalSummary}
>
<div className="space-y-4">
<TeamRosterEditorSection
members={membersDrafts}
onMembersChange={setMembersDrafts}
validateMemberName={validateMemberNameInline}
showWorkflow
showJsonEditor
draftKeyPrefix={`launchTeam:${effectiveTeamName}`}
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
existingMembers={members}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
inheritedEffort={(selectedEffort as EffortLevel) || undefined}
inheritModelSettingsByDefault
lockProviderModel={syncModelsWithLead}
forceInheritedModelSettings={syncModelsWithLead}
modelLockReason="This teammate is synced with the lead model. Turn off sync to set a custom provider, model, or effort."
providerId={selectedProviderId}
model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined}
limitContext={limitContext}
onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort}
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
softDeleteMembers
/>
<div className="space-y-1.5">
<Label htmlFor="dialog-prompt" className="label-optional">
Prompt for team lead (optional)
@ -1388,6 +1453,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div>
) : null}
{isLaunch && effectiveTeamName && (currentProvisioning || provisioningError) ? (
<div className="pt-1">
<TeamProvisioningBanner teamName={effectiveTeamName} />
</div>
) : null}
<DialogFooter className={isLaunch ? 'pt-4 sm:justify-between' : 'pt-4'}>
{/* Launch-only: CLI warm-up status */}
{isLaunch ? (
@ -1405,13 +1476,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</span>
<p className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)] opacity-70">
<span>Pre-flight check to catch errors before launch</span>
<button
type="button"
onClick={() => setPrepareState('ready')}
className="rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
>
Skip
</button>
</p>
</div>
</div>
@ -1462,7 +1526,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
disabled={isDisabled}
onClick={handleSubmit}
>
{isSubmitting ? (
{isSubmitting || launchInFlight ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
{submittingLabel}

View file

@ -160,7 +160,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<HTMLDivElement>(null);
@ -180,6 +181,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
: 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<TeamModelSelectorProps> = ({
)}
<div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5"
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{modelOptions.map((opt) => (
@ -342,9 +349,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Default model from Claude CLI (/model).
<br />
Uses the runtime default for the selected provider.
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -49,7 +49,7 @@ export const LeadModelRow = ({
return (
<div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[auto_1fr_auto]"
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -62,34 +62,15 @@ export const LeadModelRow = ({
style={{ backgroundColor: leadColorSet.border }}
aria-hidden="true"
/>
<div className="space-y-0.5">
<div className="flex h-8 items-center px-2 text-sm font-medium text-[var(--color-text)]">
lead
<div className="min-w-0">
<div className="flex h-8 items-center gap-3 px-2">
<span className="text-sm font-medium text-[var(--color-text)]">lead</span>
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
</div>
</div>
<div>
<div className="flex h-8 items-center px-2 text-xs text-[var(--color-text-secondary)]">
Team Lead
</div>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
<div className="flex items-center gap-2 pl-1 xl:pl-0">
<div className="min-w-0">
<div className="flex h-8 items-center justify-end px-2 text-xs text-[var(--color-text-secondary)]">
<div className="flex min-w-0 items-center gap-2">
<Checkbox
id="sync-models-with-lead"
checked={syncModelsWithTeammates}
@ -97,13 +78,30 @@ export const LeadModelRow = ({
/>
<Label
htmlFor="sync-models-with-lead"
className="cursor-pointer text-xs font-normal text-text-secondary"
className="cursor-pointer truncate text-xs font-normal text-text-secondary"
>
Синхронизировать модель с тимейтами
Sync model with teammates
</Label>
</div>
</div>
</div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[220px] shrink-0 justify-start gap-1 overflow-hidden text-left"
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</div>
</div>
{modelExpanded ? (
<div className="space-y-2 md:col-span-3">
<TeamModelSelector
@ -118,12 +116,14 @@ export const LeadModelRow = ({
onValueChange={onEffortChange}
id="lead-effort"
/>
<LimitContextCheckbox
id="lead-limit-context"
checked={limitContext}
onCheckedChange={onLimitContextChange}
disabled={providerId !== 'anthropic' || model === 'haiku'}
/>
{providerId === 'anthropic' ? (
<LimitContextCheckbox
id="lead-limit-context"
checked={limitContext}
onCheckedChange={onLimitContextChange}
disabled={model === 'haiku'}
/>
) : null}
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">

View file

@ -9,13 +9,14 @@ import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
@ -48,6 +49,8 @@ interface MemberDraftRowProps {
lockProviderModel?: boolean;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
isRemoved?: boolean;
onRestore?: (id: string) => void;
}
export const MemberDraftRow = ({
@ -76,6 +79,8 @@ export const MemberDraftRow = ({
lockProviderModel = false,
forceInheritedModelSettings = false,
modelLockReason,
isRemoved = false,
onRestore,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
@ -154,13 +159,19 @@ export const MemberDraftRow = ({
const effectiveEffort = forceInheritedModelSettings
? inheritedEffort
: (member.effort ?? inheritedEffort);
const modelButtonLabel = effectiveModel?.trim()
const modelButtonLabelBase = effectiveModel?.trim()
? getTeamModelLabel(effectiveModel.trim())
: 'Default';
const modelButtonLabel = forceInheritedModelSettings
? `${modelButtonLabelBase} (lead)`
: modelButtonLabelBase;
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: modelLockReason;
return (
<div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto]"
className={`relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[1fr_180px_auto] ${isRemoved ? 'opacity-55' : ''}`}
style={{
backgroundColor: isLight
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
@ -178,6 +189,7 @@ export const MemberDraftRow = ({
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
disabled={isRemoved}
onChange={(event) => onNameChange(member.id, event.target.value)}
placeholder="member-name"
style={
@ -193,6 +205,7 @@ export const MemberDraftRow = ({
<div>
<RoleSelect
value={member.roleSelection || '__none__'}
disabled={isRemoved}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
customRole={member.customRole}
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
@ -207,6 +220,7 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="relative h-8 shrink-0 gap-1"
disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)}
>
{workflowExpanded ? (
@ -221,36 +235,62 @@ export const MemberDraftRow = ({
</Button>
) : null}
<div className="min-w-0 space-y-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex max-w-[190px]">
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
</Button>
</span>
</TooltipTrigger>
{modelTooltipText ? (
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{modelTooltipText}
</TooltipContent>
) : null}
</Tooltip>
</div>
{isRemoved ? (
<Button
variant="outline"
size="sm"
className="h-8 max-w-[190px] shrink-0 justify-start gap-1 overflow-hidden text-left"
disabled={lockProviderModel}
title={lockProviderModel ? modelLockReason : undefined}
onClick={() => setModelExpanded((prev) => !prev)}
className="size-8 shrink-0 px-0"
aria-label={`Restore ${member.name || `member ${index + 1}`}`}
title="Restore member"
onClick={() => onRestore?.(member.id)}
>
{modelExpanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
<span className="truncate">Model: {modelButtonLabel}</span>
<RotateCcw className="size-3.5" />
</Button>
</div>
<Button
variant="outline"
size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
title="Remove member"
onClick={() => onRemove(member.id)}
>
<Trash2 className="size-3.5" />
</Button>
) : (
<Button
variant="outline"
size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
title="Remove member"
onClick={() => onRemove(member.id)}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
{isRemoved ? (
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
) : null}
</div>
{showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 md:col-span-3">
<div className="space-y-0.5 pl-3 md:col-span-3">
<label
htmlFor={`member-${member.id}-workflow`}
className="block text-[10px] font-medium text-[var(--color-text-muted)]"
@ -281,7 +321,7 @@ export const MemberDraftRow = ({
</div>
) : null}
{modelExpanded && (
<div className="space-y-2 md:col-span-3">
<div className="space-y-2 pl-3 md:col-span-3">
<TeamModelSelector
providerId={effectiveProviderId}
onProviderChange={(providerId) => {

View file

@ -98,6 +98,7 @@ export interface MembersEditorSectionProps {
inheritModelSettingsByDefault?: boolean;
forceInheritedModelSettings?: boolean;
modelLockReason?: string;
softDeleteMembers?: boolean;
}
export const MembersEditorSection = ({
@ -122,6 +123,7 @@ export const MembersEditorSection = ({
inheritModelSettingsByDefault = false,
forceInheritedModelSettings = false,
modelLockReason,
softDeleteMembers = false,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
@ -215,7 +217,21 @@ export const MembersEditorSection = ({
};
const removeMember = (memberId: string): void => {
onChange(members.filter((c) => c.id !== memberId));
if (!softDeleteMembers) {
onChange(members.filter((c) => c.id !== memberId));
return;
}
onChange(
members.map((member) =>
member.id === memberId ? { ...member, removedAt: member.removedAt ?? Date.now() } : member
)
);
};
const restoreMember = (memberId: string): void => {
onChange(
members.map((member) => (member.id === memberId ? { ...member, removedAt: null } : member))
);
};
const addMember = (): void => {
@ -230,7 +246,9 @@ export const MembersEditorSection = ({
]);
};
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const activeMembers = members.filter((member) => !member.removedAt);
const removedMembers = members.filter((member) => member.removedAt);
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
const memberColorMap = useMemo(
() => buildMemberDraftColorMap(members, existingMembers),
@ -264,7 +282,7 @@ export const MembersEditorSection = ({
{!hideContent && (
<>
<div className="space-y-2">
{members.map((member, index) => (
{activeMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
@ -294,6 +312,47 @@ export const MembersEditorSection = ({
modelLockReason={modelLockReason}
/>
))}
{softDeleteMembers && removedMembers.length > 0 ? (
<div className="pt-2">
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
<div className="space-y-2">
{removedMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={activeMembers.length + index}
resolvedColor={memberColorMap.get(member.name.trim())}
nameError={null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
onRestore={restoreMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved
/>
))}
</div>
</div>
) : null}
{jsonEditorOpen && showJsonEditor ? (
<MembersJsonEditor
value={jsonText}
@ -327,5 +386,7 @@ export {
createMemberDraft,
createMemberDraftsFromInputs,
getMemberDraftRole,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,
validateMemberNameInline,
} from './membersEditorUtils';

View file

@ -40,6 +40,7 @@ interface TeamRosterEditorSectionProps {
onSyncModelsWithTeammatesChange: (value: boolean) => void;
headerTop?: React.ReactNode;
headerBottom?: React.ReactNode;
softDeleteMembers?: boolean;
}
export const TeamRosterEditorSection = ({
@ -75,6 +76,7 @@ export const TeamRosterEditorSection = ({
onSyncModelsWithTeammatesChange,
headerTop,
headerBottom,
softDeleteMembers = false,
}: TeamRosterEditorSectionProps): React.JSX.Element => {
return (
<MembersEditorSection
@ -98,6 +100,7 @@ export const TeamRosterEditorSection = ({
lockProviderModel={lockProviderModel}
forceInheritedModelSettings={forceInheritedModelSettings}
modelLockReason={modelLockReason}
softDeleteMembers={softDeleteMembers}
headerExtra={
<div className="space-y-3">
{headerTop}

View file

@ -11,6 +11,7 @@ export interface MemberDraft {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
removedAt?: number | string | null;
}
export interface MembersEditorValue {

View file

@ -36,6 +36,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
providerId: initial?.providerId,
model: initial?.model ?? '',
effort: initial?.effort,
removedAt: initial?.removedAt,
};
}
@ -67,6 +68,7 @@ export function createMemberDraftsFromInputs(
: 'anthropic',
model: member.model ?? '',
effort: normalizeDraftEffort(member.effort),
removedAt: member.removedAt,
});
});
}
@ -80,6 +82,33 @@ export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
};
}
export function normalizeProviderForMode(
providerId: TeamProviderId | undefined,
multimodelEnabled: boolean
): TeamProviderId {
if (multimodelEnabled && (providerId === 'codex' || providerId === 'gemini')) {
return providerId;
}
return 'anthropic';
}
export function normalizeMemberDraftForProviderMode(
member: MemberDraft,
multimodelEnabled: boolean
): MemberDraft {
if (multimodelEnabled) {
return member;
}
if (member.providerId === 'codex' || member.providerId === 'gemini') {
return {
...member,
providerId: 'anthropic',
model: '',
};
}
return member;
}
function normalizeDraftEffort(value: string | undefined): EffortLevel | undefined {
if (value === 'low' || value === 'medium' || value === 'high') {
return value;
@ -155,6 +184,9 @@ export function getWorkflowForExport(member: MemberDraft): string | undefined {
export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] {
return members
.map((member) => {
if (member.removedAt) {
return null;
}
const name = member.name.trim();
if (!name) {
return null;

View file

@ -85,6 +85,8 @@ export interface CliInstallationStatus {
updateAvailable: boolean;
/** Whether user is logged in (claude auth status) */
authLoggedIn: boolean;
/** Whether runtime authentication status is still being checked */
authStatusChecking: boolean;
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
authMethod: string | null;
/** Provider-level runtime status when supported by the configured runtime */

View file

@ -94,6 +94,7 @@ describe('cliInstallerSlice', () => {
latestVersion: '2.1.59',
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: false,
authMethod: null,
providers: [],
};
@ -126,6 +127,7 @@ describe('cliInstallerSlice', () => {
latestVersion: '2.1.59',
updateAvailable: true,
authLoggedIn: true,
authStatusChecking: false,
authMethod: 'oauth_token',
providers: [],
};