diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index baa2f48b..1166dcf6 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DashboardRecentProject } from '@features/recent-projects/contracts'; import { api, isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; @@ -11,6 +12,7 @@ import { sortRecentProjectsByDisplayPriority, subscribeRecentProjectOpenHistory, } from '../utils/recentProjectOpenHistory'; +import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { getRecentProjectsClientSnapshot, loadRecentProjectsWithClientCache, @@ -62,16 +64,27 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = - useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksInitialized: state.globalTasksInitialized, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + const { + globalTasks, + globalTasksInitialized, + globalTasksLoading, + fetchAllTasks, + teams, + provisioningRuns, + currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam, + } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + provisioningRuns: state.provisioningRuns, + currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, + })) + ); const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( @@ -92,6 +105,21 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( initialSnapshot?.payload.projects ?? [] ); + const provisioningState = useMemo( + () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), + [currentProvisioningRunIdByTeam, provisioningRuns] + ); + const provisioningTeamNames = useMemo( + () => + Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => + isTeamProvisioningActive(provisioningState, teamName) + ), + [currentProvisioningRunIdByTeam, provisioningState] + ); + const provisioningTeamNamesKey = useMemo( + () => [...provisioningTeamNames].sort().join('\u0000'), + [provisioningTeamNames] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -173,7 +201,7 @@ export function useRecentProjectsSection( return () => { cancelled = true; }; - }, [teams]); + }, [provisioningTeamNamesKey, teams]); useEffect(() => { if (!searchQuery.trim()) { @@ -189,25 +217,13 @@ export function useRecentProjectsSection( const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const teamsByProject = new Map(); - - for (const team of teams) { - if (!team.projectPath || !aliveSet.has(team.teamName)) { - continue; - } - - const key = normalizePath(team.projectPath); - const existing = teamsByProject.get(key); - if (existing) { - existing.push(team); - } else { - teamsByProject.set(key, [team]); - } - } - - return teamsByProject; - }, [aliveTeams, teams]); + return buildActiveTeamsByProject({ + teams, + aliveTeamNames: aliveTeams, + provisioningTeamNames, + provisioningSnapshotByTeam, + }); + }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]); const decoratedCards = useMemo( () => diff --git a/src/features/recent-projects/renderer/utils/activeProjectTeams.ts b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts new file mode 100644 index 00000000..273d60f8 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts @@ -0,0 +1,48 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { TeamSummary } from '@shared/types'; + +interface BuildActiveTeamsByProjectInput { + teams: TeamSummary[]; + aliveTeamNames: readonly string[]; + provisioningTeamNames: readonly string[]; + provisioningSnapshotByTeam: Record; +} + +export function buildActiveTeamsByProject({ + teams, + aliveTeamNames, + provisioningTeamNames, + provisioningSnapshotByTeam, +}: BuildActiveTeamsByProjectInput): Map { + const activeTeamNames = new Set([...aliveTeamNames, ...provisioningTeamNames]); + if (activeTeamNames.size === 0) { + return new Map(); + } + + const existingTeamNames = new Set(teams.map((team) => team.teamName)); + const syntheticProvisioningTeams = provisioningTeamNames + .filter((teamName) => !existingTeamNames.has(teamName)) + .map((teamName) => provisioningSnapshotByTeam[teamName]) + .filter((team): team is TeamSummary => Boolean(team)); + + const teamsByProject = new Map(); + const visibleTeams = + syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams; + + for (const team of visibleTeams) { + if (!team.projectPath || !activeTeamNames.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; +} diff --git a/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts new file mode 100644 index 00000000..e2f2b44c --- /dev/null +++ b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { buildActiveTeamsByProject } from '@features/recent-projects/renderer/utils/activeProjectTeams'; + +import type { TeamSummary } from '@shared/types'; + +function makeTeamSummary( + overrides: Partial & Pick +): TeamSummary { + return { + ...overrides, + description: overrides.description ?? '', + memberCount: overrides.memberCount ?? 0, + taskCount: overrides.taskCount ?? 0, + lastActivity: overrides.lastActivity ?? null, + teamName: overrides.teamName, + displayName: overrides.displayName, + }; +} + +describe('buildActiveTeamsByProject', () => { + it('treats provisioning-active existing teams as active before aliveList catches up', () => { + const lintai = makeTeamSummary({ + teamName: 'signal-ops-3', + displayName: 'signal-ops-3', + projectPath: '/Users/test/lintai', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [lintai], + aliveTeamNames: [], + provisioningTeamNames: ['signal-ops-3'], + provisioningSnapshotByTeam: {}, + }); + + expect(teamsByProject.get('/users/test/lintai')).toEqual([lintai]); + }); + + it('includes synthetic provisioning snapshots for teams not yet present in team summaries', () => { + const provisioningSnapshot = makeTeamSummary({ + teamName: 'northstar-team', + displayName: 'Northstar Team', + projectPath: '/Users/test/northstar', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [], + aliveTeamNames: [], + provisioningTeamNames: ['northstar-team'], + provisioningSnapshotByTeam: { + 'northstar-team': provisioningSnapshot, + }, + }); + + expect(teamsByProject.get('/users/test/northstar')).toEqual([provisioningSnapshot]); + }); +});