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, Play, Search, Square, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import type { 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 { 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) ); } if (currentProjectPath) { const matches = (t: TeamSummary): boolean => { if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false; }; result = [...result].sort((a, b) => { const aMatch = matches(a) ? 0 : 1; const bMatch = matches(b) ? 0 : 1; return aMatch - bMatch; }); } return result; }, [teams, searchQuery, currentProjectPath]); 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.config.members ?? []).map((m) => ({ name: m.name, role: m.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 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)} 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.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}
); };