feat(team): refine team list status display

This commit is contained in:
777genius 2026-04-28 23:15:32 +03:00
parent 4895a00474
commit 756fd7f537
4 changed files with 272 additions and 70 deletions

View file

@ -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"
}

View file

@ -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<typeof getCurrentProvisioningProgressForTeam>,
leadActivityByTeam: Record<string, string>
): 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<string[] | null> => {
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<void> => {
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) {

View file

@ -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<TeamProvisioningProgress['state']>([
'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<Record<string, LeadActivityState>>,
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';
}

View file

@ -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> = {}): 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);
});
});