import { useEffect, useMemo, useRef, useState } from 'react'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; import { getNonEmptyTaskCategories, groupTasksByDate, groupTasksByProject, sortTasksByFreshness, } from '@renderer/utils/taskGrouping'; import { Archive, ListTodo, Pin, Search, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { Combobox, type ComboboxOption } from '../ui/combobox'; import { SidebarTaskItem } from './SidebarTaskItem'; import { TaskContextMenu } from './TaskContextMenu'; import { TaskFiltersPopover } from './TaskFiltersPopover'; import { defaultTaskFiltersState, getTaskUnreadCount, taskMatchesStatus, useReadStateSnapshot, } from './taskFiltersState'; import type { TaskFiltersState } from './taskFiltersState'; import type { GlobalTask } from '@shared/types'; const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping'; export type TaskGroupingMode = 'none' | 'project' | 'time'; function loadGroupingMode(): TaskGroupingMode { try { const v = localStorage.getItem(TASK_GROUPING_STORAGE_KEY); if (v === 'none' || v === 'project' || v === 'time') return v; } catch { /* ignore */ } return 'none'; } function saveGroupingMode(mode: TaskGroupingMode): void { try { localStorage.setItem(TASK_GROUPING_STORAGE_KEY, mode); } catch { /* ignore */ } } export interface GlobalTaskListProps { /** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */ hideHeader?: boolean; /** External filters state when used with sidebar tabs. */ filters?: TaskFiltersState; onFiltersChange?: (f: TaskFiltersState) => void; filtersPopoverOpen?: boolean; onFiltersPopoverOpenChange?: (open: boolean) => void; } const dateCategoryLabels: Record = { 'Previous 7 Days': 'Last 7 Days', Older: 'Earlier', }; function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] { if (!query.trim()) return tasks; const q = query.toLowerCase(); return tasks.filter( (t) => t.subject.toLowerCase().includes(q) || t.owner?.toLowerCase().includes(q) || t.teamDisplayName.toLowerCase().includes(q) ); } function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): GlobalTask[] { if (!projectPath) return tasks; const normalized = normalizePath(projectPath); return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } export const GlobalTaskList = ({ hideHeader = false, filters: externalFilters, onFiltersChange: externalOnFiltersChange, filtersPopoverOpen: externalFiltersPopoverOpen, onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, }: GlobalTaskListProps = {}): React.JSX.Element => { const { globalTasks, globalTasksLoading, globalTasksInitialized, fetchAllTasks, softDeleteTask, projects, viewMode, repositoryGroups, teams, } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, globalTasksLoading: s.globalTasksLoading, globalTasksInitialized: s.globalTasksInitialized, fetchAllTasks: s.fetchAllTasks, softDeleteTask: s.softDeleteTask, projects: s.projects, viewMode: s.viewMode, repositoryGroups: s.repositoryGroups, teams: s.teams, })) ); const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); const filters = externalFilters ?? internalFilters; const setFilters = externalOnFiltersChange ?? setInternalFilters; const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; const [searchQuery, setSearchQuery] = useState(''); const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); const [showArchived, setShowArchived] = useState(false); const [renamingTaskKey, setRenamingTaskKey] = useState(null); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); const taskLocalState = useTaskLocalState(); // Local project filter (independent from sessions tab) const [localProjectFilter, setLocalProjectFilter] = useState(null); const setGroupingMode = (mode: TaskGroupingMode): void => { setGroupingModeState(mode); saveGroupingMode(mode); }; const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { taskLocalState.renameTask(teamName, taskId, newSubject); setRenamingTaskKey(null); }; const handleRenameCancel = (): void => { setRenamingTaskKey(null); }; const handleDeleteTask = async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ title: 'Delete task', message: `Move task #${taskId} to trash?`, confirmLabel: 'Delete', cancelLabel: 'Cancel', variant: 'danger', }); if (confirmed) { await softDeleteTask(teamName, taskId); await fetchAllTasks(); } }; // Fetch tasks on mount — loading guard in the store action prevents // duplicate IPC calls when the centralized init chain is already fetching. useEffect(() => { if (!hasFetchedRef.current && !globalTasksLoading) { hasFetchedRef.current = true; void fetchAllTasks(); } }, [fetchAllTasks, globalTasksLoading]); // Build project combobox options from available projects/repos const projectFilterOptions = useMemo((): ComboboxOption[] => { const items = viewMode === 'grouped' ? repositoryGroups .filter((r) => r.totalSessions > 0) .map((r) => ({ value: r.worktrees[0]?.path ?? r.id, label: r.name, path: r.worktrees[0]?.path, })) : projects .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) .map((p) => ({ value: p.path, label: p.name, path: p.path, })); return items.map((item) => ({ value: item.value, label: item.label, description: item.path, })); }, [viewMode, repositoryGroups, projects]); // Resolve local filter to a project path const selectedProjectPath = localProjectFilter; const filtered = useMemo(() => { let result = globalTasks; result = applyProjectFilter(result, selectedProjectPath); result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); if (filters.teamName) { result = result.filter((t) => t.teamName === filters.teamName); } if (filters.unreadOnly) { result = result.filter( (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 ); } result = applySearch(result, searchQuery); // Archive filtering if (showArchived) { result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); } else { result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); } return result; }, [ globalTasks, selectedProjectPath, filters.statusIds, filters.teamName, filters.unreadOnly, searchQuery, readState, showArchived, taskLocalState, ]); // Check if any archived tasks exist (before archive filtering) to conditionally show the toggle const hasArchivedTasks = useMemo( () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), [globalTasks, taskLocalState] ); // Reset showArchived when archive becomes empty useEffect(() => { if (showArchived && !hasArchivedTasks) { setShowArchived(false); } }, [showArchived, hasArchivedTasks]); // Split into pinned and normal (non-pinned) tasks const pinnedTasks = useMemo( () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), [filtered, taskLocalState] ); const normalTasks = useMemo( () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), [filtered, taskLocalState] ); const sortedFlat = useMemo(() => sortTasksByFreshness(normalTasks), [normalTasks]); const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); const hasContent = pinnedTasks.length > 0 || (groupingMode === 'none' ? sortedFlat.length > 0 : groupingMode === 'time' ? categories.length > 0 : projectGroups.some((g) => g.tasks.length > 0)); return (
{!hideHeader && (
Tasks
)} {/* Search bar */}
setSearchQuery(e.target.value)} className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" /> {searchQuery && ( )} ({ teamName: t.teamName, displayName: t.displayName }))} filters={filters} onFiltersChange={setFilters} onApply={() => {}} />
{/* Project filter */}
setLocalProjectFilter(v)} placeholder="All Projects" searchPlaceholder="Search projects..." emptyMessage="No projects" className="text-[11px]" resetLabel="All Projects" onReset={() => setLocalProjectFilter(null)} />
{/* Pinned tasks section */} {pinnedTasks.length > 0 && !showArchived && (
Pinned
{sortTasksByFreshness(pinnedTasks).map((task) => ( taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.getRenamedSubject(t.teamName, t.id)} /> ))}
)} {/* Grouping mode — compact segmented toggle */}
Group by:
{(['none', 'project', 'time'] as const).map((mode) => { const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; return ( ); })}
{/* Archive toggle — only visible when archived tasks exist */} {hasArchivedTasks && (
{showArchived ? 'Hide archived' : 'Show archived'}
)}
{/* Content */}
{globalTasksLoading && !globalTasksInitialized && (
{[1, 2, 3].map((i) => (
))}
)} {globalTasksInitialized && !hasContent && (
{searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
)} {groupingMode === 'none' && sortedFlat.map((task) => ( taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.getRenamedSubject(t.teamName, t.id)} /> ))} {groupingMode === 'project' && projectGroups.map((group) => { if (group.tasks.length === 0) return null; let lastTeam: string | null = null; return (
{group.projectLabel}
{group.tasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return (
{showTeamHeader && (
Team: {task.teamDisplayName}
)} taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.getRenamedSubject(t.teamName, t.id) } />
); })}
); })} {groupingMode === 'time' && categories.map((category) => { const tasks = grouped[category]; let lastTeam: string | null = null; return (
{dateCategoryLabels[category] ?? category}
{tasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return (
{showTeamHeader && (
Team: {task.teamDisplayName}
)} taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > taskLocalState.getRenamedSubject(t.teamName, t.id) } />
); })}
); })}
); };