diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx
index a76f6b1f..85b2f4c0 100644
--- a/src/renderer/components/team/ProvisioningProgressBlock.tsx
+++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx
@@ -2,7 +2,15 @@ import { useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
-import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react';
+import {
+ AlertTriangle,
+ CheckCircle2,
+ ChevronDown,
+ ChevronRight,
+ Info,
+ Loader2,
+ X,
+} from 'lucide-react';
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
@@ -24,7 +32,7 @@ export interface ProvisioningProgressBlockProps {
/** Optional status message */
message?: string | null;
/** Visual severity for the message subtitle */
- messageSeverity?: 'error' | 'warning';
+ messageSeverity?: 'error' | 'warning' | 'info';
/** Visual tone (e.g. highlight errors) */
tone?: 'default' | 'error';
/** Whether Live output is expanded by default */
@@ -40,7 +48,7 @@ export interface ProvisioningProgressBlockProps {
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
successMessage?: string | null;
/** Visual tone for the status banner above the block. */
- successMessageSeverity?: 'success' | 'warning';
+ successMessageSeverity?: 'success' | 'warning' | 'info';
/** Dismiss handler — renders an X button in the block header top-right */
onDismiss?: (() => void) | null;
/** ISO timestamp when provisioning started */
@@ -204,6 +212,8 @@ export const ProvisioningProgressBlock = ({
{successMessageSeverity === 'warning' ? (
+ ) : successMessageSeverity === 'info' ? (
+
) : (
)}
@@ -212,7 +222,9 @@ export const ProvisioningProgressBlock = ({
'flex-1 text-xs',
successMessageSeverity === 'warning'
? 'text-amber-400'
- : 'text-[var(--step-success-text)]'
+ : successMessageSeverity === 'info'
+ ? 'text-sky-400'
+ : 'text-[var(--step-success-text)]'
)}
>
{successMessage}
@@ -274,7 +286,9 @@ export const ProvisioningProgressBlock = ({
? 'text-red-400'
: messageSeverity === 'warning'
? 'text-amber-400'
- : 'text-[var(--color-text-muted)]'
+ : messageSeverity === 'info'
+ ? 'text-sky-400'
+ : 'text-[var(--color-text-muted)]'
)}
>
{message}
diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx
index e2d84edc..8b7a2b5b 100644
--- a/src/renderer/components/team/TeamProvisioningBanner.tsx
+++ b/src/renderer/components/team/TeamProvisioningBanner.tsx
@@ -151,10 +151,10 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
: allTeammatesConfirmedAlive
? `Team provisioned - all ${fallbackTeammateCount} teammates joined`
: hasMembersStillJoining
- ? `Waiting for ${joiningPhrase.replace('still joining', 'to finish joining')}`
+ ? joiningPhrase
: 'Team provisioned - teammates are still joining';
const readyDetailSeverity =
- failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
+ failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : undefined;
const readyMessage =
failedSpawnCount > 0
? `Launch finished with errors - ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
@@ -163,8 +163,8 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
: allTeammatesConfirmedAlive
? `Team launched - all ${fallbackTeammateCount} teammates joined`
: hasMembersStillJoining
- ? `Team launched - ${joiningPhrase}`
- : 'Team launched - teammates are still joining';
+ ? 'Finishing launch'
+ : 'Finishing launch';
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
return (
@@ -183,7 +183,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
onCancel={null}
successMessage={readyMessage}
successMessageSeverity={
- failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : 'success'
+ failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'info' : 'success'
}
onDismiss={() => setDismissed(true)}
/>
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
index 04e7b629..195a6344 100644
--- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
@@ -102,7 +102,7 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
return 'ERR';
case 'pending':
default:
- return 'queued';
+ return 'waiting';
}
}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 5361dc8a..de346518 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -138,6 +138,12 @@ export const MemberCard = ({
spawnLaunchState !== 'failed_to_start' &&
!activityTask;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
+ const showRuntimeAdvisoryBadge =
+ !isRemoved &&
+ Boolean(runtimeAdvisoryLabel) &&
+ !showStartingBadge &&
+ spawnStatus !== 'error' &&
+ (Boolean(activityTask) || !isAwaitingReply);
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
return (
@@ -277,6 +283,24 @@ export const MemberCard = ({
{spawnError ?? 'Spawn failed'}
+ ) : showRuntimeAdvisoryBadge ? (
+
+
+
+
+
+ {runtimeAdvisoryLabel}
+
+
+
+
+ {runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
+
+
) : !activityTask ? (
{presenceLabel}
diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx
index ba991a7e..bf911bd5 100644
--- a/src/renderer/components/team/members/MemberHoverCard.tsx
+++ b/src/renderer/components/team/members/MemberHoverCard.tsx
@@ -14,6 +14,7 @@ import {
agentAvatarUrl,
displayMemberName,
getLaunchAwarePresenceLabel,
+ getMemberRuntimeAdvisoryTitle,
getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
@@ -124,6 +125,10 @@ export const MemberHoverCard = ({
false,
isLeadMember(member) ? leadActivity : undefined
);
+ const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
+ member.runtimeAdvisory,
+ member.providerId
+ );
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
@@ -167,6 +172,7 @@ export const MemberHoverCard = ({
= {
diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts
index d0f777f6..47313756 100644
--- a/test/renderer/components/team/TeamProvisioningBanner.test.ts
+++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts
@@ -40,10 +40,12 @@ vi.mock('@renderer/components/ui/button', () => ({
vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({
ProvisioningProgressBlock: ({
currentStepIndex,
+ message,
successMessage,
successMessageSeverity,
}: {
currentStepIndex: number;
+ message?: string | null;
successMessage?: string | null;
successMessageSeverity?: string;
}) =>
@@ -54,7 +56,7 @@ vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({
'data-current-step-index': String(currentStepIndex),
'data-success-severity': successMessageSeverity ?? '',
},
- successMessage ?? ''
+ [successMessage, message].filter(Boolean).join(' ')
),
}));
@@ -112,6 +114,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?.textContent).toContain('Finishing launch');
expect(block?.textContent).toContain('3 teammates still joining');
await act(async () => {
@@ -264,7 +267,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
});
});
- it('keeps warning severity while runtimes are online but teammate contact is still pending', async () => {
+ it('uses info severity while runtimes are online but teammate contact is still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
@@ -290,7 +293,8 @@ 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-success-severity')).toBe('warning');
+ expect(block?.getAttribute('data-success-severity')).toBe('info');
+ expect(block?.textContent).toContain('Finishing launch');
expect(block?.textContent).toContain('3 teammates still joining');
await act(async () => {
@@ -377,8 +381,9 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
});
const block = host.querySelector('[data-testid="progress-block"]');
+ expect(block?.textContent).toContain('Finishing launch');
expect(block?.textContent).toContain('2 teammates still joining');
- expect(block?.getAttribute('data-success-severity')).toBe('warning');
+ expect(block?.getAttribute('data-success-severity')).toBe('info');
await act(async () => {
root.unmount();
diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts
new file mode 100644
index 00000000..6d5f69cf
--- /dev/null
+++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts
@@ -0,0 +1,38 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ ProvisioningProviderStatusList,
+ createInitialProviderChecks,
+} from '@renderer/components/team/dialogs/ProvisioningProviderStatusList';
+
+describe('ProvisioningProviderStatusList', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('shows waiting for pending provider checks', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ProvisioningProviderStatusList, {
+ checks: createInitialProviderChecks(['anthropic', 'codex']),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Anthropic: waiting');
+ expect(host.textContent).toContain('Codex: waiting');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+});
diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts
index fa012866..ff2426eb 100644
--- a/test/renderer/components/team/members/MemberCard.test.ts
+++ b/test/renderer/components/team/members/MemberCard.test.ts
@@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import type { ResolvedTeamMember } from '@shared/types';
+import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({
@@ -49,6 +49,13 @@ const member: ResolvedTeamMember = {
removedAt: undefined,
};
+const currentTask: TeamTaskWithKanban = {
+ id: 'task-1',
+ displayId: 'abc12345',
+ subject: 'Build calculator UI',
+ status: 'in_progress',
+} as unknown as TeamTaskWithKanban;
+
describe('MemberCard starting-state visuals', () => {
afterEach(() => {
document.body.innerHTML = '';
@@ -125,6 +132,47 @@ describe('MemberCard starting-state visuals', () => {
});
});
+ it('keeps runtime retry visible even while the teammate already has an active task', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberCard, {
+ member: {
+ ...member,
+ currentTaskId: currentTask.id,
+ runtimeAdvisory: {
+ kind: 'sdk_retrying',
+ observedAt: '2026-04-07T09:00:00.000Z',
+ retryUntil: '2099-04-07T09:00:45.000Z',
+ retryDelayMs: 45_000,
+ reasonCode: 'quota_exhausted',
+ message: 'Gemini cli backend error: capacity exceeded.',
+ },
+ },
+ memberColor: 'blue',
+ currentTask,
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'online',
+ spawnLaunchState: 'confirmed_alive',
+ spawnRuntimeAlive: true,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Gemini quota retry');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
it('keeps the starting skeleton visible while a runtime is alive but still joining', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
diff --git a/test/renderer/components/team/members/MemberDetailHeader.test.ts b/test/renderer/components/team/members/MemberDetailHeader.test.ts
index 57bb9890..0658e1a9 100644
--- a/test/renderer/components/team/members/MemberDetailHeader.test.ts
+++ b/test/renderer/components/team/members/MemberDetailHeader.test.ts
@@ -29,6 +29,7 @@ const member: ResolvedTeamMember = {
lastActiveAt: null,
messageCount: 0,
color: 'blue',
+ providerId: 'gemini',
agentType: 'reviewer',
role: 'Reviewer',
removedAt: undefined,
@@ -98,4 +99,43 @@ describe('MemberDetailHeader spawn-aware presence', () => {
await Promise.resolve();
});
});
+
+ it('shows runtime retry text after the teammate has already joined', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberDetailHeader, {
+ member: {
+ ...member,
+ runtimeAdvisory: {
+ kind: 'sdk_retrying',
+ observedAt: '2026-04-07T09:00:00.000Z',
+ retryUntil: '2099-04-07T09:00:45.000Z',
+ retryDelayMs: 45_000,
+ reasonCode: 'quota_exhausted',
+ message: 'Gemini cli backend error: capacity exceeded.',
+ },
+ },
+ isTeamAlive: true,
+ isTeamProvisioning: false,
+ spawnStatus: 'online',
+ spawnLaunchState: 'confirmed_alive',
+ spawnRuntimeAlive: true,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Gemini quota retry');
+ expect(host.textContent).not.toContain('idle');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
});
diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts
index 8a595e47..280c0b17 100644
--- a/test/renderer/components/team/members/MemberHoverCard.test.ts
+++ b/test/renderer/components/team/members/MemberHoverCard.test.ts
@@ -12,6 +12,7 @@ const member: ResolvedTeamMember = {
lastActiveAt: null,
messageCount: 0,
color: 'blue',
+ providerId: 'gemini',
agentType: 'reviewer',
role: 'Reviewer',
removedAt: undefined,
@@ -185,4 +186,50 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve();
});
});
+
+ it('surfaces runtime retry state in the hover card after the teammate has already joined', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ storeState.selectedTeamData.members = [
+ {
+ ...member,
+ runtimeAdvisory: {
+ kind: 'sdk_retrying',
+ observedAt: '2026-04-09T10:00:00.000Z',
+ retryUntil: '2099-04-09T10:00:45.000Z',
+ retryDelayMs: 45_000,
+ reasonCode: 'quota_exhausted',
+ message: 'Gemini cli backend error: capacity exceeded.',
+ },
+ },
+ ];
+ storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
+ status: 'online',
+ launchState: 'confirmed_alive',
+ updatedAt: '2026-04-09T10:00:00.000Z',
+ runtimeAlive: true,
+ livenessSource: 'heartbeat',
+ };
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(MemberHoverCard, {
+ name: 'alice',
+ children: React.createElement('button', { type: 'button' }, 'alice'),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Gemini quota retry');
+ expect(host.textContent).not.toContain('idle');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
});
diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts
index 55e5875a..dd50312b 100644
--- a/test/renderer/utils/memberHelpers.test.ts
+++ b/test/renderer/utils/memberHelpers.test.ts
@@ -300,4 +300,28 @@ describe('memberHelpers spawn-aware presence', () => {
)
).toBe('starting');
});
+
+ it('keeps retry advisory visible after contact when the teammate is otherwise just idle or ready', () => {
+ expect(
+ getLaunchAwarePresenceLabel(
+ member,
+ 'online',
+ 'confirmed_alive',
+ 'heartbeat',
+ true,
+ {
+ kind: 'sdk_retrying',
+ observedAt: '2026-04-07T09:00:00.000Z',
+ retryUntil: '2099-04-07T09:00:45.000Z',
+ retryDelayMs: 45_000,
+ reasonCode: 'quota_exhausted',
+ message: 'Gemini cli backend error: capacity exceeded.',
+ },
+ false,
+ true,
+ false,
+ undefined
+ )
+ ).toContain('Gemini quota retry');
+ });
});