feat(team): refine team list status display
This commit is contained in:
parent
4895a00474
commit
756fd7f537
4 changed files with 272 additions and 70 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
78
src/renderer/utils/teamListStatus.ts
Normal file
78
src/renderer/utils/teamListStatus.ts
Normal 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';
|
||||
}
|
||||
123
test/renderer/utils/teamListStatus.test.ts
Normal file
123
test/renderer/utils/teamListStatus.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue