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'); + }); });