diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 08de8533..aebb6891 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -5,11 +5,36 @@ const React = require('react'); +// Helper to create mock icon component +const createIcon = (name) => (props) => React.createElement('svg', { 'data-testid': `${name}-icon`, ...props }); + // Mock all icon exports module.exports = { - Download01Icon: (props) => React.createElement('svg', { 'data-testid': 'download-icon', ...props }), - Cancel01Icon: (props) => React.createElement('svg', { 'data-testid': 'cancel-icon', ...props }), - Tick01Icon: (props) => React.createElement('svg', { 'data-testid': 'tick-icon', ...props }), - ArrowDown01Icon: (props) => React.createElement('svg', { 'data-testid': 'arrow-down-icon', ...props }), - ArrowUp01Icon: (props) => React.createElement('svg', { 'data-testid': 'arrow-up-icon', ...props }), + // UI component icons + Download01Icon: createIcon('download'), + Cancel01Icon: createIcon('cancel'), + Tick01Icon: createIcon('tick'), + ArrowDown01Icon: createIcon('arrow-down'), + ArrowUp01Icon: createIcon('arrow-up'), + + // TaskStats icons + CheckListIcon: createIcon('check-list'), + CheckmarkCircle01Icon: createIcon('checkmark-circle'), + Alert02Icon: createIcon('alert'), + Loading03Icon: createIcon('loading'), + + // Dashboard icons + UserGroupIcon: createIcon('user-group'), + WorkHistoryIcon: createIcon('work-history'), + TestTube01Icon: createIcon('test-tube'), + CheckmarkSquare01Icon: createIcon('checkmark-square'), + BotIcon: createIcon('bot'), + Logout02Icon: createIcon('logout'), + GitCommitIcon: createIcon('git-commit'), + AnalyticsUpIcon: createIcon('analytics-up'), + ClipboardIcon: createIcon('clipboard'), + Search01Icon: createIcon('search'), + Target02Icon: createIcon('target'), + FloppyDiskIcon: createIcon('floppy-disk'), + Add01Icon: createIcon('add'), }; diff --git a/web-ui/__tests__/components/TaskReview.test.tsx b/web-ui/__tests__/components/TaskReview.test.tsx index cc30bb0c..7bbd9257 100644 --- a/web-ui/__tests__/components/TaskReview.test.tsx +++ b/web-ui/__tests__/components/TaskReview.test.tsx @@ -762,7 +762,7 @@ describe('TaskReview', () => { render(); await waitFor(() => { - expect(projectsApi.getIssues).toHaveBeenCalledWith('1'); + expect(projectsApi.getIssues).toHaveBeenCalledWith('1', { include: 'tasks' }); }); }); @@ -770,7 +770,7 @@ describe('TaskReview', () => { render(); await waitFor(() => { - expect(projectsApi.getIssues).toHaveBeenCalledWith(1); + expect(projectsApi.getIssues).toHaveBeenCalledWith(1, { include: 'tasks' }); }); }); diff --git a/web-ui/__tests__/components/tasks/TaskStats.test.tsx b/web-ui/__tests__/components/tasks/TaskStats.test.tsx index d204cef5..64a730c1 100644 --- a/web-ui/__tests__/components/tasks/TaskStats.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskStats.test.tsx @@ -709,5 +709,83 @@ describe('TaskStats', () => { String(consistentIssuesData.total_tasks) ); }); + + it('test_planning_phase_uses_total_tasks_when_tasks_arrays_empty', () => { + // CRITICAL: This test replicates the actual production bug. + // The API returns total_tasks count but does NOT populate the nested + // issues[].tasks arrays. The component must use total_tasks directly + // rather than trying to flatten empty task arrays. + // + // Production scenario: "Review (24)" badge shows correct count from + // issuesData.total_tasks, but TaskStats shows 0 because it tries to + // flatten empty tasks arrays. + + // ARRANGE: Realistic API response - tasks arrays are empty but total_tasks is populated + const productionLikeResponse = { + issues: [ + { + id: '1', + issue_number: '1', + title: 'Authentication Feature', + description: 'Implement user authentication', + status: 'pending' as const, + priority: 1, + depends_on: [], + proposed_by: 'agent' as const, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + completed_at: null, + tasks: [], // Empty - API doesn't populate nested tasks + }, + { + id: '2', + issue_number: '2', + title: 'Dashboard Feature', + description: 'Build analytics dashboard', + status: 'pending' as const, + priority: 2, + depends_on: [], + proposed_by: 'agent' as const, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + completed_at: null, + tasks: [], // Empty - API doesn't populate nested tasks + }, + ], + total_issues: 2, + total_tasks: 24, // This is the authoritative count from the database + }; + + mockUseAgentState.mockReturnValue(emptyAgentState); + + // ACT + render(); + + // ASSERT: Must show 24 (from total_tasks), NOT 0 (from flattening empty arrays) + expect(screen.getByTestId('total-tasks')).toHaveTextContent('24'); + + // During planning phase, completed/blocked/in-progress are unavailable + // since we don't have the individual task status data + expect(screen.getByTestId('completed-tasks')).toHaveTextContent('0'); + expect(screen.getByTestId('blocked-tasks')).toHaveTextContent('0'); + expect(screen.getByTestId('in-progress-tasks')).toHaveTextContent('0'); + }); + + it('test_planning_phase_handles_undefined_total_tasks', () => { + // Edge case: total_tasks field is missing from response + const responseWithoutTotalTasks = { + issues: [], + total_issues: 0, + // total_tasks intentionally omitted + } as unknown as typeof mockIssuesData; + + mockUseAgentState.mockReturnValue(emptyAgentState); + + // ACT + render(); + + // ASSERT: Should gracefully default to 0 + expect(screen.getByTestId('total-tasks')).toHaveTextContent('0'); + }); }); }); diff --git a/web-ui/src/components/TaskReview.tsx b/web-ui/src/components/TaskReview.tsx index 5502e3ce..7a3105cd 100644 --- a/web-ui/src/components/TaskReview.tsx +++ b/web-ui/src/components/TaskReview.tsx @@ -89,13 +89,14 @@ const TaskReview = memo(function TaskReview({ // Refs for indeterminate checkboxes const issueCheckboxRefs = useRef>(new Map()); - // Fetch issues on mount + // Fetch issues with tasks on mount + // Must pass include='tasks' to get nested task arrays for approval selection const fetchIssues = useCallback(async () => { setLoading(true); setError(null); try { - const response = await projectsApi.getIssues(projectId); + const response = await projectsApi.getIssues(projectId, { include: 'tasks' }); const fetchedIssues = response.data.issues; setIssues(fetchedIssues); @@ -195,12 +196,19 @@ const TaskReview = memo(function TaskReview({ const handleApprove = useCallback(async () => { if (selectedTaskIds.size === 0) return; + // Validate projectId before calling API + const numericProjectId = typeof projectId === 'string' ? parseInt(projectId, 10) : projectId; + if (isNaN(numericProjectId) || numericProjectId <= 0) { + setApprovalError('Invalid project ID. Please refresh and try again.'); + return; + } + setApproving(true); setApprovalError(null); try { await projectsApi.approveTaskBreakdown( - typeof projectId === 'string' ? parseInt(projectId, 10) : projectId, + numericProjectId, Array.from(selectedTaskIds) ); diff --git a/web-ui/src/components/tasks/TaskStats.tsx b/web-ui/src/components/tasks/TaskStats.tsx index eca9e7a2..9d7df6ac 100644 --- a/web-ui/src/components/tasks/TaskStats.tsx +++ b/web-ui/src/components/tasks/TaskStats.tsx @@ -23,6 +23,12 @@ import React, { useMemo } from 'react'; import { useAgentState } from '@/hooks/useAgentState'; import type { IssuesResponse, Task as ApiTask } from '@/types/api'; +import { + CheckListIcon, + CheckmarkCircle01Icon, + Alert02Icon, + Loading03Icon, +} from '@hugeicons/react'; /** * Props for TaskStats component @@ -46,7 +52,17 @@ interface TaskStatsProps { /** * Extract task statistics from issues data (planning phase). - * Iterates through all issues and their nested tasks to calculate counts. + * + * IMPORTANT: Uses `issuesData.total_tasks` directly for the total count because + * the API does not populate the nested `issues[].tasks` arrays in the response. + * The `total_tasks` field is the authoritative count from the database. + * + * For status-specific counts (completed, blocked, in-progress), we still try to + * calculate from nested tasks when available. However, during planning phase, + * these will typically be 0 since tasks haven't started execution yet. + * + * This fixes the "late-joining user" bug where TaskStats showed 0 tasks during + * planning phase while the tab badge showed the correct count (e.g., "Review (24)"). */ function calculateStatsFromIssues(issuesData: IssuesResponse | undefined): { total: number; @@ -54,17 +70,22 @@ function calculateStatsFromIssues(issuesData: IssuesResponse | undefined): { blocked: number; inProgress: number; } { - if (!issuesData?.issues) { + if (!issuesData) { return { total: 0, completed: 0, blocked: 0, inProgress: 0 }; } - // Flatten all tasks from all issues - const allTasks: ApiTask[] = issuesData.issues.flatMap( + // Use total_tasks directly - this is the authoritative count from the API. + // The nested issues[].tasks arrays may be empty even when total_tasks > 0. + const total = issuesData.total_tasks ?? 0; + + // Try to calculate status-specific counts from nested tasks when available. + // During planning phase, tasks arrays are typically empty, so these will be 0. + const allTasks: ApiTask[] = issuesData.issues?.flatMap( (issue) => issue.tasks || [] - ); + ) ?? []; return { - total: allTasks.length, + total, completed: allTasks.filter((t) => t.status === 'completed').length, blocked: allTasks.filter((t) => t.status === 'blocked').length, inProgress: allTasks.filter((t) => t.status === 'in_progress').length, @@ -117,7 +138,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element { {/* Total Tasks */}
- 📋 +
Total Tasks
- +
Completed
- 🚫 +
Blocked
- ⚙️ +
In Progress
api.get(`/api/projects/${projectId}/prd`), - getIssues: (projectId: number | string, cursor?: string) => + getIssues: (projectId: number | string, options?: { cursor?: string; include?: 'tasks' }) => api.get(`/api/projects/${projectId}/issues`, { - params: cursor ? { cursor } : {}, + params: { + ...(options?.cursor && { cursor: options.cursor }), + ...(options?.include && { include: options.include }), + }, }), getDiscoveryProgress: (projectId: number | string) => api.get(`/api/projects/${projectId}/discovery/progress`),