fix(team-ui): clarify launch progress and retry states

This commit is contained in:
iliya 2026-04-10 12:28:52 +03:00
parent a03c22aace
commit 0dd4746700
13 changed files with 280 additions and 25 deletions

View file

@ -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 = ({
<div className="mb-1.5 flex items-center gap-2">
{successMessageSeverity === 'warning' ? (
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
) : successMessageSeverity === 'info' ? (
<Info size={14} className="shrink-0 text-sky-400" />
) : (
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
)}
@ -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}

View file

@ -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)}
/>

View file

@ -102,7 +102,7 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
return 'ERR';
case 'pending':
default:
return 'queued';
return 'waiting';
}
}

View file

@ -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 = ({
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
) : showRuntimeAdvisoryBadge ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-amber-400" />
<Badge
variant="secondary"
className="shrink-0 bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-amber-300"
title={runtimeAdvisoryTitle}
>
{runtimeAdvisoryLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</TooltipContent>
</Tooltip>
) : !activityTask ? (
<Badge
variant="secondary"

View file

@ -8,6 +8,7 @@ import {
agentAvatarUrl,
displayMemberName,
getLaunchAwarePresenceLabel,
getMemberRuntimeAdvisoryTitle,
getSpawnAwareDotClass,
} from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -82,6 +83,10 @@ export const MemberDetailHeader = ({
isTeamProvisioning,
leadActivity
);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
member.runtimeAdvisory,
member.providerId
);
const canEditRole =
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
@ -140,6 +145,7 @@ export const MemberDetailHeader = ({
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={runtimeAdvisoryTitle}
>
{presenceLabel}
</Badge>

View file

@ -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 = ({
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0 text-[10px] font-normal leading-tight"
title={runtimeAdvisoryTitle}
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight),

View file

@ -384,15 +384,7 @@ export function getLaunchAwarePresenceLabel(
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
const advisoryLabel =
!keepLaunchSettlingVisuals && spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId)
: null;
if (advisoryLabel) {
return advisoryLabel;
}
return getSpawnAwarePresenceLabel(
const basePresenceLabel = getSpawnAwarePresenceLabel(
member,
spawnStatus,
spawnLaunchState,
@ -403,6 +395,17 @@ export function getLaunchAwarePresenceLabel(
isTeamProvisioning,
leadActivity
);
if (
basePresenceLabel === 'starting' ||
basePresenceLabel === 'connecting' ||
basePresenceLabel === 'spawn failed' ||
basePresenceLabel === 'offline' ||
basePresenceLabel === 'terminated'
) {
return basePresenceLabel;
}
const advisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId);
return advisoryLabel ?? basePresenceLabel;
}
export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = {

View file

@ -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();

View file

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

View file

@ -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');

View file

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

View file

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

View file

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