import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamProvisioningActive, } from '@renderer/store/slices/teamSlice'; import { getProjectSelectionResetState, getWorktreeNavigationState, } from '@renderer/store/utils/stateResetHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; 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, Clock, Copy, FolderOpen, GitBranch, Play, RotateCcw, Search, Square, Trash2, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { findTeamProjectSelectionTarget, resolveTeamProjectSelection, teamMatchesProjectSelection, } from './teamProjectSelection'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { ResolvedTeamMember, TeamCreateRequest, TeamLaunchRequest, TeamMemberSnapshot, TeamSummary, TeamSummaryMember, } from '@shared/types'; const CreateTeamDialog = lazy(() => import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) ); const LaunchTeamDialog = lazy(() => import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) ); function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); const existing = new Set(existingNames); for (let i = 1; ; i++) { const candidate = `${base}-${i}`; if (!existing.has(candidate)) { return candidate; } } } function getRecentProjects(team: TeamSummary): string[] { const history = team.projectPathHistory; if (!history || history.length === 0) { return team.projectPath ? [team.projectPath] : []; } return history.slice(-3).reverse(); } function folderName(fullPath: string): string { return getBaseName(fullPath) || fullPath; } function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] { return members.map((member) => { return { ...member, status: member.currentTaskId ? 'active' : 'idle', messageCount: 0, lastActiveAt: null, }; }); } function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element { const teamColorMap = buildMemberColorMap(members); return ( <> {members.map((m) => { const resolvedColor = teamColorMap.get(m.name); const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null; return ( {m.name} {m.role ? ( {m.role} ) : null} ); })} ); } function renderTeamRecentPaths( team: TeamSummary, status: TeamStatus, matchesCurrentProject: boolean, isLight: boolean ): React.JSX.Element | null { const recentPaths = getRecentProjects(team); if (recentPaths.length === 0) return null; return (
{matchesCurrentProject ? ( {recentPaths.map((p, i) => ( {folderName(p)} {i < recentPaths.length - 1 ? ', ' : ''} ))} ) : ( <> {recentPaths.map((p, i) => ( {i === 0 && (status === 'active' || status === 'idle') ? ( {folderName(p)} ) : ( folderName(p) )} {i < recentPaths.length - 1 ? ', ' : ''} ))} )}
); } const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { switch (status) { case 'active': return ( Active ); case 'idle': return ( Running ); case 'provisioning': return ( Launching... ); case 'offline': return ( Offline ); case 'partial_failure': return ( Launch failed partway ); case 'partial_skipped': return ( Launch skipped member ); case 'partial_pending': return ( Bootstrap pending ); } }; export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); const [copyData, setCopyData] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState(EMPTY_TEAM_FILTER); const [aliveTeams, setAliveTeams] = useState([]); const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam, restoreTeam, permanentlyDeleteTeam, projects, globalTasks, fetchAllTasks, repositoryGroups, selectedRepositoryId, selectedWorktreeId, selectedProjectId, activeProjectId, branchByPath, } = useStore( useShallow((s) => ({ teams: s.teams, teamsLoading: s.teamsLoading, teamsError: s.teamsError, fetchTeams: s.fetchTeams, openTeamTab: s.openTeamTab, deleteTeam: s.deleteTeam, restoreTeam: s.restoreTeam, permanentlyDeleteTeam: s.permanentlyDeleteTeam, projects: s.projects, globalTasks: s.globalTasks, fetchAllTasks: s.fetchAllTasks, repositoryGroups: s.repositoryGroups, selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, selectedProjectId: s.selectedProjectId, activeProjectId: s.activeProjectId, branchByPath: s.branchByPath, })) ); const { connectionMode, createTeam, launchTeam, provisioningErrorByTeam, clearProvisioningError, provisioningRuns, provisioningSnapshotByTeam, currentProvisioningRunIdByTeam, leadActivityByTeam, } = useStore( useShallow((s) => ({ connectionMode: s.connectionMode, createTeam: s.createTeam, launchTeam: s.launchTeam, provisioningErrorByTeam: s.provisioningErrorByTeam, clearProvisioningError: s.clearProvisioningError, provisioningRuns: s.provisioningRuns, provisioningSnapshotByTeam: s.provisioningSnapshotByTeam, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, leadActivityByTeam: s.leadActivityByTeam, })) ); const canCreate = electronMode && connectionMode === 'local'; const provisioningState = useMemo( () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), [currentProvisioningRunIdByTeam, provisioningRuns] ); /** Team names currently in active provisioning — prevents name conflicts in create dialog. */ const provisioningTeamNames = useMemo(() => { return Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => isTeamProvisioningActive(provisioningState, teamName) ); }, [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]); const fetchAliveTeams = useCallback(async (): Promise => { 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(() => { let cancelled = false; void fetchAliveTeams().then((list) => { if (!cancelled && list) { setAliveTeams(list); } }); return () => { cancelled = true; }; }, [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 fetchAliveTeams().then((list) => { if (!cancelled && list) { setAliveTeams(list); } }); return () => { cancelled = true; }; }, [electronMode, fetchAliveTeams, showCreateDialog]); const currentProjectSelection = useMemo( () => resolveTeamProjectSelection({ repositoryGroups, projects, selectedRepositoryId, selectedWorktreeId, selectedProjectId, activeProjectId, }), [ repositoryGroups, projects, selectedRepositoryId, selectedWorktreeId, selectedProjectId, activeProjectId, ] ); const currentProjectPath = currentProjectSelection.projectPath; const filteredTeams = useMemo(() => { let result = teamsWithProvisioning; const q = searchQuery.trim().toLowerCase(); if (q) { result = result.filter( (t) => t.teamName.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) ); } if (filter.selectedStatuses.size > 0) { result = result.filter((t) => { const status = resolveTeamStatus( t, t.teamName, aliveTeams, getCurrentProvisioningProgressForTeam(provisioningState, t.teamName), leadActivityByTeam ); const isRunning = isTeamListStatusRunning(status); if (filter.selectedStatuses.has('running') && isRunning) return true; if (filter.selectedStatuses.has('offline') && !isRunning) return true; return false; }); } 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. 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) { const projectA = matchesCurrentProject(a) ? 0 : 1; const projectB = matchesCurrentProject(b) ? 0 : 1; if (projectA !== projectB) return projectA - projectB; } // 3. Most recently active teams first (stable secondary sort) const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; if (tsA !== tsB) return tsB - tsA; // 4. Fallback: alphabetical by team name for deterministic order return a.teamName.localeCompare(b.teamName); }); return result; }, [ teamsWithProvisioning, searchQuery, currentProjectPath, aliveTeams, filter, provisioningState, leadActivityByTeam, ]); const handleProjectSelectionChange = useCallback( (projectPath: string | null): void => { if (!projectPath) { useStore.setState(getProjectSelectionResetState()); return; } const target = findTeamProjectSelectionTarget(repositoryGroups, projects, projectPath); if (!target) { console.warn('Unable to resolve selected team project path:', projectPath); return; } if (target.kind === 'grouped') { useStore.setState(getWorktreeNavigationState(target.repositoryId, target.worktreeId)); void useStore.getState().fetchSessionsInitial(target.worktreeId); recordRecentProjectOpenPaths([projectPath]); return; } useStore.getState().selectProject(target.projectId); recordRecentProjectOpenPaths([projectPath]); }, [projects, repositoryGroups] ); // Fetch branches once for all visible team project paths (no live polling) const teamPaths = useMemo( () => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[], [filteredTeams] ); useBranchSync(teamPaths, { live: false }); const handleDeleteTeam = useCallback( (teamName: string, isDraft: boolean, e: React.MouseEvent) => { e.stopPropagation(); void (async () => { if (isDraft) { const confirmed = await confirm({ title: 'Delete draft', message: `Delete draft team "${teamName}"? This cannot be undone.`, confirmLabel: 'Delete', cancelLabel: 'Cancel', variant: 'danger', }); if (confirmed) { void api.teams.deleteDraft(teamName).catch(() => {}); } return; } const confirmed = await confirm({ title: 'Move to trash', message: `Move team "${teamName}" to trash? You can restore it later.`, confirmLabel: 'Move to trash', cancelLabel: 'Cancel', variant: 'danger', }); if (confirmed) { try { await deleteTeam(teamName); } catch { // error via store } } })(); }, [deleteTeam] ); const handleRestoreTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); void (async () => { try { await restoreTeam(teamName); } catch { // error via store } })(); }, [restoreTeam] ); const handlePermanentlyDeleteTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); void (async () => { const confirmed = await confirm({ title: 'Delete permanently', message: `Delete team "${teamName}" permanently? All data will be lost.`, confirmLabel: 'Delete forever', cancelLabel: 'Cancel', variant: 'danger', }); if (confirmed) { try { await permanentlyDeleteTeam(teamName); } catch { // error via store } } })(); }, [permanentlyDeleteTeam] ); const handleCopyTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); void (async () => { try { const data = await api.teams.getData(teamName, { includeMemberBranches: false, }); const existingNames = teams.map((t) => t.teamName); const uniqueName = generateUniqueName(teamName, existingNames); const members = (data.members ?? []) .filter((m) => !m.removedAt && !isLeadMember(m)) .map((m) => { let role = m.role; if (!role && m.agentType && m.agentType !== 'general-purpose') { role = m.agentType; } return { name: m.name, role }; }); setCopyData({ teamName: uniqueName, description: data.config.description, color: data.config.color, members, }); setShowCreateDialog(true); } catch { // silently ignore — team data may be unavailable } })(); }, [teams] ); const [stoppingTeamName, setStoppingTeamName] = useState(null); const handleStopTeam = useCallback(async (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); setStoppingTeamName(teamName); try { await api.teams.stop(teamName); setAliveTeams((prev) => prev.filter((n) => n !== teamName)); } catch (err) { console.error('Failed to stop team:', err); } finally { setStoppingTeamName(null); } }, []); const [launchingTeamName, setLaunchingTeamName] = useState(null); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [launchDialogTeamName, setLaunchDialogTeamName] = useState(''); const [launchDialogMembers, setLaunchDialogMembers] = useState([]); const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState(); const handleLaunchTeam = useCallback( async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => { e.stopPropagation(); if (!projectPath) return; try { const data = await api.teams.getData(teamName, { includeMemberBranches: false, }); setLaunchDialogTeamName(teamName); setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); setLaunchDialogOpen(true); } catch (err) { // Draft teams (no config.json) throw TEAM_DRAFT — expected, use fallback if (!(err instanceof Error && err.message.includes('TEAM_DRAFT'))) { console.error('Failed to load team data for launch dialog:', err); } // Fallback: open dialog with minimal data setLaunchDialogTeamName(teamName); setLaunchDialogMembers([]); setLaunchDialogDefaultPath(projectPath); setLaunchDialogOpen(true); } }, [] ); const handleLaunchSubmit = useCallback( async (request: TeamLaunchRequest) => { setLaunchingTeamName(request.teamName); try { await launchTeam(request); } catch (err) { console.error('Failed to launch team:', err); throw err; } finally { setLaunchingTeamName(null); } }, [launchTeam] ); useEffect(() => { if (!electronMode) { return; } void fetchTeams(); void fetchAllTasks(); }, [electronMode, fetchTeams, fetchAllTasks]); const taskCountsByTeam = useMemo(() => buildTaskCountsByTeam(globalTasks), [globalTasks]); const activeTeams = useMemo(() => { const aliveSet = new Set(aliveTeams); return teams .filter((t) => aliveSet.has(t.teamName) && t.projectPath) .map((t) => ({ teamName: t.teamName, displayName: t.displayName, projectPath: t.projectPath!, })); }, [teams, aliveTeams]); const handleCreateDialogClose = useCallback(() => { setShowCreateDialog(false); setCopyData(null); }, []); const handleCreateSubmit = useCallback( async (request: TeamCreateRequest) => { await createTeam(request); }, [createTeam] ); if (!electronMode) { return (

Teams is only available in Electron mode

In browser mode, access to local `~/.claude/teams` directories is not available.

); } const createDialogElement = showCreateDialog && ( t.teamName)} provisioningTeamNames={provisioningTeamNames} activeTeams={activeTeams} initialData={copyData ?? undefined} defaultProjectPath={currentProjectPath} onClose={handleCreateDialogClose} onCreate={handleCreateSubmit} onOpenTeam={openTeamTab} /> ); const launchDialogElement = launchDialogOpen && ( setLaunchDialogOpen(false)} onLaunch={handleLaunchSubmit} /> ); const renderHeader = (): React.JSX.Element => (

Select Team

{!canCreate ? (

Only available in local Electron mode.

) : null} {teamsWithProvisioning.length > 0 ? (
setSearchQuery(e.target.value)} className="h-8 pl-8 text-xs" />
) : null}
); const renderContent = (): React.JSX.Element => { if (teamsLoading) { return (
Loading teams...
); } if (teamsError) { return (

Failed to load teams

{teamsError}

); } if (teamsWithProvisioning.length === 0) { return ( setShowCreateDialog(true)} /> ); } const hasActiveFilters = filter.selectedStatuses.size > 0; if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) { return (
No teams matching current filters
); } const activeFiltered = filteredTeams.filter((t) => !t.deletedAt); const deletedFiltered = filteredTeams.filter((t) => t.deletedAt); return ( <>
{activeFiltered.map((team) => { const status = resolveTeamStatus( team, team.teamName, aliveTeams, getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), leadActivityByTeam ); const teamColorSet = team.color ? getTeamColorSet(team.color) : nameColorSet(team.displayName); const matchesCurrentProject = currentProjectPath ? teamMatchesProjectSelection(team, currentProjectPath) : false; return (
openTeamTab(team.teamName, team.projectPath)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openTeamTab(team.teamName, team.projectPath); } }} >

{team.displayName}

{team.projectPath && (() => { const branch = branchByPath[normalizePath(team.projectPath)]; if (!branch) return null; return ( {branch} ); })()}
{(status === 'offline' || status === 'partial_failure' || status === 'partial_skipped' || status === 'partial_pending') && team.projectPath && ( {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} )} {(status === 'active' || status === 'idle') && ( {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} )} {!team.pendingCreate && ( Copy team )} Delete team

{team.description || 'No description'}

{team.teamLaunchState === 'partial_pending' ? (

{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0 ? buildPendingRuntimeSummaryCopy({ confirmedCount: team.confirmedCount, expectedMemberCount: team.expectedMemberCount, memberCount: team.memberCount, runtimeProcessPendingCount: team.runtimeProcessPendingCount, includePeriod: true, }) : 'Last launch is still reconciling.'}

) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? (

{team.missingMembers?.length ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.` : 'Last launch stopped before all teammates joined.'}

) : team.teamLaunchState === 'partial_skipped' ? (

{team.skippedMembers?.length ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.` : 'Last launch has skipped teammates.'}

) : null}
{team.members && team.members.length > 0 ? ( renderMemberChips(team.members, isLight) ) : team.memberCount === 0 ? ( Solo ) : ( Members: {team.memberCount} )}
{(() => { const tc = taskCountsByTeam.get(team.teamName); const pending = tc?.pending ?? 0; const inProgress = tc?.inProgress ?? 0; const completed = tc?.completed ?? 0; const totalTasks = pending + inProgress + completed; const completedRatio = totalTasks > 0 ? completed / totalTasks : 0; return (
{completed}/{totalTasks}
{totalTasks > 0 && (
{inProgress > 0 && ( {inProgress} in_progress )} {pending > 0 && ( {pending} pending )} {completed > 0 && ( {completed} completed )}
)}
); })()} {renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
); })}
{deletedFiltered.length > 0 && ( <>
Trash ({deletedFiltered.length})
{deletedFiltered.map((team) => (

{team.displayName}

Deleted
Restore Delete forever

{team.description || 'No description'}

{team.members && team.members.length > 0 && (
{renderMemberChips(team.members, isLight)}
)}
))}
)} ); }; return (
{renderHeader()} {renderContent()} {createDialogElement} {launchDialogElement}
); });