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 { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { Copy, FolderOpen, Search, 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 { 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 { // eslint-disable-next-line sonarjs/slow-regex -- Anchored regex on short path strings, no backtracking risk const parts = fullPath.replace(/\/+$/, '').split('/'); return parts[parts.length - 1] || 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 } = useStore( useShallow((s) => ({ teams: s.teams, teamsLoading: s.teamsLoading, teamsError: s.teamsError, fetchTeams: s.fetchTeams, openTeamTab: s.openTeamTab, deleteTeam: s.deleteTeam, })) ); 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 filteredTeams = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return teams; return teams.filter( (t) => t.teamName.toLowerCase().includes(q) || t.displayName.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) ); }, [teams, searchQuery]); 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] ); useEffect(() => { if (!electronMode) { return; } void fetchTeams(); }, [electronMode, fetchTeams]); 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} onClose={() => { setShowCreateDialog(false); setCopyData(null); }} onCreate={async (request) => { await createTeam(request); }} 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; return (
openTeamTab(team.teamName)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openTeamTab(team.teamName); } }} > {teamColorSet ? (
) : null}

{team.displayName}

{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} )} Tasks: {team.taskCount}
{(() => { const projects = getRecentProjects(team); if (projects.length === 0) return null; return (
{projects.map((p, i) => ( {i === 0 && status === 'running' ? ( {folderName(p)} ) : ( folderName(p) )} {i < projects.length - 1 ? ', ' : ''} ))}
); })()}
); })}
)} {createDialogElement}
); };