)}
+ {groupingMode === 'none' &&
+ sortedFlat.map((task) => (
+
+ ))}
+
{groupingMode === 'project' &&
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
@@ -281,10 +322,16 @@ export const GlobalTaskList = ({
return (
- {group.projectLabel}
+
+
+ {group.projectLabel}
+
{group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx
index b0efd85e..e76e6451 100644
--- a/src/renderer/components/sidebar/SidebarTaskItem.tsx
+++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx
@@ -1,7 +1,14 @@
+import { useMemo } from 'react';
+
+import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
+import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
+import { nameColorSet } from '@renderer/utils/projectColor';
+import { projectColor } from '@renderer/utils/projectColor';
+import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
-import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck } from 'lucide-react';
+import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react';
@@ -47,13 +54,16 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
interface SidebarTaskItemProps {
task: GlobalTask;
hideTeamName?: boolean;
+ showTeamName?: boolean;
}
export const SidebarTaskItem = ({
task,
hideTeamName,
+ showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
+ const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg =
task.kanbanColumn === 'approved'
@@ -65,14 +75,40 @@ export const SidebarTaskItem = ({
const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
+ const ownerColorSet = useMemo(() => {
+ if (!teamMembers || !task.owner) return null;
+ const colorMap = buildMemberColorMap(teamMembers);
+ const colorName = colorMap.get(task.owner);
+ return colorName ? getTeamColorSet(colorName) : null;
+ }, [teamMembers, task.owner]);
+
+ const projectLabel = useMemo(() => {
+ if (!task.projectPath?.trim()) return null;
+ return projectLabelFromPath(task.projectPath);
+ }, [task.projectPath]);
+
+ const projectColorSet = useMemo(
+ () => (projectLabel ? projectColor(projectLabel) : null),
+ [projectLabel]
+ );
+
+ const teamColor = useMemo(
+ () => (showTeamName ? nameColorSet(task.teamDisplayName) : null),
+ [showTeamName, task.teamDisplayName]
+ );
+
+ const showTeamRow = showTeamName && !hideTeamName;
+
return (
);
};
diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
index 42014c47..5fe94de2 100644
--- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx
+++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
@@ -54,10 +54,9 @@ export const TaskFiltersPopover = ({
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index f3cbf9ba..2d13487f 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
+import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -17,9 +18,11 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
+import { nameColorSet } from '@renderer/utils/projectColor';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
+ AlertTriangle,
Bell,
CheckCheck,
FolderOpen,
@@ -177,6 +180,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery,
clearKanbanFilter,
softDeleteTask,
+ restoreTask,
fetchDeletedTasks,
deletedTasks,
} = useStore(
@@ -214,6 +218,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask,
+ restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
}))
@@ -510,7 +515,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const handleDeleteTask = useCallback(
(taskId: string) => {
- void softDeleteTask(teamName, taskId);
+ void (async () => {
+ const confirmed = await confirm({
+ title: 'Delete task',
+ message: `Move task #${taskId} to trash?`,
+ confirmLabel: 'Delete',
+ cancelLabel: 'Cancel',
+ variant: 'danger',
+ });
+ if (confirmed) {
+ void softDeleteTask(teamName, taskId);
+ }
+ })();
},
[teamName, softDeleteTask]
);
@@ -626,7 +642,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
);
}
- const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null;
+ const headerColorSet = data.config.color
+ ? getTeamColorSet(data.config.color)
+ : nameColorSet(data.config.name);
return (
@@ -772,7 +790,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{!data.isAlive && !isTeamProvisioning ? (
-
Team is offline
+
+
+ Team is offline
+
-
setTrashOpen(false)} />
+ setTrashOpen(false)}
+ onRestore={(taskId) => {
+ void restoreTask(teamName, taskId);
+ }}
+ />
;
+ selectedStatuses: Set;
+}
+
+export const EMPTY_TEAM_FILTER: TeamListFilterState = {
+ selectedProjects: new Set(),
+ selectedStatuses: new Set(),
+};
+
+function folderName(fullPath: string): string {
+ return getBaseName(fullPath) || fullPath;
+}
+
+interface TeamListFilterPopoverProps {
+ filter: TeamListFilterState;
+ teams: TeamSummary[];
+ aliveTeams: string[];
+ onFilterChange: (filter: TeamListFilterState) => void;
+}
+
+export const TeamListFilterPopover = ({
+ filter,
+ teams,
+ aliveTeams,
+ onFilterChange,
+}: TeamListFilterPopoverProps): React.JSX.Element => {
+ const activeCount = useMemo(() => {
+ let count = 0;
+ if (filter.selectedStatuses.size > 0) count += 1;
+ if (filter.selectedProjects.size > 0) count += 1;
+ return count;
+ }, [filter.selectedStatuses, filter.selectedProjects]);
+
+ const uniqueProjects = useMemo(() => {
+ const paths = new Set();
+ for (const team of teams) {
+ if (team.projectPath?.trim()) paths.add(team.projectPath.trim());
+ }
+ return [...paths].sort((a, b) => folderName(a).localeCompare(folderName(b)));
+ }, [teams]);
+
+ const handleStatusToggle = (status: string): void => {
+ const next = new Set(filter.selectedStatuses);
+ if (next.has(status)) {
+ next.delete(status);
+ } else {
+ next.add(status);
+ }
+ onFilterChange({ ...filter, selectedStatuses: next });
+ };
+
+ const handleProjectToggle = (project: string): void => {
+ const next = new Set(filter.selectedProjects);
+ if (next.has(project)) {
+ next.delete(project);
+ } else {
+ next.add(project);
+ }
+ onFilterChange({ ...filter, selectedProjects: next });
+ };
+
+ const handleClearAll = (): void => {
+ onFilterChange(EMPTY_TEAM_FILTER);
+ };
+
+ const aliveSet = useMemo(() => new Set(aliveTeams), [aliveTeams]);
+ const runningCount = useMemo(
+ () => teams.filter((t) => aliveSet.has(t.teamName)).length,
+ [teams, aliveSet]
+ );
+ const offlineCount = useMemo(
+ () => teams.filter((t) => !aliveSet.has(t.teamName)).length,
+ [teams, aliveSet]
+ );
+
+ return (
+
+
+
+
+
+
+
+ Filter teams
+
+
+ {/* Status section */}
+
+
+ Status
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
+
+
+
+
+ {/* Project section */}
+ {uniqueProjects.length > 0 && (
+
+
+ Project
+
+
+ {uniqueProjects.map((project) => (
+
+ ))}
+
+
+ )}
+
+ {/* Footer */}
+
+
+
+
+
+ );
+};
+/* eslint-enable react-refresh/only-export-components -- pair for file-level disable */
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index cd9b399e..0ebb1ba0 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -16,6 +16,7 @@ import { useStore } from '@renderer/store';
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,
@@ -23,6 +24,7 @@ import {
FolderOpen,
GitBranch,
Play,
+ RotateCcw,
Search,
Square,
Trash2,
@@ -31,8 +33,10 @@ 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,
TeamProvisioningProgress,
@@ -176,6 +180,7 @@ export const TeamListView = (): React.JSX.Element => {
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 [branchByPath, setBranchByPath] = useState