From 21e9fb8c90bc62a3b6fa033cc3128966ab4cc829 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 9 Apr 2026 21:16:24 +0300 Subject: [PATCH] feat(team-ui): clarify launch and retry member states --- .../team/TeamMemberRuntimeAdvisoryService.ts | 57 ++ .../components/team/TeamDetailView.tsx | 641 +++++++++++------- .../team/TeamProvisioningBanner.tsx | 157 ++--- .../team/activity/PendingRepliesBlock.tsx | 10 +- .../components/team/members/MemberCard.tsx | 20 +- .../team/members/MemberDetailDialog.tsx | 3 + .../team/members/MemberDetailHeader.tsx | 4 + .../team/members/MemberHoverCard.tsx | 55 +- .../components/team/members/MemberList.tsx | 5 + .../components/team/provisioningSteps.ts | 211 +++++- src/renderer/constants/teamColors.ts | 29 + src/renderer/utils/memberHelpers.ts | 150 +++- src/shared/types/team.ts | 8 + .../TeamMemberRuntimeAdvisoryService.test.ts | 43 ++ .../team/TeamProvisioningBanner.test.ts | 118 +++- .../team/activity/PendingRepliesBlock.test.ts | 77 +++ .../team/members/MemberCard.test.ts | 73 +- .../team/members/MemberDetailHeader.test.ts | 30 + .../team/members/MemberHoverCard.test.ts | 77 ++- test/renderer/constants/teamColors.test.ts | 37 +- test/renderer/utils/memberHelpers.test.ts | 149 +++- 21 files changed, 1502 insertions(+), 452 deletions(-) create mode 100644 test/renderer/components/team/activity/PendingRepliesBlock.test.ts diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 851d8fad..4b4e4560 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -9,6 +9,35 @@ const LOOKBACK_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 5_000; const TAIL_BYTES = 64 * 1024; const BATCH_WARN_MS = 200; +const QUOTA_EXHAUSTED_TOKENS = [ + 'exhausted your capacity', + 'capacity exceeded', + 'quota exceeded', + 'quota exhausted', +]; +const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429']; +const AUTH_ERROR_TOKENS = [ + 'unauthorized', + 'forbidden', + 'invalid api key', + 'authentication', + 'api key', +]; +const NETWORK_ERROR_TOKENS = [ + 'timeout', + 'timed out', + 'network', + 'connection', + 'econn', + 'enotfound', + 'fetch failed', +]; +const PROVIDER_OVERLOADED_TOKENS = [ + 'overloaded', + 'temporarily unavailable', + 'service unavailable', + '503', +]; const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); @@ -23,6 +52,33 @@ interface CachedTeamBatchAdvisories { expiresAt: number; } +function includesAnyToken(value: string, tokens: readonly string[]): boolean { + return tokens.some((token) => value.includes(token)); +} + +function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory['reasonCode'] { + const normalized = message?.trim().toLowerCase(); + if (!normalized) { + return 'unknown'; + } + if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) { + return 'quota_exhausted'; + } + if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) { + return 'rate_limited'; + } + if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) { + return 'auth_error'; + } + if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) { + return 'network_error'; + } + if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) { + return 'provider_overloaded'; + } + return 'backend_error'; +} + export class TeamMemberRuntimeAdvisoryService { private readonly memberCache = new Map(); private readonly teamBatchCacheByTeam = new Map(); @@ -308,6 +364,7 @@ export class TeamMemberRuntimeAdvisoryService { observedAt: new Date(observedAt).toISOString(), retryUntil: new Date(retryUntil).toISOString(), retryDelayMs: retryInMs, + reasonCode: classifyRetryReason(message), ...(message ? { message } : {}), }; } catch { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 1c28c44e..e7c748c6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -15,13 +15,16 @@ import { } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useResizablePanel } from '@renderer/hooks/useResizablePanel'; -import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { + getCurrentProvisioningProgressForTeam, + isTeamProvisioningActive, +} from '@renderer/store/slices/teamSlice'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; @@ -95,6 +98,7 @@ import { } from './teamSessionFetchGuards'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -158,6 +162,7 @@ function areResolvedMembersEqual( prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt || prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil || prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs || + prevMember.runtimeAdvisory?.reasonCode !== nextMember.runtimeAdvisory?.reasonCode || prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message ) { return false; @@ -287,6 +292,24 @@ type TeamSidebarRailBridgeProps = Omit< > & { messagesPanelProps: SharedTeamMessagesPanelProps; }; +interface LeadContextWatcherProps { + teamName: string; + tabId: string | null; + projectId: string | null; + leadSessionId: string | null; + sessionHistoryKey: string; + isThisTabActive: boolean; + isTeamAlive?: boolean; + sessions: readonly Session[]; + sessionsLoading: boolean; +} +interface LeadContextBridgeProps { + teamName: string; + tabId: string | null; + projectId: string | null; + leadSessionId: string | null; + fallbackProjectRoot?: string; +} function buildMemberSpawnStatusMap( memberSpawnStatuses: Record | undefined @@ -336,23 +359,332 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ return null; }); +const LeadContextWatcher = memo(function LeadContextWatcher({ + teamName, + tabId, + projectId, + leadSessionId, + sessionHistoryKey, + isThisTabActive, + isTeamAlive, + sessions, + sessionsLoading, +}: LeadContextWatcherProps): null { + const fetchSessionDetail = useStore((s) => s.fetchSessionDetail); + const missingLeadSessionFetchKeyRef = useRef(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 && ( +
+ {leadSessionLoaded ? ( + setContextPanelVisible(false)} + projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot} + totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={leadSessionDetail?.metrics} + subagentCostUsd={leadSubagentCostUsd} + phaseInfo={leadSessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + side="left" + /> + ) : ( +
+
+
+

Visible Context

+

+ {leadSessionLoading ? 'Loading…' : 'No session loaded'} +

+
+ +
+
+

+ {leadSessionLoading + ? 'Loading context…' + : 'Open the team lead session to view context.'} +

+
+
+ )} +
+ )} + +
+ +
+ + ); +}); + const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { - const { leadActivity, memberSpawnStatuses } = useStore( + const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], })) ); const memberSpawnStatusMap = useMemo( () => buildMemberSpawnStatusMap(memberSpawnStatuses), [memberSpawnStatuses] ); + const isLaunchSettling = useMemo(() => { + if (progress?.state !== 'ready') { + return false; + } + return getLaunchJoinState( + getLaunchJoinMilestonesFromMembers({ + members: props.members, + memberSpawnStatuses, + memberSpawnSnapshot, + }) + ).hasMembersStillJoining; + }, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members]); return ( - + ); }); @@ -404,16 +736,36 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( member, ...props }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { - const leadActivity = useStore((s) => s.leadActivityByTeam[teamName]); - const spawnEntry = useStore((s) => - member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined - ); + const { leadActivity, progress, members, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry } = + useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), + members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, + })) + ); + const isLaunchSettling = useMemo(() => { + if (progress?.state !== 'ready') { + return false; + } + return getLaunchJoinState( + getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses, + memberSpawnSnapshot, + }) + ).hasMembersStillJoining; + }, [memberSpawnSnapshot, memberSpawnStatuses, members, progress?.state]); return ( @@ -648,7 +1000,6 @@ export const TeamDetailView = ({ const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsError, setSessionsError] = useState(null); - const missingLeadSessionFetchKeyRef = useRef(null); const [kanbanFilter, setKanbanFilter] = useState({ sessionId: null, selectedOwners: new Set(), @@ -662,7 +1013,6 @@ export const TeamDetailView = ({ error, projects, repositoryGroups, - fetchSessionDetail, initTabUIState, selectTeam, updateKanban, @@ -705,7 +1055,6 @@ export const TeamDetailView = ({ useShallow((s) => ({ projects: s.projects, repositoryGroups: s.repositoryGroups, - fetchSessionDetail: s.fetchSessionDetail, initTabUIState: s.initTabUIState, selectTeam: s.selectTeam, updateKanban: s.updateKanban, @@ -750,17 +1099,9 @@ export const TeamDetailView = ({ })) ); - // Per-tab UI state (context panel visibility + selected phase) - const { - tabId, - isContextPanelVisible, - setContextPanelVisible, - selectedContextPhase, - setSelectedContextPhase, - } = useTabUI(); + const tabId = useTabIdOptional(); const activeTabId = useStore((s) => s.activeTabId); const isThisTabActive = tabId ? activeTabId === tabId : false; - const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); useEffect(() => { const now = Date.now(); @@ -900,160 +1241,11 @@ export const TeamDetailView = ({ [projects, repositoryGroups, data?.config.projectPath] ); - // Lead session context panel (reuses the same session context pipeline for exact stats) const leadSessionId = data?.config.leadSessionId ?? null; - const leadTabData = useStore(useShallow((s) => (tabId ? s.tabSessionData[tabId] : null))); - const leadSessionDetail = leadTabData?.sessionDetail ?? null; - const leadConversation = leadTabData?.conversation ?? null; - const leadSessionContextStats = leadTabData?.sessionContextStats ?? null; - const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null; - const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false; - const leadSessionLoaded = Boolean( - leadSessionId && leadSessionDetail?.session?.id === leadSessionId - ); const sessionHistoryKey = useMemo( () => (data?.config.sessionHistory ?? []).join('|'), [data?.config.sessionHistory] ); - const missingLeadSessionFetchKey = useMemo( - () => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`, - [teamName, projectId, leadSessionId, sessionHistoryKey] - ); - - const leadSubagentCostUsd = useMemo(() => { - const processes = leadSessionDetail?.processes; - if (!processes || processes.length === 0) return undefined; - const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); - return total > 0 ? total : undefined; - }, [leadSessionDetail?.processes]); - const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { - if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { - return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; - } - - // Determine which phase to show - const effectivePhase = selectedContextPhase; - - // If a specific phase is selected, find the last AI group in that phase - let targetAiGroupId: string | undefined; - if (effectivePhase !== null && leadSessionPhaseInfo) { - const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); - if (phase) { - targetAiGroupId = phase.lastAIGroupId; - } - } - - // Default: use the last AI group overall - if (!targetAiGroupId) { - const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); - if (lastAiItem?.type !== 'ai') { - return { - allContextInjections: [] as ContextInjection[], - lastAiGroupTotalTokens: undefined, - }; - } - targetAiGroupId = lastAiItem.group.id; - } - - const stats = leadSessionContextStats.get(targetAiGroupId); - const injections = stats?.accumulatedInjections ?? []; - - // Get total tokens from the target AI group - let totalTokens: number | undefined; - const targetItem = leadConversation.items.find( - (item) => item.type === 'ai' && item.group.id === targetAiGroupId - ); - if (targetItem?.type === 'ai') { - const responses = targetItem.group.responses || []; - for (let i = responses.length - 1; i >= 0; i--) { - const msg = responses[i]; - if (msg.type === 'assistant' && msg.usage) { - const usage = msg.usage; - totalTokens = - (usage.input_tokens ?? 0) + - (usage.output_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - (usage.cache_creation_input_tokens ?? 0); - break; - } - } - } - - return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; - }, [ - leadSessionLoaded, - leadSessionContextStats, - leadConversation, - selectedContextPhase, - leadSessionPhaseInfo, - ]); - - const visibleContextTokens = useMemo( - () => sumContextInjectionTokens(allContextInjections), - [allContextInjections] - ); - const visibleContextPercentLabel = useMemo( - () => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens), - [visibleContextTokens, lastAiGroupTotalTokens] - ); - - useEffect(() => { - missingLeadSessionFetchKeyRef.current = null; - }, [missingLeadSessionFetchKey]); - - // Keep lead-session context fresh in the background while the team tab is active. - // This keeps the button value current even when the panel is closed. - // For offline teams: fetch once on mount so the percentage shows immediately. - // For alive teams: fetch on mount + periodic refresh every 30s. - useEffect(() => { - if (!isThisTabActive) return; - if (!tabId || !projectId || !leadSessionId) return; - - const leadSessionMissing = isLeadSessionMissing({ - leadSessionId, - projectId, - sessionsLoading, - knownSessions: sessions, - }); - if (leadSessionMissing) { - missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey; - return; - } - - const fetchLeadSessionDetail = () => { - const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({ - leadSessionId, - projectId, - sessionsLoading, - knownSessions: sessions, - suppressionKey: missingLeadSessionFetchKeyRef.current, - currentKey: missingLeadSessionFetchKey, - }); - if (suppressRepeatedFetch) { - return; - } - void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); - }; - - fetchLeadSessionDetail(); - - if (!data?.isAlive) return; - - const id = window.setInterval(() => { - fetchLeadSessionDetail(); - }, 10_000); - return () => window.clearInterval(id); - }, [ - isThisTabActive, - tabId, - projectId, - leadSessionId, - data?.isAlive, - fetchSessionDetail, - sessions, - sessionsLoading, - missingLeadSessionFetchKey, - ]); // Keep team message state fresh while we are explicitly waiting for a reply. // Use a delayed single-shot refresh instead of a tight polling loop so we @@ -1529,6 +1721,19 @@ export const TeamDetailView = ({ isTeamAlive={data?.isAlive} /> ); + const leadContextWatcher = ( + + ); const renderBody = (): React.JSX.Element => { if ((loading && !data) || (data && data.teamName !== teamName)) { @@ -1634,56 +1839,13 @@ export const TeamDetailView = ({ return ( <>
- {/* Context panel sidebar (left) */} - {isContextPanelVisible && leadSessionId && ( -
- {leadSessionLoaded ? ( - 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" - /> - ) : ( -
-
-
-

- Visible Context -

-

- {leadSessionLoading ? 'Loading…' : 'No session loaded'} -

-
- -
-
-

- {leadSessionLoading - ? 'Loading context…' - : 'Open the team lead session to view context.'} -

-
-
- )} -
- )} + {/* Messages sidebar (left, after context panel) */} - {/* Context button pinned to bottom-left of viewport */} - {leadSessionId && ( -
- -
- )} -
{headerColorSet ? (
{spawnStatusWatcher} + {leadContextWatcher} {renderBody()} ); diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 2d1ae933..e2d84edc 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -3,36 +3,16 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ProvisioningProgressBlock } from './ProvisioningProgressBlock'; -import { getDisplayStepIndex } from './provisioningSteps'; - -function formatRetryingRuntimePhrase(retryingRuntimeCount: number): string { - if (retryingRuntimeCount <= 0) { - return ''; - } - return `${retryingRuntimeCount} teammate${retryingRuntimeCount === 1 ? '' : 's'} retrying provider capacity`; -} - -function formatProcessOnlyAlivePhrase( - processOnlyAliveCount: number, - retryingRuntimeCount: number -): string { - if (processOnlyAliveCount <= 0) { - return ''; - } - if (retryingRuntimeCount >= processOnlyAliveCount) { - return formatRetryingRuntimePhrase(processOnlyAliveCount); - } - const plainOnlineCount = processOnlyAliveCount - retryingRuntimeCount; - if (retryingRuntimeCount <= 0) { - return `${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`; - } - return `${formatRetryingRuntimePhrase(retryingRuntimeCount)}, ${plainOnlineCount} teammate${plainOnlineCount === 1 ? '' : 's'} online`; -} +import { + DISPLAY_COMPLETE_STEP_INDEX, + getDisplayStepIndex, + getLaunchJoinMilestonesFromMembers, + getLaunchJoinState, +} from './provisioningSteps'; interface TeamProvisioningBannerProps { teamName: string; @@ -91,9 +71,37 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ progress.state === 'finalizing' || progress.state === 'verifying'; - const progressStepIndex = getDisplayStepIndex(progress.state); + const { + expectedTeammateCount: fallbackTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + } = getLaunchJoinMilestonesFromMembers({ + members: teamMembers ?? [], + memberSpawnStatuses, + memberSpawnSnapshot, + }); + const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = + getLaunchJoinState({ + expectedTeammateCount: fallbackTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + }); + const progressStepIndex = getDisplayStepIndex({ + progress, + expectedTeammateCount: fallbackTeammateCount, + heartbeatConfirmedCount, + processOnlyAliveCount, + pendingSpawnCount, + failedSpawnCount, + }); - // Remember last active step so we can show it as the error location when failed + // Keep the error marker aligned to the last meaningful UI milestone, not the + // raw backend phase enum. The launch flow now moves through some backend + // states too quickly for the old enum mapping to stay user-meaningful. if (progressStepIndex >= 0 && !isFailed) { lastActiveStepRef.current = progressStepIndex; } @@ -130,92 +138,33 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ ); } - const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member)); - const expectedTeammateCount = memberSpawnSnapshot?.expectedMembers?.length; - const fallbackTeammateCount = expectedTeammateCount ?? teammates.length; - const snapshotSummary = memberSpawnSnapshot?.summary; - const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter( - ([, entry]) => entry.launchState === 'failed_to_start' - ); - const failedSpawnCount = snapshotSummary?.failedCount ?? failedSpawnEntries.length; - const heartbeatConfirmedCount = - snapshotSummary?.confirmedCount ?? - teammates.filter((member) => { - const entry = memberSpawnStatuses?.[member.name]; - return entry?.launchState === 'confirmed_alive'; - }).length; - const processOnlyAliveCount = - snapshotSummary?.runtimeAlivePendingCount ?? - teammates.filter((member) => { - const entry = memberSpawnStatuses?.[member.name]; - return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true; - }).length; - const retryingRuntimeCount = teammates.filter((member) => { - const entry = memberSpawnStatuses?.[member.name]; - return ( - entry?.launchState === 'runtime_pending_bootstrap' && - entry.runtimeAlive === true && - member.runtimeAdvisory?.kind === 'sdk_retrying' - ); - }).length; - const pendingSpawnCount = snapshotSummary - ? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount) - : teammates.filter((member) => { - const entry = memberSpawnStatuses?.[member.name]; - return ( - entry?.launchState === 'starting' || - (entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true) - ); - }).length; - const allTeammatesConfirmedAlive = - fallbackTeammateCount > 0 && - failedSpawnCount === 0 && - heartbeatConfirmedCount === fallbackTeammateCount; - const allPendingRuntimesStarted = - fallbackTeammateCount > 0 && - heartbeatConfirmedCount === 0 && - processOnlyAliveCount === fallbackTeammateCount && - pendingSpawnCount === 0; - const hasMembersStillJoining = - fallbackTeammateCount > 0 && - failedSpawnCount === 0 && - (processOnlyAliveCount > 0 || pendingSpawnCount > 0); - if (isReady) { - const processOnlyAlivePhrase = formatProcessOnlyAlivePhrase( - processOnlyAliveCount, - retryingRuntimeCount - ); + const joiningPhrase = + remainingJoinCount === 1 + ? '1 teammate still joining' + : `${remainingJoinCount} teammates still joining`; const readyDetailMessage = failedSpawnCount > 0 ? progress.message : fallbackTeammateCount === 0 - ? 'Team provisioned — lead online' + ? 'Team provisioned - lead online' : allTeammatesConfirmedAlive - ? `Team provisioned — all ${fallbackTeammateCount} teammates made contact` - : allPendingRuntimesStarted - ? processOnlyAlivePhrase - ? `Team provisioned — ${processOnlyAlivePhrase}` - : 'Team provisioned — teammates online' - : processOnlyAliveCount > 0 || pendingSpawnCount > 0 - ? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` - : 'Team provisioned — teammates are still starting'; + ? `Team provisioned - all ${fallbackTeammateCount} teammates joined` + : hasMembersStillJoining + ? `Waiting for ${joiningPhrase.replace('still joining', 'to finish joining')}` + : 'Team provisioned - teammates are still joining'; const readyDetailSeverity = failedSpawnCount > 0 || hasMembersStillJoining ? 'warning' : undefined; const readyMessage = failedSpawnCount > 0 - ? `Launch finished with errors — ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start` + ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start` : fallbackTeammateCount === 0 - ? 'Team launched — lead online' + ? 'Team launched - lead online' : allTeammatesConfirmedAlive - ? `Team launched — all ${fallbackTeammateCount} teammates made contact` - : allPendingRuntimesStarted - ? processOnlyAlivePhrase - ? `Team launched — ${processOnlyAlivePhrase}` - : 'Team launched — teammates online' - : processOnlyAliveCount > 0 || pendingSpawnCount > 0 - ? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates made contact${processOnlyAlivePhrase ? `, ${processOnlyAlivePhrase}` : ''}${pendingSpawnCount > 0 ? `${processOnlyAlivePhrase ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` - : 'Team launched — teammates are still starting'; + ? `Team launched - all ${fallbackTeammateCount} teammates joined` + : hasMembersStillJoining + ? `Team launched - ${joiningPhrase}` + : 'Team launched - teammates are still joining'; const readyStepIndex = hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX; return ( @@ -223,7 +172,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ 0 ? readyDetailMessage : null} + message={failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null} messageSeverity={readyDetailSeverity} currentStepIndex={readyStepIndex} startedAt={progress.startedAt} @@ -271,5 +220,3 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ return null; }); - -const DISPLAY_COMPLETE_STEP_INDEX = 4; diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index a768869e..ce37f74b 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -81,8 +81,14 @@ export const PendingRepliesBlock = ({ const roleLabel = formatAgentRole( member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) ); - const advisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory); - const advisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory); + const advisoryLabel = getMemberRuntimeAdvisoryLabel( + member.runtimeAdvisory, + member.providerId + ); + const advisoryTitle = getMemberRuntimeAdvisoryTitle( + member.runtimeAdvisory, + member.providerId + ); const isRetrying = advisoryLabel !== null; return ( diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 7ef8746f..5361dc8a 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { @@ -44,6 +44,7 @@ interface MemberCardProps { spawnLivenessSource?: MemberSpawnLivenessSource; spawnLaunchState?: MemberLaunchState; spawnRuntimeAlive?: boolean; + isLaunchSettling?: boolean; onOpenTask?: () => void; onOpenReviewTask?: () => void; onClick?: () => void; @@ -68,6 +69,7 @@ export const MemberCard = ({ spawnLivenessSource, spawnLaunchState, spawnRuntimeAlive, + isLaunchSettling, onOpenTask, onOpenReviewTask, onClick, @@ -84,12 +86,19 @@ export const MemberCard = ({ spawnStatus, spawnLaunchState, spawnRuntimeAlive, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity ); - const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(member.runtimeAdvisory); - const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(member.runtimeAdvisory); + const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel( + member.runtimeAdvisory, + member.providerId + ); + const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle( + member.runtimeAdvisory, + member.providerId + ); const presenceLabel = getLaunchAwarePresenceLabel( member, spawnStatus, @@ -97,6 +106,7 @@ export const MemberCard = ({ spawnLivenessSource, spawnRuntimeAlive, member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity @@ -105,6 +115,7 @@ export const MemberCard = ({ spawnStatus, spawnLaunchState, spawnRuntimeAlive, + isLaunchSettling, isTeamAlive, isTeamProvisioning ); @@ -127,6 +138,7 @@ export const MemberCard = ({ spawnLaunchState !== 'failed_to_start' && !activityTask; const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask; + const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5); return (
void; @@ -50,6 +51,7 @@ export const MemberDetailDialog = ({ messages, isTeamAlive, isTeamProvisioning, + isLaunchSettling, leadActivity, spawnEntry, onClose, @@ -107,6 +109,7 @@ export const MemberDetailDialog = ({ spawnLaunchState={spawnEntry?.launchState} spawnLivenessSource={spawnEntry?.livenessSource} spawnRuntimeAlive={spawnEntry?.runtimeAlive} + isLaunchSettling={isLaunchSettling} onUpdateRole={ onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined } diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 826c4175..94f6e3f5 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -32,6 +32,7 @@ interface MemberDetailHeaderProps { spawnLaunchState?: MemberLaunchState; spawnLivenessSource?: MemberSpawnLivenessSource; spawnRuntimeAlive?: boolean; + isLaunchSettling?: boolean; onUpdateRole?: (newRole: string | undefined) => Promise | void; updatingRole?: boolean; } @@ -45,6 +46,7 @@ export const MemberDetailHeader = ({ spawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, + isLaunchSettling, onUpdateRole, updatingRole, }: MemberDetailHeaderProps): React.JSX.Element => { @@ -65,6 +67,7 @@ export const MemberDetailHeader = ({ spawnLivenessSource, spawnRuntimeAlive, member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity @@ -74,6 +77,7 @@ export const MemberDetailHeader = ({ spawnStatus, spawnLaunchState, spawnRuntimeAlive, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index c9ca7d31..ba991a7e 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -8,6 +8,7 @@ import { } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, @@ -19,6 +20,7 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import { ExternalLink } from 'lucide-react'; import { CurrentTaskIndicator } from './CurrentTaskIndicator'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps'; import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; @@ -49,21 +51,37 @@ export const MemberHoverCard = ({ const { isLight } = useTheme(); const selectedTeamName = useStore((s) => s.selectedTeamName); const effectiveTeamName = teamName ?? selectedTeamName; - const member = useStore((s) => { - if (!effectiveTeamName || s.selectedTeamName !== effectiveTeamName) return null; - return s.selectedTeamData?.members.find((m) => m.name === name) ?? null; + const { + member, + members, + isTeamAlive, + progress, + memberSpawnSnapshot, + memberSpawnStatuses, + spawnEntry, + leadActivity, + } = useStore((s) => { + const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName); + const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null; + return { + member: selectedTeamData?.members.find((m) => m.name === name) ?? null, + members: selectedTeamData?.members ?? [], + isTeamAlive: selectedTeamData?.isAlive, + progress: effectiveTeamName + ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) + : null, + memberSpawnSnapshot: effectiveTeamName + ? s.memberSpawnSnapshotsByTeam[effectiveTeamName] + : undefined, + memberSpawnStatuses: effectiveTeamName + ? s.memberSpawnStatusesByTeam[effectiveTeamName] + : undefined, + spawnEntry: effectiveTeamName + ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] + : undefined, + leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, + }; }); - const isTeamAlive = useStore((s) => - effectiveTeamName && s.selectedTeamName === effectiveTeamName - ? s.selectedTeamData?.isAlive - : undefined - ); - const spawnEntry = useStore((s) => - effectiveTeamName ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined - ); - const leadActivity: LeadActivityState | undefined = useStore((s) => - effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined - ); const openMemberProfile = useStore((s) => s.openMemberProfile); const tasks = useStore((s) => effectiveTeamName && s.selectedTeamName === effectiveTeamName @@ -75,6 +93,13 @@ export const MemberHoverCard = ({ return <>{children}; } + const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses, + memberSpawnSnapshot, + }); + const isLaunchSettling = + progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining; const colors = getTeamColorSet(color ?? member.color ?? ''); const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const presenceLabel = getLaunchAwarePresenceLabel( @@ -84,6 +109,7 @@ export const MemberHoverCard = ({ spawnEntry?.livenessSource, spawnEntry?.runtimeAlive, member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, false, isLeadMember(member) ? leadActivity : undefined @@ -93,6 +119,7 @@ export const MemberHoverCard = ({ spawnEntry?.status, spawnEntry?.launchState, spawnEntry?.runtimeAlive, + isLaunchSettling, isTeamAlive, false, isLeadMember(member) ? leadActivity : undefined diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index df0f46e8..f7b824c2 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -26,6 +26,7 @@ interface MemberListProps { taskMap?: Map; pendingRepliesByMember?: Record; memberSpawnStatuses?: Map; + isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; @@ -65,6 +66,7 @@ function areResolvedMembersEquivalent( leftMember.runtimeAdvisory?.observedAt !== rightMember.runtimeAdvisory?.observedAt || leftMember.runtimeAdvisory?.retryUntil !== rightMember.runtimeAdvisory?.retryUntil || leftMember.runtimeAdvisory?.retryDelayMs !== rightMember.runtimeAdvisory?.retryDelayMs || + leftMember.runtimeAdvisory?.reasonCode !== rightMember.runtimeAdvisory?.reasonCode || leftMember.runtimeAdvisory?.message !== rightMember.runtimeAdvisory?.message ) { return false; @@ -184,6 +186,7 @@ function areMemberListPropsEqual( areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && + prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && prev.leadActivity === next.leadActivity && @@ -197,6 +200,7 @@ export const MemberList = memo(function MemberList({ taskMap, pendingRepliesByMember, memberSpawnStatuses, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity, @@ -287,6 +291,7 @@ export const MemberList = memo(function MemberList({ spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive} + isLaunchSettling={isRemoved ? false : isLaunchSettling} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined} onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined} onClick={() => onMemberClick?.(member)} diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 1127cd38..6edd3771 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -1,4 +1,11 @@ -import type { TeamProvisioningState } from '@shared/types/team'; +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + ResolvedTeamMember, + TeamProvisioningProgress, +} from '@shared/types'; /** Display steps for the provisioning stepper (0-indexed). */ export const DISPLAY_STEPS = [ @@ -8,25 +15,187 @@ export const DISPLAY_STEPS = [ { key: 'finalizing', label: 'Finalizing' }, ] as const; -/** - * Maps a backend provisioning state to a 0-based display step index. - * Returns DISPLAY_STEPS.length for 'ready' (all steps complete), -1 for terminal/unknown. - */ -export function getDisplayStepIndex(state: Exclude): number { - switch (state) { - case 'validating': - case 'spawning': - return 0; - case 'configuring': - return 1; - case 'assembling': - return 2; - case 'finalizing': - case 'verifying': - return 3; - case 'ready': - return DISPLAY_STEPS.length; - default: - return -1; +export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length; + +export type LaunchJoinMilestones = { + expectedTeammateCount: number; + heartbeatConfirmedCount: number; + processOnlyAliveCount: number; + pendingSpawnCount: number; + failedSpawnCount: number; +}; + +type DisplayStepMilestones = LaunchJoinMilestones & { + progress: Pick; +}; + +type MemberSpawnStatusCollection = + | Record + | Map + | undefined; + +function getSpawnEntry( + memberSpawnStatuses: MemberSpawnStatusCollection, + memberName: string +): MemberSpawnStatusEntry | undefined { + if (!memberSpawnStatuses) { + return undefined; } + if (memberSpawnStatuses instanceof Map) { + return memberSpawnStatuses.get(memberName); + } + return memberSpawnStatuses[memberName]; +} + +export function getLaunchJoinMilestonesFromMembers({ + members, + memberSpawnStatuses, + memberSpawnSnapshot, +}: { + members: readonly ResolvedTeamMember[]; + memberSpawnStatuses?: MemberSpawnStatusCollection; + memberSpawnSnapshot?: Pick; +}): 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; } diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index 3ccb2c19..bc462313 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -208,3 +208,32 @@ export function getThemedText(colorSet: TeamColorSet, isLight: boolean): string export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): string { return isLight && colorSet.borderLight ? colorSet.borderLight : colorSet.border; } + +export function scaleColorAlpha(color: string, factor: number): string { + const safeFactor = Math.max(0, factor); + const rgbaMatch = color.match( + /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i + ); + if (rgbaMatch) { + const [, r, g, b, alpha] = rgbaMatch; + return `rgba(${r}, ${g}, ${b}, ${Number(alpha) * safeFactor})`; + } + + const hslaMatch = color.match( + /^hsla\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)$/i + ); + if (hslaMatch) { + const [, hue, saturation, lightness, alpha] = hslaMatch; + return `hsla(${hue}, ${saturation}, ${lightness}, ${Number(alpha) * safeFactor})`; + } + + const hexAlphaMatch = color.match(/^#([\da-f]{6})([\da-f]{2})$/i); + if (hexAlphaMatch) { + const [, hex, alphaHex] = hexAlphaMatch; + const alpha = parseInt(alphaHex, 16) / 255; + const scaledAlpha = Math.max(0, Math.min(255, Math.round(alpha * safeFactor * 255))); + return `#${hex}${scaledAlpha.toString(16).padStart(2, '0')}`; + } + + return color; +} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 3f74eb90..cd4e2c59 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -13,6 +13,7 @@ import type { MemberSpawnStatus, MemberStatus, ResolvedTeamMember, + TeamProviderId, TeamReviewState, TeamTaskStatus, } from '@shared/types'; @@ -110,13 +111,17 @@ export const SPAWN_PRESENCE_LABELS: Record = { function isLaunchStillStarting( spawnStatus: MemberSpawnStatus | undefined, spawnLaunchState: MemberLaunchState | undefined, - runtimeAlive: boolean | undefined + runtimeAlive: boolean | undefined, + keepRuntimePendingInStarting = false ): boolean { if (spawnLaunchState === 'failed_to_start') { return false; } - if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) { - return false; + if (spawnLaunchState === 'runtime_pending_bootstrap') { + if (runtimeAlive !== true) { + return true; + } + return keepRuntimePendingInStarting; } return spawnLaunchState === 'starting' || spawnStatus === 'waiting' || spawnStatus === 'spawning'; } @@ -130,17 +135,21 @@ export function getSpawnAwareDotClass( spawnStatus: MemberSpawnStatus | undefined, spawnLaunchState: MemberLaunchState | undefined, runtimeAlive: boolean | undefined, + isLaunchSettling = false, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { + const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; if (isTeamAlive === false && !isTeamProvisioning) { return STATUS_DOT_COLORS.terminated; } if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_DOT_COLORS.error; } - if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) { + if ( + isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) + ) { return spawnStatus === 'spawning' ? SPAWN_DOT_COLORS.spawning : SPAWN_DOT_COLORS.waiting; } if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') { @@ -170,22 +179,26 @@ export function getSpawnAwarePresenceLabel( spawnLaunchState: MemberLaunchState | undefined, livenessSource: MemberSpawnLivenessSource | undefined, runtimeAlive: boolean | undefined, + isLaunchSettling = false, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { + const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; if (isTeamAlive === false && !isTeamProvisioning) { return 'offline'; } if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_PRESENCE_LABELS.error; } - if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) { - return 'online'; - } - if (isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive)) { + if ( + isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) + ) { return 'starting'; } + if (spawnStatus === 'online' && keepLaunchSettlingVisuals) { + return SPAWN_PRESENCE_LABELS.online; + } if (spawnStatus === 'online' && livenessSource === 'process') { return 'online'; } @@ -203,12 +216,19 @@ export function getSpawnCardClass( spawnStatus: MemberSpawnStatus | undefined, spawnLaunchState?: MemberLaunchState, runtimeAlive?: boolean, + isLaunchSettling = false, isTeamAlive?: boolean, isTeamProvisioning?: boolean ): string { + const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; if (isTeamAlive === false && !isTeamProvisioning) { return 'opacity-40'; } + if ( + isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) + ) { + return 'member-waiting-shimmer'; + } switch (spawnStatus) { case 'offline': return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40'; @@ -221,9 +241,7 @@ export function getSpawnCardClass( case 'error': return 'opacity-80'; default: - return isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive) - ? 'member-waiting-shimmer' - : ''; + return ''; } } @@ -237,34 +255,121 @@ function formatRetryCountdown(ms: number): string { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } +function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined): string | null { + switch (providerId) { + case 'anthropic': + return 'Anthropic'; + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + default: + return null; + } +} + +function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string { + const trimmed = message?.trim(); + return trimmed ? `${base}\n\n${trimmed}` : base; +} + +function formatRuntimeAdvisoryBaseLabel( + advisory: MemberRuntimeAdvisory, + providerId: TeamProviderId | undefined +): string { + const providerLabel = getRuntimeAdvisoryProviderLabel(providerId); + switch (advisory.reasonCode) { + case 'quota_exhausted': + return providerLabel ? `${providerLabel} quota retry` : 'Quota retry'; + case 'rate_limited': + return providerLabel ? `${providerLabel} rate limit` : 'Rate limit retry'; + case 'auth_error': + return providerLabel ? `${providerLabel} auth retry` : 'Auth retry'; + case 'network_error': + return 'Network retry'; + case 'provider_overloaded': + return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry'; + case 'backend_error': + case 'unknown': + return 'Provider retry'; + default: + return 'retrying now'; + } +} + +function formatRuntimeAdvisoryTitle( + advisory: MemberRuntimeAdvisory, + providerId: TeamProviderId | undefined +): string { + const providerLabel = getRuntimeAdvisoryProviderLabel(providerId); + switch (advisory.reasonCode) { + case 'quota_exhausted': + return appendRuntimeAdvisoryRawMessage( + `${providerLabel ?? 'Provider'} quota exhausted. SDK is retrying automatically.`, + advisory.message + ); + case 'rate_limited': + return appendRuntimeAdvisoryRawMessage( + `${providerLabel ?? 'Provider'} rate limited the request. SDK is retrying automatically.`, + advisory.message + ); + case 'auth_error': + return appendRuntimeAdvisoryRawMessage( + `${providerLabel ?? 'Provider'} authentication issue. SDK is retrying automatically.`, + advisory.message + ); + case 'network_error': + return appendRuntimeAdvisoryRawMessage( + 'Network or connectivity issue. SDK is retrying automatically.', + advisory.message + ); + case 'provider_overloaded': + return appendRuntimeAdvisoryRawMessage( + 'Provider is temporarily overloaded. SDK is retrying automatically.', + advisory.message + ); + case 'backend_error': + case 'unknown': + return appendRuntimeAdvisoryRawMessage( + 'The SDK is retrying this request after a provider or backend error.', + advisory.message + ); + default: + return ( + advisory.message?.trim() || + 'The SDK is retrying this request after a provider or backend error.' + ); + } +} + export function getMemberRuntimeAdvisoryLabel( advisory: MemberRuntimeAdvisory | undefined, + providerId?: TeamProviderId, nowMs = Date.now() ): string | null { if (!advisory || advisory.kind !== 'sdk_retrying') { return null; } + const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId); const retryUntilMs = Date.parse(advisory.retryUntil); if (!Number.isFinite(retryUntilMs)) { - return 'retrying now'; + return baseLabel; } const remainingMs = retryUntilMs - nowMs; if (remainingMs <= 0) { - return 'retrying now'; + return baseLabel; } - return `retrying now · ${formatRetryCountdown(remainingMs)}`; + return `${baseLabel} · ${formatRetryCountdown(remainingMs)}`; } export function getMemberRuntimeAdvisoryTitle( - advisory: MemberRuntimeAdvisory | undefined + advisory: MemberRuntimeAdvisory | undefined, + providerId?: TeamProviderId ): string | undefined { if (!advisory || advisory.kind !== 'sdk_retrying') { return undefined; } - return ( - advisory.message?.trim() || - 'The SDK is retrying this request after a provider or backend error.' - ); + return formatRuntimeAdvisoryTitle(advisory, providerId); } export function getLaunchAwarePresenceLabel( @@ -274,13 +379,15 @@ export function getLaunchAwarePresenceLabel( livenessSource: MemberSpawnLivenessSource | undefined, runtimeAlive: boolean | undefined, runtimeAdvisory: MemberRuntimeAdvisory | undefined, + isLaunchSettling = false, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { + const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling; const advisoryLabel = - spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive - ? getMemberRuntimeAdvisoryLabel(runtimeAdvisory) + !keepLaunchSettlingVisuals && spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive + ? getMemberRuntimeAdvisoryLabel(runtimeAdvisory, member.providerId) : null; if (advisoryLabel) { return advisoryLabel; @@ -291,6 +398,7 @@ export function getLaunchAwarePresenceLabel( spawnLaunchState, livenessSource, runtimeAlive, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dfff29d4..c0b6df1a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -506,6 +506,14 @@ export interface MemberRuntimeAdvisory { observedAt: string; retryUntil: string; retryDelayMs: number; + reasonCode?: + | 'quota_exhausted' + | 'rate_limited' + | 'auth_error' + | 'network_error' + | 'provider_overloaded' + | 'backend_error' + | 'unknown'; message?: string; } diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 94b3c086..2e6e5098 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -38,6 +38,7 @@ function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory { observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2026-04-09T10:01:00.000Z', retryDelayMs: 60_000, + reasonCode: 'backend_error', message: `retry:${label}`, }; } @@ -145,9 +146,51 @@ describe('TeamMemberRuntimeAdvisoryService', () => { expect(advisory).not.toBeNull(); expect(advisory?.kind).toBe('sdk_retrying'); + expect(advisory?.reasonCode).toBe('quota_exhausted'); expect(advisory?.message).toContain('capacity exceeded'); }); + it.each([ + ['rate_limited', 'Provider returned 429 rate limit for this request.'], + ['auth_error', 'Authentication failed due to invalid API key.'], + ['network_error', 'Fetch failed because the network connection timed out.'], + ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], + ['backend_error', 'Unexpected backend blew up during request processing.'], + ] as const)('classifies %s retry causes from api_error messages', async (expected, message) => { + const service = new TeamMemberRuntimeAdvisoryService({} as never); + const advisory = (service as any).extractApiRetryAdvisory( + JSON.stringify({ + type: 'system', + subtype: 'api_error', + timestamp: '2099-04-09T10:00:00.000Z', + retryInMs: 45_000, + error: { + error: { + error: { + message, + }, + }, + }, + }) + ) as MemberRuntimeAdvisory | null; + + expect(advisory?.reasonCode).toBe(expected); + }); + + it('classifies missing api_error message text as unknown', () => { + const service = new TeamMemberRuntimeAdvisoryService({} as never); + const advisory = (service as any).extractApiRetryAdvisory( + JSON.stringify({ + type: 'system', + subtype: 'api_error', + timestamp: '2099-04-09T10:00:00.000Z', + retryInMs: 45_000, + }) + ) as MemberRuntimeAdvisory | null; + + expect(advisory?.reasonCode).toBe('unknown'); + }); + it('ignores expired retry advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index 3ac59d24..d0f777f6 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -112,7 +112,117 @@ describe('TeamProvisioningBanner launch-step alignment', () => { const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); - expect(block?.textContent).toContain('0/3 teammates made contact'); + expect(block?.textContent).toContain('3 teammates still joining'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps Starting active until a real provisioning pid exists', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.progress = { + runId: 'run-1', + teamName: 'northstar-core', + state: 'configuring', + startedAt: '2026-04-08T16:00:00.000Z', + message: 'Waiting for team configuration...', + messageSeverity: undefined, + cliLogsTail: '', + assistantOutput: '', + configReady: false, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('0'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps Team setup active while config is not ready after the process starts', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.progress = { + runId: 'run-1', + teamName: 'northstar-core', + state: 'configuring', + startedAt: '2026-04-08T16:00:00.000Z', + message: 'Waiting for team configuration...', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + configReady: false, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('advances to Finalizing once teammate runtimes are attached even before contact is confirmed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.progress = { + runId: 'run-1', + teamName: 'northstar-core', + state: 'finalizing', + startedAt: '2026-04-08T16:00:00.000Z', + message: 'Waiting for teammate bootstrap confirmations...', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + configReady: true, + }; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { + runId: 'run-1', + expectedMembers: ['alice', 'bob', 'jack'], + statuses: {}, + summary: { + confirmedCount: 0, + pendingCount: 3, + failedCount: 0, + runtimeAlivePendingCount: 3, + }, + source: 'merged', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('3'); await act(async () => { root.unmount(); @@ -146,7 +256,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('4'); - expect(block?.textContent).toContain('all 3 teammates made contact'); + expect(block?.textContent).toContain('all 3 teammates joined'); await act(async () => { root.unmount(); @@ -181,7 +291,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.getAttribute('data-success-severity')).toBe('warning'); - expect(block?.textContent).toContain('teammates online'); + expect(block?.textContent).toContain('3 teammates still joining'); await act(async () => { root.unmount(); @@ -267,7 +377,7 @@ describe('TeamProvisioningBanner launch-step alignment', () => { }); const block = host.querySelector('[data-testid="progress-block"]'); - expect(block?.textContent).toContain('retrying provider capacity'); + expect(block?.textContent).toContain('2 teammates still joining'); expect(block?.getAttribute('data-success-severity')).toBe('warning'); await act(async () => { diff --git a/test/renderer/components/team/activity/PendingRepliesBlock.test.ts b/test/renderer/components/team/activity/PendingRepliesBlock.test.ts new file mode 100644 index 00000000..ffca383e --- /dev/null +++ b/test/renderer/components/team/activity/PendingRepliesBlock.test.ts @@ -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(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 6598e873..fa012866 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -17,7 +17,8 @@ vi.mock('@renderer/components/ui/badge', () => ({ })); vi.mock('@renderer/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), TooltipTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), TooltipContent: ({ children }: { children: React.ReactNode }) => @@ -44,6 +45,7 @@ const member: ResolvedTeamMember = { color: 'blue', agentType: 'reviewer', role: 'Reviewer', + providerId: 'gemini', removedAt: undefined, }; @@ -100,6 +102,7 @@ describe('MemberCard starting-state visuals', () => { observedAt: '2026-04-07T09:00:00.000Z', retryUntil: '2099-04-07T09:00:45.000Z', retryDelayMs: 45_000, + reasonCode: 'quota_exhausted', }, }, memberColor: 'blue', @@ -113,7 +116,7 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('retrying now'); + expect(host.textContent).toContain('Gemini quota retry'); expect(host.textContent).not.toContain('online'); await act(async () => { @@ -121,4 +124,70 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('keeps the starting skeleton visible while a runtime is alive but still joining', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeSummary: 'Anthropic · sonnet · Medium', + isTeamAlive: true, + isTeamProvisioning: false, + isLaunchSettling: true, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnRuntimeAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('starting'); + expect(host.textContent).not.toContain('online'); + expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull(); + expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThan(0); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows ready instead of idle for confirmed teammates while launch is still settling', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + runtimeSummary: 'Anthropic · sonnet · Medium', + isTeamAlive: true, + isTeamProvisioning: false, + isLaunchSettling: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('ready'); + expect(host.textContent).not.toContain('idle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailHeader.test.ts b/test/renderer/components/team/members/MemberDetailHeader.test.ts index bc05a375..57bb9890 100644 --- a/test/renderer/components/team/members/MemberDetailHeader.test.ts +++ b/test/renderer/components/team/members/MemberDetailHeader.test.ts @@ -68,4 +68,34 @@ describe('MemberDetailHeader spawn-aware presence', () => { await Promise.resolve(); }); }); + + it('shows ready instead of idle while launch is still settling after contact', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailHeader, { + member, + isTeamAlive: true, + isTeamProvisioning: false, + isLaunchSettling: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('ready'); + expect(host.textContent).not.toContain('idle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index fb467514..8a595e47 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -24,6 +24,7 @@ const storeState = { tasks: [], }, selectedTeamName: 'northstar-core', + progress: null as Record | null, memberSpawnStatusesByTeam: { 'northstar-core': { alice: { @@ -33,7 +34,22 @@ const storeState = { runtimeAlive: false, }, }, - }, + } as Record< + string, + Record< + string, + { + status: string; + launchState: string; + updatedAt: string; + runtimeAlive: boolean; + livenessSource?: string; + } + > + >, + memberSpawnSnapshotsByTeam: { + 'northstar-core': undefined, + } as Record, leadActivityByTeam: {}, openMemberProfile: vi.fn(), }; @@ -42,6 +58,10 @@ vi.mock('@renderer/store', () => ({ useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), })); +vi.mock('@renderer/store/slices/teamSlice', () => ({ + getCurrentProvisioningProgressForTeam: () => storeState.progress, +})); + vi.mock('@renderer/hooks/useTheme', () => ({ useTheme: () => ({ isLight: false }), })); @@ -77,12 +97,14 @@ describe('MemberHoverCard spawn-aware presence', () => { storeState.selectedTeamData.isAlive = true; storeState.selectedTeamData.tasks = []; storeState.selectedTeamName = 'northstar-core'; + storeState.progress = null; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'spawning', launchState: 'starting', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, }; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; storeState.openMemberProfile.mockReset(); }); @@ -110,4 +132,57 @@ describe('MemberHoverCard spawn-aware presence', () => { await Promise.resolve(); }); }); + + it('keeps runtime-pending members in starting state while launch is still settling', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.progress = { + runId: 'run-1', + teamName: 'northstar-core', + state: 'ready', + startedAt: '2026-04-09T10:00:00.000Z', + pid: 4321, + configReady: true, + }; + storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { + status: 'online', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: true, + livenessSource: 'process', + }; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { + runId: 'run-1', + expectedMembers: ['alice'], + statuses: {}, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + source: 'merged', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberHoverCard, { + name: 'alice', + children: React.createElement('button', { type: 'button' }, 'alice'), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('starting'); + expect(host.textContent).not.toContain('online'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/constants/teamColors.test.ts b/test/renderer/constants/teamColors.test.ts index cfc389a7..2ae91058 100644 --- a/test/renderer/constants/teamColors.test.ts +++ b/test/renderer/constants/teamColors.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { getSubagentTypeColorSet, getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors'; +import { + getSubagentTypeColorSet, + getTeamColorSet, + scaleColorAlpha, + TeamColorSet, +} from '@renderer/constants/teamColors'; function isValidColorSet(cs: TeamColorSet): boolean { - return typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string'; + return ( + typeof cs.border === 'string' && typeof cs.badge === 'string' && typeof cs.text === 'string' + ); } // ============================================================================= @@ -71,8 +78,16 @@ describe('getSubagentTypeColorSet', () => { it('different types can produce different colors', () => { const results = new Set( - ['Explore', 'Plan', 'test-agent', 'quality-fixer', 'claude-md-auditor', 'Bash', 'general-purpose', 'statusline-setup'] - .map((t) => getSubagentTypeColorSet(t).border) + [ + 'Explore', + 'Plan', + 'test-agent', + 'quality-fixer', + 'claude-md-auditor', + 'Bash', + 'general-purpose', + 'statusline-setup', + ].map((t) => getSubagentTypeColorSet(t).border) ); expect(results.size).toBeGreaterThan(1); }); @@ -124,3 +139,17 @@ describe('getSubagentTypeColorSet', () => { expect(getTeamColorSet('green').border).toBe('#22c55e'); }); }); + +describe('scaleColorAlpha', () => { + it('halves rgba badge opacity', () => { + expect(scaleColorAlpha('rgba(59, 130, 246, 0.15)', 0.5)).toBe('rgba(59, 130, 246, 0.075)'); + }); + + it('halves hsla badge opacity', () => { + expect(scaleColorAlpha('hsla(220, 80%, 50%, 0.12)', 0.5)).toBe('hsla(220, 80%, 50%, 0.06)'); + }); + + it('halves hex alpha badge opacity', () => { + expect(scaleColorAlpha('#ff550026', 0.5)).toBe('#ff550013'); + }); +}); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 00e7b44c..55e5875a 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -19,6 +19,7 @@ const member: ResolvedTeamMember = { color: 'blue', agentType: 'reviewer', role: 'Reviewer', + providerId: 'gemini', removedAt: undefined, }; @@ -31,6 +32,7 @@ describe('memberHelpers spawn-aware presence', () => { 'runtime_pending_bootstrap', 'process', true, + false, true, false, undefined @@ -43,6 +45,7 @@ describe('memberHelpers spawn-aware presence', () => { 'online', 'runtime_pending_bootstrap', true, + false, true, false, undefined @@ -58,6 +61,7 @@ describe('memberHelpers spawn-aware presence', () => { 'starting', undefined, false, + false, true, false, undefined @@ -73,17 +77,22 @@ describe('memberHelpers spawn-aware presence', () => { 'starting', undefined, false, + false, true, false, undefined ) ).toBe('starting'); - expect(getSpawnAwareDotClass(member, 'spawning', 'starting', false, true, false, undefined)).toContain( + expect( + getSpawnAwareDotClass(member, 'spawning', 'starting', false, false, true, false, undefined) + ).toContain( 'bg-amber-400' ); - expect(getSpawnCardClass('spawning', 'starting', false)).toContain('member-waiting-shimmer'); + expect(getSpawnCardClass('spawning', 'starting', false, false)).toContain( + 'member-waiting-shimmer' + ); }); it('shows offline instead of stale starting visuals when the team is offline', () => { @@ -96,6 +105,7 @@ describe('memberHelpers spawn-aware presence', () => { false, false, false, + false, undefined ) ).toBe('offline'); @@ -108,11 +118,63 @@ describe('memberHelpers spawn-aware presence', () => { false, false, false, + false, undefined ) ).toContain('bg-red-400'); - expect(getSpawnCardClass('spawning', 'starting', false, false, false)).toBe('opacity-40'); + expect(getSpawnCardClass('spawning', 'starting', false, false, false, false)).toBe( + 'opacity-40' + ); + }); + + it('keeps runtime-pending teammates in starting state while launch is still settling', () => { + expect( + getSpawnAwarePresenceLabel( + member, + 'online', + 'runtime_pending_bootstrap', + 'process', + true, + true, + true, + false, + undefined + ) + ).toBe('starting'); + + expect( + getSpawnAwareDotClass( + member, + 'online', + 'runtime_pending_bootstrap', + true, + true, + true, + false, + undefined + ) + ).toContain('bg-zinc-400'); + + expect(getSpawnCardClass('online', 'runtime_pending_bootstrap', true, true, true, false)).toContain( + 'member-waiting-shimmer' + ); + }); + + it('shows confirmed teammates as ready instead of idle while launch is still settling', () => { + expect( + getSpawnAwarePresenceLabel( + member, + 'online', + 'confirmed_alive', + 'heartbeat', + true, + true, + true, + false, + undefined + ) + ).toBe('ready'); }); it('renders unified retry advisory labels for provider retries', () => { @@ -123,21 +185,74 @@ describe('memberHelpers spawn-aware presence', () => { observedAt: '2026-04-07T09:00:00.000Z', retryUntil: '2026-04-07T09:00:45.000Z', retryDelayMs: 45_000, + reasonCode: 'quota_exhausted', message: 'Gemini cli backend error: capacity exceeded.', }, + 'gemini', + Date.parse('2026-04-07T09:00:00.000Z') + ) + ).toBe('Gemini quota retry · 45s'); + + expect( + getMemberRuntimeAdvisoryTitle( + { + kind: 'sdk_retrying', + observedAt: '2026-04-07T09:00:00.000Z', + retryUntil: '2026-04-07T09:00:45.000Z', + retryDelayMs: 45_000, + reasonCode: 'rate_limited', + message: 'Gemini cli backend error: rate limit 429.', + }, + 'gemini' + ) + ).toContain('Gemini rate limited the request'); + }); + + it('keeps network advisories provider-neutral and appends raw details to the title', () => { + expect( + getMemberRuntimeAdvisoryLabel( + { + kind: 'sdk_retrying', + observedAt: '2026-04-07T09:00:00.000Z', + retryUntil: '2026-04-07T09:00:45.000Z', + retryDelayMs: 45_000, + reasonCode: 'network_error', + message: 'Connection timed out while contacting provider.', + }, + 'gemini', + Date.parse('2026-04-07T09:00:00.000Z') + ) + ).toBe('Network retry · 45s'); + + expect( + getMemberRuntimeAdvisoryTitle( + { + kind: 'sdk_retrying', + observedAt: '2026-04-07T09:00:00.000Z', + retryUntil: '2026-04-07T09:00:45.000Z', + retryDelayMs: 45_000, + reasonCode: 'network_error', + message: 'Connection timed out while contacting provider.', + }, + 'gemini' + ) + ).toContain('Connection timed out while contacting provider.'); + }); + + it('falls back to the existing generic retry wording when no structured reason is present', () => { + expect( + getMemberRuntimeAdvisoryLabel( + { + kind: 'sdk_retrying', + observedAt: '2026-04-07T09:00:00.000Z', + retryUntil: '2026-04-07T09:00:45.000Z', + retryDelayMs: 45_000, + message: 'Gemini cli backend error: capacity exceeded.', + }, + 'gemini', Date.parse('2026-04-07T09:00:00.000Z') ) ).toBe('retrying now · 45s'); - - expect( - getMemberRuntimeAdvisoryTitle({ - kind: 'sdk_retrying', - observedAt: '2026-04-07T09:00:00.000Z', - retryUntil: '2026-04-07T09:00:45.000Z', - retryDelayMs: 45_000, - message: 'Gemini cli backend error: capacity exceeded.', - }) - ).toContain('capacity exceeded'); }); it('surfaces retry advisory text instead of plain online while bootstrap contact is still pending', () => { @@ -153,13 +268,15 @@ describe('memberHelpers spawn-aware presence', () => { observedAt: '2026-04-07T09:00:00.000Z', retryUntil: '2099-04-07T09:00:45.000Z', retryDelayMs: 45_000, + reasonCode: 'quota_exhausted', message: 'Gemini cli backend error: capacity exceeded.', }, + false, true, false, undefined ) - ).toContain('retrying now'); + ).toContain('Gemini quota retry'); expect( getLaunchAwarePresenceLabel( @@ -173,12 +290,14 @@ describe('memberHelpers spawn-aware presence', () => { observedAt: '2026-04-07T09:00:00.000Z', retryUntil: '2099-04-07T09:00:45.000Z', retryDelayMs: 45_000, + reasonCode: 'quota_exhausted', message: 'Gemini cli backend error: capacity exceeded.', }, + false, true, false, undefined ) - ).toBe('online'); + ).toBe('starting'); }); });