Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions web-ui/__mocks__/@hugeicons/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
4 changes: 2 additions & 2 deletions web-ui/__tests__/components/TaskReview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -762,15 +762,15 @@ describe('TaskReview', () => {
render(<TaskReview projectId="1" />);

await waitFor(() => {
expect(projectsApi.getIssues).toHaveBeenCalledWith('1');
expect(projectsApi.getIssues).toHaveBeenCalledWith('1', { include: 'tasks' });
});
});

it('should handle numeric projectId', async () => {
render(<TaskReview projectId={1} />);

await waitFor(() => {
expect(projectsApi.getIssues).toHaveBeenCalledWith(1);
expect(projectsApi.getIssues).toHaveBeenCalledWith(1, { include: 'tasks' });
});
});

Expand Down
78 changes: 78 additions & 0 deletions web-ui/__tests__/components/tasks/TaskStats.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TaskStats phase="planning" issuesData={productionLikeResponse} />);

// 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(<TaskStats phase="planning" issuesData={responseWithoutTotalTasks} />);

// ASSERT: Should gracefully default to 0
expect(screen.getByTestId('total-tasks')).toHaveTextContent('0');
});
});
});
14 changes: 11 additions & 3 deletions web-ui/src/components/TaskReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,14 @@ const TaskReview = memo(function TaskReview({
// Refs for indeterminate checkboxes
const issueCheckboxRefs = useRef<Map<string, HTMLInputElement>>(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);

Expand Down Expand Up @@ -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)
);

Expand Down
41 changes: 31 additions & 10 deletions web-ui/src/components/tasks/TaskStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,25 +52,40 @@ 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;
completed: number;
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,
Expand Down Expand Up @@ -117,7 +138,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element {
{/* Total Tasks */}
<div className="p-4 rounded-lg bg-primary/10 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">📋</span>
<CheckListIcon className="h-6 w-6 text-primary" />
</div>
<div className="text-sm text-muted-foreground mb-1">Total Tasks</div>
<div
Expand All @@ -131,7 +152,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element {
{/* Completed Tasks */}
<div className="p-4 rounded-lg bg-secondary/10 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">✅</span>
<CheckmarkCircle01Icon className="h-6 w-6 text-secondary" />
</div>
<div className="text-sm text-muted-foreground mb-1">Completed</div>
<div
Expand All @@ -145,7 +166,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element {
{/* Blocked Tasks */}
<div className="p-4 rounded-lg bg-destructive/10 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">🚫</span>
<Alert02Icon className="h-6 w-6 text-destructive" />
</div>
<div className="text-sm text-muted-foreground mb-1">Blocked</div>
<div
Expand All @@ -159,7 +180,7 @@ function TaskStats({ phase, issuesData }: TaskStatsProps): JSX.Element {
{/* In-Progress Tasks */}
<div className="p-4 rounded-lg bg-accent/10 border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">⚙️</span>
<Loading03Icon className="h-6 w-6 text-accent-foreground" />
</div>
<div className="text-sm text-muted-foreground mb-1">In Progress</div>
<div
Expand Down
7 changes: 5 additions & 2 deletions web-ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ export const projectsApi = {
api.post(`/api/projects/${projectId}/resume`),
getPRD: (projectId: number | string) =>
api.get<PRDResponse>(`/api/projects/${projectId}/prd`),
getIssues: (projectId: number | string, cursor?: string) =>
getIssues: (projectId: number | string, options?: { cursor?: string; include?: 'tasks' }) =>
api.get<IssuesResponse>(`/api/projects/${projectId}/issues`, {
params: cursor ? { cursor } : {},
params: {
...(options?.cursor && { cursor: options.cursor }),
...(options?.include && { include: options.include }),
},
}),
getDiscoveryProgress: (projectId: number | string) =>
api.get<DiscoveryProgressResponse>(`/api/projects/${projectId}/discovery/progress`),
Expand Down
Loading