- Updated the task assignment message to emphasize the importance of starting tasks promptly, with improved formatting for clarity. - Revised action mode instructions for the 'delegate' mode to provide clearer guidance on delegation responsibilities and expectations, including the creation of investigation tasks for ambiguous requests. - Added new protocols in TeamProvisioningService to clarify ownership and task refinement processes during investigations, ensuring better task management and accountability.
985 lines
37 KiB
TypeScript
985 lines
37 KiB
TypeScript
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,
|
|
isTeamProvisioningActive,
|
|
} 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 (
|
|
<span key={m.name} className="inline-flex items-center gap-1">
|
|
<span
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
|
style={
|
|
memberColor
|
|
? {
|
|
backgroundColor: getThemedBadge(memberColor, isLight),
|
|
color: memberColor.text,
|
|
border: `1px solid ${memberColor.border}40`,
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{m.name}
|
|
</span>
|
|
{m.role ? (
|
|
<span className="text-[9px] text-[var(--color-text-muted)]">{m.role}</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function renderTeamRecentPaths(team: TeamSummary, status: TeamStatus): React.JSX.Element | null {
|
|
const recentPaths = getRecentProjects(team);
|
|
if (recentPaths.length === 0) return null;
|
|
return (
|
|
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
|
<FolderOpen size={10} className="shrink-0" />
|
|
<span className="truncate">
|
|
{recentPaths.map((p, i) => (
|
|
<span key={p} title={p}>
|
|
{i === 0 && (status === 'active' || status === 'idle') ? (
|
|
<span className="text-emerald-400">{folderName(p)}</span>
|
|
) : (
|
|
folderName(p)
|
|
)}
|
|
{i < recentPaths.length - 1 ? ', ' : ''}
|
|
</span>
|
|
))}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function resolveTeamStatus(
|
|
teamName: string,
|
|
aliveTeams: string[],
|
|
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
|
|
leadActivityByTeam: Record<string, string>
|
|
): 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 (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
|
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
|
Active
|
|
</span>
|
|
);
|
|
case 'idle':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
|
<span className="size-1.5 rounded-full bg-emerald-400" />
|
|
Running
|
|
</span>
|
|
);
|
|
case 'provisioning':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
|
|
<span className="size-1.5 animate-pulse rounded-full bg-amber-400" />
|
|
Launching...
|
|
</span>
|
|
);
|
|
case 'offline':
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
|
|
<span className="size-1.5 rounded-full bg-zinc-500" />
|
|
Offline
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
export const TeamListView = (): React.JSX.Element => {
|
|
const { isLight } = useTheme();
|
|
const electronMode = isElectronMode();
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
|
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
|
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,
|
|
launchTeam,
|
|
provisioningErrorByTeam,
|
|
clearProvisioningError,
|
|
provisioningRuns,
|
|
provisioningSnapshotByTeam,
|
|
currentProvisioningRunIdByTeam,
|
|
leadActivityByTeam,
|
|
} = useStore(
|
|
useShallow((s) => ({
|
|
connectionMode: s.connectionMode,
|
|
createTeam: s.createTeam,
|
|
launchTeam: s.launchTeam,
|
|
provisioningErrorByTeam: s.provisioningErrorByTeam,
|
|
clearProvisioningError: s.clearProvisioningError,
|
|
provisioningRuns: s.provisioningRuns,
|
|
provisioningSnapshotByTeam: s.provisioningSnapshotByTeam,
|
|
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
|
leadActivityByTeam: s.leadActivityByTeam,
|
|
}))
|
|
);
|
|
const canCreate = electronMode && connectionMode === 'local';
|
|
const provisioningState = useMemo(
|
|
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
|
[currentProvisioningRunIdByTeam, provisioningRuns]
|
|
);
|
|
|
|
/** Team names currently in active provisioning — prevents name conflicts in create dialog. */
|
|
const provisioningTeamNames = useMemo(() => {
|
|
return Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
|
isTeamProvisioningActive(provisioningState, teamName)
|
|
);
|
|
}, [currentProvisioningRunIdByTeam, provisioningState]);
|
|
|
|
/** Merge real teams with synthetic launching cards for active provisioning. */
|
|
const teamsWithProvisioning = useMemo(() => {
|
|
const existingNames = new Set(teams.map((t) => t.teamName));
|
|
const synthetic = provisioningTeamNames
|
|
.filter((name) => !existingNames.has(name) && provisioningSnapshotByTeam[name])
|
|
.map((name) => provisioningSnapshotByTeam[name]);
|
|
return synthetic.length > 0 ? [...teams, ...synthetic] : teams;
|
|
}, [teams, provisioningTeamNames, provisioningSnapshotByTeam]);
|
|
|
|
// Fetch alive teams on mount and when teams list changes
|
|
useEffect(() => {
|
|
if (!electronMode) return;
|
|
let cancelled = false;
|
|
const fetchAlive = async (): Promise<void> => {
|
|
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<TeamSummary[]>(() => {
|
|
let result = teamsWithProvisioning;
|
|
|
|
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;
|
|
}, [
|
|
teamsWithProvisioning,
|
|
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<string | null>(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);
|
|
}
|
|
}, []);
|
|
|
|
const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null);
|
|
const handleLaunchTeam = useCallback(
|
|
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!projectPath) return;
|
|
setLaunchingTeamName(teamName);
|
|
try {
|
|
await launchTeam({ teamName, cwd: projectPath });
|
|
openTeamTab(teamName, projectPath);
|
|
} catch (err) {
|
|
console.error('Failed to launch team:', err);
|
|
} finally {
|
|
setLaunchingTeamName(null);
|
|
}
|
|
},
|
|
[launchTeam, openTeamTab]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!electronMode) {
|
|
return;
|
|
}
|
|
void fetchTeams();
|
|
void fetchAllTasks();
|
|
}, [electronMode, fetchTeams, fetchAllTasks]);
|
|
|
|
const taskCountsByTeam = useMemo(() => buildTaskCountsByTeam(globalTasks), [globalTasks]);
|
|
|
|
const activeTeams = useMemo<ActiveTeamRef[]>(() => {
|
|
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 (
|
|
<div className="flex size-full items-center justify-center p-6">
|
|
<div className="max-w-md text-center">
|
|
<p className="text-sm font-medium text-[var(--color-text)]">
|
|
Teams is only available in Electron mode
|
|
</p>
|
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
|
In browser mode, access to local `~/.claude/teams` directories is not available.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const createDialogElement = (
|
|
<CreateTeamDialog
|
|
open={showCreateDialog}
|
|
canCreate={canCreate}
|
|
provisioningErrorsByTeam={provisioningErrorByTeam}
|
|
clearProvisioningError={clearProvisioningError}
|
|
existingTeamNames={teams.map((t) => t.teamName)}
|
|
provisioningTeamNames={provisioningTeamNames}
|
|
activeTeams={activeTeams}
|
|
initialData={copyData ?? undefined}
|
|
defaultProjectPath={currentProjectPath}
|
|
onClose={handleCreateDialogClose}
|
|
onCreate={handleCreateSubmit}
|
|
onOpenTeam={openTeamTab}
|
|
/>
|
|
);
|
|
|
|
const renderHeader = (): React.JSX.Element => (
|
|
<div className="mb-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold text-[var(--color-text)]">Select Team</h2>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!canCreate}
|
|
onClick={() => setShowCreateDialog(true)}
|
|
>
|
|
Create Team
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{!canCreate ? (
|
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
|
Only available in local Electron mode.
|
|
</p>
|
|
) : null}
|
|
|
|
{teamsWithProvisioning.length > 0 ? (
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search
|
|
size={14}
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
placeholder="Search teams..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-8 pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
<TeamListFilterPopover
|
|
filter={filter}
|
|
teams={teamsWithProvisioning}
|
|
aliveTeams={aliveTeams}
|
|
onFilterChange={setFilter}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
const renderContent = (): React.JSX.Element => {
|
|
if (teamsLoading) {
|
|
return (
|
|
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
|
|
Loading teams...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (teamsError) {
|
|
return (
|
|
<div className="flex size-full items-center justify-center p-6">
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-red-400">Failed to load teams</p>
|
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4"
|
|
onClick={() => {
|
|
void fetchTeams();
|
|
}}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (teamsWithProvisioning.length === 0) {
|
|
return <TeamEmptyState />;
|
|
}
|
|
|
|
const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.size > 0;
|
|
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
|
No teams matching current filters
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeFiltered = filteredTeams.filter((t) => !t.deletedAt);
|
|
const deletedFiltered = filteredTeams.filter((t) => t.deletedAt);
|
|
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{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 (
|
|
<div
|
|
key={team.teamName}
|
|
role="button"
|
|
tabIndex={0}
|
|
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
|
|
matchesCurrentProject
|
|
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
|
|
: 'border-[var(--color-border)]'
|
|
}`}
|
|
style={
|
|
teamColorSet
|
|
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
|
|
: undefined
|
|
}
|
|
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
openTeamTab(team.teamName, team.projectPath);
|
|
}
|
|
}}
|
|
>
|
|
{teamColorSet ? (
|
|
<div
|
|
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
|
style={{ backgroundColor: getThemedBadge(teamColorSet, isLight) }}
|
|
/>
|
|
) : null}
|
|
<div
|
|
className={
|
|
teamColorSet ? 'relative z-10 flex flex-1 flex-col' : 'flex flex-1 flex-col'
|
|
}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
|
{team.displayName}
|
|
</h3>
|
|
<StatusBadge status={status} />
|
|
{team.projectPath &&
|
|
(() => {
|
|
const branch = branchByPath[normalizePath(team.projectPath)];
|
|
if (!branch) return null;
|
|
return (
|
|
<span
|
|
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
|
title={branch}
|
|
>
|
|
<GitBranch size={10} />
|
|
<span className="max-w-24 truncate">{branch}</span>
|
|
</span>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="flex shrink-0 gap-1">
|
|
{status === 'offline' && team.projectPath && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
|
|
onClick={(e) => handleLaunchTeam(team.teamName, team.projectPath, e)}
|
|
disabled={launchingTeamName === team.teamName}
|
|
aria-label="Launch team"
|
|
>
|
|
<Play size={14} fill="currentColor" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{(status === 'active' || status === 'idle') && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
|
|
onClick={(e) => handleStopTeam(team.teamName, e)}
|
|
disabled={stoppingTeamName === team.teamName}
|
|
aria-label="Stop team"
|
|
>
|
|
<Square size={14} fill="currentColor" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
|
|
onClick={(e) => handleCopyTeam(team.teamName, e)}
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Copy team</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
|
onClick={(e) => handleDeleteTeam(team.teamName, e)}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Delete team</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex min-h-10 items-start gap-2">
|
|
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
|
|
{team.description || 'No description'}
|
|
</p>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
|
{team.members && team.members.length > 0 ? (
|
|
renderMemberChips(team.members, isLight)
|
|
) : team.memberCount === 0 ? (
|
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
|
Solo
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
|
Members: {team.memberCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-auto">
|
|
{(() => {
|
|
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 (
|
|
<div className="mt-2 w-full space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
|
role="progressbar"
|
|
aria-valuenow={completed}
|
|
aria-valuemin={0}
|
|
aria-valuemax={totalTasks}
|
|
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
|
>
|
|
<div
|
|
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
|
style={{ width: `${Math.round(completedRatio * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
|
{completed}/{totalTasks}
|
|
</span>
|
|
</div>
|
|
{totalTasks > 0 && (
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
|
{inProgress > 0 && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Play size={10} className="shrink-0 text-blue-400" />
|
|
{inProgress} in_progress
|
|
</span>
|
|
)}
|
|
{pending > 0 && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock size={10} className="shrink-0 text-amber-400" />
|
|
{pending} pending
|
|
</span>
|
|
)}
|
|
{completed > 0 && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
|
|
{completed} completed
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
{renderTeamRecentPaths(team, status)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{deletedFiltered.length > 0 && (
|
|
<>
|
|
<div className="my-6 flex items-center gap-3">
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" />
|
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
Trash ({deletedFiltered.length})
|
|
</span>
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{deletedFiltered.map((team) => (
|
|
<div
|
|
key={team.teamName}
|
|
className="group relative cursor-default overflow-hidden rounded-lg border border-[var(--color-border)] bg-zinc-800/40 p-4 opacity-60"
|
|
>
|
|
<Trash2
|
|
size={64}
|
|
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-zinc-400 opacity-[0.06]"
|
|
/>
|
|
<div className="relative z-10">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
|
{team.displayName}
|
|
</h3>
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
|
|
Deleted
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 group-hover:opacity-100"
|
|
onClick={(e) => handleRestoreTeam(team.teamName, e)}
|
|
aria-label="Restore team"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Restore</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
|
|
onClick={(e) => handlePermanentlyDeleteTeam(team.teamName, e)}
|
|
aria-label="Delete permanently"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Delete forever</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 line-clamp-2 text-xs text-[var(--color-text-muted)]">
|
|
{team.description || 'No description'}
|
|
</p>
|
|
{team.members && team.members.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
|
{renderMemberChips(team.members, isLight)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="size-full overflow-auto p-4">
|
|
{renderHeader()}
|
|
{renderContent()}
|
|
{createDialogElement}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
};
|