import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; 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 } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { getBaseName } from '@renderer/utils/pathUtils'; import { CheckCircle, Clock, Copy, FolderOpen, GitBranch, Play, Search, Square, Trash2, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } 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 = 'running' | '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 resolveTeamStatus( teamName: string, aliveTeams: string[], provisioningRuns: Record ): TeamStatus { if (aliveTeams.includes(teamName)) { return 'running'; } const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']); for (const run of Object.values(provisioningRuns)) { if (run.teamName === teamName && activeStates.has(run.state)) { return 'provisioning'; } } return 'offline'; } const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { switch (status) { case 'running': return ( Running ); case 'provisioning': return ( Launching... ); case 'offline': return ( Offline ); } }; export const TeamListView = (): React.JSX.Element => { const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); const [copyData, setCopyData] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [aliveTeams, setAliveTeams] = useState([]); const [branchByPath, setBranchByPath] = useState>(new Map()); 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, 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, provisioningError, provisioningRuns } = useStore( useShallow((s) => ({ connectionMode: s.connectionMode, createTeam: s.createTeam, provisioningError: s.provisioningError, provisioningRuns: s.provisioningRuns, })) ); const canCreate = electronMode && connectionMode === 'local'; // 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]); 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) ); } 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]); // Live branch/worktree for team project paths (poll so it updates during process) const projectPathsToPoll = useMemo(() => { const byKey = new Map(); for (const team of filteredTeams) { const p = team.projectPath?.trim(); if (p) { const key = normalizePath(p); if (!byKey.has(key)) byKey.set(key, p); } } return Array.from(byKey.entries()); }, [filteredTeams]); useEffect(() => { if (!electronMode || projectPathsToPoll.length === 0) return; let cancelled = false; const poll = async (): Promise => { const next = new Map(); for (const [pathKey, actualPath] of projectPathsToPoll) { if (cancelled) return; try { const branch = await api.teams.getProjectBranch(actualPath); if (!cancelled) next.set(pathKey, branch); } catch { if (!cancelled) next.set(pathKey, null); } } if (!cancelled && next.size > 0) { setBranchByPath((prev) => { const m = new Map(prev); for (const [k, v] of next) m.set(k, v); return m; }); } }; void poll(); const interval = setInterval(poll, 6000); return () => { cancelled = true; clearInterval(interval); }; }, [electronMode, projectPathsToPoll]); const handleDeleteTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`); if (!confirmed) { return; } void deleteTeam(teamName); }, [deleteTeam] ); 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) .map((m) => { let role = m.role; if (!role && m.agentType && m.agentType !== 'general-purpose') { role = m.agentType === 'team-lead' ? 'lead' : 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 => (

Teams

{!canCreate ? (

Only available in local Electron mode.

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

Failed to load teams

{teamsError}

{createDialogElement}
); } if (teams.length === 0) { return (
{renderHeader()} {createDialogElement}
); } return (
{renderHeader()} {filteredTeams.length === 0 && searchQuery.trim() ? (
No teams matching "{searchQuery.trim()}"
) : (
{filteredTeams.map((team) => { const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); const teamColorSet = team.color ? getTeamColorSet(team.color) : null; const matchesCurrentProject = !!currentProjectPath && (() => { if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath) return true; return ( 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 === 'running' && ( {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} )} Copy team Delete team

{team.description || 'No description'}

{team.projectPath && (() => { const branch = branchByPath.get(normalizePath(team.projectPath)); if (!branch) return null; return ( {branch} ); })()}
{team.members && team.members.length > 0 ? ( team.members.map((m) => { const memberColor = m.color ? getTeamColorSet(m.color) : null; return ( {m.name} {m.role ? ( {m.role} ) : null} ); }) ) : ( 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 )}
)}
); })()}
{(() => { const recentPaths = getRecentProjects(team); if (recentPaths.length === 0) return null; return (
{recentPaths.map((p, i) => ( {i === 0 && status === 'running' ? ( {folderName(p)} ) : ( folderName(p) )} {i < recentPaths.length - 1 ? ', ' : ''} ))}
); })()}
); })}
)} {createDialogElement}
); };