From 344ac19eac030df60ed7830a4db9cd533fa86f36 Mon Sep 17 00:00:00 2001 From: frankbria Date: Fri, 9 Jan 2026 12:14:33 -0700 Subject: [PATCH 1/3] fix(ui): use total_tasks field directly in TaskStats planning phase The previous phase-aware fix (#234) still failed because it tried to flatten the issues[].tasks arrays, which the API does not populate. The fix now uses issuesData.total_tasks directly as the authoritative count during planning phase. Root cause: API response includes total_tasks count but not nested task objects. The tab badge correctly used total_tasks (showing 24) while TaskStats flattened empty arrays (showing 0). Changes: - calculateStatsFromIssues now uses total_tasks field for total count - Still calculates status counts from nested tasks when available - Added test for production-like scenario with empty tasks arrays - Added edge case test for missing total_tasks field --- .../components/tasks/TaskStats.test.tsx | 78 +++++++++++++++++++ web-ui/src/components/tasks/TaskStats.tsx | 27 +++++-- 2 files changed, 99 insertions(+), 6 deletions(-) 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/tasks/TaskStats.tsx b/web-ui/src/components/tasks/TaskStats.tsx index eca9e7a2..b1edf308 100644 --- a/web-ui/src/components/tasks/TaskStats.tsx +++ b/web-ui/src/components/tasks/TaskStats.tsx @@ -46,7 +46,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 +64,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, From dd4d189ab3998982f3d59f989e110609d0f354bd Mon Sep 17 00:00:00 2001 From: frankbria Date: Fri, 9 Jan 2026 12:18:47 -0700 Subject: [PATCH 2/3] fix(ui): TaskReview now requests tasks with include=tasks param The API's /issues endpoint only populates issue.tasks[] arrays when include=tasks query param is passed. Without this, TaskReview displayed "No tasks available for approval" during planning phase. Changes: - api.ts: getIssues now accepts options object with cursor and include - TaskReview: passes { include: 'tasks' } to get nested task data - Tests: Updated expected API call signatures --- web-ui/__tests__/components/TaskReview.test.tsx | 4 ++-- web-ui/src/components/TaskReview.tsx | 5 +++-- web-ui/src/lib/api.ts | 7 +++++-- 3 files changed, 10 insertions(+), 6 deletions(-) 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/src/components/TaskReview.tsx b/web-ui/src/components/TaskReview.tsx index 5502e3ce..25fee762 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); diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index 4bdd18c1..87d86f85 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -45,9 +45,12 @@ export const projectsApi = { api.post(`/api/projects/${projectId}/resume`), getPRD: (projectId: number | string) => 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`), From 9596efc1388d8e657643313962b98573dffa646d Mon Sep 17 00:00:00 2001 From: frankbria Date: Fri, 9 Jan 2026 13:40:56 -0700 Subject: [PATCH 3/3] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskReview: - Add validation guard for non-numeric projectId before approval API call - Surface user-facing error if projectId is invalid (NaN or <= 0) TaskStats: - Replace emoji icons (📋, ✅, 🚫, ⚙️) with Hugeicons components - Use CheckListIcon, CheckmarkCircle01Icon, Alert02Icon, Loading03Icon - Aligns with repo guidelines for consistent iconography Jest mocks: - Add missing Hugeicons to @hugeicons/react mock - Refactor mock creation with helper function --- web-ui/__mocks__/@hugeicons/react.js | 35 +++++++++++++++++++---- web-ui/src/components/TaskReview.tsx | 9 +++++- web-ui/src/components/tasks/TaskStats.tsx | 14 ++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) 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/src/components/TaskReview.tsx b/web-ui/src/components/TaskReview.tsx index 25fee762..7a3105cd 100644 --- a/web-ui/src/components/TaskReview.tsx +++ b/web-ui/src/components/TaskReview.tsx @@ -196,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 b1edf308..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 @@ -132,7 +138,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element { {/* Total Tasks */}
- 📋 +
Total Tasks
- +
Completed
- 🚫 +
Blocked
- ⚙️ +
In Progress