From 756fd7f537553a24dd7722d011411e33e192bcb6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 23:15:32 +0300 Subject: [PATCH] feat(team): refine team list status display --- runtime.lock.json | 12 +- src/renderer/components/team/TeamListView.tsx | 129 +++++++++--------- src/renderer/utils/teamListStatus.ts | 78 +++++++++++ test/renderer/utils/teamListStatus.test.ts | 123 +++++++++++++++++ 4 files changed, 272 insertions(+), 70 deletions(-) create mode 100644 src/renderer/utils/teamListStatus.ts create mode 100644 test/renderer/utils/teamListStatus.test.ts diff --git a/runtime.lock.json b/runtime.lock.json index 41c72111..11995845 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.11", - "sourceRef": "v0.0.11", + "version": "0.0.12", + "sourceRef": "v0.0.12", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.11.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.11.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.11.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.11.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.12.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 29862972..357ee15a 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -29,6 +29,7 @@ import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormal import { getBaseName } from '@renderer/utils/pathUtils'; import { nameColorSet } from '@renderer/utils/projectColor'; import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy'; +import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus'; import { isLeadMember } from '@shared/utils/leadDetection'; import { CheckCircle, @@ -56,6 +57,7 @@ import { import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; +import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { ResolvedTeamMember, TeamCreateRequest, @@ -76,15 +78,6 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string } } -type TeamStatus = - | 'active' - | 'idle' - | 'provisioning' - | 'offline' - | 'partial_failure' - | 'partial_skipped' - | 'partial_pending'; - function getRecentProjects(team: TeamSummary): string[] { const history = team.projectPathHistory; if (!history || history.length === 0) { @@ -186,36 +179,6 @@ function renderTeamRecentPaths( ); } -function resolveTeamStatus( - team: TeamSummary, - teamName: string, - aliveTeams: string[], - currentProgress: ReturnType, - leadActivityByTeam: Record -): TeamStatus { - if (aliveTeams.includes(teamName)) { - return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle'; - } - if ( - currentProgress && - ['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying'].includes( - currentProgress.state - ) - ) { - return 'provisioning'; - } - if (team.teamLaunchState === 'partial_pending') { - return 'partial_pending'; - } - if (team.teamLaunchState === 'partial_skipped') { - return 'partial_skipped'; - } - if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') { - return 'partial_failure'; - } - return 'offline'; -} - const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { switch (status) { case 'active': @@ -362,38 +325,68 @@ export const TeamListView = (): React.JSX.Element => { return synthetic.length > 0 ? [...teams, ...synthetic] : teams; }, [teams, provisioningTeamNames, provisioningSnapshotByTeam]); - // Fetch alive teams on mount and when teams list changes + const fetchAliveTeams = useCallback(async (): Promise => { + if (!electronMode) return null; + try { + return await api.teams.aliveList(); + } catch { + return null; + } + }, [electronMode]); + + // Fetch alive teams on mount and when teams list changes. useEffect(() => { - if (!electronMode) return; let cancelled = false; - const fetchAlive = async (): Promise => { - try { - const list = await api.teams.aliveList(); - if (!cancelled) setAliveTeams(list); - } catch { - // best-effort + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); } - }; - void fetchAlive(); + }); return () => { cancelled = true; }; - }, [electronMode, teams]); + }, [fetchAliveTeams, teams]); + + const readyProgressRefreshKey = useMemo(() => { + return Object.entries(currentProvisioningRunIdByTeam) + .map(([teamName, runId]) => { + if (!runId) return null; + const progress = provisioningRuns[runId]; + return progress?.state === 'ready' + ? `${teamName}:${progress.runId}:${progress.updatedAt}` + : null; + }) + .filter((item): item is string => Boolean(item)) + .join('|'); + }, [currentProvisioningRunIdByTeam, provisioningRuns]); + + // Terminal launch progress can arrive before aliveList catches up. + useEffect(() => { + if (!readyProgressRefreshKey) return; + let cancelled = false; + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); + } + }); + return () => { + cancelled = true; + }; + }, [fetchAliveTeams, readyProgressRefreshKey]); // Refresh alive teams when opening the create dialog so conflict warning is accurate. useEffect(() => { if (!electronMode || !showCreateDialog) return; let cancelled = false; - void api.teams - .aliveList() - .then((list) => { - if (!cancelled) setAliveTeams(list); - }) - .catch(() => undefined); + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); + } + }); return () => { cancelled = true; }; - }, [electronMode, showCreateDialog]); + }, [electronMode, fetchAliveTeams, showCreateDialog]); const currentProjectSelection = useMemo( () => @@ -438,24 +431,32 @@ export const TeamListView = (): React.JSX.Element => { getCurrentProvisioningProgressForTeam(provisioningState, t.teamName), leadActivityByTeam ); - const isRunning = - status !== 'offline' && status !== 'partial_failure' && status !== 'partial_pending'; + const isRunning = isTeamListStatusRunning(status); if (filter.selectedStatuses.has('running') && isRunning) return true; if (filter.selectedStatuses.has('offline') && !isRunning) return true; return false; }); } - const aliveSet = new Set(aliveTeams); const matchesCurrentProject = currentProjectPath ? (team: TeamSummary): boolean => teamMatchesProjectSelection(team, currentProjectPath) : null; + const nowMs = Date.now(); + const statusForTeam = (team: TeamSummary): TeamStatus => + resolveTeamStatus( + team, + team.teamName, + aliveTeams, + getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), + leadActivityByTeam, + nowMs + ); result = [...result].sort((a, b) => { - // 1. Alive (running) teams first - const aliveA = aliveSet.has(a.teamName) ? 0 : 1; - const aliveB = aliveSet.has(b.teamName) ? 0 : 1; - if (aliveA !== aliveB) return aliveA - aliveB; + // 1. Running teams first, including the short ready-before-alive-list gap. + const runningA = isTeamListStatusRunning(statusForTeam(a)) ? 0 : 1; + const runningB = isTeamListStatusRunning(statusForTeam(b)) ? 0 : 1; + if (runningA !== runningB) return runningA - runningB; // 2. Teams related to the selected project are prioritized next if (matchesCurrentProject) { diff --git a/src/renderer/utils/teamListStatus.ts b/src/renderer/utils/teamListStatus.ts new file mode 100644 index 00000000..bab7c4a4 --- /dev/null +++ b/src/renderer/utils/teamListStatus.ts @@ -0,0 +1,78 @@ +import type { LeadActivityState, TeamProvisioningProgress, TeamSummary } from '@shared/types'; + +export type TeamStatus = + | 'active' + | 'idle' + | 'provisioning' + | 'offline' + | 'partial_failure' + | 'partial_skipped' + | 'partial_pending'; + +const ACTIVE_PROVISIONING_STATES = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +const READY_RUNNING_GRACE_MS = 45_000; + +function isRecentReadyProgress( + currentProgress: TeamProvisioningProgress | null, + nowMs: number +): boolean { + if (currentProgress?.state !== 'ready') { + return false; + } + + const updatedAtMs = Date.parse(currentProgress.updatedAt); + return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs <= READY_RUNNING_GRACE_MS; +} + +export function resolveTeamStatus( + team: TeamSummary, + teamName: string, + aliveTeams: string[], + currentProgress: TeamProvisioningProgress | null, + leadActivityByTeam: Partial>, + nowMs: number = Date.now() +): TeamStatus { + if (currentProgress && ACTIVE_PROVISIONING_STATES.has(currentProgress.state)) { + return 'provisioning'; + } + + const leadActivity = leadActivityByTeam[teamName]; + if (leadActivity === 'offline') { + return 'offline'; + } + + if (aliveTeams.includes(teamName)) { + return leadActivity === 'active' ? 'active' : 'idle'; + } + + if (team.teamLaunchState === 'partial_pending') { + return 'partial_pending'; + } + if (team.teamLaunchState === 'partial_skipped') { + return 'partial_skipped'; + } + if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') { + return 'partial_failure'; + } + + // The alive-list API is refreshed asynchronously after terminal launch progress. + // Keep a short optimistic running state to avoid a false Offline flicker between + // progress=ready and the next authoritative alive-list response. + if (isRecentReadyProgress(currentProgress, nowMs)) { + return leadActivity === 'active' ? 'active' : 'idle'; + } + + return 'offline'; +} + +export function isTeamListStatusRunning(status: TeamStatus): boolean { + return status !== 'offline' && status !== 'partial_failure' && status !== 'partial_pending'; +} diff --git a/test/renderer/utils/teamListStatus.test.ts b/test/renderer/utils/teamListStatus.test.ts new file mode 100644 index 00000000..4fc345ac --- /dev/null +++ b/test/renderer/utils/teamListStatus.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus'; + +import type { TeamProvisioningProgress, TeamSummary } from '@shared/types'; + +function team(patch: Partial = {}): TeamSummary { + return { + teamName: 'atlas-hq-10', + displayName: 'atlas-hq-10', + description: '', + color: 'blue', + memberCount: 4, + members: [], + taskCount: 0, + lastActivity: null, + ...patch, + } as TeamSummary; +} + +function progress( + state: TeamProvisioningProgress['state'], + updatedAt: string +): TeamProvisioningProgress { + return { + runId: 'run-1', + teamName: 'atlas-hq-10', + state, + message: state, + startedAt: updatedAt, + updatedAt, + }; +} + +describe('team list status', () => { + const nowMs = Date.parse('2026-04-28T20:00:00.000Z'); + + it('treats active provisioning as launching even if the previous lead state was offline', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('assembling', '2026-04-28T19:59:59.000Z'), + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('provisioning'); + }); + + it('keeps a recent ready launch running until aliveList catches up', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + {}, + nowMs + ) + ).toBe('idle'); + }); + + it('does not let optimistic ready override an explicit offline lead event', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('offline'); + }); + + it('does not let stale aliveList data override an explicit offline lead event', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + ['atlas-hq-10'], + null, + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('offline'); + }); + + it('expires optimistic ready state if aliveList still does not report the team alive', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:58:00.000Z'), + {}, + nowMs + ) + ).toBe('offline'); + }); + + it('does not mask partial launch failures as optimistic running', () => { + expect( + resolveTeamStatus( + team({ partialLaunchFailure: true, teamLaunchState: 'partial_failure' }), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + {}, + nowMs + ) + ).toBe('partial_failure'); + }); + + it('classifies running filter state consistently', () => { + expect(isTeamListStatusRunning('idle')).toBe(true); + expect(isTeamListStatusRunning('provisioning')).toBe(true); + expect(isTeamListStatusRunning('offline')).toBe(false); + expect(isTeamListStatusRunning('partial_failure')).toBe(false); + expect(isTeamListStatusRunning('partial_pending')).toBe(false); + }); +});