From 416c4acf04e5cacb38c6daa11b8ffa814e5bc4e8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:09:51 +0300 Subject: [PATCH] feat(sidebar): improve project task grouping --- .../components/sidebar/GlobalTaskList.tsx | 124 ++++++-- .../sidebar/projectGroupPagination.ts | 89 ++++++ .../components/sidebar/GlobalTaskList.test.ts | 278 ++++++++++++++++++ .../sidebar/projectGroupPagination.test.ts | 75 +++++ 4 files changed, 539 insertions(+), 27 deletions(-) create mode 100644 src/renderer/components/sidebar/projectGroupPagination.ts create mode 100644 test/renderer/components/sidebar/GlobalTaskList.test.ts create mode 100644 test/renderer/components/sidebar/projectGroupPagination.test.ts diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 188bc53f..de860219 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -33,6 +33,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal'; import { type ComboboxOption } from '../ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from './projectGroupPagination'; import { SidebarTaskItem } from './SidebarTaskItem'; import { TaskContextMenu } from './TaskContextMenu'; import { TaskFiltersPopover } from './TaskFiltersPopover'; @@ -207,6 +215,9 @@ export const GlobalTaskList = ({ const [sortPopoverOpen, setSortPopoverOpen] = useState(false); const [showArchived, setShowArchived] = useState(false); const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); @@ -221,21 +232,23 @@ export const GlobalTaskList = ({ return new Set(); } - // First load: seed all known IDs, no animations + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (isInitialTaskLoadRef.current) { isInitialTaskLoadRef.current = false; for (const t of globalTasks) { + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } return new Set(); } - // Subsequent updates: detect truly new tasks const newIds = new Set(); for (const t of globalTasks) { const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (!knownTaskIdsRef.current.has(key)) { newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(key); } } @@ -326,6 +339,11 @@ export const GlobalTaskList = ({ // Resolve project filter from filters state const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; const filtered = useMemo(() => { let result = globalTasks; @@ -345,7 +363,7 @@ export const GlobalTaskList = ({ } result = applySearch(result, searchQuery); // Archive filtering - if (showArchived) { + if (effectiveShowArchived) { result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); } else { result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); @@ -353,29 +371,16 @@ export const GlobalTaskList = ({ return result; }, [ globalTasks, - filters.projectPath, + selectedProjectPath, filters.statusIds, filters.teamName, filters.readFilter, searchQuery, readState, - showArchived, + effectiveShowArchived, 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)), @@ -400,6 +405,19 @@ export const GlobalTaskList = ({ [projectGroups] ); const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); @@ -499,7 +517,7 @@ export const GlobalTaskList = ({ {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !showArchived && ( + {pinnedTasks.length > 0 && !effectiveShowArchived && (
@@ -562,7 +580,7 @@ export const GlobalTaskList = ({ onClick={() => setShowArchived(!showArchived)} className={cn( 'rounded p-0.5 transition-colors', - showArchived + effectiveShowArchived ? 'bg-surface-raised text-text-secondary' : 'text-text-muted hover:text-text-secondary' )} @@ -571,7 +589,7 @@ export const GlobalTaskList = ({ - {showArchived ? 'Hide archived' : 'Show archived'} + {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
@@ -627,14 +645,25 @@ export const GlobalTaskList = ({ if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); const groupColor = projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); let lastTeam: string | null = null; return (
{!isGroupCollapsed && - group.tasks.map((task) => { + visibleTasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return ( @@ -691,6 +723,44 @@ export const GlobalTaskList = ({
); })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )}
); })} diff --git a/src/renderer/components/sidebar/projectGroupPagination.ts b/src/renderer/components/sidebar/projectGroupPagination.ts new file mode 100644 index 00000000..58d32732 --- /dev/null +++ b/src/renderer/components/sidebar/projectGroupPagination.ts @@ -0,0 +1,89 @@ +export const PROJECT_GROUP_PAGE_SIZE = 5; + +export interface ProjectGroupVisibilityDescriptor { + projectKey: string; + taskCount: number; +} + +export function getProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + if (taskCount <= 0) { + return 0; + } + + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + if (visibleCount == null || !Number.isFinite(visibleCount)) { + return minimumVisibleCount; + } + + const normalizedVisibleCount = Math.floor(visibleCount); + return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount)); +} + +export function getNextProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + if (currentVisibleCount >= taskCount) { + return currentVisibleCount; + } + return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE); +} + +export function getPreviousProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE); +} + +export function canProjectGroupShowMore( + visibleCount: number | undefined, + taskCount: number +): boolean { + return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount; +} + +export function canProjectGroupShowLess( + visibleCount: number | undefined, + taskCount: number +): boolean { + if (taskCount <= PROJECT_GROUP_PAGE_SIZE) { + return false; + } + return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE; +} + +export function syncProjectGroupVisibleCountByKey( + previousVisibleCountByKey: Record, + groups: readonly ProjectGroupVisibilityDescriptor[] +): Record { + let changed = false; + const nextVisibleCountByKey: Record = {}; + + for (const group of groups) { + const nextVisibleCount = getProjectGroupVisibleCount( + previousVisibleCountByKey[group.projectKey], + group.taskCount + ); + + if (nextVisibleCount > 0) { + nextVisibleCountByKey[group.projectKey] = nextVisibleCount; + } + + if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) { + changed = true; + } + } + + if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) { + changed = true; + } + + return changed ? nextVisibleCountByKey : previousVisibleCountByKey; +} diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts new file mode 100644 index 00000000..3dcacade --- /dev/null +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -0,0 +1,278 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '../../../../src/shared/types'; + +interface StoreState { + globalTasks: GlobalTask[]; + globalTasksLoading: boolean; + globalTasksInitialized: boolean; + fetchAllTasks: ReturnType; + softDeleteTask: ReturnType; + projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; + viewMode: 'flat' | 'grouped'; + repositoryGroups: { + id: string; + name: string; + totalSessions: number; + worktrees: { path: string }[]; + }[]; + teams: { teamName: string; displayName: string }[]; +} + +const storeState = {} as StoreState; +const toggleCollapsedGroup = vi.fn(); +const taskLocalState = { + isPinned: vi.fn(() => false), + isArchived: vi.fn(() => false), + getRenamedSubject: vi.fn(() => undefined), + togglePin: vi.fn(), + toggleArchive: vi.fn(), + renameTask: vi.fn(), +}; + +vi.mock('../../../../src/renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({ + useCollapsedGroups: () => ({ + isCollapsed: () => false, + toggle: toggleCollapsedGroup, + }), +})); + +vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({ + useTaskLocalState: () => taskLocalState, +})); + +vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + AnimatedHeightReveal: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({ + TaskContextMenu: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ + SidebarTaskItem: ({ task }: { task: GlobalTask }) => + React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ + TaskFiltersPopover: () => null, +})); + +vi.mock('../../../../src/renderer/components/ui/popover', () => ({ + Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Archive: Icon, + ArrowUpDown: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronRight: Icon, + Folder: Icon, + ListTodo: Icon, + Pin: Icon, + Search: Icon, + X: Icon, + }; +}); + +import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function findButton(host: HTMLElement, label: string): HTMLButtonElement | null { + return Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) ?? null; +} + +function visibleSubjects(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map( + (node) => node.textContent ?? '' + ); +} + +function makeTask(index: number, overrides: Partial = {}): GlobalTask { + const timestamp = String(60 - index).padStart(2, '0'); + return { + id: `task-${index}`, + displayId: `task${index}`, + teamName: 'alpha-team', + teamDisplayName: 'Alpha Team', + subject: `Task ${index}`, + description: '', + status: 'in_progress', + owner: 'alice', + createdAt: `2026-04-18T10:${timestamp}:00.000Z`, + updatedAt: `2026-04-18T10:${timestamp}:00.000Z`, + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + projectPath: '/workspace/hookplex', + ...overrides, + } as GlobalTask; +} + +describe('GlobalTaskList project grouping', () => { + beforeEach(() => { + storeState.globalTasks = []; + storeState.globalTasksLoading = false; + storeState.globalTasksInitialized = true; + storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined)); + storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); + storeState.projects = []; + storeState.viewMode = 'flat'; + storeState.repositoryGroups = []; + storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; + toggleCollapsedGroup.mockReset(); + taskLocalState.isPinned.mockClear(); + taskLocalState.isArchived.mockClear(); + taskLocalState.getRenamedSubject.mockClear(); + taskLocalState.togglePin.mockClear(); + taskLocalState.toggleArchive.mockClear(); + taskLocalState.renameTask.mockClear(); + localStorage.clear(); + localStorage.setItem('sidebarTasksGrouping', 'project'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + findButton(host, 'Show less')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps the hard visible limit when new tasks arrive after expansion', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(findButton(host, 'Show less')).not.toBeNull(); + + storeState.globalTasks = [ + makeTask(0, { + id: 'task-new', + displayId: 'task-new', + subject: 'Task 0', + createdAt: '2026-04-18T11:00:00.000Z', + updatedAt: '2026-04-18T11:00:00.000Z', + }), + ...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)), + ]; + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(visibleSubjects(host)).toEqual([ + 'Task 0', + 'Task 1', + 'Task 2', + 'Task 3', + 'Task 4', + 'Task 5', + 'Task 6', + 'Task 7', + 'Task 8', + 'Task 9', + ]); + expect(visibleSubjects(host)).not.toContain('Task 10'); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/sidebar/projectGroupPagination.test.ts b/test/renderer/components/sidebar/projectGroupPagination.test.ts new file mode 100644 index 00000000..b9651f04 --- /dev/null +++ b/test/renderer/components/sidebar/projectGroupPagination.test.ts @@ -0,0 +1,75 @@ +import { + PROJECT_GROUP_PAGE_SIZE, + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from '../../../../src/renderer/components/sidebar/projectGroupPagination'; +import { describe, expect, it } from 'vitest'; + +describe('projectGroupPagination', () => { + it('defaults to the first page and respects small groups', () => { + expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0); + expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3); + expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE); + }); + + it('expands in steps of five and clamps to the group size', () => { + let visibleCount = getProjectGroupVisibleCount(undefined, 17); + expect(visibleCount).toBe(5); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(10); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(15); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(17); + + expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false); + }); + + it('collapses in steps of five and never goes below the first page', () => { + expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10); + expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5); + expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5); + + expect(canProjectGroupShowLess(5, 17)).toBe(false); + expect(canProjectGroupShowLess(10, 17)).toBe(true); + }); + + it('clamps existing counts when the group shrinks and removes missing groups', () => { + const previousVisibleCounts = { + active: 15, + compact: 7, + removed: 10, + }; + + expect( + syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 9 }, + { projectKey: 'compact', taskCount: 4 }, + ]) + ).toEqual({ + active: 9, + compact: 4, + }); + }); + + it('returns the same object when nothing changes', () => { + const previousVisibleCounts = { + active: 10, + compact: 4, + }; + + const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 12 }, + { projectKey: 'compact', taskCount: 4 }, + ]); + + expect(nextVisibleCounts).toBe(previousVisibleCounts); + }); +});