feat(team-ui): clarify launch and retry member states
This commit is contained in:
parent
32ec3a6123
commit
21e9fb8c90
21 changed files with 1502 additions and 452 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue