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 CACHE_TTL_MS = 5_000;
|
||||||
const TAIL_BYTES = 64 * 1024;
|
const TAIL_BYTES = 64 * 1024;
|
||||||
const BATCH_WARN_MS = 200;
|
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');
|
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||||
|
|
||||||
|
|
@ -23,6 +52,33 @@ interface CachedTeamBatchAdvisories {
|
||||||
expiresAt: number;
|
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 {
|
export class TeamMemberRuntimeAdvisoryService {
|
||||||
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
||||||
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
||||||
|
|
@ -308,6 +364,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
observedAt: new Date(observedAt).toISOString(),
|
observedAt: new Date(observedAt).toISOString(),
|
||||||
retryUntil: new Date(retryUntil).toISOString(),
|
retryUntil: new Date(retryUntil).toISOString(),
|
||||||
retryDelayMs: retryInMs,
|
retryDelayMs: retryInMs,
|
||||||
|
reasonCode: classifyRetryReason(message),
|
||||||
...(message ? { message } : {}),
|
...(message ? { message } : {}),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
|
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
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 { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
|
|
@ -95,6 +98,7 @@ import {
|
||||||
} from './teamSessionFetchGuards';
|
} from './teamSessionFetchGuards';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||||||
|
|
||||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||||
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
||||||
|
|
@ -158,6 +162,7 @@ function areResolvedMembersEqual(
|
||||||
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
|
prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt ||
|
||||||
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
|
prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil ||
|
||||||
prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs ||
|
prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs ||
|
||||||
|
prevMember.runtimeAdvisory?.reasonCode !== nextMember.runtimeAdvisory?.reasonCode ||
|
||||||
prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message
|
prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -287,6 +292,24 @@ type TeamSidebarRailBridgeProps = Omit<
|
||||||
> & {
|
> & {
|
||||||
messagesPanelProps: SharedTeamMessagesPanelProps;
|
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(
|
function buildMemberSpawnStatusMap(
|
||||||
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
||||||
|
|
@ -336,23 +359,332 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
||||||
return null;
|
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({
|
const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
teamName,
|
teamName,
|
||||||
...props
|
...props
|
||||||
}: TeamMemberListBridgeProps): React.JSX.Element {
|
}: TeamMemberListBridgeProps): React.JSX.Element {
|
||||||
const { leadActivity, memberSpawnStatuses } = useStore(
|
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const memberSpawnStatusMap = useMemo(
|
const memberSpawnStatusMap = useMemo(
|
||||||
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
|
||||||
[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 (
|
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,
|
member,
|
||||||
...props
|
...props
|
||||||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||||
const leadActivity = useStore((s) => s.leadActivityByTeam[teamName]);
|
const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } =
|
||||||
const spawnEntry = useStore((s) =>
|
useStore(
|
||||||
member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined
|
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 (
|
return (
|
||||||
<MemberDetailDialog
|
<MemberDetailDialog
|
||||||
{...props}
|
{...props}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
member={member}
|
member={member}
|
||||||
|
isLaunchSettling={isLaunchSettling}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
spawnEntry={spawnEntry}
|
spawnEntry={spawnEntry}
|
||||||
/>
|
/>
|
||||||
|
|
@ -648,7 +1000,6 @@ export const TeamDetailView = ({
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||||
const missingLeadSessionFetchKeyRef = useRef<string | null>(null);
|
|
||||||
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
selectedOwners: new Set(),
|
selectedOwners: new Set(),
|
||||||
|
|
@ -662,7 +1013,6 @@ export const TeamDetailView = ({
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
fetchSessionDetail,
|
|
||||||
initTabUIState,
|
initTabUIState,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
updateKanban,
|
updateKanban,
|
||||||
|
|
@ -705,7 +1055,6 @@ export const TeamDetailView = ({
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
fetchSessionDetail: s.fetchSessionDetail,
|
|
||||||
initTabUIState: s.initTabUIState,
|
initTabUIState: s.initTabUIState,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
updateKanban: s.updateKanban,
|
updateKanban: s.updateKanban,
|
||||||
|
|
@ -750,17 +1099,9 @@ export const TeamDetailView = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Per-tab UI state (context panel visibility + selected phase)
|
const tabId = useTabIdOptional();
|
||||||
const {
|
|
||||||
tabId,
|
|
||||||
isContextPanelVisible,
|
|
||||||
setContextPanelVisible,
|
|
||||||
selectedContextPhase,
|
|
||||||
setSelectedContextPhase,
|
|
||||||
} = useTabUI();
|
|
||||||
const activeTabId = useStore((s) => s.activeTabId);
|
const activeTabId = useStore((s) => s.activeTabId);
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -900,160 +1241,11 @@ export const TeamDetailView = ({
|
||||||
[projects, repositoryGroups, data?.config.projectPath]
|
[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 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(
|
const sessionHistoryKey = useMemo(
|
||||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||||
[data?.config.sessionHistory]
|
[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.
|
// 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
|
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
||||||
|
|
@ -1529,6 +1721,19 @@ export const TeamDetailView = ({
|
||||||
isTeamAlive={data?.isAlive}
|
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 => {
|
const renderBody = (): React.JSX.Element => {
|
||||||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||||
|
|
@ -1634,56 +1839,13 @@ export const TeamDetailView = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex size-full overflow-hidden">
|
<div className="flex size-full overflow-hidden">
|
||||||
{/* Context panel sidebar (left) */}
|
<LeadContextBridge
|
||||||
{isContextPanelVisible && leadSessionId && (
|
teamName={teamName}
|
||||||
<div className="w-80 shrink-0">
|
tabId={tabId}
|
||||||
{leadSessionLoaded ? (
|
projectId={projectId}
|
||||||
<SessionContextPanel
|
leadSessionId={leadSessionId}
|
||||||
injections={allContextInjections}
|
fallbackProjectRoot={data.config.projectPath}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages sidebar (left, after context panel) */}
|
{/* Messages sidebar (left, after context panel) */}
|
||||||
<TeamSidebarHost
|
<TeamSidebarHost
|
||||||
|
|
@ -1714,46 +1876,6 @@ export const TeamDetailView = ({
|
||||||
className="relative size-full flex-1 overflow-auto p-4"
|
className="relative size-full flex-1 overflow-auto p-4"
|
||||||
data-team-name={teamName}
|
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">
|
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
|
||||||
{headerColorSet ? (
|
{headerColorSet ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -2620,6 +2742,7 @@ export const TeamDetailView = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{spawnStatusWatcher}
|
{spawnStatusWatcher}
|
||||||
|
{leadContextWatcher}
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,16 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
|
import { ProvisioningProgressBlock } from './ProvisioningProgressBlock';
|
||||||
import { getDisplayStepIndex } from './provisioningSteps';
|
import {
|
||||||
|
DISPLAY_COMPLETE_STEP_INDEX,
|
||||||
function formatRetryingRuntimePhrase(retryingRuntimeCount: number): string {
|
getDisplayStepIndex,
|
||||||
if (retryingRuntimeCount <= 0) {
|
getLaunchJoinMilestonesFromMembers,
|
||||||
return '';
|
getLaunchJoinState,
|
||||||
}
|
} from './provisioningSteps';
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeamProvisioningBannerProps {
|
interface TeamProvisioningBannerProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -91,9 +71,37 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
||||||
progress.state === 'finalizing' ||
|
progress.state === 'finalizing' ||
|
||||||
progress.state === 'verifying';
|
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) {
|
if (progressStepIndex >= 0 && !isFailed) {
|
||||||
lastActiveStepRef.current = progressStepIndex;
|
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) {
|
if (isReady) {
|
||||||
const processOnlyAlivePhrase = formatProcessOnlyAlivePhrase(
|
const joiningPhrase =
|
||||||
processOnlyAliveCount,
|
remainingJoinCount === 1
|
||||||
retryingRuntimeCount
|
? '1 teammate still joining'
|
||||||
);
|
: `${remainingJoinCount} teammates still joining`;
|
||||||
const readyDetailMessage =
|
const readyDetailMessage =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? progress.message
|
? progress.message
|
||||||
: fallbackTeammateCount === 0
|
: fallbackTeammateCount === 0
|
||||||
? 'Team provisioned — lead online'
|
? 'Team provisioned - lead online'
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
? `Team provisioned — all ${fallbackTeammateCount} teammates made contact`
|
? `Team provisioned - all ${fallbackTeammateCount} teammates joined`
|
||||||
: allPendingRuntimesStarted
|
: hasMembersStillJoining
|
||||||
? processOnlyAlivePhrase
|
? `Waiting for ${joiningPhrase.replace('still joining', 'to finish joining')}`
|
||||||
? `Team provisioned — ${processOnlyAlivePhrase}`
|
: 'Team provisioned - teammates are still joining';
|
||||||
: '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';
|
|
||||||
const readyDetailSeverity =
|
const readyDetailSeverity =
|
||||||
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
|
failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined;
|
||||||
const readyMessage =
|
const readyMessage =
|
||||||
failedSpawnCount > 0
|
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
|
: fallbackTeammateCount === 0
|
||||||
? 'Team launched — lead online'
|
? 'Team launched - lead online'
|
||||||
: allTeammatesConfirmedAlive
|
: allTeammatesConfirmedAlive
|
||||||
? `Team launched — all ${fallbackTeammateCount} teammates made contact`
|
? `Team launched - all ${fallbackTeammateCount} teammates joined`
|
||||||
: allPendingRuntimesStarted
|
: hasMembersStillJoining
|
||||||
? processOnlyAlivePhrase
|
? `Team launched - ${joiningPhrase}`
|
||||||
? `Team launched — ${processOnlyAlivePhrase}`
|
: 'Team launched - teammates are still joining';
|
||||||
: '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';
|
|
||||||
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
|
const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -223,7 +172,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
||||||
<ProvisioningProgressBlock
|
<ProvisioningProgressBlock
|
||||||
key={progress.runId}
|
key={progress.runId}
|
||||||
title="Launch details"
|
title="Launch details"
|
||||||
message={failedSpawnCount > 0 ? readyDetailMessage : null}
|
message={failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null}
|
||||||
messageSeverity={readyDetailSeverity}
|
messageSeverity={readyDetailSeverity}
|
||||||
currentStepIndex={readyStepIndex}
|
currentStepIndex={readyStepIndex}
|
||||||
startedAt={progress.startedAt}
|
startedAt={progress.startedAt}
|
||||||
|
|
@ -271,5 +220,3 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const DISPLAY_COMPLETE_STEP_INDEX = 4;
|
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,14 @@ export const PendingRepliesBlock = ({
|
||||||
const roleLabel = formatAgentRole(
|
const roleLabel = formatAgentRole(
|
||||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||||
);
|
);
|
||||||
const advisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
|
const advisoryLabel = getMemberRuntimeAdvisoryLabel(
|
||||||
const advisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
|
member.runtimeAdvisory,
|
||||||
|
member.providerId
|
||||||
|
);
|
||||||
|
const advisoryTitle = getMemberRuntimeAdvisoryTitle(
|
||||||
|
member.runtimeAdvisory,
|
||||||
|
member.providerId
|
||||||
|
);
|
||||||
const isRetrying = advisoryLabel !== null;
|
const isRetrying = advisoryLabel !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
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 { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
|
|
@ -44,6 +44,7 @@ interface MemberCardProps {
|
||||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
spawnRuntimeAlive?: boolean;
|
spawnRuntimeAlive?: boolean;
|
||||||
|
isLaunchSettling?: boolean;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onOpenReviewTask?: () => void;
|
onOpenReviewTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
@ -68,6 +69,7 @@ export const MemberCard = ({
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onOpenReviewTask,
|
onOpenReviewTask,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -84,12 +86,19 @@ export const MemberCard = ({
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
);
|
);
|
||||||
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory);
|
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(
|
||||||
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory);
|
member.runtimeAdvisory,
|
||||||
|
member.providerId
|
||||||
|
);
|
||||||
|
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(
|
||||||
|
member.runtimeAdvisory,
|
||||||
|
member.providerId
|
||||||
|
);
|
||||||
const presenceLabel = getLaunchAwarePresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -97,6 +106,7 @@ export const MemberCard = ({
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
member.runtimeAdvisory,
|
member.runtimeAdvisory,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
|
|
@ -105,6 +115,7 @@ export const MemberCard = ({
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning
|
isTeamProvisioning
|
||||||
);
|
);
|
||||||
|
|
@ -127,6 +138,7 @@ export const MemberCard = ({
|
||||||
spawnLaunchState !== 'failed_to_start' &&
|
spawnLaunchState !== 'failed_to_start' &&
|
||||||
!activityTask;
|
!activityTask;
|
||||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||||
|
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -136,7 +148,7 @@ export const MemberCard = ({
|
||||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
|
background: `linear-gradient(to right, ${cardTint}, transparent)`,
|
||||||
}}
|
}}
|
||||||
title={activityTitle}
|
title={activityTitle}
|
||||||
role="button"
|
role="button"
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ interface MemberDetailDialogProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
isLaunchSettling?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
spawnEntry?: MemberSpawnStatusEntry;
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -50,6 +51,7 @@ export const MemberDetailDialog = ({
|
||||||
messages,
|
messages,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
isLaunchSettling,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -107,6 +109,7 @@ export const MemberDetailDialog = ({
|
||||||
spawnLaunchState={spawnEntry?.launchState}
|
spawnLaunchState={spawnEntry?.launchState}
|
||||||
spawnLivenessSource={spawnEntry?.livenessSource}
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
|
isLaunchSettling={isLaunchSettling}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ interface MemberDetailHeaderProps {
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnRuntimeAlive?: boolean;
|
spawnRuntimeAlive?: boolean;
|
||||||
|
isLaunchSettling?: boolean;
|
||||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
updatingRole?: boolean;
|
updatingRole?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +46,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
updatingRole,
|
updatingRole,
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
|
|
@ -65,6 +67,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
member.runtimeAdvisory,
|
member.runtimeAdvisory,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
|
|
@ -74,6 +77,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from '@renderer/constants/teamColors';
|
} from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
|
@ -19,6 +20,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||||
|
|
||||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -49,21 +51,37 @@ export const MemberHoverCard = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
const member = useStore((s) => {
|
const {
|
||||||
if (!effectiveTeamName || s.selectedTeamName !== effectiveTeamName) return null;
|
member,
|
||||||
return s.selectedTeamData?.members.find((m) => m.name === name) ?? null;
|
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 openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||||
const tasks = useStore((s) =>
|
const tasks = useStore((s) =>
|
||||||
effectiveTeamName && s.selectedTeamName === effectiveTeamName
|
effectiveTeamName && s.selectedTeamName === effectiveTeamName
|
||||||
|
|
@ -75,6 +93,13 @@ export const MemberHoverCard = ({
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
||||||
|
members,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshot,
|
||||||
|
});
|
||||||
|
const isLaunchSettling =
|
||||||
|
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
||||||
const colors = getTeamColorSet(color ?? member.color ?? '');
|
const colors = getTeamColorSet(color ?? member.color ?? '');
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getLaunchAwarePresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
|
|
@ -84,6 +109,7 @@ export const MemberHoverCard = ({
|
||||||
spawnEntry?.livenessSource,
|
spawnEntry?.livenessSource,
|
||||||
spawnEntry?.runtimeAlive,
|
spawnEntry?.runtimeAlive,
|
||||||
member.runtimeAdvisory,
|
member.runtimeAdvisory,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
isLeadMember(member) ? leadActivity : undefined
|
isLeadMember(member) ? leadActivity : undefined
|
||||||
|
|
@ -93,6 +119,7 @@ export const MemberHoverCard = ({
|
||||||
spawnEntry?.status,
|
spawnEntry?.status,
|
||||||
spawnEntry?.launchState,
|
spawnEntry?.launchState,
|
||||||
spawnEntry?.runtimeAlive,
|
spawnEntry?.runtimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
false,
|
false,
|
||||||
isLeadMember(member) ? leadActivity : undefined
|
isLeadMember(member) ? leadActivity : undefined
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ interface MemberListProps {
|
||||||
taskMap?: Map<string, TeamTaskWithKanban>;
|
taskMap?: Map<string, TeamTaskWithKanban>;
|
||||||
pendingRepliesByMember?: Record<string, number>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
||||||
|
isLaunchSettling?: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
|
@ -65,6 +66,7 @@ function areResolvedMembersEquivalent(
|
||||||
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
|
leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt ||
|
||||||
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
|
leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil ||
|
||||||
leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs ||
|
leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs ||
|
||||||
|
leftMember.runtimeAdvisory?.reasonCode !== rightMember.runtimeAdvisory?.reasonCode ||
|
||||||
leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message
|
leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -184,6 +186,7 @@ function areMemberListPropsEqual(
|
||||||
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
|
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
|
||||||
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
||||||
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
||||||
|
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
prev.leadActivity === next.leadActivity &&
|
prev.leadActivity === next.leadActivity &&
|
||||||
|
|
@ -197,6 +200,7 @@ export const MemberList = memo(function MemberList({
|
||||||
taskMap,
|
taskMap,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -287,6 +291,7 @@ export const MemberList = memo(function MemberList({
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||||
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
||||||
|
isLaunchSettling={isRemoved ? false : isLaunchSettling}
|
||||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
|
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
|
||||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
|
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
|
||||||
onClick={() => onMemberClick?.(member)}
|
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). */
|
/** Display steps for the provisioning stepper (0-indexed). */
|
||||||
export const DISPLAY_STEPS = [
|
export const DISPLAY_STEPS = [
|
||||||
|
|
@ -8,25 +15,187 @@ export const DISPLAY_STEPS = [
|
||||||
{ key: 'finalizing', label: 'Finalizing' },
|
{ key: 'finalizing', label: 'Finalizing' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
|
||||||
* 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 type LaunchJoinMilestones = {
|
||||||
*/
|
expectedTeammateCount: number;
|
||||||
export function getDisplayStepIndex(state: Exclude<TeamProvisioningState, 'idle'>): number {
|
heartbeatConfirmedCount: number;
|
||||||
switch (state) {
|
processOnlyAliveCount: number;
|
||||||
case 'validating':
|
pendingSpawnCount: number;
|
||||||
case 'spawning':
|
failedSpawnCount: number;
|
||||||
return 0;
|
};
|
||||||
case 'configuring':
|
|
||||||
return 1;
|
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||||
case 'assembling':
|
progress: Pick<TeamProvisioningProgress, 'configReady' | 'pid' | 'state'>;
|
||||||
return 2;
|
};
|
||||||
case 'finalizing':
|
|
||||||
case 'verifying':
|
type MemberSpawnStatusCollection =
|
||||||
return 3;
|
| Record<string, MemberSpawnStatusEntry>
|
||||||
case 'ready':
|
| Map<string, MemberSpawnStatusEntry>
|
||||||
return DISPLAY_STEPS.length;
|
| undefined;
|
||||||
default:
|
|
||||||
return -1;
|
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 {
|
export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): string {
|
||||||
return isLight && colorSet.borderLight ? colorSet.borderLight : colorSet.border;
|
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,
|
MemberSpawnStatus,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamProviderId,
|
||||||
TeamReviewState,
|
TeamReviewState,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -110,13 +111,17 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
function isLaunchStillStarting(
|
function isLaunchStillStarting(
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState: MemberLaunchState | undefined,
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
runtimeAlive: boolean | undefined
|
runtimeAlive: boolean | undefined,
|
||||||
|
keepRuntimePendingInStarting = false
|
||||||
): boolean {
|
): boolean {
|
||||||
if (spawnLaunchState === 'failed_to_start') {
|
if (spawnLaunchState === 'failed_to_start') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
|
if (spawnLaunchState === 'runtime_pending_bootstrap') {
|
||||||
return false;
|
if (runtimeAlive !== true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return keepRuntimePendingInStarting;
|
||||||
}
|
}
|
||||||
return spawnLaunchState === 'starting' || spawnStatus === 'waiting' || spawnStatus === 'spawning';
|
return spawnLaunchState === 'starting' || spawnStatus === 'waiting' || spawnStatus === 'spawning';
|
||||||
}
|
}
|
||||||
|
|
@ -130,17 +135,21 @@ export function getSpawnAwareDotClass(
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState: MemberLaunchState | undefined,
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
runtimeAlive: boolean | undefined,
|
runtimeAlive: boolean | undefined,
|
||||||
|
isLaunchSettling = false,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||||
if (isTeamAlive === false && !isTeamProvisioning) {
|
if (isTeamAlive === false && !isTeamProvisioning) {
|
||||||
return STATUS_DOT_COLORS.terminated;
|
return STATUS_DOT_COLORS.terminated;
|
||||||
}
|
}
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_DOT_COLORS.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;
|
return spawnStatus === 'spawning' ? SPAWN_DOT_COLORS.spawning : SPAWN_DOT_COLORS.waiting;
|
||||||
}
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
|
if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') {
|
||||||
|
|
@ -170,22 +179,26 @@ export function getSpawnAwarePresenceLabel(
|
||||||
spawnLaunchState: MemberLaunchState | undefined,
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
livenessSource: MemberSpawnLivenessSource | undefined,
|
livenessSource: MemberSpawnLivenessSource | undefined,
|
||||||
runtimeAlive: boolean | undefined,
|
runtimeAlive: boolean | undefined,
|
||||||
|
isLaunchSettling = false,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||||
if (isTeamAlive === false && !isTeamProvisioning) {
|
if (isTeamAlive === false && !isTeamProvisioning) {
|
||||||
return 'offline';
|
return 'offline';
|
||||||
}
|
}
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_PRESENCE_LABELS.error;
|
return SPAWN_PRESENCE_LABELS.error;
|
||||||
}
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) {
|
if (
|
||||||
return 'online';
|
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
|
||||||
}
|
) {
|
||||||
if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) {
|
|
||||||
return 'starting';
|
return 'starting';
|
||||||
}
|
}
|
||||||
|
if (spawnStatus === 'online' && keepLaunchSettlingVisuals) {
|
||||||
|
return SPAWN_PRESENCE_LABELS.online;
|
||||||
|
}
|
||||||
if (spawnStatus === 'online' && livenessSource === 'process') {
|
if (spawnStatus === 'online' && livenessSource === 'process') {
|
||||||
return 'online';
|
return 'online';
|
||||||
}
|
}
|
||||||
|
|
@ -203,12 +216,19 @@ export function getSpawnCardClass(
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState?: MemberLaunchState,
|
spawnLaunchState?: MemberLaunchState,
|
||||||
runtimeAlive?: boolean,
|
runtimeAlive?: boolean,
|
||||||
|
isLaunchSettling = false,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean
|
isTeamProvisioning?: boolean
|
||||||
): string {
|
): string {
|
||||||
|
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||||
if (isTeamAlive === false && !isTeamProvisioning) {
|
if (isTeamAlive === false && !isTeamProvisioning) {
|
||||||
return 'opacity-40';
|
return 'opacity-40';
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals)
|
||||||
|
) {
|
||||||
|
return 'member-waiting-shimmer';
|
||||||
|
}
|
||||||
switch (spawnStatus) {
|
switch (spawnStatus) {
|
||||||
case 'offline':
|
case 'offline':
|
||||||
return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40';
|
return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40';
|
||||||
|
|
@ -221,9 +241,7 @@ export function getSpawnCardClass(
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'opacity-80';
|
return 'opacity-80';
|
||||||
default:
|
default:
|
||||||
return isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)
|
return '';
|
||||||
? 'member-waiting-shimmer'
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,34 +255,121 @@ function formatRetryCountdown(ms: number): string {
|
||||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
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(
|
export function getMemberRuntimeAdvisoryLabel(
|
||||||
advisory: MemberRuntimeAdvisory | undefined,
|
advisory: MemberRuntimeAdvisory | undefined,
|
||||||
|
providerId?: TeamProviderId,
|
||||||
nowMs = Date.now()
|
nowMs = Date.now()
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
||||||
const retryUntilMs = Date.parse(advisory.retryUntil);
|
const retryUntilMs = Date.parse(advisory.retryUntil);
|
||||||
if (!Number.isFinite(retryUntilMs)) {
|
if (!Number.isFinite(retryUntilMs)) {
|
||||||
return 'retrying now';
|
return baseLabel;
|
||||||
}
|
}
|
||||||
const remainingMs = retryUntilMs - nowMs;
|
const remainingMs = retryUntilMs - nowMs;
|
||||||
if (remainingMs <= 0) {
|
if (remainingMs <= 0) {
|
||||||
return 'retrying now';
|
return baseLabel;
|
||||||
}
|
}
|
||||||
return `retrying now · ${formatRetryCountdown(remainingMs)}`;
|
return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMemberRuntimeAdvisoryTitle(
|
export function getMemberRuntimeAdvisoryTitle(
|
||||||
advisory: MemberRuntimeAdvisory | undefined
|
advisory: MemberRuntimeAdvisory | undefined,
|
||||||
|
providerId?: TeamProviderId
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return (
|
return formatRuntimeAdvisoryTitle(advisory, providerId);
|
||||||
advisory.message?.trim() ||
|
|
||||||
'The SDK is retrying this request after a provider or backend error.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLaunchAwarePresenceLabel(
|
export function getLaunchAwarePresenceLabel(
|
||||||
|
|
@ -274,13 +379,15 @@ export function getLaunchAwarePresenceLabel(
|
||||||
livenessSource: MemberSpawnLivenessSource | undefined,
|
livenessSource: MemberSpawnLivenessSource | undefined,
|
||||||
runtimeAlive: boolean | undefined,
|
runtimeAlive: boolean | undefined,
|
||||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined,
|
runtimeAdvisory: MemberRuntimeAdvisory | undefined,
|
||||||
|
isLaunchSettling = false,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||||
const advisoryLabel =
|
const advisoryLabel =
|
||||||
spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
|
!keepLaunchSettlingVisuals && spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive
|
||||||
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory)
|
? getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId)
|
||||||
: null;
|
: null;
|
||||||
if (advisoryLabel) {
|
if (advisoryLabel) {
|
||||||
return advisoryLabel;
|
return advisoryLabel;
|
||||||
|
|
@ -291,6 +398,7 @@ export function getLaunchAwarePresenceLabel(
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
livenessSource,
|
livenessSource,
|
||||||
runtimeAlive,
|
runtimeAlive,
|
||||||
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,14 @@ export interface MemberRuntimeAdvisory {
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
retryUntil: string;
|
retryUntil: string;
|
||||||
retryDelayMs: number;
|
retryDelayMs: number;
|
||||||
|
reasonCode?:
|
||||||
|
| 'quota_exhausted'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'auth_error'
|
||||||
|
| 'network_error'
|
||||||
|
| 'provider_overloaded'
|
||||||
|
| 'backend_error'
|
||||||
|
| 'unknown';
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory {
|
||||||
observedAt: '2026-04-09T10:00:00.000Z',
|
observedAt: '2026-04-09T10:00:00.000Z',
|
||||||
retryUntil: '2026-04-09T10:01:00.000Z',
|
retryUntil: '2026-04-09T10:01:00.000Z',
|
||||||
retryDelayMs: 60_000,
|
retryDelayMs: 60_000,
|
||||||
|
reasonCode: 'backend_error',
|
||||||
message: `retry:${label}`,
|
message: `retry:${label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -145,9 +146,51 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
|
|
||||||
expect(advisory).not.toBeNull();
|
expect(advisory).not.toBeNull();
|
||||||
expect(advisory?.kind).toBe('sdk_retrying');
|
expect(advisory?.kind).toBe('sdk_retrying');
|
||||||
|
expect(advisory?.reasonCode).toBe('quota_exhausted');
|
||||||
expect(advisory?.message).toContain('capacity exceeded');
|
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 () => {
|
it('ignores expired retry advisories', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,117 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
|
|
||||||
const block = host.querySelector('[data-testid="progress-block"]');
|
const block = host.querySelector('[data-testid="progress-block"]');
|
||||||
expect(block?.getAttribute('data-current-step-index')).toBe('2');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -146,7 +256,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
|
|
||||||
const block = host.querySelector('[data-testid="progress-block"]');
|
const block = host.querySelector('[data-testid="progress-block"]');
|
||||||
expect(block?.getAttribute('data-current-step-index')).toBe('4');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -181,7 +291,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
const block = host.querySelector('[data-testid="progress-block"]');
|
const block = host.querySelector('[data-testid="progress-block"]');
|
||||||
expect(block?.getAttribute('data-current-step-index')).toBe('2');
|
expect(block?.getAttribute('data-current-step-index')).toBe('2');
|
||||||
expect(block?.getAttribute('data-success-severity')).toBe('warning');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -267,7 +377,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const block = host.querySelector('[data-testid="progress-block"]');
|
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');
|
expect(block?.getAttribute('data-success-severity')).toBe('warning');
|
||||||
|
|
||||||
await act(async () => {
|
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', () => ({
|
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 }) =>
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement(React.Fragment, null, children),
|
React.createElement(React.Fragment, null, children),
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
|
@ -44,6 +45,7 @@ const member: ResolvedTeamMember = {
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
agentType: 'reviewer',
|
agentType: 'reviewer',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
|
providerId: 'gemini',
|
||||||
removedAt: undefined,
|
removedAt: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -100,6 +102,7 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
observedAt: '2026-04-07T09:00:00.000Z',
|
observedAt: '2026-04-07T09:00:00.000Z',
|
||||||
retryUntil: '2099-04-07T09:00:45.000Z',
|
retryUntil: '2099-04-07T09:00:45.000Z',
|
||||||
retryDelayMs: 45_000,
|
retryDelayMs: 45_000,
|
||||||
|
reasonCode: 'quota_exhausted',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
memberColor: 'blue',
|
memberColor: 'blue',
|
||||||
|
|
@ -113,7 +116,7 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('retrying now');
|
expect(host.textContent).toContain('Gemini quota retry');
|
||||||
expect(host.textContent).not.toContain('online');
|
expect(host.textContent).not.toContain('online');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -121,4 +124,70 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
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();
|
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: [],
|
tasks: [],
|
||||||
},
|
},
|
||||||
selectedTeamName: 'northstar-core',
|
selectedTeamName: 'northstar-core',
|
||||||
|
progress: null as Record<string, unknown> | null,
|
||||||
memberSpawnStatusesByTeam: {
|
memberSpawnStatusesByTeam: {
|
||||||
'northstar-core': {
|
'northstar-core': {
|
||||||
alice: {
|
alice: {
|
||||||
|
|
@ -33,7 +34,22 @@ const storeState = {
|
||||||
runtimeAlive: false,
|
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: {},
|
leadActivityByTeam: {},
|
||||||
openMemberProfile: vi.fn(),
|
openMemberProfile: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
@ -42,6 +58,10 @@ vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||||
|
getCurrentProvisioningProgressForTeam: () => storeState.progress,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useTheme', () => ({
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||||
useTheme: () => ({ isLight: false }),
|
useTheme: () => ({ isLight: false }),
|
||||||
}));
|
}));
|
||||||
|
|
@ -77,12 +97,14 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
storeState.selectedTeamData.isAlive = true;
|
storeState.selectedTeamData.isAlive = true;
|
||||||
storeState.selectedTeamData.tasks = [];
|
storeState.selectedTeamData.tasks = [];
|
||||||
storeState.selectedTeamName = 'northstar-core';
|
storeState.selectedTeamName = 'northstar-core';
|
||||||
|
storeState.progress = null;
|
||||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||||
status: 'spawning',
|
status: 'spawning',
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
updatedAt: '2026-04-09T10:00:00.000Z',
|
updatedAt: '2026-04-09T10:00:00.000Z',
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
};
|
};
|
||||||
|
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
|
||||||
storeState.openMemberProfile.mockReset();
|
storeState.openMemberProfile.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -110,4 +132,57 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
await Promise.resolve();
|
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 { 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 {
|
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', () => {
|
it('different types can produce different colors', () => {
|
||||||
const results = new Set(
|
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);
|
expect(results.size).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
@ -124,3 +139,17 @@ describe('getSubagentTypeColorSet', () => {
|
||||||
expect(getTeamColorSet('green').border).toBe('#22c55e');
|
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',
|
color: 'blue',
|
||||||
agentType: 'reviewer',
|
agentType: 'reviewer',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
|
providerId: 'gemini',
|
||||||
removedAt: undefined,
|
removedAt: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
'runtime_pending_bootstrap',
|
'runtime_pending_bootstrap',
|
||||||
'process',
|
'process',
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -43,6 +45,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
'online',
|
'online',
|
||||||
'runtime_pending_bootstrap',
|
'runtime_pending_bootstrap',
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -58,6 +61,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
'starting',
|
'starting',
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -73,17 +77,22 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
'starting',
|
'starting',
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toBe('starting');
|
).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'
|
'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', () => {
|
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,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toBe('offline');
|
).toBe('offline');
|
||||||
|
|
@ -108,11 +118,63 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toContain('bg-red-400');
|
).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', () => {
|
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',
|
observedAt: '2026-04-07T09:00:00.000Z',
|
||||||
retryUntil: '2026-04-07T09:00:45.000Z',
|
retryUntil: '2026-04-07T09:00:45.000Z',
|
||||||
retryDelayMs: 45_000,
|
retryDelayMs: 45_000,
|
||||||
|
reasonCode: 'quota_exhausted',
|
||||||
message: 'Gemini cli backend error: capacity exceeded.',
|
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')
|
Date.parse('2026-04-07T09:00:00.000Z')
|
||||||
)
|
)
|
||||||
).toBe('retrying now · 45s');
|
).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', () => {
|
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',
|
observedAt: '2026-04-07T09:00:00.000Z',
|
||||||
retryUntil: '2099-04-07T09:00:45.000Z',
|
retryUntil: '2099-04-07T09:00:45.000Z',
|
||||||
retryDelayMs: 45_000,
|
retryDelayMs: 45_000,
|
||||||
|
reasonCode: 'quota_exhausted',
|
||||||
message: 'Gemini cli backend error: capacity exceeded.',
|
message: 'Gemini cli backend error: capacity exceeded.',
|
||||||
},
|
},
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toContain('retrying now');
|
).toContain('Gemini quota retry');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
getLaunchAwarePresenceLabel(
|
getLaunchAwarePresenceLabel(
|
||||||
|
|
@ -173,12 +290,14 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
observedAt: '2026-04-07T09:00:00.000Z',
|
observedAt: '2026-04-07T09:00:00.000Z',
|
||||||
retryUntil: '2099-04-07T09:00:45.000Z',
|
retryUntil: '2099-04-07T09:00:45.000Z',
|
||||||
retryDelayMs: 45_000,
|
retryDelayMs: 45_000,
|
||||||
|
reasonCode: 'quota_exhausted',
|
||||||
message: 'Gemini cli backend error: capacity exceeded.',
|
message: 'Gemini cli backend error: capacity exceeded.',
|
||||||
},
|
},
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
undefined
|
undefined
|
||||||
)
|
)
|
||||||
).toBe('online');
|
).toBe('starting');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue