feat(team-ui): clarify launch and retry member states

This commit is contained in:
iliya 2026-04-09 21:16:24 +03:00
parent 32ec3a6123
commit 21e9fb8c90
21 changed files with 1502 additions and 452 deletions

View file

@ -9,6 +9,35 @@ const LOOKBACK_MS = 10 * 60 * 1000;
const CACHE_TTL_MS = 5_000;
const TAIL_BYTES = 64 * 1024;
const BATCH_WARN_MS = 200;
const QUOTA_EXHAUSTED_TOKENS = [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
];
const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429'];
const AUTH_ERROR_TOKENS = [
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
];
const NETWORK_ERROR_TOKENS = [
'timeout',
'timed out',
'network',
'connection',
'econn',
'enotfound',
'fetch failed',
];
const PROVIDER_OVERLOADED_TOKENS = [
'overloaded',
'temporarily unavailable',
'service unavailable',
'503',
];
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
@ -23,6 +52,33 @@ interface CachedTeamBatchAdvisories {
expiresAt: number;
}
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
return tokens.some((token) => value.includes(token));
}
function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory['reasonCode'] {
const normalized = message?.trim().toLowerCase();
if (!normalized) {
return 'unknown';
}
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
return 'quota_exhausted';
}
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
return 'rate_limited';
}
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
return 'auth_error';
}
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
return 'network_error';
}
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded';
}
return 'backend_error';
}
export class TeamMemberRuntimeAdvisoryService {
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
@ -308,6 +364,7 @@ export class TeamMemberRuntimeAdvisoryService {
observedAt: new Date(observedAt).toISOString(),
retryUntil: new Date(retryUntil).toISOString(),
retryDelayMs: retryInMs,
reasonCode: classifyRetryReason(message),
...(message ? { message } : {}),
};
} catch {

View file

@ -15,13 +15,16 @@ import {
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import {
getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive,
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
@ -95,6 +98,7 @@ import {
} from './teamSessionFetchGuards';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
@ -158,6 +162,7 @@ function areResolvedMembersEqual(
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs ||
prevMember.runtimeAdvisory?.reasonCode !== nextMember.runtimeAdvisory?.reasonCode ||
prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message
) {
return false;
@ -287,6 +292,24 @@ type TeamSidebarRailBridgeProps = Omit<
> & {
messagesPanelProps: SharedTeamMessagesPanelProps;
};
interface LeadContextWatcherProps {
teamName: string;
tabId: string | null;
projectId: string | null;
leadSessionId: string | null;
sessionHistoryKey: string;
isThisTabActive: boolean;
isTeamAlive?: boolean;
sessions: readonly Session[];
sessionsLoading: boolean;
}
interface LeadContextBridgeProps {
teamName: string;
tabId: string | null;
projectId: string | null;
leadSessionId: string | null;
fallbackProjectRoot?: string;
}
function buildMemberSpawnStatusMap(
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
@ -336,23 +359,332 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
return null;
});
const LeadContextWatcher = memo(function LeadContextWatcher({
teamName,
tabId,
projectId,
leadSessionId,
sessionHistoryKey,
isThisTabActive,
isTeamAlive,
sessions,
sessionsLoading,
}: LeadContextWatcherProps): null {
const fetchSessionDetail = useStore((s) => s.fetchSessionDetail);
const missingLeadSessionFetchKeyRef = useRef<string | null>(null);
const missingLeadSessionFetchKey = useMemo(
() => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`,
[teamName, projectId, leadSessionId, sessionHistoryKey]
);
useEffect(() => {
missingLeadSessionFetchKeyRef.current = null;
}, [missingLeadSessionFetchKey]);
useEffect(() => {
if (!isThisTabActive) return;
if (!tabId || !projectId || !leadSessionId) return;
const leadSessionMissing = isLeadSessionMissing({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
});
if (leadSessionMissing) {
missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey;
return;
}
const fetchLeadSessionDetail = () => {
const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
suppressionKey: missingLeadSessionFetchKeyRef.current,
currentKey: missingLeadSessionFetchKey,
});
if (suppressRepeatedFetch) {
return;
}
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
};
fetchLeadSessionDetail();
if (!isTeamAlive) return;
const id = window.setInterval(() => {
fetchLeadSessionDetail();
}, 10_000);
return () => window.clearInterval(id);
}, [
fetchSessionDetail,
isTeamAlive,
isThisTabActive,
leadSessionId,
missingLeadSessionFetchKey,
projectId,
sessions,
sessionsLoading,
tabId,
]);
return null;
});
const LeadContextBridge = memo(function LeadContextBridge({
teamName,
tabId,
projectId,
leadSessionId,
fallbackProjectRoot,
}: LeadContextBridgeProps): React.JSX.Element | null {
const {
leadTabData,
isContextPanelVisible,
selectedContextPhase,
setContextPanelVisibleForTab,
setSelectedContextPhaseForTab,
fetchSessionDetail,
} = useStore(
useShallow((s) => ({
leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null,
isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false,
selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null,
setContextPanelVisibleForTab: s.setContextPanelVisibleForTab,
setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab,
fetchSessionDetail: s.fetchSessionDetail,
}))
);
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
const setContextPanelVisible = useCallback(
(visible: boolean) => {
if (!tabId) return;
setContextPanelVisibleForTab(tabId, visible);
},
[setContextPanelVisibleForTab, tabId]
);
const setSelectedContextPhase = useCallback(
(phase: number | null) => {
if (!tabId) return;
setSelectedContextPhaseForTab(tabId, phase);
},
[setSelectedContextPhaseForTab, tabId]
);
const leadSessionDetail = leadTabData?.sessionDetail ?? null;
const leadConversation = leadTabData?.conversation ?? null;
const leadSessionContextStats = leadTabData?.sessionContextStats ?? null;
const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null;
const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false;
const leadSessionLoaded = Boolean(
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
);
const leadSubagentCostUsd = useMemo(() => {
const processes = leadSessionDetail?.processes;
if (!processes || processes.length === 0) return undefined;
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
return total > 0 ? total : undefined;
}, [leadSessionDetail?.processes]);
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
}
const effectivePhase = selectedContextPhase;
let targetAiGroupId: string | undefined;
if (effectivePhase !== null && leadSessionPhaseInfo) {
const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase);
if (phase) {
targetAiGroupId = phase.lastAIGroupId;
}
}
if (!targetAiGroupId) {
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
if (lastAiItem?.type !== 'ai') {
return {
allContextInjections: [] as ContextInjection[],
lastAiGroupTotalTokens: undefined,
};
}
targetAiGroupId = lastAiItem.group.id;
}
const stats = leadSessionContextStats.get(targetAiGroupId);
const injections = stats?.accumulatedInjections ?? [];
let totalTokens: number | undefined;
const targetItem = leadConversation.items.find(
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
);
if (targetItem?.type === 'ai') {
const responses = targetItem.group.responses || [];
for (let i = responses.length - 1; i >= 0; i--) {
const msg = responses[i];
if (msg.type === 'assistant' && msg.usage) {
const usage = msg.usage;
totalTokens =
(usage.input_tokens ?? 0) +
(usage.output_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0);
break;
}
}
}
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [
leadConversation,
leadSessionContextStats,
leadSessionLoaded,
leadSessionPhaseInfo,
selectedContextPhase,
]);
const visibleContextTokens = useMemo(
() => sumContextInjectionTokens(allContextInjections),
[allContextInjections]
);
const visibleContextPercentLabel = useMemo(
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
[visibleContextTokens, lastAiGroupTotalTokens]
);
if (!leadSessionId) {
return null;
}
return (
<>
{isContextPanelVisible && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={leadSessionDetail?.metrics}
subagentCostUsd={leadSubagentCostUsd}
phaseInfo={leadSessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
side="left"
/>
) : (
<div
className="flex h-full flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label={`Close ${teamName} context panel`}
>
×
</button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
</p>
</div>
</div>
)}
</div>
)}
<div
className="pointer-events-none fixed bottom-4 z-20"
style={{ left: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
>
<button
onClick={() => {
const next = !isContextPanelVisible;
setContextPanelVisible(next);
if (tabId && projectId) {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex w-fit items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
: isContextButtonHovered
? 'var(--context-btn-bg-hover)'
: 'var(--context-btn-bg)',
color: isContextPanelVisible
? 'var(--context-btn-active-text)'
: 'var(--color-text-secondary)',
}}
title={
leadSessionLoaded
? `Session: ${leadSessionId}`
: leadSessionLoading
? 'Loading context…'
: leadSessionId
}
>
{visibleContextPercentLabel ?? 'Context'}
</button>
</div>
</>
);
});
const TeamMemberListBridge = memo(function TeamMemberListBridge({
teamName,
...props
}: TeamMemberListBridgeProps): React.JSX.Element {
const { leadActivity, memberSpawnStatuses } = useStore(
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
progress: getCurrentProvisioningProgressForTeam(s, teamName),
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
}))
);
const memberSpawnStatusMap = useMemo(
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
[memberSpawnStatuses]
);
const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') {
return false;
}
return getLaunchJoinState(
getLaunchJoinMilestonesFromMembers({
members: props.members,
memberSpawnStatuses,
memberSpawnSnapshot,
})
).hasMembersStillJoining;
}, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members]);
return (
<MemberList {...props} leadActivity={leadActivity} memberSpawnStatuses={memberSpawnStatusMap} />
<MemberList
{...props}
leadActivity={leadActivity}
memberSpawnStatuses={memberSpawnStatusMap}
isLaunchSettling={isLaunchSettling}
/>
);
});
@ -404,16 +736,36 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
member,
...props
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
const leadActivity = useStore((s) => s.leadActivityByTeam[teamName]);
const spawnEntry = useStore((s) =>
member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined
);
const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } =
useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
progress: getCurrentProvisioningProgressForTeam(s, teamName),
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
}))
);
const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') {
return false;
}
return getLaunchJoinState(
getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
})
).hasMembersStillJoining;
}, [memberSpawnSnapshot, memberSpawnStatuses, members, progress?.state]);
return (
<MemberDetailDialog
{...props}
teamName={teamName}
member={member}
isLaunchSettling={isLaunchSettling}
leadActivity={leadActivity}
spawnEntry={spawnEntry}
/>
@ -648,7 +1000,6 @@ export const TeamDetailView = ({
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const missingLeadSessionFetchKeyRef = useRef<string | null>(null);
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
sessionId: null,
selectedOwners: new Set(),
@ -662,7 +1013,6 @@ export const TeamDetailView = ({
error,
projects,
repositoryGroups,
fetchSessionDetail,
initTabUIState,
selectTeam,
updateKanban,
@ -705,7 +1055,6 @@ export const TeamDetailView = ({
useShallow((s) => ({
projects: s.projects,
repositoryGroups: s.repositoryGroups,
fetchSessionDetail: s.fetchSessionDetail,
initTabUIState: s.initTabUIState,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
@ -750,17 +1099,9 @@ export const TeamDetailView = ({
}))
);
// Per-tab UI state (context panel visibility + selected phase)
const {
tabId,
isContextPanelVisible,
setContextPanelVisible,
selectedContextPhase,
setSelectedContextPhase,
} = useTabUI();
const tabId = useTabIdOptional();
const activeTabId = useStore((s) => s.activeTabId);
const isThisTabActive = tabId ? activeTabId === tabId : false;
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
useEffect(() => {
const now = Date.now();
@ -900,160 +1241,11 @@ export const TeamDetailView = ({
[projects, repositoryGroups, data?.config.projectPath]
);
// Lead session context panel (reuses the same session context pipeline for exact stats)
const leadSessionId = data?.config.leadSessionId ?? null;
const leadTabData = useStore(useShallow((s) => (tabId ? s.tabSessionData[tabId] : null)));
const leadSessionDetail = leadTabData?.sessionDetail ?? null;
const leadConversation = leadTabData?.conversation ?? null;
const leadSessionContextStats = leadTabData?.sessionContextStats ?? null;
const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null;
const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false;
const leadSessionLoaded = Boolean(
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
);
const sessionHistoryKey = useMemo(
() => (data?.config.sessionHistory ?? []).join('|'),
[data?.config.sessionHistory]
);
const missingLeadSessionFetchKey = useMemo(
() => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`,
[teamName, projectId, leadSessionId, sessionHistoryKey]
);
const leadSubagentCostUsd = useMemo(() => {
const processes = leadSessionDetail?.processes;
if (!processes || processes.length === 0) return undefined;
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
return total > 0 ? total : undefined;
}, [leadSessionDetail?.processes]);
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
}
// Determine which phase to show
const effectivePhase = selectedContextPhase;
// If a specific phase is selected, find the last AI group in that phase
let targetAiGroupId: string | undefined;
if (effectivePhase !== null && leadSessionPhaseInfo) {
const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase);
if (phase) {
targetAiGroupId = phase.lastAIGroupId;
}
}
// Default: use the last AI group overall
if (!targetAiGroupId) {
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
if (lastAiItem?.type !== 'ai') {
return {
allContextInjections: [] as ContextInjection[],
lastAiGroupTotalTokens: undefined,
};
}
targetAiGroupId = lastAiItem.group.id;
}
const stats = leadSessionContextStats.get(targetAiGroupId);
const injections = stats?.accumulatedInjections ?? [];
// Get total tokens from the target AI group
let totalTokens: number | undefined;
const targetItem = leadConversation.items.find(
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
);
if (targetItem?.type === 'ai') {
const responses = targetItem.group.responses || [];
for (let i = responses.length - 1; i >= 0; i--) {
const msg = responses[i];
if (msg.type === 'assistant' && msg.usage) {
const usage = msg.usage;
totalTokens =
(usage.input_tokens ?? 0) +
(usage.output_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0);
break;
}
}
}
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [
leadSessionLoaded,
leadSessionContextStats,
leadConversation,
selectedContextPhase,
leadSessionPhaseInfo,
]);
const visibleContextTokens = useMemo(
() => sumContextInjectionTokens(allContextInjections),
[allContextInjections]
);
const visibleContextPercentLabel = useMemo(
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
[visibleContextTokens, lastAiGroupTotalTokens]
);
useEffect(() => {
missingLeadSessionFetchKeyRef.current = null;
}, [missingLeadSessionFetchKey]);
// Keep lead-session context fresh in the background while the team tab is active.
// This keeps the button value current even when the panel is closed.
// For offline teams: fetch once on mount so the percentage shows immediately.
// For alive teams: fetch on mount + periodic refresh every 30s.
useEffect(() => {
if (!isThisTabActive) return;
if (!tabId || !projectId || !leadSessionId) return;
const leadSessionMissing = isLeadSessionMissing({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
});
if (leadSessionMissing) {
missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey;
return;
}
const fetchLeadSessionDetail = () => {
const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({
leadSessionId,
projectId,
sessionsLoading,
knownSessions: sessions,
suppressionKey: missingLeadSessionFetchKeyRef.current,
currentKey: missingLeadSessionFetchKey,
});
if (suppressRepeatedFetch) {
return;
}
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
};
fetchLeadSessionDetail();
if (!data?.isAlive) return;
const id = window.setInterval(() => {
fetchLeadSessionDetail();
}, 10_000);
return () => window.clearInterval(id);
}, [
isThisTabActive,
tabId,
projectId,
leadSessionId,
data?.isAlive,
fetchSessionDetail,
sessions,
sessionsLoading,
missingLeadSessionFetchKey,
]);
// Keep team message state fresh while we are explicitly waiting for a reply.
// Use a delayed single-shot refresh instead of a tight polling loop so we
@ -1529,6 +1721,19 @@ export const TeamDetailView = ({
isTeamAlive={data?.isAlive}
/>
);
const leadContextWatcher = (
<LeadContextWatcher
teamName={teamName}
tabId={tabId}
projectId={projectId}
leadSessionId={leadSessionId}
sessionHistoryKey={sessionHistoryKey}
isThisTabActive={isThisTabActive}
isTeamAlive={data?.isAlive}
sessions={sessions}
sessionsLoading={sessionsLoading}
/>
);
const renderBody = (): React.JSX.Element => {
if ((loading && !data) || (data && data.teamName !== teamName)) {
@ -1634,56 +1839,13 @@ export const TeamDetailView = ({
return (
<>
<div className="flex size-full overflow-hidden">
{/* Context panel sidebar (left) */}
{isContextPanelVisible && leadSessionId && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={leadSessionDetail?.metrics}
subagentCostUsd={leadSubagentCostUsd}
phaseInfo={leadSessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
side="left"
/>
) : (
<div
className="flex h-full flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">
Visible Context
</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label="Close panel"
>
×
</button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
</p>
</div>
</div>
)}
</div>
)}
<LeadContextBridge
teamName={teamName}
tabId={tabId}
projectId={projectId}
leadSessionId={leadSessionId}
fallbackProjectRoot={data.config.projectPath}
/>
{/* Messages sidebar (left, after context panel) */}
<TeamSidebarHost
@ -1714,46 +1876,6 @@ export const TeamDetailView = ({
className="relative size-full flex-1 overflow-auto p-4"
data-team-name={teamName}
>
{/* Context button pinned to bottom-left of viewport */}
{leadSessionId && (
<div
className="pointer-events-none fixed bottom-4 z-20"
style={{ left: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
>
<button
onClick={() => {
const next = !isContextPanelVisible;
setContextPanelVisible(next);
if (tabId && projectId && leadSessionId) {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex w-fit items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
: isContextButtonHovered
? 'var(--context-btn-bg-hover)'
: 'var(--context-btn-bg)',
color: isContextPanelVisible
? 'var(--context-btn-active-text)'
: 'var(--color-text-secondary)',
}}
title={
leadSessionLoaded
? `Session: ${leadSessionId}`
: leadSessionLoading
? 'Loading context…'
: leadSessionId
}
>
{visibleContextPercentLabel ?? 'Context'}
</button>
</div>
)}
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
{headerColorSet ? (
<div
@ -2620,6 +2742,7 @@ export const TeamDetailView = ({
return (
<>
{spawnStatusWatcher}
{leadContextWatcher}
{renderBody()}
</>
);

View file

@ -3,36 +3,16 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { isLeadMember } from '@shared/utils/leadDetection';
import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
import { getDisplayStepIndex } from './provisioningSteps';
function formatRetryingRuntimePhrase(retryingRuntimeCount: number): string {
if (retryingRuntimeCount <= 0) {
return '';
}
return `${retryingRuntimeCount} teammate${retryingRuntimeCount === 1 ? '' : 's'} retrying provider capacity`;
}
function formatProcessOnlyAlivePhrase(
processOnlyAliveCount: number,
retryingRuntimeCount: number
): string {
if (processOnlyAliveCount <= 0) {
return '';
}
if (retryingRuntimeCount >= processOnlyAliveCount) {
return formatRetryingRuntimePhrase(processOnlyAliveCount);
}
const plainOnlineCount = processOnlyAliveCount - retryingRuntimeCount;
if (retryingRuntimeCount <= 0) {
return `${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`;
}
return `${formatRetryingRuntimePhrase(retryingRuntimeCount)}, ${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`;
}
import {
DISPLAY_COMPLETE_STEP_INDEX,
getDisplayStepIndex,
getLaunchJoinMilestonesFromMembers,
getLaunchJoinState,
} from './provisioningSteps';
interface TeamProvisioningBannerProps {
teamName: string;
@ -91,9 +71,37 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
progress.state === 'finalizing' ||
progress.state === 'verifying';
const progressStepIndex = getDisplayStepIndex(progress.state);
const {
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
} = getLaunchJoinMilestonesFromMembers({
members: teamMembers ?? [],
memberSpawnStatuses,
memberSpawnSnapshot,
});
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
getLaunchJoinState({
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
});
const progressStepIndex = getDisplayStepIndex({
progress,
expectedTeammateCount: fallbackTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
});
// Remember last active step so we can show it as the error location when failed
// Keep the error marker aligned to the last meaningful UI milestone, not the
// raw backend phase enum. The launch flow now moves through some backend
// states too quickly for the old enum mapping to stay user-meaningful.
if (progressStepIndex >= 0 && !isFailed) {
lastActiveStepRef.current = progressStepIndex;
}
@ -130,92 +138,33 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
);
}
const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member));
const expectedTeammateCount = memberSpawnSnapshot?.expectedMembers?.length;
const fallbackTeammateCount = expectedTeammateCount ?? teammates.length;
const snapshotSummary = memberSpawnSnapshot?.summary;
const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter(
([, entry]) => entry.launchState === 'failed_to_start'
);
const failedSpawnCount = snapshotSummary?.failedCount ?? failedSpawnEntries.length;
const heartbeatConfirmedCount =
snapshotSummary?.confirmedCount ??
teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.launchState === 'confirmed_alive';
}).length;
const processOnlyAliveCount =
snapshotSummary?.runtimeAlivePendingCount ??
teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true;
}).length;
const retryingRuntimeCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return (
entry?.launchState === 'runtime_pending_bootstrap' &&
entry.runtimeAlive === true &&
member.runtimeAdvisory?.kind === 'sdk_retrying'
);
}).length;
const pendingSpawnCount = snapshotSummary
? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount)
: teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return (
entry?.launchState === 'starting' ||
(entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true)
);
}).length;
const allTeammatesConfirmedAlive =
fallbackTeammateCount > 0 &&
failedSpawnCount === 0 &&
heartbeatConfirmedCount === fallbackTeammateCount;
const allPendingRuntimesStarted =
fallbackTeammateCount > 0 &&
heartbeatConfirmedCount === 0 &&
processOnlyAliveCount === fallbackTeammateCount &&
pendingSpawnCount === 0;
const hasMembersStillJoining =
fallbackTeammateCount > 0 &&
failedSpawnCount === 0 &&
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
if (isReady) {
const processOnlyAlivePhrase = formatProcessOnlyAlivePhrase(
processOnlyAliveCount,
retryingRuntimeCount
);
const joiningPhrase =
remainingJoinCount === 1
? '1 teammate still joining'
: `${remainingJoinCount} teammates still joining`;
const readyDetailMessage =
failedSpawnCount > 0
? progress.message
: fallbackTeammateCount === 0
? 'Team provisioned lead online'
? 'Team provisioned - lead online'
: allTeammatesConfirmedAlive
? `Team provisioned — all ${fallbackTeammateCount} teammates made contact`
: allPendingRuntimesStarted
? processOnlyAlivePhrase
? `Team provisioned — ${processOnlyAlivePhrase}`
: 'Team provisioned — teammates online'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team provisioned — teammates are still starting';
? `Team provisioned - all ${fallbackTeammateCount} teammates joined`
: hasMembersStillJoining
? `Waiting for ${joiningPhrase.replace('still joining', 'to finish joining')}`
: 'Team provisioned - teammates are still joining';
const readyDetailSeverity =
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
const readyMessage =
failedSpawnCount > 0
? `Launch finished with errors ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
? `Launch finished with errors - ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start`
: fallbackTeammateCount === 0
? 'Team launched lead online'
? 'Team launched - lead online'
: allTeammatesConfirmedAlive
? `Team launched — all ${fallbackTeammateCount} teammates made contact`
: allPendingRuntimesStarted
? processOnlyAlivePhrase
? `Team launched — ${processOnlyAlivePhrase}`
: 'Team launched — teammates online'
: processOnlyAliveCount > 0 || pendingSpawnCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}`
: 'Team launched — teammates are still starting';
? `Team launched - all ${fallbackTeammateCount} teammates joined`
: hasMembersStillJoining
? `Team launched - ${joiningPhrase}`
: 'Team launched - teammates are still joining';
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
return (
@ -223,7 +172,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
<ProvisioningProgressBlock
key={progress.runId}
title="Launch details"
message={failedSpawnCount > 0 ? readyDetailMessage : null}
message={failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null}
messageSeverity={readyDetailSeverity}
currentStepIndex={readyStepIndex}
startedAt={progress.startedAt}
@ -271,5 +220,3 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
return null;
});
const DISPLAY_COMPLETE_STEP_INDEX = 4;

View file

@ -81,8 +81,14 @@ export const PendingRepliesBlock = ({
const roleLabel = formatAgentRole(
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
);
const advisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
const advisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
const advisoryLabel = getMemberRuntimeAdvisoryLabel(
member.runtimeAdvisory,
member.providerId
);
const advisoryTitle = getMemberRuntimeAdvisoryTitle(
member.runtimeAdvisory,
member.providerId
);
const isRetrying = advisoryLabel !== null;
return (

View file

@ -1,6 +1,6 @@
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
@ -44,6 +44,7 @@ interface MemberCardProps {
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean;
isLaunchSettling?: boolean;
onOpenTask?: () => void;
onOpenReviewTask?: () => void;
onClick?: () => void;
@ -68,6 +69,7 @@ export const MemberCard = ({
spawnLivenessSource,
spawnLaunchState,
spawnRuntimeAlive,
isLaunchSettling,
onOpenTask,
onOpenReviewTask,
onClick,
@ -84,12 +86,19 @@ export const MemberCard = ({
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity
);
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(
member.runtimeAdvisory,
member.providerId
);
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
member.runtimeAdvisory,
member.providerId
);
const presenceLabel = getLaunchAwarePresenceLabel(
member,
spawnStatus,
@ -97,6 +106,7 @@ export const MemberCard = ({
spawnLivenessSource,
spawnRuntimeAlive,
member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity
@ -105,6 +115,7 @@ export const MemberCard = ({
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning
);
@ -127,6 +138,7 @@ export const MemberCard = ({
spawnLaunchState !== 'failed_to_start' &&
!activityTask;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
return (
<div
@ -136,7 +148,7 @@ export const MemberCard = ({
className="group relative cursor-pointer rounded px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
background: `linear-gradient(to right, ${cardTint}, transparent)`,
}}
title={activityTitle}
role="button"

View file

@ -30,6 +30,7 @@ interface MemberDetailDialogProps {
messages: InboxMessage[];
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
isLaunchSettling?: boolean;
leadActivity?: LeadActivityState;
spawnEntry?: MemberSpawnStatusEntry;
onClose: () => void;
@ -50,6 +51,7 @@ export const MemberDetailDialog = ({
messages,
isTeamAlive,
isTeamProvisioning,
isLaunchSettling,
leadActivity,
spawnEntry,
onClose,
@ -107,6 +109,7 @@ export const MemberDetailDialog = ({
spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
isLaunchSettling={isLaunchSettling}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}

View file

@ -32,6 +32,7 @@ interface MemberDetailHeaderProps {
spawnLaunchState?: MemberLaunchState;
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnRuntimeAlive?: boolean;
isLaunchSettling?: boolean;
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
updatingRole?: boolean;
}
@ -45,6 +46,7 @@ export const MemberDetailHeader = ({
spawnLaunchState,
spawnLivenessSource,
spawnRuntimeAlive,
isLaunchSettling,
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
@ -65,6 +67,7 @@ export const MemberDetailHeader = ({
spawnLivenessSource,
spawnRuntimeAlive,
member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity
@ -74,6 +77,7 @@ export const MemberDetailHeader = ({
spawnStatus,
spawnLaunchState,
spawnRuntimeAlive,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity

View file

@ -8,6 +8,7 @@ import {
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
@ -19,6 +20,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
@ -49,21 +51,37 @@ export const MemberHoverCard = ({
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
const member = useStore((s) => {
if (!effectiveTeamName || s.selectedTeamName !== effectiveTeamName) return null;
return s.selectedTeamData?.members.find((m) => m.name === name) ?? null;
const {
member,
members,
isTeamAlive,
progress,
memberSpawnSnapshot,
memberSpawnStatuses,
spawnEntry,
leadActivity,
} = useStore((s) => {
const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName);
const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null;
return {
member: selectedTeamData?.members.find((m) => m.name === name) ?? null,
members: selectedTeamData?.members ?? [],
isTeamAlive: selectedTeamData?.isAlive,
progress: effectiveTeamName
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
: null,
memberSpawnSnapshot: effectiveTeamName
? s.memberSpawnSnapshotsByTeam[effectiveTeamName]
: undefined,
memberSpawnStatuses: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]
: undefined,
spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
};
});
const isTeamAlive = useStore((s) =>
effectiveTeamName && s.selectedTeamName === effectiveTeamName
? s.selectedTeamData?.isAlive
: undefined
);
const spawnEntry = useStore((s) =>
effectiveTeamName ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined
);
const leadActivity: LeadActivityState | undefined = useStore((s) =>
effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
const tasks = useStore((s) =>
effectiveTeamName && s.selectedTeamName === effectiveTeamName
@ -75,6 +93,13 @@ export const MemberHoverCard = ({
return <>{children}</>;
}
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
});
const isLaunchSettling =
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const presenceLabel = getLaunchAwarePresenceLabel(
@ -84,6 +109,7 @@ export const MemberHoverCard = ({
spawnEntry?.livenessSource,
spawnEntry?.runtimeAlive,
member.runtimeAdvisory,
isLaunchSettling,
isTeamAlive,
false,
isLeadMember(member) ? leadActivity : undefined
@ -93,6 +119,7 @@ export const MemberHoverCard = ({
spawnEntry?.status,
spawnEntry?.launchState,
spawnEntry?.runtimeAlive,
isLaunchSettling,
isTeamAlive,
false,
isLeadMember(member) ? leadActivity : undefined

View file

@ -26,6 +26,7 @@ interface MemberListProps {
taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
isLaunchSettling?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
@ -65,6 +66,7 @@ function areResolvedMembersEquivalent(
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs ||
leftMember.runtimeAdvisory?.reasonCode !== rightMember.runtimeAdvisory?.reasonCode ||
leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message
) {
return false;
@ -184,6 +186,7 @@ function areMemberListPropsEqual(
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
prev.isLaunchSettling === next.isLaunchSettling &&
prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity &&
@ -197,6 +200,7 @@ export const MemberList = memo(function MemberList({
taskMap,
pendingRepliesByMember,
memberSpawnStatuses,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity,
@ -287,6 +291,7 @@ export const MemberList = memo(function MemberList({
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
isLaunchSettling={isRemoved ? false : isLaunchSettling}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
onClick={() => onMemberClick?.(member)}

View file

@ -1,4 +1,11 @@
import type { TeamProvisioningState } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamProvisioningProgress,
} from '@shared/types';
/** Display steps for the provisioning stepper (0-indexed). */
export const DISPLAY_STEPS = [
@ -8,25 +15,187 @@ export const DISPLAY_STEPS = [
{ key: 'finalizing', label: 'Finalizing' },
] as const;
/**
* Maps a backend provisioning state to a 0-based display step index.
* Returns DISPLAY_STEPS.length for 'ready' (all steps complete), -1 for terminal/unknown.
*/
export function getDisplayStepIndex(state: Exclude<TeamProvisioningState, 'idle'>): number {
switch (state) {
case 'validating':
case 'spawning':
return 0;
case 'configuring':
return 1;
case 'assembling':
return 2;
case 'finalizing':
case 'verifying':
return 3;
case 'ready':
return DISPLAY_STEPS.length;
default:
return -1;
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
export type LaunchJoinMilestones = {
expectedTeammateCount: number;
heartbeatConfirmedCount: number;
processOnlyAliveCount: number;
pendingSpawnCount: number;
failedSpawnCount: number;
};
type DisplayStepMilestones = LaunchJoinMilestones & {
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;
};
type MemberSpawnStatusCollection =
| Record<string, MemberSpawnStatusEntry>
| Map<string, MemberSpawnStatusEntry>
| undefined;
function getSpawnEntry(
memberSpawnStatuses: MemberSpawnStatusCollection,
memberName: string
): MemberSpawnStatusEntry | undefined {
if (!memberSpawnStatuses) {
return undefined;
}
if (memberSpawnStatuses instanceof Map) {
return memberSpawnStatuses.get(memberName);
}
return memberSpawnStatuses[memberName];
}
export function getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
}: {
members: readonly ResolvedTeamMember[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): LaunchJoinMilestones {
const teammates = members.filter((member) => !member.removedAt && !isLeadMember(member));
const expectedTeammateCount = memberSpawnSnapshot?.expectedMembers?.length ?? teammates.length;
const snapshotSummary = memberSpawnSnapshot?.summary;
if (snapshotSummary) {
return {
expectedTeammateCount,
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount,
pendingSpawnCount: Math.max(
0,
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
),
failedSpawnCount: snapshotSummary.failedCount,
};
}
let heartbeatConfirmedCount = 0;
let processOnlyAliveCount = 0;
let pendingSpawnCount = 0;
let failedSpawnCount = 0;
for (const member of teammates) {
const entry = getSpawnEntry(memberSpawnStatuses, member.name);
if (!entry) {
pendingSpawnCount += 1;
continue;
}
if (entry.launchState === 'failed_to_start') {
failedSpawnCount += 1;
continue;
}
if (entry.launchState === 'confirmed_alive') {
heartbeatConfirmedCount += 1;
continue;
}
if (entry.launchState === 'runtime_pending_bootstrap') {
if (entry.runtimeAlive === true) {
processOnlyAliveCount += 1;
} else {
pendingSpawnCount += 1;
}
continue;
}
if (entry.launchState === 'starting') {
pendingSpawnCount += 1;
}
}
return {
expectedTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
};
}
export function getLaunchJoinState({
expectedTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
}: LaunchJoinMilestones): {
allTeammatesConfirmedAlive: boolean;
hasMembersStillJoining: boolean;
remainingJoinCount: number;
} {
const allTeammatesConfirmedAlive =
expectedTeammateCount > 0 &&
failedSpawnCount === 0 &&
heartbeatConfirmedCount >= expectedTeammateCount;
const remainingJoinCount =
expectedTeammateCount > 0 && failedSpawnCount === 0
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
: 0;
const hasMembersStillJoining =
expectedTeammateCount > 0 &&
failedSpawnCount === 0 &&
remainingJoinCount > 0 &&
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
return {
allTeammatesConfirmedAlive,
hasMembersStillJoining,
remainingJoinCount,
};
}
/**
* Maps launch progress to the visible stepper milestone.
*
* The renderer intentionally derives these steps from observable launch evidence
* instead of raw backend phase names. The backend can move through
* validating/spawning/configuring very quickly, but the UI milestones should
* reflect what the user can actually observe:
* - Starting: waiting for a real CLI/runtime process
* - Team setup: process exists, but config is not readable yet
* - Members joining: config is ready, but teammate runtimes are still attaching
* - Finalizing: teammate runtimes are attached and bootstrap/contact is settling
*
* Returns DISPLAY_COMPLETE_STEP_INDEX for 'ready', -1 for failed/cancelled.
*/
export function getDisplayStepIndex({
progress,
expectedTeammateCount,
heartbeatConfirmedCount,
processOnlyAliveCount,
pendingSpawnCount,
failedSpawnCount,
}: DisplayStepMilestones): number {
switch (progress.state) {
case 'ready':
return DISPLAY_COMPLETE_STEP_INDEX;
case 'failed':
case 'disconnected':
case 'cancelled':
return -1;
default:
break;
}
if (!progress.pid) {
return 0;
}
if (progress.configReady !== true) {
return 1;
}
if (expectedTeammateCount <= 0) {
return 3;
}
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
return 2;
}
return 3;
}

View file

@ -208,3 +208,32 @@ export function getThemedText(colorSet: TeamColorSet, isLight: boolean): string
export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): string {
return isLight && colorSet.borderLight ? colorSet.borderLight : colorSet.border;
}
export function scaleColorAlpha(color: string, factor: number): string {
const safeFactor = Math.max(0, factor);
const rgbaMatch = color.match(
/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
);
if (rgbaMatch) {
const [, r, g, b, alpha] = rgbaMatch;
return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`;
}
const hslaMatch = color.match(
/^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i
);
if (hslaMatch) {
const [, hue, saturation, lightness, alpha] = hslaMatch;
return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`;
}
const hexAlphaMatch = color.match(/^#([\da-f]{6})([\da-f]{2})$/i);
if (hexAlphaMatch) {
const [, hex, alphaHex] = hexAlphaMatch;
const alpha = parseInt(alphaHex, 16) / 255;
const scaledAlpha = Math.max(0, Math.min(255, Math.round(alpha * safeFactor * 255)));
return `#${hex}${scaledAlpha.toString(16).padStart(2, '0')}`;
}
return color;
}

View file

@ -13,6 +13,7 @@ import type {
MemberSpawnStatus,
MemberStatus,
ResolvedTeamMember,
TeamProviderId,
TeamReviewState,
TeamTaskStatus,
} from '@shared/types';
@ -110,13 +111,17 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
runtimeAlive: boolean | undefined
runtimeAlive: boolean | undefined,
keepRuntimePendingInStarting = false
): boolean {
if (spawnLaunchState === 'failed_to_start') {
return false;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return false;
if (spawnLaunchState === 'runtime_pending_bootstrap') {
if (runtimeAlive !== true) {
return true;
}
return keepRuntimePendingInStarting;
}
return spawnLaunchState === 'starting' || spawnStatus === 'waiting' || spawnStatus === 'spawning';
}
@ -130,17 +135,21 @@ export function getSpawnAwareDotClass(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
runtimeAlive: boolean | undefined,
isLaunchSettling = false,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
if (isTeamAlive === false && !isTeamProvisioning) {
return STATUS_DOT_COLORS.terminated;
}
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_DOT_COLORS.error;
}
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
if (
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
) {
return spawnStatus === 'spawning' ? SPAWN_DOT_COLORS.spawning : SPAWN_DOT_COLORS.waiting;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
@ -170,22 +179,26 @@ export function getSpawnAwarePresenceLabel(
spawnLaunchState: MemberLaunchState | undefined,
livenessSource: MemberSpawnLivenessSource | undefined,
runtimeAlive: boolean | undefined,
isLaunchSettling = false,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
if (isTeamAlive === false && !isTeamProvisioning) {
return 'offline';
}
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
return SPAWN_PRESENCE_LABELS.error;
}
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
return 'online';
}
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
if (
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
) {
return 'starting';
}
if (spawnStatus === 'online' && keepLaunchSettlingVisuals) {
return SPAWN_PRESENCE_LABELS.online;
}
if (spawnStatus === 'online' && livenessSource === 'process') {
return 'online';
}
@ -203,12 +216,19 @@ export function getSpawnCardClass(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState?: MemberLaunchState,
runtimeAlive?: boolean,
isLaunchSettling = false,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
if (isTeamAlive === false && !isTeamProvisioning) {
return 'opacity-40';
}
if (
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
) {
return 'member-waiting-shimmer';
}
switch (spawnStatus) {
case 'offline':
return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40';
@ -221,9 +241,7 @@ export function getSpawnCardClass(
case 'error':
return 'opacity-80';
default:
return isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)
? 'member-waiting-shimmer'
: '';
return '';
}
}
@ -237,34 +255,121 @@ function formatRetryCountdown(ms: number): string {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null {
switch (providerId) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
default:
return null;
}
}
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
const trimmed = message?.trim();
return trimmed ? `${base}\n\n${trimmed}` : base;
}
function formatRuntimeAdvisoryBaseLabel(
advisory: MemberRuntimeAdvisory,
providerId: TeamProviderId | undefined
): string {
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
switch (advisory.reasonCode) {
case 'quota_exhausted':
return providerLabel ? `${providerLabel} quota retry` : 'Quota retry';
case 'rate_limited':
return providerLabel ? `${providerLabel} rate limit` : 'Rate limit retry';
case 'auth_error':
return providerLabel ? `${providerLabel} auth retry` : 'Auth retry';
case 'network_error':
return 'Network retry';
case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
case 'backend_error':
case 'unknown':
return 'Provider retry';
default:
return 'retrying now';
}
}
function formatRuntimeAdvisoryTitle(
advisory: MemberRuntimeAdvisory,
providerId: TeamProviderId | undefined
): string {
const providerLabel = getRuntimeAdvisoryProviderLabel(providerId);
switch (advisory.reasonCode) {
case 'quota_exhausted':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} quota exhausted. SDK is retrying automatically.`,
advisory.message
);
case 'rate_limited':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} rate limited the request. SDK is retrying automatically.`,
advisory.message
);
case 'auth_error':
return appendRuntimeAdvisoryRawMessage(
`${providerLabel ?? 'Provider'} authentication issue. SDK is retrying automatically.`,
advisory.message
);
case 'network_error':
return appendRuntimeAdvisoryRawMessage(
'Network or connectivity issue. SDK is retrying automatically.',
advisory.message
);
case 'provider_overloaded':
return appendRuntimeAdvisoryRawMessage(
'Provider is temporarily overloaded. SDK is retrying automatically.',
advisory.message
);
case 'backend_error':
case 'unknown':
return appendRuntimeAdvisoryRawMessage(
'The SDK is retrying this request after a provider or backend error.',
advisory.message
);
default:
return (
advisory.message?.trim() ||
'The SDK is retrying this request after a provider or backend error.'
);
}
}
export function getMemberRuntimeAdvisoryLabel(
advisory: MemberRuntimeAdvisory | undefined,
providerId?: TeamProviderId,
nowMs = Date.now()
): string | null {
if (!advisory || advisory.kind !== 'sdk_retrying') {
return null;
}
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
const retryUntilMs = Date.parse(advisory.retryUntil);
if (!Number.isFinite(retryUntilMs)) {
return 'retrying now';
return baseLabel;
}
const remainingMs = retryUntilMs - nowMs;
if (remainingMs <= 0) {
return 'retrying now';
return baseLabel;
}
return `retrying now · ${formatRetryCountdown(remainingMs)}`;
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;
}
export function getMemberRuntimeAdvisoryTitle(
advisory: MemberRuntimeAdvisory | undefined
advisory: MemberRuntimeAdvisory | undefined,
providerId?: TeamProviderId
): string | undefined {
if (!advisory || advisory.kind !== 'sdk_retrying') {
return undefined;
}
return (
advisory.message?.trim() ||
'The SDK is retrying this request after a provider or backend error.'
);
return formatRuntimeAdvisoryTitle(advisory, providerId);
}
export function getLaunchAwarePresenceLabel(
@ -274,13 +379,15 @@ export function getLaunchAwarePresenceLabel(
livenessSource: MemberSpawnLivenessSource | undefined,
runtimeAlive: boolean | undefined,
runtimeAdvisory: MemberRuntimeAdvisory | undefined,
isLaunchSettling = false,
isTeamAlive?: boolean,
isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState
): string {
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
const advisoryLabel =
spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory)
!keepLaunchSettlingVisuals && spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId)
: null;
if (advisoryLabel) {
return advisoryLabel;
@ -291,6 +398,7 @@ export function getLaunchAwarePresenceLabel(
spawnLaunchState,
livenessSource,
runtimeAlive,
isLaunchSettling,
isTeamAlive,
isTeamProvisioning,
leadActivity

View file

@ -506,6 +506,14 @@ export interface MemberRuntimeAdvisory {
observedAt: string;
retryUntil: string;
retryDelayMs: number;
reasonCode?:
| 'quota_exhausted'
| 'rate_limited'
| 'auth_error'
| 'network_error'
| 'provider_overloaded'
| 'backend_error'
| 'unknown';
message?: string;
}

View file

@ -38,6 +38,7 @@ function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory {
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:01:00.000Z',
retryDelayMs: 60_000,
reasonCode: 'backend_error',
message: `retry:${label}`,
};
}
@ -145,9 +146,51 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory).not.toBeNull();
expect(advisory?.kind).toBe('sdk_retrying');
expect(advisory?.reasonCode).toBe('quota_exhausted');
expect(advisory?.message).toContain('capacity exceeded');
});
it.each([
['rate_limited', 'Provider returned 429 rate limit for this request.'],
['auth_error', 'Authentication failed due to invalid API key.'],
['network_error', 'Fetch failed because the network connection timed out.'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
['backend_error', 'Unexpected backend blew up during request processing.'],
] as const)('classifies %s retry causes from api_error messages', async (expected, message) => {
const service = new TeamMemberRuntimeAdvisoryService({} as never);
const advisory = (service as any).extractApiRetryAdvisory(
JSON.stringify({
type: 'system',
subtype: 'api_error',
timestamp: '2099-04-09T10:00:00.000Z',
retryInMs: 45_000,
error: {
error: {
error: {
message,
},
},
},
})
) as MemberRuntimeAdvisory | null;
expect(advisory?.reasonCode).toBe(expected);
});
it('classifies missing api_error message text as unknown', () => {
const service = new TeamMemberRuntimeAdvisoryService({} as never);
const advisory = (service as any).extractApiRetryAdvisory(
JSON.stringify({
type: 'system',
subtype: 'api_error',
timestamp: '2099-04-09T10:00:00.000Z',
retryInMs: 45_000,
})
) as MemberRuntimeAdvisory | null;
expect(advisory?.reasonCode).toBe('unknown');
});
it('ignores expired retry advisories', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -112,7 +112,117 @@ 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('0/3 teammates made contact');
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 = {
runId: 'run-1',
teamName: 'northstar-core',
state: 'configuring',
startedAt: '2026-04-08T16:00:00.000Z',
message: 'Waiting for team configuration...',
messageSeverity: undefined,
cliLogsTail: '',
assistantOutput: '',
configReady: false,
};
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('0');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps Team setup active while config is not ready after the process starts', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = {
runId: 'run-1',
teamName: 'northstar-core',
state: 'configuring',
startedAt: '2026-04-08T16:00:00.000Z',
message: 'Waiting for team configuration...',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
configReady: false,
};
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('1');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('advances to Finalizing once teammate runtimes are attached even before contact is confirmed', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = {
runId: 'run-1',
teamName: 'northstar-core',
state: 'finalizing',
startedAt: '2026-04-08T16:00:00.000Z',
message: 'Waiting for teammate bootstrap confirmations...',
messageSeverity: undefined,
pid: 4321,
cliLogsTail: '',
assistantOutput: '',
configReady: true,
};
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice', 'bob', 'jack'],
statuses: {},
summary: {
confirmedCount: 0,
pendingCount: 3,
failedCount: 0,
runtimeAlivePendingCount: 3,
},
source: 'merged',
};
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('3');
await act(async () => {
root.unmount();
@ -146,7 +256,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.getAttribute('data-current-step-index')).toBe('4');
expect(block?.textContent).toContain('all 3 teammates made contact');
expect(block?.textContent).toContain('all 3 teammates joined');
await act(async () => {
root.unmount();
@ -181,7 +291,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-success-severity')).toBe('warning');
expect(block?.textContent).toContain('teammates online');
expect(block?.textContent).toContain('3 teammates still joining');
await act(async () => {
root.unmount();
@ -267,7 +377,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
});
const block = host.querySelector('[data-testid="progress-block"]');
expect(block?.textContent).toContain('retrying provider capacity');
expect(block?.textContent).toContain('2 teammates still joining');
expect(block?.getAttribute('data-success-severity')).toBe('warning');
await act(async () => {

View file

@ -0,0 +1,77 @@
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';
const storeState = {
pendingApprovals: [] as Array<{ toolName: string; receivedAt: string }>,
};
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
import { PendingRepliesBlock } from '@renderer/components/team/activity/PendingRepliesBlock';
const member: ResolvedTeamMember = {
name: 'alice',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
providerId: 'gemini',
runtimeAdvisory: {
kind: 'sdk_retrying',
observedAt: '2026-04-09T10:00:00.000Z',
retryUntil: '2026-04-09T10:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'quota_exhausted',
message: 'Gemini cli backend error: You have exhausted your capacity on this model.',
},
};
describe('PendingRepliesBlock', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
});
it('shows a reason-specific retry label for pending member replies', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-09T10:00:00.000Z'));
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(PendingRepliesBlock, {
members: [member],
pendingRepliesByMember: {
alice: Date.parse('2026-04-09T09:59:00.000Z'),
},
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Gemini quota retry');
const retryElement = host.querySelector('[title*="Gemini quota exhausted"]');
expect(retryElement).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -17,7 +17,8 @@ vi.mock('@renderer/components/ui/badge', () => ({
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
@ -44,6 +45,7 @@ const member: ResolvedTeamMember = {
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
providerId: 'gemini',
removedAt: undefined,
};
@ -100,6 +102,7 @@ describe('MemberCard starting-state visuals', () => {
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2099-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'quota_exhausted',
},
},
memberColor: 'blue',
@ -113,7 +116,7 @@ describe('MemberCard starting-state visuals', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('retrying now');
expect(host.textContent).toContain('Gemini quota retry');
expect(host.textContent).not.toContain('online');
await act(async () => {
@ -121,4 +124,70 @@ describe('MemberCard starting-state visuals', () => {
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');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member,
memberColor: 'blue',
runtimeSummary: 'Anthropic · sonnet · Medium',
isTeamAlive: true,
isTeamProvisioning: false,
isLaunchSettling: true,
spawnStatus: 'online',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('starting');
expect(host.textContent).not.toContain('online');
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThan(0);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows ready instead of idle for confirmed teammates while launch is still settling', 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,
memberColor: 'blue',
runtimeSummary: 'Anthropic · sonnet · Medium',
isTeamAlive: true,
isTeamProvisioning: false,
isLaunchSettling: true,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('ready');
expect(host.textContent).not.toContain('idle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -68,4 +68,34 @@ describe('MemberDetailHeader spawn-aware presence', () => {
await Promise.resolve();
});
});
it('shows ready instead of idle while launch is still settling after contact', 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,
isTeamAlive: true,
isTeamProvisioning: false,
isLaunchSettling: true,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('ready');
expect(host.textContent).not.toContain('idle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -24,6 +24,7 @@ const storeState = {
tasks: [],
},
selectedTeamName: 'northstar-core',
progress: null as Record<string, unknown> | null,
memberSpawnStatusesByTeam: {
'northstar-core': {
alice: {
@ -33,7 +34,22 @@ const storeState = {
runtimeAlive: false,
},
},
},
} as Record<
string,
Record<
string,
{
status: string;
launchState: string;
updatedAt: string;
runtimeAlive: boolean;
livenessSource?: string;
}
>
>,
memberSpawnSnapshotsByTeam: {
'northstar-core': undefined,
} as Record<string, unknown>,
leadActivityByTeam: {},
openMemberProfile: vi.fn(),
};
@ -42,6 +58,10 @@ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress,
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
@ -77,12 +97,14 @@ describe('MemberHoverCard spawn-aware presence', () => {
storeState.selectedTeamData.isAlive = true;
storeState.selectedTeamData.tasks = [];
storeState.selectedTeamName = 'northstar-core';
storeState.progress = null;
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'spawning',
launchState: 'starting',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: false,
};
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
storeState.openMemberProfile.mockReset();
});
@ -110,4 +132,57 @@ describe('MemberHoverCard spawn-aware presence', () => {
await Promise.resolve();
});
});
it('keeps runtime-pending members in starting state while launch is still settling', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.progress = {
runId: 'run-1',
teamName: 'northstar-core',
state: 'ready',
startedAt: '2026-04-09T10:00:00.000Z',
pid: 4321,
configReady: true,
};
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
status: 'online',
launchState: 'runtime_pending_bootstrap',
updatedAt: '2026-04-09T10:00:00.000Z',
runtimeAlive: true,
livenessSource: 'process',
};
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
runId: 'run-1',
expectedMembers: ['alice'],
statuses: {},
summary: {
confirmedCount: 0,
pendingCount: 1,
failedCount: 0,
runtimeAlivePendingCount: 1,
},
source: 'merged',
};
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('starting');
expect(host.textContent).not.toContain('online');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest';
import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors';
import {
getSubagentTypeColorSet,
getTeamColorSet,
scaleColorAlpha,
TeamColorSet,
} from '@renderer/constants/teamColors';
function isValidColorSet(cs: TeamColorSet): boolean {
return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string';
return (
typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string'
);
}
// =============================================================================
@ -71,8 +78,16 @@ describe('getSubagentTypeColorSet', () => {
it('different types can produce different colors', () => {
const results = new Set(
['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup']
.map((t) => getSubagentTypeColorSet(t).border)
[
'Explore',
'Plan',
'test-agent',
'quality-fixer',
'claude-md-auditor',
'Bash',
'general-purpose',
'statusline-setup',
].map((t) => getSubagentTypeColorSet(t).border)
);
expect(results.size).toBeGreaterThan(1);
});
@ -124,3 +139,17 @@ describe('getSubagentTypeColorSet', () => {
expect(getTeamColorSet('green').border).toBe('#22c55e');
});
});
describe('scaleColorAlpha', () => {
it('halves rgba badge opacity', () => {
expect(scaleColorAlpha('rgba(59, 130, 246, 0.15)', 0.5)).toBe('rgba(59, 130, 246, 0.075)');
});
it('halves hsla badge opacity', () => {
expect(scaleColorAlpha('hsla(220, 80%, 50%, 0.12)', 0.5)).toBe('hsla(220, 80%, 50%, 0.06)');
});
it('halves hex alpha badge opacity', () => {
expect(scaleColorAlpha('#ff550026', 0.5)).toBe('#ff550013');
});
});

View file

@ -19,6 +19,7 @@ const member: ResolvedTeamMember = {
color: 'blue',
agentType: 'reviewer',
role: 'Reviewer',
providerId: 'gemini',
removedAt: undefined,
};
@ -31,6 +32,7 @@ describe('memberHelpers spawn-aware presence', () => {
'runtime_pending_bootstrap',
'process',
true,
false,
true,
false,
undefined
@ -43,6 +45,7 @@ describe('memberHelpers spawn-aware presence', () => {
'online',
'runtime_pending_bootstrap',
true,
false,
true,
false,
undefined
@ -58,6 +61,7 @@ describe('memberHelpers spawn-aware presence', () => {
'starting',
undefined,
false,
false,
true,
false,
undefined
@ -73,17 +77,22 @@ describe('memberHelpers spawn-aware presence', () => {
'starting',
undefined,
false,
false,
true,
false,
undefined
)
).toBe('starting');
expect(getSpawnAwareDotClass(member, 'spawning', 'starting', false, true, false, undefined)).toContain(
expect(
getSpawnAwareDotClass(member, 'spawning', 'starting', false, false, true, false, undefined)
).toContain(
'bg-amber-400'
);
expect(getSpawnCardClass('spawning', 'starting', false)).toContain('member-waiting-shimmer');
expect(getSpawnCardClass('spawning', 'starting', false, false)).toContain(
'member-waiting-shimmer'
);
});
it('shows offline instead of stale starting visuals when the team is offline', () => {
@ -96,6 +105,7 @@ describe('memberHelpers spawn-aware presence', () => {
false,
false,
false,
false,
undefined
)
).toBe('offline');
@ -108,11 +118,63 @@ describe('memberHelpers spawn-aware presence', () => {
false,
false,
false,
false,
undefined
)
).toContain('bg-red-400');
expect(getSpawnCardClass('spawning', 'starting', false, false, false)).toBe('opacity-40');
expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe(
'opacity-40'
);
});
it('keeps runtime-pending teammates in starting state while launch is still settling', () => {
expect(
getSpawnAwarePresenceLabel(
member,
'online',
'runtime_pending_bootstrap',
'process',
true,
true,
true,
false,
undefined
)
).toBe('starting');
expect(
getSpawnAwareDotClass(
member,
'online',
'runtime_pending_bootstrap',
true,
true,
true,
false,
undefined
)
).toContain('bg-zinc-400');
expect(getSpawnCardClass('online', 'runtime_pending_bootstrap', true, true, true, false)).toContain(
'member-waiting-shimmer'
);
});
it('shows confirmed teammates as ready instead of idle while launch is still settling', () => {
expect(
getSpawnAwarePresenceLabel(
member,
'online',
'confirmed_alive',
'heartbeat',
true,
true,
true,
false,
undefined
)
).toBe('ready');
});
it('renders unified retry advisory labels for provider retries', () => {
@ -123,21 +185,74 @@ describe('memberHelpers spawn-aware presence', () => {
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'quota_exhausted',
message: 'Gemini cli backend error: capacity exceeded.',
},
'gemini',
Date.parse('2026-04-07T09:00:00.000Z')
)
).toBe('Gemini quota retry · 45s');
expect(
getMemberRuntimeAdvisoryTitle(
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'rate_limited',
message: 'Gemini cli backend error: rate limit 429.',
},
'gemini'
)
).toContain('Gemini rate limited the request');
});
it('keeps network advisories provider-neutral and appends raw details to the title', () => {
expect(
getMemberRuntimeAdvisoryLabel(
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'network_error',
message: 'Connection timed out while contacting provider.',
},
'gemini',
Date.parse('2026-04-07T09:00:00.000Z')
)
).toBe('Network retry · 45s');
expect(
getMemberRuntimeAdvisoryTitle(
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
reasonCode: 'network_error',
message: 'Connection timed out while contacting provider.',
},
'gemini'
)
).toContain('Connection timed out while contacting provider.');
});
it('falls back to the existing generic retry wording when no structured reason is present', () => {
expect(
getMemberRuntimeAdvisoryLabel(
{
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
message: 'Gemini cli backend error: capacity exceeded.',
},
'gemini',
Date.parse('2026-04-07T09:00:00.000Z')
)
).toBe('retrying now · 45s');
expect(
getMemberRuntimeAdvisoryTitle({
kind: 'sdk_retrying',
observedAt: '2026-04-07T09:00:00.000Z',
retryUntil: '2026-04-07T09:00:45.000Z',
retryDelayMs: 45_000,
message: 'Gemini cli backend error: capacity exceeded.',
})
).toContain('capacity exceeded');
});
it('surfaces retry advisory text instead of plain online while bootstrap contact is still pending', () => {
@ -153,13 +268,15 @@ describe('memberHelpers spawn-aware presence', () => {
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('retrying now');
).toContain('Gemini quota retry');
expect(
getLaunchAwarePresenceLabel(
@ -173,12 +290,14 @@ describe('memberHelpers spawn-aware presence', () => {
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
)
).toBe('online');
).toBe('starting');
});
});