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",
|
"version": "0.0.12",
|
||||||
"sourceRef": "v0.0.11",
|
"sourceRef": "v0.0.12",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.11.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.12.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormal
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
||||||
|
import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -56,6 +57,7 @@ import {
|
||||||
|
|
||||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||||
|
import type { TeamStatus } from '@renderer/utils/teamListStatus';
|
||||||
import type {
|
import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamCreateRequest,
|
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[] {
|
function getRecentProjects(team: TeamSummary): string[] {
|
||||||
const history = team.projectPathHistory;
|
const history = team.projectPathHistory;
|
||||||
if (!history || history.length === 0) {
|
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 => {
|
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
|
|
@ -362,38 +325,68 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
return synthetic.length > 0 ? [...teams, ...synthetic] : teams;
|
return synthetic.length > 0 ? [...teams, ...synthetic] : teams;
|
||||||
}, [teams, provisioningTeamNames, provisioningSnapshotByTeam]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!electronMode) return;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchAlive = async (): Promise<void> => {
|
void fetchAliveTeams().then((list) => {
|
||||||
try {
|
if (!cancelled && list) {
|
||||||
const list = await api.teams.aliveList();
|
setAliveTeams(list);
|
||||||
if (!cancelled) setAliveTeams(list);
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
void fetchAlive();
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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.
|
// Refresh alive teams when opening the create dialog so conflict warning is accurate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronMode || !showCreateDialog) return;
|
if (!electronMode || !showCreateDialog) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void api.teams
|
void fetchAliveTeams().then((list) => {
|
||||||
.aliveList()
|
if (!cancelled && list) {
|
||||||
.then((list) => {
|
setAliveTeams(list);
|
||||||
if (!cancelled) setAliveTeams(list);
|
}
|
||||||
})
|
});
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [electronMode, showCreateDialog]);
|
}, [electronMode, fetchAliveTeams, showCreateDialog]);
|
||||||
|
|
||||||
const currentProjectSelection = useMemo(
|
const currentProjectSelection = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -438,24 +431,32 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
||||||
leadActivityByTeam
|
leadActivityByTeam
|
||||||
);
|
);
|
||||||
const isRunning =
|
const isRunning = isTeamListStatusRunning(status);
|
||||||
status !== 'offline' && status !== 'partial_failure' && status !== 'partial_pending';
|
|
||||||
if (filter.selectedStatuses.has('running') && isRunning) return true;
|
if (filter.selectedStatuses.has('running') && isRunning) return true;
|
||||||
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
|
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliveSet = new Set(aliveTeams);
|
|
||||||
const matchesCurrentProject = currentProjectPath
|
const matchesCurrentProject = currentProjectPath
|
||||||
? (team: TeamSummary): boolean => teamMatchesProjectSelection(team, currentProjectPath)
|
? (team: TeamSummary): boolean => teamMatchesProjectSelection(team, currentProjectPath)
|
||||||
: null;
|
: 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) => {
|
result = [...result].sort((a, b) => {
|
||||||
// 1. Alive (running) teams first
|
// 1. Running teams first, including the short ready-before-alive-list gap.
|
||||||
const aliveA = aliveSet.has(a.teamName) ? 0 : 1;
|
const runningA = isTeamListStatusRunning(statusForTeam(a)) ? 0 : 1;
|
||||||
const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
|
const runningB = isTeamListStatusRunning(statusForTeam(b)) ? 0 : 1;
|
||||||
if (aliveA !== aliveB) return aliveA - aliveB;
|
if (runningA !== runningB) return runningA - runningB;
|
||||||
|
|
||||||
// 2. Teams related to the selected project are prioritized next
|
// 2. Teams related to the selected project are prioritized next
|
||||||
if (matchesCurrentProject) {
|
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