import { useCallback, useEffect, useMemo, useState } from 'react'; 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 } from '@renderer/store/slices/teamSlice'; 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 { CheckCircle, Clock, Copy, FolderOpen, GitBranch, Play, RotateCcw, Search, Square, Trash2, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamCreateRequest, TeamSummary, TeamSummaryMember } from '@shared/types'; 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; } } } type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline'; 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 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): React.JSX.Element | null { const recentPaths = getRecentProjects(team); if (recentPaths.length === 0) return null; return (
{recentPaths.map((p, i) => ( {i === 0 && (status === 'active' || status === 'idle') ? ( {folderName(p)} ) : ( folderName(p) )} {i < recentPaths.length - 1 ? ', ' : ''} ))}
); } function resolveTeamStatus( teamName: string, aliveTeams: string[], currentProgress: ReturnType, leadActivityByTeam: Record ): TeamStatus { if (aliveTeams.includes(teamName)) { return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle'; } if ( currentProgress && ['validating', 'spawning', 'monitoring', 'verifying'].includes(currentProgress.state) ) { return 'provisioning'; } return 'offline'; } 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 ); } }; export const 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, projects, globalTasks, fetchAllTasks, viewMode, repositoryGroups, selectedRepositoryId, selectedWorktreeId, activeProjectId, } = 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, viewMode: s.viewMode, repositoryGroups: s.repositoryGroups, selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, activeProjectId: s.activeProjectId, })) ); const { connectionMode, createTeam, provisioningErrorByTeam, clearProvisioningError, provisioningRuns, currentProvisioningRunIdByTeam, leadActivityByTeam, } = useStore( useShallow((s) => ({ connectionMode: s.connectionMode, createTeam: s.createTeam, provisioningErrorByTeam: s.provisioningErrorByTeam, clearProvisioningError: s.clearProvisioningError, provisioningRuns: s.provisioningRuns, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, leadActivityByTeam: s.leadActivityByTeam, })) ); const canCreate = electronMode && connectionMode === 'local'; const provisioningState = useMemo( () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), [currentProvisioningRunIdByTeam, provisioningRuns] ); // Fetch alive teams on mount and when teams list changes useEffect(() => { if (!electronMode) return; let cancelled = false; const fetchAlive = async (): Promise => { try { const list = await api.teams.aliveList(); if (!cancelled) setAliveTeams(list); } catch { // best-effort } }; void fetchAlive(); return () => { cancelled = true; }; }, [electronMode, teams]); // 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); return () => { cancelled = true; }; }, [electronMode, showCreateDialog]); const currentProjectPath = useMemo(() => { if (viewMode === 'grouped') { const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId); const path = worktree?.path ?? null; return path ? normalizePath(path) : null; } const project = projects.find((p) => p.id === activeProjectId); return project ? normalizePath(project.path) : null; }, [ viewMode, repositoryGroups, selectedRepositoryId, selectedWorktreeId, projects, activeProjectId, ]); const filteredTeams = useMemo(() => { let result = teams; 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.teamName, aliveTeams, getCurrentProvisioningProgressForTeam(provisioningState, t.teamName), leadActivityByTeam ); const isRunning = status !== 'offline'; if (filter.selectedStatuses.has('running') && isRunning) return true; if (filter.selectedStatuses.has('offline') && !isRunning) return true; return false; }); } if (filter.selectedProjects.size > 0) { result = result.filter( (t) => t.projectPath != null && filter.selectedProjects.has(t.projectPath.trim()) ); } const aliveSet = new Set(aliveTeams); const matchesProject = currentProjectPath ? (t: TeamSummary): boolean => { if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; return ( t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false ); } : null; 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; // 2. Matching current project second if (matchesProject) { const projA = matchesProject(a) ? 0 : 1; const projB = matchesProject(b) ? 0 : 1; if (projA !== projB) return projA - projB; } return 0; }); return result; }, [ teams, searchQuery, currentProjectPath, aliveTeams, filter, currentProvisioningRunIdByTeam, provisioningRuns, leadActivityByTeam, ]); // 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 branchByPath = useStore((s) => s.branchByPath); const restoreTeam = useStore((s) => s.restoreTeam); const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam); const handleDeleteTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); void (async () => { 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); const existingNames = teams.map((t) => t.teamName); const uniqueName = generateUniqueName(teamName, existingNames); const members = (data.members ?? []) .filter((m) => !m.removedAt && m.agentType !== 'team-lead') .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); } }, []); 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 = ( t.teamName)} activeTeams={activeTeams} initialData={copyData ?? undefined} defaultProjectPath={currentProjectPath} onClose={handleCreateDialogClose} onCreate={handleCreateSubmit} onOpenTeam={openTeamTab} /> ); const renderHeader = (): React.JSX.Element => (

Select Team

{!canCreate ? (

Only available in local Electron mode.

) : null} {teams.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 (teams.length === 0) { return ; } const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.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.teamName, aliveTeams, getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), leadActivityByTeam ); const teamColorSet = team.color ? getTeamColorSet(team.color) : nameColorSet(team.displayName); const matchesCurrentProject = !!currentProjectPath && ((team.projectPath ? normalizePath(team.projectPath) === currentProjectPath : false) || (team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false)); return (
openTeamTab(team.teamName, team.projectPath)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openTeamTab(team.teamName, team.projectPath); } }} > {teamColorSet ? (
) : null}

{team.displayName}

{(status === 'active' || status === 'idle') && ( {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} )} Copy team Delete team

{team.description || 'No description'}

{team.projectPath && (() => { const branch = branchByPath[normalizePath(team.projectPath)]; if (!branch) return null; return ( {branch} ); })()}
{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)}
); })}
{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}
); };