diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 21a1d228..9a31800a 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -71,7 +71,7 @@ export function drawAgents( drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // Breathing animation + launch-stage effects - drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel); + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); drawLaunchStage(ctx, x, y, r, node.launchVisualState, time); } @@ -122,7 +122,17 @@ export function drawAgents( if (!simplify) { // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; - drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); + drawLabel( + ctx, + x, + y, + r, + labelText, + color, + node.runtimeLabel, + node.launchStatusLabel, + node.launchVisualState + ); } // TODO: Context ring disabled — LeadContextUsage.percent is unreliable @@ -257,11 +267,11 @@ function drawLaunchStage( switch (visualState) { case 'waiting': { const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.stroke(); break; } @@ -270,8 +280,8 @@ function drawLaunchStage( const rotation = time * 2.4; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -280,8 +290,8 @@ function drawLaunchStage( const ringR = r + 8; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); - ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4); - ctx.lineWidth = 1.5; + ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); + ctx.lineWidth = 1.75; ctx.stroke(); const orbit = time * 1.6; @@ -300,8 +310,8 @@ function drawLaunchStage( const rotation = time * 1.25; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); - ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); - ctx.lineWidth = 1.75; + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); + ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -310,8 +320,8 @@ function drawLaunchStage( const ringR = r + 7 + Math.sin(time * 4) * 0.8; ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -467,12 +477,8 @@ function drawBreathing( r: number, state: string, time: number, - spawnStatus?: GraphNode['spawnStatus'], - runtimeLabel?: string + spawnStatus?: GraphNode['spawnStatus'] ): void { - const hasRuntimeLabel = Boolean(runtimeLabel?.trim()); - const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14); - // Spawning: bright animated double ring + radial glow if (spawnStatus === 'spawning') { const ringR = r + AGENT_DRAW.orbitParticleOffset; @@ -505,12 +511,6 @@ function drawBreathing( ctx.stroke(); ctx.setLineDash([]); ctx.restore(); - - // "connecting" label below name - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2)); - ctx.fillText('connecting...', x, serviceLabelY); return; } @@ -532,12 +532,6 @@ function drawBreathing( ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse); ctx.lineWidth = 1.5; ctx.stroke(); - - // "waiting" label - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5)); - ctx.fillText('waiting...', x, serviceLabelY); return; } @@ -649,7 +643,9 @@ function drawLabel( r: number, label: string, color: string, - runtimeLabel?: string + runtimeLabel?: string, + launchStatusLabel?: string, + launchVisualState?: GraphNode['launchVisualState'] ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; ctx.font = '9px monospace'; @@ -659,16 +655,27 @@ function drawLabel( ctx.fillText(label, x, labelY); const trimmedRuntimeLabel = runtimeLabel?.trim(); - if (!trimmedRuntimeLabel) { + const trimmedLaunchStatusLabel = launchStatusLabel?.trim(); + if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) { return; } - ctx.font = '8px monospace'; - ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); - ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10); + let nextLineY = labelY + 10; + if (trimmedRuntimeLabel) { + ctx.font = '8px monospace'; + ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); + ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY); + nextLineY += 10; + } + + if (trimmedLaunchStatusLabel) { + ctx.font = '7px monospace'; + ctx.fillStyle = getLaunchStatusColor(launchVisualState); + ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY); + } } -function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { +function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2); if (ctx.measureText(label).width <= maxWidth) return label; @@ -679,6 +686,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n return `${out}…`; } +function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string { + switch (visualState) { + case 'waiting': + return hexWithAlpha('#d4d4d8', 0.8); + case 'spawning': + return hexWithAlpha('#f59e0b', 0.9); + case 'runtime_pending': + return hexWithAlpha('#67e8f9', 0.9); + case 'settling': + return hexWithAlpha('#22c55e', 0.9); + case 'error': + return hexWithAlpha('#ef4444', 0.92); + default: + return hexWithAlpha(COLORS.holoBright, 0.75); + } +} + /** * Draw context usage ring around lead node. */ diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index a9e58b13..d1e5265f 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = { unassignedGap: 72, maxGeneratedRings: 12, ownerCollisionPadding: 28, - ownerBandHeight: 72, + ownerBandHeight: 96, ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index df378f75..af439ae4 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -84,6 +84,8 @@ export interface GraphNode { spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Shared launch-stage visual derived by the host app */ launchVisualState?: GraphLaunchVisualState; + /** Shared launch-stage text shown beside the node during launch only */ + launchStatusLabel?: string; /** Context window usage ratio (0..1), available for lead only */ contextUsage?: number; /** Current task ID this member is working on */ diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index a842bbf9..bb3a43af 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -135,6 +135,7 @@ export function GraphView({ y: number; color?: string | null; } | null>(null); + const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -255,6 +256,30 @@ export function GraphView({ return { x: node.x, y: node.y }; }, []); + const setInteractionSelectionDisabled = useCallback((disabled: boolean) => { + if (typeof document === 'undefined') { + return; + } + const bodyStyle = document.body.style; + if (disabled) { + if (!selectionLockRef.current) { + selectionLockRef.current = { + userSelect: bodyStyle.userSelect, + webkitUserSelect: bodyStyle.webkitUserSelect, + }; + } + bodyStyle.userSelect = 'none'; + bodyStyle.webkitUserSelect = 'none'; + return; + } + if (!selectionLockRef.current) { + return; + } + bodyStyle.userSelect = selectionLockRef.current.userSelect; + bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect; + selectionLockRef.current = null; + }, []); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -405,10 +430,15 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + e.preventDefault(); dragPreviewRef.current = null; + setInteractionSelectionDisabled(true); const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; + if (!canvas) { + setInteractionSelectionDisabled(false); + return; + } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodes = getVisibleNodes(simulation.stateRef.current.nodes); @@ -464,6 +494,9 @@ export function GraphView({ } if (isPanningRef.current) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } camera.handlePanMove(clientX, clientY); return true; } @@ -480,6 +513,9 @@ export function GraphView({ const draggedNodeId = interaction.dragNodeId.current; if (interaction.isDragging.current && draggedNodeId) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member') { const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); @@ -509,6 +545,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; + setInteractionSelectionDisabled(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -519,6 +556,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { + setInteractionSelectionDisabled(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( @@ -547,6 +585,7 @@ export function GraphView({ return; } + setInteractionSelectionDisabled(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -585,7 +624,7 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] ); const handleMouseMove = useCallback( @@ -640,6 +679,7 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { if ((event.buttons & 1) === 0) { + setInteractionSelectionDisabled(false); return; } if ( @@ -650,6 +690,7 @@ export function GraphView({ ) { return; } + event.preventDefault(); processActivePointerMove(event.clientX, event.clientY, event.buttons); }; @@ -660,6 +701,7 @@ export function GraphView({ !interaction.isDragging.current && !edgeMouseDownRef.current ) { + setInteractionSelectionDisabled(false); return; } completePointerInteraction(event.clientX, event.clientY); @@ -670,8 +712,9 @@ export function GraphView({ return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); + setInteractionSelectionDisabled(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove]); + }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -819,7 +862,10 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{visibleLanes.map((lane) => (
@@ -431,12 +431,15 @@ export const GraphActivityHud = ({ ref={(element) => { shellRefs.current.set(lane.node.id, element); }} - className="pointer-events-auto absolute z-10 origin-top-left opacity-0" + className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0" style={{ width: `${laneWidth}px`, maxWidth: `${laneWidth}px`, height: `${laneHeight}px`, }} + onDragStart={(event) => { + event.preventDefault(); + }} >
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index f8cf303f..265876e2 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -327,11 +327,17 @@ const MemberPopoverContent = ({ : null; const fallbackSpawnStatusLabel = node.spawnStatus && node.spawnStatus !== 'online' - ? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning' - ? 'starting' - : node.spawnStatus + ? node.spawnStatus === 'waiting' + ? 'waiting to start' + : node.spawnStatus === 'spawning' + ? 'starting' + : node.spawnStatus === 'error' + ? 'failed' + : node.spawnStatus : null; const statusLabel = + launchPresentation?.launchStatusLabel ?? + node.launchStatusLabel ?? launchPresentation?.presenceLabel ?? fallbackSpawnStatusLabel ?? (node.state === 'active' diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 18b4de9f..9ace3693 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -783,6 +783,36 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { }; } +interface LiveTeamAgentRuntimeMetadata { + model?: string; +} + +function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const unwrapped = trimmed.slice(1, -1).trim(); + return unwrapped.length > 0 ? unwrapped : undefined; + } + return trimmed; +} + +function extractCliFlagValue(command: string, flagName: string): string | undefined { + const escapedFlag = flagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`(?:^|\\s)${escapedFlag}\\s+("([^"]*)"|'([^']*)'|([^\\s]+))`).exec( + command + ); + if (!match) { + return undefined; + } + return stripWrappedCliFlagValue(match[2] ?? match[3] ?? match[4] ?? match[1]); +} + export function shouldAcceptDeterministicBootstrapEvent(params: { runId: string; teamName: string; @@ -911,11 +941,17 @@ function buildEffectiveTeamMemberSpec( ): TeamMemberInput { const memberProviderId = normalizeTeamMemberProviderId(member.providerId); const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); - const model = member.model?.trim() || defaults.model?.trim() || undefined; + const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; + const model = + member.model?.trim() || + (memberProviderId == null || memberProviderId === defaultProviderId + ? defaults.model?.trim() + : undefined) || + undefined; return { ...member, - providerId: memberProviderId ?? defaultProviderId ?? 'anthropic', + providerId: effectiveProviderId, model, effort: member.effort ?? defaults.effort, }; @@ -3396,16 +3432,19 @@ export class TeamProvisioningService { }> { const runId = this.getTrackedRunId(teamName); if (!runId) { - return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({ - statuses, - runId: null, - teamLaunchState: snapshot?.teamLaunchState, - launchPhase: snapshot?.launchPhase, - expectedMembers: snapshot?.expectedMembers, - updatedAt: snapshot?.updatedAt, - summary: snapshot?.summary, - source: snapshot ? 'persisted' : 'persisted', - })); + return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => { + this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); + return { + statuses, + runId: null, + teamLaunchState: snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers: snapshot?.expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: snapshot?.summary, + source: snapshot ? 'persisted' : 'persisted', + }; + }); } const run = this.runs.get(runId); if (!run) { @@ -3426,6 +3465,7 @@ export class TeamProvisioningService { }); const snapshot = persisted ?? liveSnapshot; const statuses = snapshotToMemberSpawnStatuses(snapshot); + this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); return { statuses, runId, @@ -3552,7 +3592,6 @@ export class TeamProvisioningService { !current || current.launchState === 'failed_to_start' || current.launchState === 'confirmed_alive' || - current.runtimeAlive === true || current.hardFailure === true || current.agentToolAccepted !== true ) { @@ -3902,6 +3941,89 @@ export class TeamProvisioningService { : null; } + private async materializeEffectiveTeamMemberSpecs(params: { + claudePath: string; + cwd: string; + members: TeamCreateRequest['members']; + defaults: { + providerId?: TeamProviderId; + model?: string; + effort?: TeamCreateRequest['effort']; + }; + primaryProviderId?: TeamProviderId; + primaryEnv?: ProvisioningEnvResolution; + limitContext?: boolean; + }): Promise { + const envByProvider = new Map>(); + const defaultModelByProvider = new Map>(); + const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId); + + const getProvisioningEnv = (providerId: TeamProviderId): Promise => { + if (normalizedPrimaryProviderId === providerId && params.primaryEnv != null) { + return Promise.resolve(params.primaryEnv); + } + + const cached = envByProvider.get(providerId); + if (cached) { + return cached; + } + + const created = this.buildProvisioningEnv(providerId); + envByProvider.set(providerId, created); + return created; + }; + + const getResolvedDefaultModel = (providerId: TeamProviderId): Promise => { + const cached = defaultModelByProvider.get(providerId); + if (cached) { + return cached; + } + + const providerLabel = getTeamProviderLabel(providerId); + const created = (async () => { + const envResolution = await getProvisioningEnv(providerId); + if (envResolution.warning) { + throw new Error(envResolution.warning); + } + + const resolvedDefaultModel = await this.resolveProviderDefaultModel( + params.claudePath, + params.cwd, + providerId, + envResolution.env, + params.limitContext === true + ); + const normalized = resolvedDefaultModel?.trim(); + if (!normalized) { + throw new Error( + `Could not resolve the runtime default model for ${providerLabel} teammates. Select an explicit model and retry.` + ); + } + return normalized; + })(); + + defaultModelByProvider.set(providerId, created); + return created; + }; + + const effectiveMembers: TeamCreateRequest['members'] = []; + for (const member of params.members) { + const effectiveMember = buildEffectiveTeamMemberSpec(member, params.defaults); + const providerId = normalizeTeamMemberProviderId(effectiveMember.providerId) ?? 'anthropic'; + if (providerId === 'anthropic' || effectiveMember.model?.trim()) { + effectiveMembers.push(effectiveMember); + continue; + } + + effectiveMembers.push({ + ...effectiveMember, + model: await getResolvedDefaultModel(providerId), + }); + } + + return effectiveMembers; + } + private getFreshCachedProbeResult( cwd: string, providerId: TeamProviderId | undefined @@ -4756,10 +4878,23 @@ 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 provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + if (envWarning) { + throw new Error(envWarning); + } + const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd: request.cwd, + members: request.members, + defaults: { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }, + primaryProviderId: request.providerId, + primaryEnv: provisioningEnv, + limitContext: request.limitContext, }); const runId = randomUUID(); const startedAt = nowIso(); @@ -4864,14 +4999,6 @@ export class TeamProvisioningService { const initialUserPrompt = request.prompt?.trim() ?? ''; const promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; - const { - env: shellEnv, - geminiRuntimeAuth, - warning: envWarning, - } = await this.buildProvisioningEnv(request.providerId); - if (envWarning) { - throw new Error(envWarning); - } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -4963,10 +5090,16 @@ export class TeamProvisioningService { }); await this.membersMetaStore.writeMembers( request.teamName, - request.members.map((m) => ({ + effectiveMemberSpecs.map((m) => ({ name: m.name.trim(), role: m.role?.trim() || undefined, workflow: m.workflow?.trim() || undefined, + providerId: normalizeOptionalTeamProviderId(m.providerId), + model: m.model?.trim() || undefined, + effort: + m.effort === 'low' || m.effort === 'medium' || m.effort === 'high' + ? m.effort + : undefined, agentType: 'general-purpose' as const, color: getMemberColorByName(m.name.trim()), joinedAt: Date.now(), @@ -5300,16 +5433,30 @@ export class TeamProvisioningService { const runId = randomUUID(); const startedAt = nowIso(); - const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, { - providerId: request.providerId, - model: request.model, - effort: request.effort, + const provisioningEnv = await this.buildProvisioningEnv(request.providerId); + const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv; + if (envWarning) { + throw new Error(envWarning); + } + + const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ + claudePath, + cwd: request.cwd, + members: expectedMemberSpecs, + defaults: { + providerId: request.providerId, + model: request.model, + effort: request.effort, + }, + primaryProviderId: request.providerId, + primaryEnv: provisioningEnv, + limitContext: request.limitContext, }); // Build a synthetic TeamCreateRequest for reuse by shared infrastructure const syntheticRequest: TeamCreateRequest = { teamName: request.teamName, - members: expectedMemberSpecs, + members: effectiveMemberSpecs, cwd: request.cwd, providerId: request.providerId, model: request.model, @@ -5448,14 +5595,6 @@ export class TeamProvisioningService { ); const promptSize = getPromptSizeSummary(prompt); let child: ReturnType; - const { - env: shellEnv, - geminiRuntimeAuth, - warning: envWarning, - } = await this.buildProvisioningEnv(request.providerId); - if (envWarning) { - throw new Error(envWarning); - } shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); if (teammateModeDecision.forceProcessTeammates) { @@ -6842,12 +6981,34 @@ export class TeamProvisioningService { } private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { - return this.getLiveTeamAgentNames(teamName).has(memberName); + return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName); + } + + private attachLiveRuntimeMetadataToStatuses( + teamName: string, + statuses: Record + ): void { + for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) { + const current = statuses[memberName]; + if (!current || !metadata.model) { + continue; + } + statuses[memberName] = { + ...current, + runtimeModel: metadata.model, + }; + } } private getLiveTeamAgentNames(teamName: string): Set { + return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); + } + + private getLiveTeamAgentRuntimeMetadata( + teamName: string + ): Map { if (process.platform === 'win32') { - return new Set(); + return new Map(); } let output = ''; @@ -6857,11 +7018,11 @@ export class TeamProvisioningService { stdio: ['ignore', 'pipe', 'ignore'], }); } catch { - return new Set(); + return new Map(); } const teamMarker = `--team-name ${teamName}`; - const names = new Set(); + const metadataByAgent = new Map(); for (const line of output.split('\n')) { const trimmed = line.trim(); if (!trimmed.includes(teamMarker)) continue; @@ -6869,10 +7030,13 @@ export class TeamProvisioningService { if (!match) continue; const agentName = match[1]?.trim(); if (agentName) { - names.add(agentName); + const model = extractCliFlagValue(trimmed, '--model'); + metadataByAgent.set(agentName, { + ...(model ? { model } : {}), + }); } } - return names; + return metadataByAgent; } private async clearPersistedLaunchState(teamName: string): Promise { @@ -7107,7 +7271,7 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) { + if (!current.bootstrapConfirmed && !current.hardFailure) { const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason( teamName, expected, diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0dd81283..1a3037f6 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -593,7 +593,7 @@ export const CreateTeamDialog = ({ selectedMemberProviders ); setPrepareState('loading'); - setPrepareMessage('Checking selected providers...'); + setPrepareMessage('Checking selected providers in parallel...'); setPrepareWarnings([]); setPrepareChecks(initialChecks); @@ -601,118 +601,128 @@ export const CreateTeamDialog = ({ const timer = setTimeout(() => { void (async () => { let checks = initialChecks; - let anyFailure = false; - let anyNotes = false; - const collectedWarnings: string[] = []; - - try { - for (const providerId of selectedMemberProviders) { - const selectedModelChecks = (() => { - const next = new Set(); - let hasDefaultSelection = false; - const supportsProviderDefaultCheck = - providerId === 'codex' || - providerId === 'gemini' || - (providerId === 'anthropic' && selectedProviderId === 'anthropic'); - const leadModel = computeEffectiveTeamModel( - selectedModel, - limitContext, - selectedProviderId - ); - if (selectedProviderId === providerId && selectedModel.trim()) { - if (leadModel?.trim()) { - next.add(leadModel.trim()); - } - } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + const providerPlans = selectedMemberProviders.map((providerId) => { + const selectedModelChecks = (() => { + const next = new Set(); + let hasDefaultSelection = false; + const supportsProviderDefaultCheck = + providerId === 'codex' || + providerId === 'gemini' || + (providerId === 'anthropic' && selectedProviderId === 'anthropic'); + const leadModel = computeEffectiveTeamModel( + selectedModel, + limitContext, + selectedProviderId + ); + if (selectedProviderId === providerId && selectedModel.trim()) { + if (leadModel?.trim()) { + next.add(leadModel.trim()); + } + } else if (selectedProviderId === providerId && supportsProviderDefaultCheck) { + hasDefaultSelection = true; + } + for (const member of effectiveMemberDrafts) { + if (member.removedAt) { + continue; + } + const memberProviderId = + normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; + if (memberProviderId !== providerId) { + continue; + } + const memberModel = member.model?.trim(); + if (memberModel) { + next.add(memberModel); + } else if (supportsProviderDefaultCheck) { hasDefaultSelection = true; } - for (const member of effectiveMemberDrafts) { - if (member.removedAt) { - continue; - } - const memberProviderId = - normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId; - if (memberProviderId !== providerId) { - continue; - } - const memberModel = member.model?.trim(); - if (memberModel) { - next.add(memberModel); - } else if (supportsProviderDefaultCheck) { - hasDefaultSelection = true; - } - } - if (supportsProviderDefaultCheck && hasDefaultSelection) { - next.add(DEFAULT_PROVIDER_MODEL_SELECTION); - } - return Array.from(next); - })(); - const backendSummary = - runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds: selectedModelChecks, - cachedModelResultsById, - }); - checks = updateProviderCheck(checks, providerId, { - status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', - backendSummary, - details: cachedSnapshot.details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - selectedModelChecks.length > 0 - ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` - : `Checking ${getProviderLabel(providerId)} runtime...` - ); } + if (supportsProviderDefaultCheck && hasDefaultSelection) { + next.add(DEFAULT_PROVIDER_MODEL_SELECTION); + } + return Array.from(next); + })(); + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); + return { + providerId, + selectedModelChecks, + backendSummary, + cacheKey, + cachedModelResultsById, + cachedSnapshot, + }; + }); - const prepResult = await runProviderPrepareDiagnostics({ - cwd: effectiveCwd, - providerId, - selectedModelIds: selectedModelChecks, - prepareProvisioning: api.teams.prepareProvisioning, - limitContext, - cachedModelResultsById, - onModelProgress: ({ details, completedCount, totalCount }) => { - checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary, - details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` - ); - } - }, + try { + for (const plan of providerPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, }); - if (prepResult.warnings.length > 0) { + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + const providerResults = await Promise.all( + providerPlans.map(async (plan) => { + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, + providerId: plan.providerId, + selectedModelIds: plan.selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById: plan.cachedModelResultsById, + onModelProgress: ({ details }) => { + checks = updateProviderCheck(checks, plan.providerId, { + status: 'checking', + backendSummary: plan.backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + }, + }); + return { ...plan, prepResult }; + }) + ); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + for (const plan of providerResults) { + if (plan.prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( - ...prepResult.warnings.map( - (warning) => `${getProviderLabel(providerId)}: ${warning}` + ...plan.prepResult.warnings.map( + (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` ) ); } - if (prepResult.status === 'failed') { + if (plan.prepResult.status === 'failed') { anyFailure = true; - } else if (prepResult.status === 'notes') { + } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); - checks = updateProviderCheck(checks, providerId, { - status: prepResult.status, - backendSummary, - details: prepResult.details, + prepareModelResultsCacheRef.current.set( + plan.cacheKey, + plan.prepResult.modelResultsById + ); + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.prepResult.status, + backendSummary: plan.backendSummary, + details: plan.prepResult.details, }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index fcc80825..2b3a6f4e 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -923,82 +923,92 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedMemberProviders ); setPrepareState('loading'); - setPrepareMessage('Checking selected providers...'); + setPrepareMessage('Checking selected providers in parallel...'); setPrepareWarnings([]); setPrepareChecks(initialChecks); void (async () => { let checks = initialChecks; - let anyFailure = false; - let anyNotes = false; - const collectedWarnings: string[] = []; + const providerPlans = selectedMemberProviders.map((providerId) => { + const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; + const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; + const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); + const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; + const cachedSnapshot = getProviderPrepareCachedSnapshot({ + providerId, + selectedModelIds: selectedModelChecks, + cachedModelResultsById, + }); + return { + providerId, + selectedModelChecks, + backendSummary, + cacheKey, + cachedModelResultsById, + cachedSnapshot, + }; + }); try { - for (const providerId of selectedMemberProviders) { - const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? []; - const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; - const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary); - const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {}; - const cachedSnapshot = getProviderPrepareCachedSnapshot({ - providerId, - selectedModelIds: selectedModelChecks, - cachedModelResultsById, + for (const plan of providerPlans) { + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking', + backendSummary: plan.backendSummary, + details: plan.cachedSnapshot.details, }); - checks = updateProviderCheck(checks, providerId, { - status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking', - backendSummary, - details: cachedSnapshot.details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - selectedModelChecks.length > 0 - ? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...` - : `Checking ${getProviderLabel(providerId)} runtime...` - ); - } - - const prepResult = await runProviderPrepareDiagnostics({ - cwd: effectiveCwd, - providerId, - selectedModelIds: selectedModelChecks, - prepareProvisioning: api.teams.prepareProvisioning, - limitContext, - cachedModelResultsById, - onModelProgress: ({ details, completedCount, totalCount }) => { - checks = updateProviderCheck(checks, providerId, { - status: 'checking', - backendSummary, - details, - }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - setPrepareMessage( - `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...` - ); - } - }, - }); - if (prepResult.warnings.length > 0) { + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + const providerResults = await Promise.all( + providerPlans.map(async (plan) => { + const prepResult = await runProviderPrepareDiagnostics({ + cwd: effectiveCwd, + providerId: plan.providerId, + selectedModelIds: plan.selectedModelChecks, + prepareProvisioning: api.teams.prepareProvisioning, + limitContext, + cachedModelResultsById: plan.cachedModelResultsById, + onModelProgress: ({ details }) => { + checks = updateProviderCheck(checks, plan.providerId, { + status: 'checking', + backendSummary: plan.backendSummary, + details, + }); + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); + } + }, + }); + return { ...plan, prepResult }; + }) + ); + let anyFailure = false; + let anyNotes = false; + const collectedWarnings: string[] = []; + for (const plan of providerResults) { + if (plan.prepResult.warnings.length > 0) { anyNotes = true; collectedWarnings.push( - ...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`) + ...plan.prepResult.warnings.map( + (warning) => `${getProviderLabel(plan.providerId)}: ${warning}` + ) ); } - if (prepResult.status === 'failed') { + if (plan.prepResult.status === 'failed') { anyFailure = true; - } else if (prepResult.status === 'notes') { + } else if (plan.prepResult.status === 'notes') { anyNotes = true; } - prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById); - checks = updateProviderCheck(checks, providerId, { - status: prepResult.status, - backendSummary, - details: prepResult.details, + prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById); + checks = updateProviderCheck(checks, plan.providerId, { + status: plan.prepResult.status, + backendSummary: plan.backendSummary, + details: plan.prepResult.details, }); - if (!cancelled && prepareRequestSeqRef.current === requestSeq) { - setPrepareChecks(checks); - } + } + if (!cancelled && prepareRequestSeqRef.current === requestSeq) { + setPrepareChecks(checks); } if (cancelled || prepareRequestSeqRef.current !== requestSeq) return; const failureMessage = diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 3937e09f..bcf7ebc2 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -111,7 +111,8 @@ export const MemberCard = ({ !isRemoved && presenceLabel === 'starting' && spawnLaunchState !== 'failed_to_start' && - !activityTask; + !activityTask && + !runtimeSummary; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; const showRuntimeAdvisoryBadge = !isRemoved && diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index c19f4b10..58db6a84 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,13 +1,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - formatTeamModelSummary, - getTeamEffortLabel, - getTeamModelLabel, - getTeamProviderLabel, -} from '@renderer/components/team/dialogs/TeamModelSelector'; +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { MemberCard } from './MemberCard'; @@ -152,6 +147,7 @@ function areMemberSpawnStatusesEquivalent( leftEntry.launchState !== rightEntry.launchState || leftEntry.error !== rightEntry.error || leftEntry.livenessSource !== rightEntry.livenessSource || + leftEntry.runtimeModel !== rightEntry.runtimeModel || leftEntry.runtimeAlive !== rightEntry.runtimeAlive ) { return false; @@ -242,12 +238,11 @@ export const MemberList = memo(function MemberList({ const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const buildRuntimeSummary = useCallback( - (member: ResolvedTeamMember): string | undefined => { - const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic'; - const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || ''; - const effectiveEffort = member.effort ?? launchParams?.effort; - - return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort); + ( + member: ResolvedTeamMember, + spawnEntry: MemberSpawnStatusEntry | undefined + ): string | undefined => { + return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); }, [launchParams] ); @@ -293,7 +288,7 @@ export const MemberList = memo(function MemberList({ reviewTask={isRemoved ? null : reviewTask} isAwaitingReply={isRemoved ? false : awaitingReply} isRemoved={isRemoved} - runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)} + runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)} spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnError={isRemoved ? undefined : spawnEntry?.error} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b473e828..3d2e02ba 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -456,6 +456,7 @@ function areMemberSpawnStatusEntriesEqual( left.error === right.error && left.livenessSource === right.livenessSource && left.runtimeAlive === right.runtimeAlive && + left.runtimeModel === right.runtimeModel && left.bootstrapConfirmed === right.bootstrapConfirmed && left.hardFailure === right.hardFailure ); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index da447536..ce19b342 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -423,9 +423,27 @@ export interface MemberLaunchPresentation { runtimeAdvisoryLabel: string | null; runtimeAdvisoryTitle?: string; launchVisualState: MemberLaunchVisualState; + launchStatusLabel: string | null; spawnBadgeLabel: string | null; } +export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null { + switch (visualState) { + case 'waiting': + return 'waiting to start'; + case 'spawning': + return 'starting'; + case 'runtime_pending': + return 'connecting'; + case 'settling': + return 'joining team'; + case 'error': + return 'failed'; + default: + return null; + } +} + export function buildMemberLaunchPresentation({ member, spawnStatus, @@ -511,6 +529,7 @@ export function buildMemberLaunchPresentation({ } } + const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' @@ -525,6 +544,7 @@ export function buildMemberLaunchPresentation({ runtimeAdvisoryLabel, runtimeAdvisoryTitle, launchVisualState, + launchStatusLabel, spawnBadgeLabel, }; } diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts new file mode 100644 index 00000000..937a4f0f --- /dev/null +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -0,0 +1,41 @@ +import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; + +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types'; +import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; + +function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { + if (!spawnEntry) { + return false; + } + + return ( + spawnEntry.launchState === 'starting' || + spawnEntry.launchState === 'runtime_pending_bootstrap' || + spawnEntry.status === 'waiting' || + spawnEntry.status === 'spawning' + ); +} + +export function resolveMemberRuntimeSummary( + member: ResolvedTeamMember, + launchParams: TeamLaunchParams | undefined, + spawnEntry: MemberSpawnStatusEntry | undefined +): string | undefined { + const configuredProvider: TeamProviderId = + member.providerId ?? launchParams?.providerId ?? 'anthropic'; + const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; + const configuredEffort = member.effort ?? launchParams?.effort; + const runtimeModel = spawnEntry?.runtimeModel?.trim(); + + if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { + const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; + return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); + } + + if (isMemberLaunchPending(spawnEntry)) { + return undefined; + } + + return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 69e5e5ab..22421731 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -945,6 +945,8 @@ export interface MemberSpawnStatusEntry { firstSpawnAcceptedAt?: string; /** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */ lastHeartbeatAt?: string; + /** Live runtime model observed from the teammate process, when available. */ + runtimeModel?: string; /** ISO timestamp of the last status change. */ updatedAt: string; } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 659d5d68..889c74c8 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -752,7 +752,7 @@ describe('TeamProvisioningService', () => { expect(result.teamLaunchState).toBe('partial_failure'); }); - it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => { + it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => { allowConsoleLogs(); const teamName = 'zz-live-bootstrap-model-unavailable'; const leadSessionId = 'lead-session'; @@ -816,8 +816,8 @@ describe('TeamProvisioningService', () => { launchState: 'runtime_pending_bootstrap', error: undefined, updatedAt: acceptedAt, - runtimeAlive: false, - livenessSource: undefined, + runtimeAlive: true, + livenessSource: 'process', bootstrapConfirmed: false, hardFailure: false, agentToolAccepted: true, @@ -846,4 +846,77 @@ describe('TeamProvisioningService', () => { ); expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available'); }); + + it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => { + allowConsoleLogs(); + const teamName = 'zz-persisted-live-bootstrap-model-unavailable'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 5_000).toISOString(); + const errorAt = new Date(Date.now() - 4_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + writeLaunchState(teamName, leadSessionId, { + jack: { + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: acceptedAt, + }, + }); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: errorAt, + teamName, + agentName: 'jack', + type: 'assistant', + isApiErrorMessage: true, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'API Error: 400 {"detail":"The requested model is not available for your account."}', + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack'])); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: true, + }); + expect(result.statuses.jack?.error).toContain('requested model is not available'); + expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); + expect(result.teamLaunchState).toBe('partial_failure'); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 8ceb6d05..7eef52f4 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -276,6 +276,76 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + const { runId } = await svc.createTeam( + { + teamName: 'codex-default-team', + cwd: process.cwd(), + providerId: 'codex', + members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], + }, + () => {} + ); + + const bootstrapSpec = extractBootstrapSpec(); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4', + }), + ]); + + await svc.cancelProvisioning(runId); + }); + + it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => { + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + vi.mocked(spawnCli).mockReset(); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => null); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'codex-default-missing', + cwd: process.cwd(), + providerId: 'codex', + members: [{ name: 'alice', providerId: 'codex' }], + }, + () => {} + ) + ).rejects.toThrow( + 'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.' + ); + + expect(spawnCli).not.toHaveBeenCalled(); + }); + it('add-member spawn prompt tells teammates to keep review on the same task', () => { const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { name: 'alice', @@ -429,4 +499,67 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + + it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => { + const teamName = 'codex-default-launch'; + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, + { name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' }, + ], + }), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + const { child } = createFakeChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { PATH: '/usr/bin' }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + })); + (svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4'); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice', role: 'developer', providerId: 'codex' }], + source: 'config-fallback', + warning: undefined, + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + + const { runId } = await svc.launchTeam( + { + teamName, + cwd: process.cwd(), + providerId: 'codex', + clearContext: true, + } as any, + () => {} + ); + + const bootstrapSpec = extractBootstrapSpec(); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4', + }), + ]); + + await svc.cancelProvisioning(runId); + }); }); diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 7eaa65b5..e5d8ce6b 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -70,7 +70,7 @@ describe('GraphNodePopover spawn badge labels', () => { vi.unstubAllGlobals(); }); - it('shows human-facing starting for raw waiting/spawning spawn statuses', async () => { + it('shows human-readable launch-status labels for waiting and spawning spawn states', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -96,9 +96,9 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); + expect(host.textContent).toContain('waiting to start'); expect(host.textContent).toContain('starting'); expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium'); - expect(host.textContent).not.toContain('waiting'); expect(host.textContent).not.toContain('spawning'); await act(async () => { @@ -193,7 +193,7 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('online'); + expect(host.textContent).toContain('connecting'); expect(host.textContent).not.toContain('Idle'); await act(async () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 86f3c872..55294abe 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1078,7 +1078,10 @@ describe('TeamGraphAdapter particles', () => { } as never ); - expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('runtime_pending'); + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + launchVisualState: 'runtime_pending', + launchStatusLabel: 'connecting', + }); }); it('keeps confirmed teammates in settling visuals while launch is still joining', () => { @@ -1125,7 +1128,10 @@ describe('TeamGraphAdapter particles', () => { } as never ); - expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('settling'); + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + launchVisualState: 'settling', + launchStatusLabel: 'joining team', + }); }); it('scopes inbox particle ids by team name to avoid cross-team collisions', () => { diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts index 4d5bcbb7..1208a2c6 100644 --- a/test/renderer/features/agent-graph/drawAgents.test.ts +++ b/test/renderer/features/agent-graph/drawAgents.test.ts @@ -108,4 +108,36 @@ describe('drawAgents', () => { expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y); expect(toolCall!.y).toBeLessThan(node.y!); }); + + it('renders launch text as a third label line and removes old ad-hoc waiting text', () => { + const { ctx, fillTextCalls } = createMockContext(); + const node: GraphNode = { + id: 'member:demo:alice', + kind: 'member', + label: 'alice', + state: 'idle', + color: '#60a5fa', + runtimeLabel: 'Codex · GPT-5.4 Mini · Medium', + launchVisualState: 'runtime_pending', + launchStatusLabel: 'connecting', + spawnStatus: 'online', + domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' }, + x: 320, + y: 240, + }; + + drawAgents(ctx, [node], 0, null, null, null, 1); + + const labelCall = fillTextCalls.find((call) => call.text === 'alice'); + const runtimeCall = fillTextCalls.find((call) => call.text.includes('Codex')); + const launchCall = fillTextCalls.find((call) => call.text === 'connecting'); + + expect(labelCall).toBeDefined(); + expect(runtimeCall).toBeDefined(); + expect(launchCall).toBeDefined(); + expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y); + expect(launchCall!.y).toBeGreaterThan(runtimeCall!.y); + expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false); + expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false); + }); }); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 56a8dfe9..b626c0fe 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -177,33 +177,90 @@ describe('memberHelpers spawn-aware presence', () => { }); it('derives runtime-pending and settling visual states from the same launch inputs', () => { + const runtimePending = buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + const settling = buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'heartbeat', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + expect(runtimePending.launchVisualState).toBe('runtime_pending'); + expect(runtimePending.launchStatusLabel).toBe('connecting'); + expect(settling.launchVisualState).toBe('settling'); + expect(settling.launchStatusLabel).toBe('joining team'); + }); + + it('returns shared launch status labels without changing generic presence labels', () => { expect( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'runtime_pending_bootstrap', - spawnLivenessSource: 'process', - spawnRuntimeAlive: true, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, isLaunchSettling: false, isTeamAlive: true, isTeamProvisioning: false, - }).launchVisualState - ).toBe('runtime_pending'); + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', + }); expect( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'confirmed_alive', - spawnLivenessSource: 'heartbeat', - spawnRuntimeAlive: true, + spawnStatus: 'spawning', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, - isLaunchSettling: true, + isLaunchSettling: false, isTeamAlive: true, isTeamProvisioning: false, - }).launchVisualState - ).toBe('settling'); + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'spawning', + launchStatusLabel: 'starting', + }); + + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'spawn failed', + launchVisualState: 'error', + launchStatusLabel: 'failed', + }); }); it('renders unified retry advisory labels for provider retries', () => { diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts new file mode 100644 index 00000000..dd8ab9b5 --- /dev/null +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; + +import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; + +function createMember(overrides: Partial = {}): ResolvedTeamMember { + return { + name: 'alice', + agentId: 'alice@test-team', + agentType: 'general-purpose', + role: 'developer', + providerId: 'codex', + effort: 'medium', + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + color: 'blue', + ...overrides, + }; +} + +function createSpawnEntry(overrides: Partial = {}): MemberSpawnStatusEntry { + return { + status: 'waiting', + launchState: 'starting', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + updatedAt: '2026-04-16T17:10:48.646Z', + ...overrides, + }; +} + +describe('resolveMemberRuntimeSummary', () => { + it('shows the live runtime model for loading members when available', () => { + const member = createMember(); + const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-6', runtimeAlive: true }); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe( + 'Anthropic · Opus 4.6 · Medium' + ); + }); + + it('keeps the loading skeleton when a pending member has no live runtime model yet', () => { + const member = createMember(); + const spawnEntry = createSpawnEntry(); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBeUndefined(); + }); + + it('uses the live runtime model as a fallback when config has no explicit model', () => { + const member = createMember({ providerId: 'codex', model: undefined }); + const spawnEntry = createSpawnEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + runtimeModel: 'gpt-5.4-mini', + }); + + expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium'); + }); +});