agent-ecosystem/src/renderer/components/sidebar/GlobalTaskList.tsx
iliya 9b27378087 feat: enhance task notification and management features
- Updated task assignment notifications to skip inbox messages when leads assign tasks to themselves, improving user experience for solo teams.
- Refactored notification logic in TeamAgentToolsInstaller and TeamDataService to ensure clarity and maintainability.
- Introduced new functionality in GlobalTaskList to manage pinned and archived tasks, enhancing task visibility and organization.
- Added renaming capabilities for tasks in SidebarTaskItem, allowing users to edit task subjects directly.
- Improved overall task filtering and grouping logic to support better task management practices.
2026-03-04 16:15:40 +02:00

523 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
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<string, string> = {
'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,
projects,
viewMode,
repositoryGroups,
teams,
} = useStore(
useShallow((s) => ({
globalTasks: s.globalTasks,
globalTasksLoading: s.globalTasksLoading,
globalTasksInitialized: s.globalTasksInitialized,
fetchAllTasks: s.fetchAllTasks,
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<TaskGroupingMode>(loadGroupingMode);
const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
const taskLocalState = useTaskLocalState();
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(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);
};
// 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,
]);
// 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 (
<div className="flex size-full min-w-0 flex-col">
{!hideHeader && (
<div
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
</div>
)}
{/* Search bar */}
<div
className="mb-[5px] flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
style={{ borderColor: 'var(--color-border)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />
<input
ref={searchInputRef}
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
/>
{searchQuery && (
<button
type="button"
className="shrink-0 text-text-muted hover:text-text-secondary"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<X className="size-3" />
</button>
)}
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div>
{/* Project filter */}
<div className="shrink-0 px-2 py-1">
<Combobox
options={projectFilterOptions}
value={localProjectFilter ?? ''}
onValueChange={(v) => setLocalProjectFilter(v)}
placeholder="All Projects"
searchPlaceholder="Search projects..."
emptyMessage="No projects"
className="text-[11px]"
resetLabel="All Projects"
onReset={() => setLocalProjectFilter(null)}
/>
</div>
{/* Pinned tasks section */}
{pinnedTasks.length > 0 && !showArchived && (
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
<div className="flex items-center gap-1 px-2 py-1">
<Pin className="size-3 text-text-muted" />
<span className="text-[11px] text-text-muted">Pinned</span>
</div>
{sortTasksByFreshness(pinnedTasks).map((task) => (
<TaskContextMenu
key={`pinned-${task.teamName}-${task.id}`}
task={task}
isPinned={true}
isArchived={false}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
showTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/>
</TaskContextMenu>
))}
</div>
)}
{/* Grouping mode — compact segmented toggle */}
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
<div
className="bg-surface-raised/60 inline-flex rounded-md p-0.5 text-[11px]"
role="group"
aria-label="Group by"
>
{(['none', 'project', 'time'] as const).map((mode) => {
const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
return (
<button
key={mode}
type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{label}
</button>
);
})}
</div>
{/* Archive toggle */}
<div className="ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className={cn(
'rounded p-0.5 transition-colors',
showArchived
? 'bg-surface-raised text-text-secondary'
: 'text-text-muted hover:text-text-secondary'
)}
>
<Archive className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{showArchived ? 'Hide archived' : 'Show archived'}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{globalTasksLoading && !globalTasksInitialized && (
<div className="space-y-2 p-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-[48px] animate-pulse rounded bg-surface-raised" />
))}
</div>
)}
{globalTasksInitialized && !hasContent && (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-text-muted">
<ListTodo className="size-8 opacity-40" />
<span className="text-[12px]">
{searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
</span>
</div>
)}
{groupingMode === 'none' &&
sortedFlat.map((task) => (
<TaskContextMenu
key={`${task.teamName}-${task.id}`}
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
showTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) => taskLocalState.getRenamedSubject(t.teamName, t.id)}
/>
</TaskContextMenu>
))}
{groupingMode === 'project' &&
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
let lastTeam: string | null = null;
return (
<div key={group.projectKey}>
<div
className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: projectColor(group.projectLabel).border }}
/>
<span style={{ color: projectColor(group.projectLabel).text }}>
{group.projectLabel}
</span>
</div>
{group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
hideTeamName
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}
</div>
);
})}
{groupingMode === 'time' &&
categories.map((category) => {
const tasks = grouped[category];
let lastTeam: string | null = null;
return (
<div key={category}>
<div
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{dateCategoryLabels[category] ?? category}
</div>
{tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<TaskContextMenu
task={task}
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
>
<SidebarTaskItem
task={task}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
getDisplaySubject={(t) =>
taskLocalState.getRenamedSubject(t.teamName, t.id)
}
/>
</TaskContextMenu>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
};