diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
index 0c8b8855..87933fb6 100644
--- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx
+++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
@@ -5,6 +5,7 @@ import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBad
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import {
@@ -238,11 +239,13 @@ export const KanbanTaskCard = memo(
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element {
+ const { isLight } = useTheme();
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0;
+ const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]';
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const canDisplay = useMemo(
@@ -291,8 +294,8 @@ export const KanbanTaskCard = memo(
data-task-id={task.id}
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
hasBlockedBy
- ? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
- : 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
+ ? `border-yellow-500/30 ${cardSurfaceClass}`
+ : `border-[var(--color-border)] ${cardSurfaceClass}`
}`}
role="button"
tabIndex={0}
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx
index b981ef1d..cdf9befd 100644
--- a/src/renderer/components/team/members/LeadModelRow.tsx
+++ b/src/renderer/components/team/members/LeadModelRow.tsx
@@ -1,9 +1,11 @@
import React, { useState } from 'react';
+import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
import {
getProviderScopedTeamModelLabel,
+ getTeamProviderLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -52,6 +54,7 @@ export const LeadModelRow = ({
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
: 'Default';
+ const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
return (
setModelExpanded((prev) => !prev)}
>
{modelExpanded ? (
@@ -104,7 +108,8 @@ export const LeadModelRow = ({
) : (
)}
-
Model: {modelButtonLabel}
+
+
{modelButtonLabel}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index de346518..3937e09f 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -5,12 +5,8 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
+ buildMemberLaunchPresentation,
displayMemberName,
- getLaunchAwarePresenceLabel,
- getMemberRuntimeAdvisoryLabel,
- getMemberRuntimeAdvisoryTitle,
- getSpawnAwareDotClass,
- getSpawnCardClass,
} from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
@@ -81,44 +77,23 @@ export const MemberCard = ({
// const leadContext = useStore((s) =>
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
- const dotClass = getSpawnAwareDotClass(
- member,
- spawnStatus,
- spawnLaunchState,
- spawnRuntimeAlive,
- isLaunchSettling,
- isTeamAlive,
- isTeamProvisioning,
- leadActivity
- );
- const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(
- member.runtimeAdvisory,
- member.providerId
- );
- const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
- member.runtimeAdvisory,
- member.providerId
- );
- const presenceLabel = getLaunchAwarePresenceLabel(
+ const launchPresentation = buildMemberLaunchPresentation({
member,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
- member.runtimeAdvisory,
+ runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
- leadActivity
- );
- const spawnCardClass = getSpawnCardClass(
- spawnStatus,
- spawnLaunchState,
- spawnRuntimeAlive,
- isLaunchSettling,
- isTeamAlive,
- isTeamProvisioning
- );
+ leadActivity,
+ });
+ const dotClass = launchPresentation.dotClass;
+ const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel;
+ const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
+ const presenceLabel = launchPresentation.presenceLabel;
+ const spawnCardClass = launchPresentation.cardClass;
const colors = getTeamColorSet(memberColor);
const { isLight } = useTheme();
const pending = taskCounts?.pending ?? 0;
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx
index d2b4f014..5dfaee99 100644
--- a/src/renderer/components/team/members/MemberDetailHeader.tsx
+++ b/src/renderer/components/team/members/MemberDetailHeader.tsx
@@ -6,10 +6,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
+ buildMemberLaunchPresentation,
displayMemberName,
- getLaunchAwarePresenceLabel,
- getMemberRuntimeAdvisoryTitle,
- getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { Pencil } from 'lucide-react';
@@ -61,32 +59,21 @@ export const MemberDetailHeader = ({
const colors = getTeamColorSet(member.color ?? '');
const role = member.role || formatAgentRole(member.agentType);
- const presenceLabel = getLaunchAwarePresenceLabel(
+ const launchPresentation = buildMemberLaunchPresentation({
member,
spawnStatus,
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
- member.runtimeAdvisory,
+ runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
- leadActivity
- );
- const dotClass = getSpawnAwareDotClass(
- member,
- spawnStatus,
- spawnLaunchState,
- spawnRuntimeAlive,
- isLaunchSettling,
- isTeamAlive,
- isTeamProvisioning,
- leadActivity
- );
- const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
- member.runtimeAdvisory,
- member.providerId
- );
+ leadActivity,
+ });
+ const presenceLabel = launchPresentation.presenceLabel;
+ const dotClass = launchPresentation.dotClass;
+ const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
const canEditRole =
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index f9e30076..704e0076 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
import {
getProviderScopedTeamModelLabel,
+ getTeamProviderLabel,
TeamModelSelector,
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
@@ -169,6 +171,7 @@ export const MemberDraftRow = ({
const modelButtonLabel = forceInheritedModelSettings
? `${modelButtonLabelBase} (lead)`
: modelButtonLabelBase;
+ const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
const modelTooltipText = forceInheritedModelSettings
? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
: modelLockReason;
@@ -246,6 +249,7 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="h-8 w-full justify-start gap-1 overflow-hidden text-left"
+ aria-label={modelButtonAriaLabel}
disabled={lockProviderModel || isRemoved}
onClick={() => setModelExpanded((prev) => !prev)}
>
@@ -254,7 +258,11 @@ export const MemberDraftRow = ({
) : (
)}
-
Model: {modelButtonLabel}
+
+
{modelButtonLabel}
diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx
index 1834a571..c43e7d31 100644
--- a/src/renderer/components/team/members/MemberHoverCard.tsx
+++ b/src/renderer/components/team/members/MemberHoverCard.tsx
@@ -12,10 +12,8 @@ import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/te
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
+ buildMemberLaunchPresentation,
displayMemberName,
- getLaunchAwarePresenceLabel,
- getMemberRuntimeAdvisoryTitle,
- getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
@@ -104,32 +102,21 @@ export const MemberHoverCard = ({
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
- const presenceLabel = getLaunchAwarePresenceLabel(
+ const launchPresentation = buildMemberLaunchPresentation({
member,
- spawnEntry?.status,
- spawnEntry?.launchState,
- spawnEntry?.livenessSource,
- spawnEntry?.runtimeAlive,
- member.runtimeAdvisory,
+ spawnStatus: spawnEntry?.status,
+ spawnLaunchState: spawnEntry?.launchState,
+ spawnLivenessSource: spawnEntry?.livenessSource,
+ spawnRuntimeAlive: spawnEntry?.runtimeAlive,
+ runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
- false,
- isLeadMember(member) ? leadActivity : undefined
- );
- const dotClass = getSpawnAwareDotClass(
- member,
- spawnEntry?.status,
- spawnEntry?.launchState,
- spawnEntry?.runtimeAlive,
- isLaunchSettling,
- isTeamAlive,
- false,
- isLeadMember(member) ? leadActivity : undefined
- );
- const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
- member.runtimeAdvisory,
- member.providerId
- );
+ isTeamProvisioning: false,
+ leadActivity: isLeadMember(member) ? leadActivity : undefined,
+ });
+ const presenceLabel = launchPresentation.presenceLabel;
+ const dotClass = launchPresentation.dotClass;
+ const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts
new file mode 100644
index 00000000..f7b3570b
--- /dev/null
+++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts
@@ -0,0 +1,45 @@
+import { useMemo } from 'react';
+
+import { useStore } from '@renderer/store';
+import {
+ selectTeamDataForName,
+ getCurrentProvisioningProgressForTeam,
+} from '@renderer/store/slices/teamSlice';
+import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
+import { useShallow } from 'zustand/react/shallow';
+
+import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
+
+export function useTeamProvisioningPresentation(teamName: string): {
+ presentation: TeamProvisioningPresentation | null;
+ cancelProvisioning: ((runId: string) => Promise
) | null;
+ runInstanceKey: string | null;
+} {
+ const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } =
+ useStore(
+ useShallow((s) => ({
+ progress: getCurrentProvisioningProgressForTeam(s, teamName),
+ cancelProvisioning: s.cancelProvisioning,
+ teamMembers: selectTeamDataForName(s, teamName)?.members ?? [],
+ memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
+ memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
+ }))
+ );
+
+ const presentation = useMemo(
+ () =>
+ buildTeamProvisioningPresentation({
+ progress,
+ members: teamMembers,
+ memberSpawnStatuses,
+ memberSpawnSnapshot,
+ }),
+ [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers]
+ );
+
+ return {
+ presentation,
+ cancelProvisioning,
+ runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null,
+ };
+}
diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts
index b6f53552..50a56ee3 100644
--- a/src/renderer/constants/teamColors.ts
+++ b/src/renderer/constants/teamColors.ts
@@ -134,6 +134,70 @@ function hsla(hue: number, saturation: number, lightness: number, alpha = 1): st
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
}
+function normalizeHexColor(color: string): string | null {
+ const trimmed = color.trim();
+ const shortHexMatch = /^#([\da-f]{3,4})$/i.exec(trimmed);
+ if (shortHexMatch) {
+ const expanded = shortHexMatch[1]
+ .slice(0, 3)
+ .split('')
+ .map((char) => char + char)
+ .join('');
+ return `#${expanded.toLowerCase()}`;
+ }
+
+ const longHexMatch = /^#([\da-f]{6})([\da-f]{2})?$/i.exec(trimmed);
+ if (longHexMatch) {
+ return `#${longHexMatch[1].toLowerCase()}`;
+ }
+
+ return null;
+}
+
+function hexToRgb(color: string): { r: number; g: number; b: number } | null {
+ const normalized = normalizeHexColor(color);
+ if (!normalized) return null;
+
+ return {
+ r: parseInt(normalized.slice(1, 3), 16),
+ g: parseInt(normalized.slice(3, 5), 16),
+ b: parseInt(normalized.slice(5, 7), 16),
+ };
+}
+
+function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
+ const rn = r / 255;
+ const gn = g / 255;
+ const bn = b / 255;
+ const max = Math.max(rn, gn, bn);
+ const min = Math.min(rn, gn, bn);
+ const delta = max - min;
+ const lightness = (max + min) / 2;
+
+ if (delta === 0) {
+ return { h: 0, s: 0, l: lightness * 100 };
+ }
+
+ let hue = 0;
+ if (max === rn) {
+ hue = ((gn - bn) / delta) % 6;
+ } else if (max === gn) {
+ hue = (bn - rn) / delta + 2;
+ } else {
+ hue = (rn - gn) / delta + 4;
+ }
+
+ hue = Math.round(hue * 60);
+ if (hue < 0) hue += 360;
+
+ const saturation = delta / (1 - Math.abs(2 * lightness - 1));
+ return {
+ h: hue,
+ s: saturation * 100,
+ l: lightness * 100,
+ };
+}
+
function buildGeneratedMemberColorSet(colorName: string): TeamColorSet | null {
const hue = MEMBER_COLOR_HUE[colorName];
if (hue === undefined) {
@@ -162,6 +226,28 @@ function buildColorSetFromHue(hue: number): TeamColorSet {
};
}
+function buildColorSetFromHex(color: string): TeamColorSet {
+ const normalized = normalizeHexColor(color);
+ const rgb = normalized ? hexToRgb(normalized) : null;
+ if (!normalized || !rgb) {
+ return {
+ border: color,
+ badge: `${color}26`,
+ text: color,
+ };
+ }
+
+ const { h } = rgbToHsl(rgb.r, rgb.g, rgb.b);
+ const derived = buildColorSetFromHue(h);
+
+ return {
+ ...derived,
+ border: normalized,
+ badge: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.15)`,
+ badgeLight: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.12)`,
+ };
+}
+
export function getTeamColorSet(colorName: string): TeamColorSet {
if (!colorName) return DEFAULT_COLOR;
@@ -174,11 +260,7 @@ export function getTeamColorSet(colorName: string): TeamColorSet {
// If it's a hex color, generate a set from it
if (colorName.startsWith('#')) {
- return {
- border: colorName,
- badge: `${colorName}26`,
- text: colorName,
- };
+ return buildColorSetFromHex(colorName);
}
// Hash unknown palette names (e.g. "coral", "sapphire") to one of the
diff --git a/src/renderer/index.css b/src/renderer/index.css
index 492b89d1..60a5262a 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -259,7 +259,7 @@
}
.kanban-grid-layout {
- min-height: 640px;
+ min-height: 0;
overflow: visible;
transition: none;
}
@@ -357,11 +357,11 @@
}
.kanban-grid-resize-handle-n {
- top: -3px;
+ top: 0;
}
.kanban-grid-resize-handle-s {
- bottom: -3px;
+ bottom: 0;
}
.kanban-grid-resize-handle-e,
@@ -374,11 +374,11 @@
}
.kanban-grid-resize-handle-e {
- right: -3px;
+ right: 0;
}
.kanban-grid-resize-handle-w {
- left: -3px;
+ left: 0;
}
.kanban-grid-resize-handle-ne,
@@ -390,26 +390,26 @@
}
.kanban-grid-resize-handle-ne {
- top: -4px;
- right: -4px;
+ top: 0;
+ right: 0;
cursor: nesw-resize;
}
.kanban-grid-resize-handle-nw {
- top: -4px;
- left: -4px;
+ top: 0;
+ left: 0;
cursor: nwse-resize;
}
.kanban-grid-resize-handle-se {
- right: -4px;
- bottom: -4px;
+ right: 0;
+ bottom: 0;
cursor: nwse-resize;
}
.kanban-grid-resize-handle-sw {
- left: -4px;
- bottom: -4px;
+ left: 0;
+ bottom: 0;
cursor: nesw-resize;
}
diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts
index 2d348feb..9066c898 100644
--- a/src/renderer/utils/memberHelpers.ts
+++ b/src/renderer/utils/memberHelpers.ts
@@ -222,7 +222,7 @@ export function getSpawnCardClass(
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
if (isTeamAlive === false && !isTeamProvisioning) {
- return 'opacity-40';
+ return '';
}
if (
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
@@ -408,6 +408,127 @@ export function getLaunchAwarePresenceLabel(
return advisoryLabel ?? basePresenceLabel;
}
+export type MemberLaunchVisualState =
+ | 'waiting'
+ | 'spawning'
+ | 'runtime_pending'
+ | 'settling'
+ | 'error'
+ | null;
+
+export interface MemberLaunchPresentation {
+ presenceLabel: string;
+ dotClass: string;
+ cardClass: string;
+ runtimeAdvisoryLabel: string | null;
+ runtimeAdvisoryTitle?: string;
+ launchVisualState: MemberLaunchVisualState;
+ spawnBadgeLabel: string | null;
+}
+
+export function buildMemberLaunchPresentation({
+ member,
+ spawnStatus,
+ spawnLaunchState,
+ spawnLivenessSource,
+ spawnRuntimeAlive,
+ runtimeAdvisory,
+ isLaunchSettling = false,
+ isTeamAlive,
+ isTeamProvisioning,
+ leadActivity,
+}: {
+ member: ResolvedTeamMember;
+ spawnStatus: MemberSpawnStatus | undefined;
+ spawnLaunchState: MemberLaunchState | undefined;
+ spawnLivenessSource: MemberSpawnLivenessSource | undefined;
+ spawnRuntimeAlive: boolean | undefined;
+ runtimeAdvisory: MemberRuntimeAdvisory | undefined;
+ isLaunchSettling?: boolean;
+ isTeamAlive?: boolean;
+ isTeamProvisioning?: boolean;
+ leadActivity?: LeadActivityState;
+}): MemberLaunchPresentation {
+ const presenceLabel = getLaunchAwarePresenceLabel(
+ member,
+ spawnStatus,
+ spawnLaunchState,
+ spawnLivenessSource,
+ spawnRuntimeAlive,
+ runtimeAdvisory,
+ isLaunchSettling,
+ isTeamAlive,
+ isTeamProvisioning,
+ leadActivity
+ );
+ const dotClass = getSpawnAwareDotClass(
+ member,
+ spawnStatus,
+ spawnLaunchState,
+ spawnRuntimeAlive,
+ isLaunchSettling,
+ isTeamAlive,
+ isTeamProvisioning,
+ leadActivity
+ );
+ const cardClass = getSpawnCardClass(
+ spawnStatus,
+ spawnLaunchState,
+ spawnRuntimeAlive,
+ isLaunchSettling,
+ isTeamAlive,
+ isTeamProvisioning
+ );
+ const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId);
+ const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
+ const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
+
+ let launchVisualState: MemberLaunchVisualState = null;
+ if (isTeamAlive === false && !isTeamProvisioning) {
+ launchVisualState = null;
+ } else if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
+ launchVisualState = 'error';
+ } else if (
+ spawnLaunchState === 'runtime_pending_bootstrap' &&
+ spawnStatus === 'online' &&
+ spawnRuntimeAlive === true
+ ) {
+ launchVisualState = 'runtime_pending';
+ } else if (
+ isLaunchStillStarting(
+ spawnStatus,
+ spawnLaunchState,
+ spawnRuntimeAlive,
+ keepLaunchSettlingVisuals
+ )
+ ) {
+ launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
+ } else if (
+ isLaunchSettling &&
+ spawnStatus === 'online' &&
+ spawnLaunchState === 'confirmed_alive'
+ ) {
+ launchVisualState = 'settling';
+ }
+
+ const spawnBadgeLabel =
+ spawnStatus && spawnStatus !== 'online'
+ ? spawnStatus === 'waiting' || spawnStatus === 'spawning'
+ ? 'starting'
+ : spawnStatus
+ : null;
+
+ return {
+ presenceLabel,
+ dotClass,
+ cardClass,
+ runtimeAdvisoryLabel,
+ runtimeAdvisoryTitle,
+ launchVisualState,
+ spawnBadgeLabel,
+ };
+}
+
export const TASK_STATUS_STYLES: Record = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts
new file mode 100644
index 00000000..2253c177
--- /dev/null
+++ b/src/renderer/utils/teamProvisioningPresentation.ts
@@ -0,0 +1,245 @@
+import {
+ DISPLAY_COMPLETE_STEP_INDEX,
+ getDisplayStepIndex,
+ getLaunchJoinMilestonesFromMembers,
+ getLaunchJoinState,
+} from '@renderer/components/team/provisioningSteps';
+
+import type {
+ MemberSpawnStatusEntry,
+ MemberSpawnStatusesSnapshot,
+ ResolvedTeamMember,
+ TeamProvisioningProgress,
+} from '@shared/types';
+
+type MemberSpawnStatusCollection =
+ | Record
+ | Map
+ | undefined;
+
+const ACTIVE_PROVISIONING_STATES = new Set([
+ 'validating',
+ 'spawning',
+ 'configuring',
+ 'assembling',
+ 'finalizing',
+ 'verifying',
+]);
+
+export interface TeamProvisioningPresentation {
+ progress: TeamProvisioningProgress;
+ isActive: boolean;
+ isReady: boolean;
+ isFailed: boolean;
+ canCancel: boolean;
+ currentStepIndex: number;
+ expectedTeammateCount: number;
+ heartbeatConfirmedCount: number;
+ processOnlyAliveCount: number;
+ pendingSpawnCount: number;
+ failedSpawnCount: number;
+ allTeammatesConfirmedAlive: boolean;
+ hasMembersStillJoining: boolean;
+ remainingJoinCount: number;
+ panelTitle: string;
+ panelMessage?: string | null;
+ panelMessageSeverity?: 'error' | 'warning' | 'info';
+ panelTone?: 'default' | 'error';
+ successMessage?: string | null;
+ successMessageSeverity?: 'success' | 'warning' | 'info';
+ defaultLiveOutputOpen: boolean;
+ compactTitle: string;
+ compactDetail?: string | null;
+ compactTone: 'default' | 'warning' | 'error' | 'success';
+}
+
+export function isProvisioningProgressActive(
+ progress: Pick | null | undefined
+): boolean {
+ return progress != null && ACTIVE_PROVISIONING_STATES.has(progress.state);
+}
+
+export function buildTeamProvisioningPresentation({
+ progress,
+ members,
+ memberSpawnStatuses,
+ memberSpawnSnapshot,
+}: {
+ progress: TeamProvisioningProgress | null | undefined;
+ members: readonly ResolvedTeamMember[];
+ memberSpawnStatuses?: MemberSpawnStatusCollection;
+ memberSpawnSnapshot?: Pick;
+}): TeamProvisioningPresentation | null {
+ if (!progress) {
+ return null;
+ }
+
+ if (progress.state === 'cancelled' || progress.state === 'disconnected') {
+ return null;
+ }
+
+ const isReady = progress.state === 'ready';
+ const isFailed = progress.state === 'failed';
+ const isActive = isProvisioningProgressActive(progress);
+ const canCancel =
+ progress.state === 'spawning' ||
+ progress.state === 'configuring' ||
+ progress.state === 'assembling' ||
+ progress.state === 'finalizing' ||
+ progress.state === 'verifying';
+
+ const {
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ } = getLaunchJoinMilestonesFromMembers({
+ members,
+ memberSpawnStatuses,
+ memberSpawnSnapshot,
+ });
+
+ const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
+ getLaunchJoinState({
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ });
+
+ const progressStepIndex = getDisplayStepIndex({
+ progress,
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ });
+
+ if (isFailed) {
+ return {
+ progress,
+ isActive: false,
+ isReady: false,
+ isFailed: true,
+ canCancel: false,
+ currentStepIndex: progressStepIndex,
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ allTeammatesConfirmedAlive,
+ hasMembersStillJoining,
+ remainingJoinCount,
+ panelTitle: 'Launch failed',
+ panelMessage: progress.error ?? null,
+ panelTone: 'error',
+ defaultLiveOutputOpen: true,
+ compactTitle: 'Launch failed',
+ compactDetail: progress.message ?? null,
+ compactTone: 'error',
+ };
+ }
+
+ if (isReady) {
+ const joiningPhrase =
+ remainingJoinCount === 1
+ ? '1 teammate still joining'
+ : `${remainingJoinCount} teammates still joining`;
+ const readyCompactDetail =
+ failedSpawnCount > 0
+ ? `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`
+ : hasMembersStillJoining
+ ? joiningPhrase
+ : expectedTeammateCount === 0
+ ? 'Lead online'
+ : `All ${expectedTeammateCount} teammates joined`;
+ const readyDetailMessage =
+ failedSpawnCount > 0
+ ? progress.message
+ : expectedTeammateCount === 0
+ ? 'Team provisioned - lead online'
+ : allTeammatesConfirmedAlive
+ ? `Team provisioned - all ${expectedTeammateCount} teammates joined`
+ : hasMembersStillJoining
+ ? joiningPhrase
+ : 'Team provisioned - teammates are still joining';
+ const readyDetailSeverity =
+ failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined;
+ const readyMessage =
+ failedSpawnCount > 0
+ ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
+ : expectedTeammateCount === 0
+ ? 'Team launched - lead online'
+ : allTeammatesConfirmedAlive
+ ? `Team launched - all ${expectedTeammateCount} teammates joined`
+ : 'Finishing launch';
+
+ return {
+ progress,
+ isActive: false,
+ isReady: true,
+ isFailed: false,
+ canCancel: false,
+ currentStepIndex: hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ allTeammatesConfirmedAlive,
+ hasMembersStillJoining,
+ remainingJoinCount,
+ panelTitle: 'Launch details',
+ panelMessage: failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null,
+ panelMessageSeverity: readyDetailSeverity,
+ successMessage: readyMessage,
+ successMessageSeverity:
+ failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success',
+ defaultLiveOutputOpen: false,
+ compactTitle:
+ failedSpawnCount > 0
+ ? 'Launch finished with errors'
+ : hasMembersStillJoining
+ ? 'Finishing launch'
+ : 'Team launched',
+ compactDetail: readyCompactDetail,
+ compactTone:
+ failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
+ };
+ }
+
+ if (isActive) {
+ return {
+ progress,
+ isActive: true,
+ isReady: false,
+ isFailed: false,
+ canCancel,
+ currentStepIndex: progressStepIndex >= 0 ? progressStepIndex : -1,
+ expectedTeammateCount,
+ heartbeatConfirmedCount,
+ processOnlyAliveCount,
+ pendingSpawnCount,
+ failedSpawnCount,
+ allTeammatesConfirmedAlive,
+ hasMembersStillJoining,
+ remainingJoinCount,
+ panelTitle: 'Launching team',
+ panelMessage: progress.message,
+ panelMessageSeverity: progress.messageSeverity,
+ defaultLiveOutputOpen: true,
+ compactTitle: 'Launching team',
+ compactDetail:
+ expectedTeammateCount > 0 && progressStepIndex >= 2
+ ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ : progress.message,
+ compactTone: 'default',
+ };
+ }
+
+ return null;
+}
diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts
index b4b305e5..7c68f7d0 100644
--- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts
+++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts
@@ -140,6 +140,14 @@ vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({
getProviderRuntimeBackendSummary: () => null,
}));
+vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
+ ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
+ React.createElement('span', {
+ 'data-testid': `provider-logo-${providerId}`,
+ 'data-provider-id': providerId,
+ }),
+}));
+
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
function createCodexProvider(
@@ -280,6 +288,10 @@ function findButtonByText(container: HTMLElement, text: string): HTMLButtonEleme
return button;
}
+function countOccurrences(text: string, fragment: string): number {
+ return text.split(fragment).length - 1;
+}
+
describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@@ -424,6 +436,68 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
});
});
+ it('removes duplicate Codex summary and API key source text when connection cards are visible', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ storeState.appConfig.providerConnections.codex = {
+ apiKeyBetaEnabled: true,
+ authMode: 'oauth',
+ };
+
+ await act(async () => {
+ root.render(
+ React.createElement(ProviderRuntimeSettingsDialog, {
+ open: true,
+ onOpenChange: vi.fn(),
+ providers: [
+ createCodexProvider({
+ apiKeyBetaEnabled: true,
+ configuredAuthMode: 'oauth',
+ apiKeyConfigured: true,
+ apiKeySource: 'environment',
+ apiKeySourceLabel: 'Detected from OPENAI_API_KEY',
+ }),
+ ],
+ initialProviderId: 'codex',
+ onSelectBackend: vi.fn(),
+ onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).not.toContain('Current runtime: Codex subscription');
+ expect(host.textContent).not.toContain('Mode: Codex subscription');
+ expect(host.textContent).not.toContain('Runtime: Default adapter');
+ expect(countOccurrences(host.textContent ?? '', 'Using Codex subscription')).toBe(0);
+ expect(countOccurrences(host.textContent ?? '', 'Detected from OPENAI_API_KEY')).toBe(1);
+ expect(host.textContent).not.toContain('Connected');
+ });
+
+ it('renders provider logos inside the provider tabs', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ProviderRuntimeSettingsDialog, {
+ open: true,
+ onOpenChange: vi.fn(),
+ providers: [createAnthropicProvider(), createCodexProvider()],
+ initialProviderId: 'anthropic',
+ onSelectBackend: vi.fn(),
+ onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="provider-logo-anthropic"]')).not.toBeNull();
+ expect(host.querySelector('[data-testid="provider-logo-codex"]')).not.toBeNull();
+ });
+
it('renders Anthropics connection methods as cards and hides the empty runtime section', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@@ -456,6 +530,35 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
expect(host.textContent).toContain('API key');
expect(host.textContent).not.toContain('Authentication method');
expect(host.textContent).not.toContain('Runtime backend is not configurable');
+ expect(host.textContent).not.toContain('Mode: Auto');
+ expect(countOccurrences(host.textContent ?? '', 'Using Anthropic subscription')).toBe(1);
+ expect(countOccurrences(host.textContent ?? '', 'Detected from ANTHROPIC_API_KEY')).toBe(1);
+ });
+
+ it('keeps the API key icon container square', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ProviderRuntimeSettingsDialog, {
+ open: true,
+ onOpenChange: vi.fn(),
+ providers: [createAnthropicProvider()],
+ initialProviderId: 'anthropic',
+ onSelectBackend: vi.fn(),
+ onRefreshProvider: vi.fn(() => Promise.resolve(undefined)),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const icon = host.querySelector('[data-testid="provider-api-key-icon"]');
+ expect(icon).not.toBeNull();
+ expect(icon?.className).toContain('h-8');
+ expect(icon?.className).toContain('w-8');
+ expect(icon?.className).toContain('shrink-0');
});
it('switches Anthropic to API key mode from the connection cards', async () => {
@@ -772,7 +875,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
expect(storeState.updateConfig).toHaveBeenCalled();
expect(onRefreshProvider).toHaveBeenCalledWith('anthropic');
- expect(host.textContent).toContain('Mode: API key');
+ expect(host.textContent).not.toContain('Mode: API key');
+ expect(host.textContent).toContain('API keySelected');
expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.');
expect(host.textContent).not.toContain('Failed to update connection');
});
@@ -812,7 +916,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('Mode: Anthropic subscription');
+ expect(host.textContent).not.toContain('Mode: Anthropic subscription');
+ expect(host.textContent).toContain('Anthropic subscriptionSelected');
expect(host.textContent).toContain('Connect Anthropic');
expect(host.textContent).toContain(
'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.'
@@ -859,7 +964,8 @@ describe('ProviderRuntimeSettingsDialog Codex connection flows', () => {
authMode: 'api_key',
},
});
- expect(host.textContent).toContain('Mode: API key');
+ expect(host.textContent).not.toContain('Mode: API key');
+ expect(host.textContent).toContain('Selected');
expect(host.textContent).toContain('Disable API key mode');
expect(host.textContent).toContain('Connection updated, but failed to refresh provider status.');
});
diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts
index 47313756..24e1ca81 100644
--- a/test/renderer/components/team/TeamProvisioningBanner.test.ts
+++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts
@@ -14,6 +14,16 @@ const storeState = {
{ name: 'jack', agentType: 'developer' },
] as Array>,
},
+ teamDataCacheByName: {
+ 'northstar-core': {
+ members: [
+ { name: 'team-lead', agentType: 'team-lead' },
+ { name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined },
+ { name: 'bob', agentType: 'developer' },
+ { name: 'jack', agentType: 'developer' },
+ ],
+ },
+ } as Record> }>,
memberSpawnStatusesByTeam: {
'northstar-core': {},
},
@@ -26,6 +36,9 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress,
+ selectTeamDataForName: (_state: typeof storeState, teamName: string) =>
+ storeState.teamDataCacheByName[teamName] ??
+ (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null),
}));
vi.mock('zustand/react/shallow', () => ({
@@ -40,11 +53,13 @@ vi.mock('@renderer/components/ui/button', () => ({
vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({
ProvisioningProgressBlock: ({
currentStepIndex,
+ loading,
message,
successMessage,
successMessageSeverity,
}: {
currentStepIndex: number;
+ loading?: boolean;
message?: string | null;
successMessage?: string | null;
successMessageSeverity?: string;
@@ -54,6 +69,7 @@ vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({
{
'data-testid': 'progress-block',
'data-current-step-index': String(currentStepIndex),
+ 'data-loading': loading ? 'true' : 'false',
'data-success-severity': successMessageSeverity ?? '',
},
[successMessage, message].filter(Boolean).join(' ')
@@ -87,6 +103,9 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
{ name: 'bob', agentType: 'developer', runtimeAdvisory: undefined },
{ name: 'jack', agentType: 'developer', runtimeAdvisory: undefined },
];
+ storeState.teamDataCacheByName['northstar-core'] = {
+ members: [...storeState.selectedTeamData.members],
+ };
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
@@ -114,6 +133,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('2');
+ expect(block?.getAttribute('data-loading')).toBe('true');
expect(block?.textContent).toContain('Finishing launch');
expect(block?.textContent).toContain('3 teammates still joining');
@@ -123,6 +143,46 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
});
});
+ it('derives teammate counts from team cache even when the team is not selected', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ storeState.selectedTeamName = 'other-team';
+ storeState.progress = {
+ runId: 'run-2',
+ teamName: 'northstar-core',
+ state: 'ready',
+ startedAt: '2026-04-08T16:00:00.000Z',
+ message: 'Launch completed',
+ messageSeverity: undefined,
+ pid: 1234,
+ cliLogsTail: '',
+ assistantOutput: '',
+ };
+ storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record;
+ storeState.memberSpawnStatusesByTeam['northstar-core'] = {
+ alice: { status: 'waiting', launchState: 'starting' },
+ bob: { status: 'waiting', launchState: 'starting' },
+ jack: { status: 'waiting', launchState: 'starting' },
+ } as Record;
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' }));
+ await Promise.resolve();
+ });
+
+ const block = host.querySelector('[data-testid="progress-block"]');
+ expect(block?.getAttribute('data-current-step-index')).toBe('2');
+ expect(block?.textContent).toContain('3 teammates still joining');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
it('keeps Starting active until a real provisioning pid exists', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = {
diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts
index 2ae91058..c4f7aa0f 100644
--- a/test/renderer/constants/teamColors.test.ts
+++ b/test/renderer/constants/teamColors.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
+ getThemedText,
getSubagentTypeColorSet,
getTeamColorSet,
scaleColorAlpha,
@@ -37,8 +38,11 @@ describe('getTeamColorSet', () => {
it('generates a color set from hex strings', () => {
const result = getTeamColorSet('#ff5500');
expect(result.border).toBe('#ff5500');
- expect(result.badge).toBe('#ff550026');
- expect(result.text).toBe('#ff5500');
+ expect(result.badge).toBe('rgba(255, 85, 0, 0.15)');
+ expect(result.badgeLight).toBe('rgba(255, 85, 0, 0.12)');
+ expect(result.text).toBeTruthy();
+ expect(result.textLight).toBeTruthy();
+ expect(getThemedText(result, true)).not.toBe('#ff5500');
});
it('hashes unknown non-hex strings to a valid named color (not always blue)', () => {
diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts
index dd50312b..56a8dfe9 100644
--- a/test/renderer/utils/memberHelpers.test.ts
+++ b/test/renderer/utils/memberHelpers.test.ts
@@ -1,4 +1,5 @@
import {
+ buildMemberLaunchPresentation,
getLaunchAwarePresenceLabel,
getSpawnAwareDotClass,
getSpawnAwarePresenceLabel,
@@ -123,9 +124,7 @@ describe('memberHelpers spawn-aware presence', () => {
)
).toContain('bg-red-400');
- expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe(
- 'opacity-40'
- );
+ expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe('');
});
it('keeps runtime-pending teammates in starting state while launch is still settling', () => {
@@ -177,6 +176,36 @@ describe('memberHelpers spawn-aware presence', () => {
).toBe('ready');
});
+ it('derives runtime-pending and settling visual states from the same launch inputs', () => {
+ expect(
+ buildMemberLaunchPresentation({
+ member,
+ spawnStatus: 'online',
+ spawnLaunchState: 'runtime_pending_bootstrap',
+ spawnLivenessSource: 'process',
+ spawnRuntimeAlive: true,
+ runtimeAdvisory: undefined,
+ isLaunchSettling: false,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ }).launchVisualState
+ ).toBe('runtime_pending');
+
+ expect(
+ buildMemberLaunchPresentation({
+ member,
+ spawnStatus: 'online',
+ spawnLaunchState: 'confirmed_alive',
+ spawnLivenessSource: 'heartbeat',
+ spawnRuntimeAlive: true,
+ runtimeAdvisory: undefined,
+ isLaunchSettling: true,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ }).launchVisualState
+ ).toBe('settling');
+ });
+
it('renders unified retry advisory labels for provider retries', () => {
expect(
getMemberRuntimeAdvisoryLabel(
diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts
new file mode 100644
index 00000000..f24f8896
--- /dev/null
+++ b/test/renderer/utils/teamProvisioningPresentation.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
+
+describe('buildTeamProvisioningPresentation', () => {
+ it('uses a lead-online compact detail for ready teams without teammates', () => {
+ const presentation = buildTeamProvisioningPresentation({
+ progress: {
+ runId: 'run-1',
+ teamName: 'solo-team',
+ state: 'ready',
+ startedAt: '2026-04-13T10:00:00.000Z',
+ updatedAt: '2026-04-13T10:00:05.000Z',
+ message: 'Launch completed',
+ messageSeverity: undefined,
+ pid: 4321,
+ cliLogsTail: '',
+ assistantOutput: '',
+ },
+ members: [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ],
+ memberSpawnStatuses: {},
+ memberSpawnSnapshot: undefined,
+ });
+
+ expect(presentation?.compactTitle).toBe('Team launched');
+ expect(presentation?.compactDetail).toBe('Lead online');
+ });
+});