Skip to content

Commit ab198d1

Browse files
authored
feat(web-ui): task generation confirmation toast with count and link (#478)
## Summary - Adds `sonner` toast library with `<Toaster />` in root layout - Shows success toast `"Generated N tasks from PRD"` (4s duration) with "Go to Tasks →" action after generation - Shows error toast with specific API error detail on failure - Moves `AssociatedTasksSummary` above the editor grid for immediate post-generation visibility - Adds `/tasks` link to `AssociatedTasksSummary` with hover styling - Decouples `mutateTasks()` cache refresh from generation error handling (CodeRabbit fix) ## Validation - Review feedback: All addressed (1 round — CodeRabbit major issue fixed) - Demo: All 5 acceptance criteria verified (unit tests + browser screenshots) - Tests: 47/47 passing - CI: All checks green (Backend, Lint, CodeRabbit, claude-review) - Linting: Clean (0 errors) Closes #478
1 parent 2b81a71 commit ab198d1

9 files changed

Lines changed: 256 additions & 12 deletions

File tree

web-ui/__tests__/components/prd/AssociatedTasksSummary.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('AssociatedTasksSummary', () => {
1616
BACKLOG: 2, READY: 3, IN_PROGRESS: 1, DONE: 5, BLOCKED: 0, FAILED: 0, MERGED: 0,
1717
};
1818
render(<AssociatedTasksSummary taskCounts={counts} />);
19-
expect(screen.getByText('Tasks (11)')).toBeInTheDocument();
19+
expect(screen.getByText(/Tasks \(11\)/)).toBeInTheDocument();
2020
});
2121

