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 ? (
) : 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 (
{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}
);
};