feat: integrate provisioning snapshots into TeamListView for enhanced team display

- Added provisioningSnapshotByTeam to manage synthetic team summaries during active provisioning.
- Implemented teamsWithProvisioning to merge real teams with synthetic cards, improving the team list display.
- Updated filtering and rendering logic in TeamListView to utilize the new teamsWithProvisioning array, ensuring accurate representation of teams during provisioning states.
This commit is contained in:
iliya 2026-03-15 11:03:26 +02:00
parent 7502cc8e53
commit 65a9928cb5
2 changed files with 60 additions and 6 deletions

View file

@ -225,6 +225,7 @@ export const TeamListView = (): React.JSX.Element => {
provisioningErrorByTeam, provisioningErrorByTeam,
clearProvisioningError, clearProvisioningError,
provisioningRuns, provisioningRuns,
provisioningSnapshotByTeam,
currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam,
leadActivityByTeam, leadActivityByTeam,
} = useStore( } = useStore(
@ -234,6 +235,7 @@ export const TeamListView = (): React.JSX.Element => {
provisioningErrorByTeam: s.provisioningErrorByTeam, provisioningErrorByTeam: s.provisioningErrorByTeam,
clearProvisioningError: s.clearProvisioningError, clearProvisioningError: s.clearProvisioningError,
provisioningRuns: s.provisioningRuns, provisioningRuns: s.provisioningRuns,
provisioningSnapshotByTeam: s.provisioningSnapshotByTeam,
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
leadActivityByTeam: s.leadActivityByTeam, leadActivityByTeam: s.leadActivityByTeam,
})) }))
@ -251,6 +253,15 @@ export const TeamListView = (): React.JSX.Element => {
); );
}, [currentProvisioningRunIdByTeam, provisioningState]); }, [currentProvisioningRunIdByTeam, provisioningState]);
/** Merge real teams with synthetic launching cards for active provisioning. */
const teamsWithProvisioning = useMemo(() => {
const existingNames = new Set(teams.map((t) => t.teamName));
const synthetic = provisioningTeamNames
.filter((name) => !existingNames.has(name) && provisioningSnapshotByTeam[name])
.map((name) => provisioningSnapshotByTeam[name]);
return synthetic.length > 0 ? [...teams, ...synthetic] : teams;
}, [teams, provisioningTeamNames, provisioningSnapshotByTeam]);
// Fetch alive teams on mount and when teams list changes // Fetch alive teams on mount and when teams list changes
useEffect(() => { useEffect(() => {
if (!electronMode) return; if (!electronMode) return;
@ -303,7 +314,7 @@ export const TeamListView = (): React.JSX.Element => {
]); ]);
const filteredTeams = useMemo<TeamSummary[]>(() => { const filteredTeams = useMemo<TeamSummary[]>(() => {
let result = teams; let result = teamsWithProvisioning;
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
if (q) { if (q) {
@ -364,7 +375,7 @@ export const TeamListView = (): React.JSX.Element => {
return result; return result;
}, [ }, [
teams, teamsWithProvisioning,
searchQuery, searchQuery,
currentProjectPath, currentProjectPath,
aliveTeams, aliveTeams,
@ -577,7 +588,7 @@ export const TeamListView = (): React.JSX.Element => {
</p> </p>
) : null} ) : null}
{teams.length > 0 ? ( {teamsWithProvisioning.length > 0 ? (
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search <Search
@ -594,7 +605,7 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
<TeamListFilterPopover <TeamListFilterPopover
filter={filter} filter={filter}
teams={teams} teams={teamsWithProvisioning}
aliveTeams={aliveTeams} aliveTeams={aliveTeams}
onFilterChange={setFilter} onFilterChange={setFilter}
/> />
@ -633,7 +644,7 @@ export const TeamListView = (): React.JSX.Element => {
); );
} }
if (teams.length === 0) { if (teamsWithProvisioning.length === 0) {
return <TeamEmptyState />; return <TeamEmptyState />;
} }

View file

@ -353,6 +353,8 @@ export interface TeamSlice {
lastSendMessageResult: SendMessageResult | null; lastSendMessageResult: SendMessageResult | null;
reviewActionError: string | null; reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>; provisioningRuns: Record<string, TeamProvisioningProgress>;
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
provisioningSnapshotByTeam: Record<string, TeamSummary>;
currentProvisioningRunIdByTeam: Record<string, string | null>; currentProvisioningRunIdByTeam: Record<string, string | null>;
currentRuntimeRunIdByTeam: Record<string, string | null>; currentRuntimeRunIdByTeam: Record<string, string | null>;
/** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */ /** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */
@ -610,6 +612,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
crossTeamTargetsLoading: false, crossTeamTargetsLoading: false,
reviewActionError: null, reviewActionError: null,
provisioningRuns: {}, provisioningRuns: {},
provisioningSnapshotByTeam: {},
currentProvisioningRunIdByTeam: {}, currentProvisioningRunIdByTeam: {},
currentRuntimeRunIdByTeam: {}, currentRuntimeRunIdByTeam: {},
ignoredProvisioningRunIds: {}, ignoredProvisioningRunIds: {},
@ -743,7 +746,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
} }
} }
set({ teams, teamByName, teamBySessionId, teamsLoading: false, teamsError: null }); // Atomic update: set teams AND clean up provisioning snapshots in one call
// to prevent any render cycle with duplicate cards.
set((state) => {
const nextSnapshots = { ...state.provisioningSnapshotByTeam };
for (const team of teams) {
delete nextSnapshots[team.teamName];
}
return {
teams,
teamByName,
teamBySessionId,
teamsLoading: false,
teamsError: null,
provisioningSnapshotByTeam: nextSnapshots,
};
});
} catch (error) { } catch (error) {
// On refresh failure, keep existing teams visible // On refresh failure, keep existing teams visible
set({ set({
@ -1413,6 +1431,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentProvisioningRunIdByTeam, ...state.currentProvisioningRunIdByTeam,
[request.teamName]: pendingRunId, [request.teamName]: pendingRunId,
}, },
// Synthetic card for the team list — visible until fetchTeams() picks up the real team.
provisioningSnapshotByTeam: {
...state.provisioningSnapshotByTeam,
[request.teamName]: {
teamName: request.teamName,
displayName: request.displayName || request.teamName,
description: request.description || '',
color: request.color,
memberCount: request.members.length,
members: request.members.map((m) => ({ name: m.name, role: m.role })),
taskCount: 0,
lastActivity: null,
projectPath: request.cwd || undefined,
},
},
})); }));
try { try {
if (typeof api.teams.createTeam !== 'function') { if (typeof api.teams.createTeam !== 'function') {
@ -1751,6 +1784,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} else { } else {
delete nextErrors[progress.teamName]; delete nextErrors[progress.teamName];
} }
// Clean up provisioning snapshot on terminal failure states
const nextSnapshots =
progress.state === 'failed' || progress.state === 'cancelled'
? (() => {
const s = { ...state.provisioningSnapshotByTeam };
delete s[progress.teamName];
return s;
})()
: state.provisioningSnapshotByTeam;
return { return {
provisioningRuns: nextRuns, provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam, currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
@ -1759,6 +1801,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
[progress.teamName]: progress.runId, [progress.teamName]: progress.runId,
}, },
provisioningErrorByTeam: nextErrors, provisioningErrorByTeam: nextErrors,
provisioningSnapshotByTeam: nextSnapshots,
}; };
}); });