2222
it('renders badges only for non-zero statuses', () => {

web-ui/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"react": "^19.2.4",
3737
"react-dom": "^19.2.4",
3838
"react-markdown": "^10.1.0",
39+
"sonner": "^2.0.7",
3940
"swr": "^2.2.4",
4041
"tailwind-merge": "^2.2.0"
4142
},
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { AssociatedTasksSummary } from '@/components/prd/AssociatedTasksSummary';
4+
import type { TaskStatusCounts } from '@/types';
5+
6+
jest.mock('next/link', () => {
7+
const MockLink = ({
8+
href,
9+
children,
10+
className,
11+
}: {
12+
href: string;
13+
children: React.ReactNode;
14+
className?: string;
15+
}) => (
16+
<a href={href} className={className}>
17+
{children}
18+
</a>
19+
);
20+
MockLink.displayName = 'MockLink';
21+
return MockLink;
22+
});
23+
24+
const emptyCounts: TaskStatusCounts = {
25+
BACKLOG: 0,
26+
READY: 0,
27+
IN_PROGRESS: 0,
28+
BLOCKED: 0,
29+
FAILED: 0,
30+
DONE: 0,
31+
MERGED: 0,
32+
};
33+
34+
describe('AssociatedTasksSummary', () => {
35+
it('returns null when all counts are zero', () => {
36+
const { container } = render(
37+
<AssociatedTasksSummary taskCounts={emptyCounts} />
38+
);
39+
expect(container.firstChild).toBeNull();
40+
});
41+
42+
it('renders a link to /tasks', () => {
43+
const counts: TaskStatusCounts = { ...emptyCounts, READY: 3, DONE: 2 };
44+
render(<AssociatedTasksSummary taskCounts={counts} />);
45+
46+
const link = screen.getByRole('link');
47+
expect(link).toHaveAttribute('href', '/tasks');
48+
});
49+
50+
it('shows total task count', () => {
51+
const counts: TaskStatusCounts = { ...emptyCounts, READY: 3, DONE: 2 };
52+
render(<AssociatedTasksSummary taskCounts={counts} />);
53+
54+
expect(screen.getByText(/Tasks \(5\)/)).toBeInTheDocument();
55+
});
56+
57+
it('shows badges only for non-zero statuses', () => {
58+
const counts: TaskStatusCounts = { ...emptyCounts, READY: 4, BLOCKED: 1 };
59+
render(<AssociatedTasksSummary taskCounts={counts} />);
60+
61+
expect(screen.getByText('Ready: 4')).toBeInTheDocument();
62+
expect(screen.getByText('Blocked: 1')).toBeInTheDocument();
63+
expect(screen.queryByText(/Backlog/)).not.toBeInTheDocument();
64+
expect(screen.queryByText(/Done/)).not.toBeInTheDocument();
65+
});
66+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import useSWR from 'swr';
4+
import { toast } from 'sonner';
5+
import PrdPage from '@/app/prd/page';
6+
import { discoveryApi } from '@/lib/api';
7+
import * as storage from '@/lib/workspace-storage';
8+
9+
// ── Mocks ────────────────────────────────────────────────────────────────
10+
11+
jest.mock('swr');
12+
jest.mock('sonner', () => ({
13+
toast: {
14+
success: jest.fn(),
15+
error: jest.fn(),
16+
},
17+
}));
18+
19+
jest.mock('@/lib/workspace-storage', () => ({
20+
getSelectedWorkspacePath: jest.fn(),
21+
}));
22+
23+
jest.mock('@/lib/api', () => ({
24+
prdApi: { getLatest: jest.fn(), createVersion: jest.fn() },
25+
tasksApi: { getAll: jest.fn() },
26+
discoveryApi: { generateTasks: jest.fn() },
27+
}));
28+
29+
30+
// Stub out heavy child components
31+
jest.mock('@/components/prd', () => ({
32+
PRDView: ({
33+
onGenerateTasks,
34+
isGeneratingTasks,
35+
}: {
36+
onGenerateTasks: () => void;
37+
isGeneratingTasks: boolean;
38+
}) => (
39+
<div>
40+
<button onClick={onGenerateTasks} disabled={isGeneratingTasks}>
41+
Generate Tasks
42+
</button>
43+
</div>
44+
),
45+
}));
46+
47+
jest.mock('@/components/prd/UploadPRDModal', () => ({
48+
UploadPRDModal: () => null,
49+
}));
50+
51+
jest.mock('next/link', () => {
52+
const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
53+
<a href={href}>{children}</a>
54+
);
55+
MockLink.displayName = 'MockLink';
56+
return MockLink;
57+
});
58+
59+
// ── Helpers ───────────────────────────────────────────────────────────────
60+
61+
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
62+
const mockGetSelectedWorkspacePath = storage.getSelectedWorkspacePath as jest.MockedFunction<
63+
typeof storage.getSelectedWorkspacePath
64+
>;
65+
const mockGenerateTasks = discoveryApi.generateTasks as jest.MockedFunction<
66+
typeof discoveryApi.generateTasks
67+
>;
68+
69+
const WORKSPACE = '/home/user/project';
70+
71+
const fakePrd = {
72+
id: 'prd-1',
73+
title: 'My PRD',
74+
content: '# Overview',
75+
version: 1,
76+
created_at: '2026-01-01T00:00:00Z',
77+
workspace_path: WORKSPACE,
78+
};
79+
80+
function setupSWR() {
81+
mockUseSWR.mockImplementation((key) => {
82+
if (typeof key === 'string' && key.includes('prd')) {
83+
return { data: fakePrd, error: undefined, isLoading: false, mutate: jest.fn() } as ReturnType<typeof useSWR>;
84+
}
85+
return { data: { tasks: [], by_status: {} }, error: undefined, isLoading: false, mutate: jest.fn() } as ReturnType<typeof useSWR>;
86+
});
87+
}
88+
89+
// ── Tests ─────────────────────────────────────────────────────────────────
90+
91+
describe('PrdPage — handleGenerateTasks', () => {
92+
beforeEach(() => {
93+
jest.clearAllMocks();
94+
mockGetSelectedWorkspacePath.mockReturnValue(WORKSPACE);
95+
setupSWR();
96+
});
97+
98+
it('shows success toast with task count after generation', async () => {
99+
mockGenerateTasks.mockResolvedValueOnce({ task_count: 8, tasks: [] });
100+
101+
render(<PrdPage />);
102+
103+
fireEvent.click(screen.getByRole('button', { name: /generate tasks/i }));
104+
105+
await waitFor(() => {
106+
expect(toast.success).toHaveBeenCalledWith(
107+
'Generated 8 tasks from PRD',
108+
expect.objectContaining({
109+
duration: 4000,
110+
action: expect.objectContaining({ label: 'Go to Tasks →' }),
111+
})
112+
);
113+
});
114+
});
115+
116+
it('uses singular "task" when count is 1', async () => {
117+
mockGenerateTasks.mockResolvedValueOnce({ task_count: 1, tasks: [] });
118+
119+
render(<PrdPage />);
120+
fireEvent.click(screen.getByRole('button', { name: /generate tasks/i }));
121+
122+
await waitFor(() => {
123+
expect(toast.success).toHaveBeenCalledWith(
124+
'Generated 1 task from PRD',
125+
expect.anything()
126+
);
127+
});
128+
});
129+
130+
it('shows error toast with API error detail on failure', async () => {
131+
mockGenerateTasks.mockRejectedValueOnce({ detail: 'No PRD content found' });
132+
133+
render(<PrdPage />);
134+
fireEvent.click(screen.getByRole('button', { name: /generate tasks/i }));
135+
136+
await waitFor(() => {
137+
expect(toast.error).toHaveBeenCalledWith('No PRD content found');
138+
});
139+
});
140+
141+
it('shows fallback error message when no detail provided', async () => {
142+
mockGenerateTasks.mockRejectedValueOnce({});
143+
144+
render(<PrdPage />);
145+
fireEvent.click(screen.getByRole('button', { name: /generate tasks/i }));
146+
147+
await waitFor(() => {
148+
expect(toast.error).toHaveBeenCalledWith(
149+
'Failed to generate tasks. Please try again.'
150+
);
151+
});
152+
});
153+
});

web-ui/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from 'next';
22
import { Nunito_Sans } from 'next/font/google';
3+
import { Toaster } from 'sonner';
34
import { AppLayout } from '@/components/layout';
45
import './globals.css';
56

@@ -31,6 +32,7 @@ export default function RootLayout({
3132
<html lang="en">
3233
<body className={`${nunitoSans.variable} font-sans antialiased`}>
3334
<AppLayout>{children}</AppLayout>
35+
<Toaster richColors position="top-right" />
3436
</body>
3537
</html>
3638
);

web-ui/src/app/prd/page.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useEffect } from 'react';
44
import Link from 'next/link';
5+
import { toast } from 'sonner';
56
import useSWR from 'swr';
67
import { PRDView } from '@/components/prd';
78
import { UploadPRDModal } from '@/components/prd/UploadPRDModal';
@@ -130,11 +131,20 @@ export default function PrdPage() {
130131
if (!workspacePath || !prd) return;
131132
setIsGeneratingTasks(true);
132133
try {
133-
await discoveryApi.generateTasks(workspacePath);
134-
await mutateTasks();
134+
const result = await discoveryApi.generateTasks(workspacePath);
135+
toast.success(`Generated ${result.task_count} task${result.task_count !== 1 ? 's' : ''} from PRD`, {
136+
duration: 4000,
137+
action: {
138+
label: 'Go to Tasks →',
139+
onClick: () => { window.location.href = '/tasks'; },
140+
},
141+
});
142+
void mutateTasks().catch((refreshError) => {
143+
console.error('[PRD] Task refresh failed after successful generation:', refreshError);
144+
});
135145
} catch (err) {
136146
const apiError = err as ApiError;
137-
console.error('[PRD] Task generation failed:', apiError.detail);
147+
toast.error(apiError.detail || 'Failed to generate tasks. Please try again.');
138148
} finally {
139149
setIsGeneratingTasks(false);
140150
}

web-ui/src/components/prd/AssociatedTasksSummary.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import Link from 'next/link';
34
import { Badge } from '@/components/ui/badge';
45
import type { TaskStatusCounts } from '@/types';
56
import type { BadgeProps } from '@/components/ui/badge';
@@ -31,9 +32,9 @@ export function AssociatedTasksSummary({
3132
if (total === 0) return null;
3233

3334
return (
34-
<div className="flex items-center gap-3">
35-
<span className="text-xs font-medium text-muted-foreground">
36-
Tasks ({total})
35+
<Link href="/tasks" className="group flex items-center gap-3 transition-opacity hover:opacity-80">
36+
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
37+
Tasks ({total})
3738
</span>
3839
<div className="flex flex-wrap gap-1.5">
3940
{STATUS_CONFIG.map(
@@ -45,6 +46,6 @@ export function AssociatedTasksSummary({
4546
)
4647
)}
4748
</div>
48-
</div>
49+
</Link>
4950
);
5051
}

web-ui/src/components/prd/PRDView.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export function PRDView({
9696
onGenerateTasks={onGenerateTasks}
9797
/>
9898

99+
{taskCounts && (
100+
<AssociatedTasksSummary taskCounts={taskCounts} />
101+
)}
102+
99103
<div
100104
className={`grid gap-4 ${
101105
discoveryOpen ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'
@@ -129,10 +133,6 @@ export function PRDView({
129133
</div>
130134
)}
131135
</div>
132-
133-
{taskCounts && (
134-
<AssociatedTasksSummary taskCounts={taskCounts} />
135-
)}
136136
</div>
137137
);
138138
}

0 commit comments

Comments
 (0)