diff --git a/web-ui/__tests__/components/Dashboard.test.tsx b/web-ui/__tests__/components/Dashboard.test.tsx
index 37d8bbf4..39dd0628 100644
--- a/web-ui/__tests__/components/Dashboard.test.tsx
+++ b/web-ui/__tests__/components/Dashboard.test.tsx
@@ -11,34 +11,63 @@ import * as api from '@/lib/api';
import * as websocket from '@/lib/websocket';
import * as agentAssignment from '@/api/agentAssignment';
-// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components)
-jest.mock('@hugeicons/react', () => ({
- // PhaseProgress icons
- Search01Icon: ({ className }: { className?: string }) => ,
- TaskEdit01Icon: ({ className }: { className?: string }) => ,
- Wrench01Icon: ({ className }: { className?: string }) => ,
- CheckmarkCircle01Icon: ({ className }: { className?: string }) => ,
- Award01Icon: ({ className }: { className?: string }) => ,
- RocketIcon: ({ className }: { className?: string }) => ,
- HelpCircleIcon: ({ className }: { className?: string }) => ,
- Idea01Icon: ({ className }: { className?: string }) => ,
- // Dashboard section header icons
- UserGroupIcon: ({ className }: { className?: string }) => ,
- Loading03Icon: ({ className }: { className?: string }) => ,
- WorkHistoryIcon: ({ className }: { className?: string }) => ,
- AnalyticsUpIcon: ({ className }: { className?: string }) => ,
- ClipboardIcon: ({ className }: { className?: string }) => ,
- CheckListIcon: ({ className }: { className?: string }) => ,
- Target02Icon: ({ className }: { className?: string }) => ,
- FloppyDiskIcon: ({ className }: { className?: string }) => ,
- // Activity item icons
- TestTube01Icon: ({ className }: { className?: string }) => ,
- Alert02Icon: ({ className }: { className?: string }) => ,
- CheckmarkSquare01Icon: ({ className }: { className?: string }) => ,
- BotIcon: ({ className }: { className?: string }) => ,
- Logout02Icon: ({ className }: { className?: string }) => ,
- GitCommitIcon: ({ className }: { className?: string }) => ,
-}));
+// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components)
+// This mock must include ALL icons used by Dashboard and its children
+jest.mock('@hugeicons/react', () => {
+ const createMockIcon = (name: string) => {
+ const Icon = ({ className }: { className?: string }) => (
+
+ );
+ Icon.displayName = name;
+ return Icon;
+ };
+
+ return {
+ // PhaseProgress icons
+ Search01Icon: createMockIcon('search-icon'),
+ TaskEdit01Icon: createMockIcon('task-edit-icon'),
+ Wrench01Icon: createMockIcon('wrench-icon'),
+ Award01Icon: createMockIcon('award-icon'),
+ RocketIcon: createMockIcon('rocket-icon'),
+ HelpCircleIcon: createMockIcon('help-icon'),
+ Idea01Icon: createMockIcon('idea-icon'),
+ // Dashboard section header icons
+ UserGroupIcon: createMockIcon('user-group-icon'),
+ Loading03Icon: createMockIcon('loading-icon'),
+ WorkHistoryIcon: createMockIcon('work-history-icon'),
+ AnalyticsUpIcon: createMockIcon('analytics-icon'),
+ ClipboardIcon: createMockIcon('clipboard-icon'),
+ CheckListIcon: createMockIcon('checklist-icon'),
+ Target02Icon: createMockIcon('target-icon'),
+ FloppyDiskIcon: createMockIcon('floppy-icon'),
+ GitPullRequestIcon: createMockIcon('git-pull-request-icon'),
+ // Activity item icons
+ TestTube01Icon: createMockIcon('test-tube-icon'),
+ Alert02Icon: createMockIcon('alert-icon'),
+ CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'),
+ CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'),
+ BotIcon: createMockIcon('bot-icon'),
+ Logout02Icon: createMockIcon('logout-icon'),
+ GitCommitIcon: createMockIcon('git-commit-icon'),
+ // UI component icons (select, checkbox, dialog, radio-group)
+ Tick01Icon: createMockIcon('tick-icon'),
+ ArrowDown01Icon: createMockIcon('arrow-down-icon'),
+ ArrowUp01Icon: createMockIcon('arrow-up-icon'),
+ Cancel01Icon: createMockIcon('cancel-icon'),
+ CircleIcon: createMockIcon('circle-icon'),
+ // PR component icons
+ AlertCircleIcon: createMockIcon('alert-circle-icon'),
+ GitBranchIcon: createMockIcon('git-branch-icon'),
+ ArrowRight01Icon: createMockIcon('arrow-right-icon'),
+ Link01Icon: createMockIcon('link-icon'),
+ Add01Icon: createMockIcon('add-icon'),
+ Cancel02Icon: createMockIcon('cancel02-icon'),
+ Time01Icon: createMockIcon('time-icon'),
+ // Other icons
+ Download01Icon: createMockIcon('download-icon'),
+ AlertDiamondIcon: createMockIcon('alert-diamond-icon'),
+ };
+});
// Create a shared mock WebSocket client that will be used across all tests
const sharedMockWsClient = {
diff --git a/web-ui/__tests__/components/PRCreationDialog.test.tsx b/web-ui/__tests__/components/PRCreationDialog.test.tsx
new file mode 100644
index 00000000..366ca4b5
--- /dev/null
+++ b/web-ui/__tests__/components/PRCreationDialog.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * Tests for PRCreationDialog Component
+ * TDD: Tests written first to define expected behavior
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import PRCreationDialog from '@/components/pr/PRCreationDialog';
+
+// Mock the API
+jest.mock('@/lib/api', () => ({
+ pullRequestsApi: {
+ create: jest.fn().mockResolvedValue({
+ data: {
+ pr_id: 1,
+ pr_number: 42,
+ pr_url: 'https://github.com/org/repo/pull/42',
+ status: 'open',
+ },
+ }),
+ },
+}));
+
+// Mock git API for branch list
+jest.mock('@/api/git', () => ({
+ gitApi: {
+ getBranches: jest.fn().mockResolvedValue({
+ data: {
+ branches: [
+ { id: 1, branch_name: 'feature/auth', status: 'active' },
+ { id: 2, branch_name: 'feature/dashboard', status: 'active' },
+ { id: 3, branch_name: 'main', status: 'active' },
+ ],
+ },
+ }),
+ },
+}));
+
+describe('PRCreationDialog', () => {
+ const defaultProps = {
+ projectId: 1,
+ isOpen: true,
+ onClose: jest.fn(),
+ onSuccess: jest.fn(),
+ defaultBranch: 'feature/auth',
+ defaultTitle: 'Add authentication feature',
+ defaultDescription: 'Implements OAuth 2.0 login flow',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render the dialog when open', () => {
+ render();
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText(/create pull request/i)).toBeInTheDocument();
+ });
+
+ it('should not render when closed', () => {
+ render();
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('should pre-fill form with default values', () => {
+ render();
+
+ expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('Implements OAuth 2.0 login flow')).toBeInTheDocument();
+ });
+ });
+
+ describe('Form Fields', () => {
+ it('should have title input field', () => {
+ render();
+
+ const titleInput = screen.getByLabelText(/title/i);
+ expect(titleInput).toBeInTheDocument();
+ expect(titleInput).toHaveAttribute('type', 'text');
+ });
+
+ it('should have description textarea', () => {
+ render();
+
+ const descriptionInput = screen.getByLabelText(/description/i);
+ expect(descriptionInput).toBeInTheDocument();
+ expect(descriptionInput.tagName.toLowerCase()).toBe('textarea');
+ });
+
+ it('should have branch selector', () => {
+ render();
+
+ expect(screen.getByLabelText(/source branch/i)).toBeInTheDocument();
+ });
+
+ it('should have base branch selector with main as default', () => {
+ render();
+
+ const baseBranchSelect = screen.getByLabelText(/target branch/i);
+ expect(baseBranchSelect).toBeInTheDocument();
+ });
+ });
+
+ describe('Form Validation', () => {
+ it('should require title', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ expect(screen.getByText(/title is required/i)).toBeInTheDocument();
+ });
+
+ it('should require branch selection', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ expect(screen.getByText(/branch is required/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('Form Submission', () => {
+ it('should call API with form data on submit', async () => {
+ const user = userEvent.setup();
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(pullRequestsApi.create).toHaveBeenCalledWith(1, {
+ branch: 'feature/auth',
+ title: 'Add authentication feature',
+ body: 'Implements OAuth 2.0 login flow',
+ base: 'main',
+ });
+ });
+ });
+
+ it('should call onSuccess after successful creation', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onSuccess).toHaveBeenCalled();
+ });
+ });
+
+ it('should close dialog after successful creation', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('should show loading state during submission', async () => {
+ // Make the API call take time to show loading state
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+ let resolvePromise: (value: unknown) => void;
+ pullRequestsApi.create.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ })
+ );
+
+ const user = userEvent.setup();
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ // Button should show loading state
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument();
+ });
+
+ // Cleanup: resolve the promise
+ resolvePromise!({
+ data: { pr_id: 1, pr_number: 42, pr_url: 'https://github.com/org/repo/pull/42', status: 'open' },
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should display error message on API failure', async () => {
+ const user = userEvent.setup();
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+ pullRequestsApi.create.mockRejectedValueOnce(new Error('Branch already has an open PR'));
+
+ render();
+
+ const submitButton = screen.getByRole('button', { name: /create/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/branch already has an open pr/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Cancel Action', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ await user.click(cancelButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ it('should reset form when reopened', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render();
+
+ // Modify title
+ const titleInput = screen.getByLabelText(/title/i);
+ await user.clear(titleInput);
+ await user.type(titleInput, 'Modified title');
+
+ // Close and reopen
+ rerender();
+ rerender();
+
+ // Should have original default value
+ expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/web-ui/__tests__/components/PRList.test.tsx b/web-ui/__tests__/components/PRList.test.tsx
new file mode 100644
index 00000000..d62e66cb
--- /dev/null
+++ b/web-ui/__tests__/components/PRList.test.tsx
@@ -0,0 +1,350 @@
+/**
+ * Tests for PRList Component
+ * TDD: Tests written first to define expected behavior
+ *
+ * PRList displays pull requests for a project with:
+ * - List view with status badges
+ * - Status filtering (all, open, merged, closed)
+ * - Create PR button
+ * - Quick actions (view, merge, close)
+ * - Real-time updates via WebSocket
+ */
+
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import PRList from '@/components/pr/PRList';
+import type { PullRequest, PRStatus } from '@/types/pullRequest';
+
+// Mock Hugeicons to prevent import issues in tests
+jest.mock('@hugeicons/react', () => ({
+ GitPullRequestIcon: () => ,
+ CheckmarkCircle01Icon: () => ,
+ Cancel01Icon: () => ,
+ Loading03Icon: () => ,
+ AlertCircleIcon: () => ,
+ GitBranchIcon: () => ,
+ Link01Icon: () => ,
+ Add01Icon: () => ,
+ ArrowRight01Icon: () => ,
+ CheckmarkSquare01Icon: () => ,
+ Cancel02Icon: () => ,
+ Time01Icon: () => ,
+}));
+
+// Mock data
+const mockPRs: PullRequest[] = [
+ {
+ id: 1,
+ pr_number: 42,
+ title: 'Add user authentication',
+ body: 'Implements OAuth 2.0 flow',
+ status: 'open',
+ head_branch: 'feature/auth',
+ base_branch: 'main',
+ pr_url: 'https://github.com/org/repo/pull/42',
+ issue_id: null,
+ created_at: '2024-01-15T10:00:00Z',
+ updated_at: '2024-01-15T12:00:00Z',
+ merged_at: null,
+ merge_commit_sha: null,
+ files_changed: 15,
+ additions: 450,
+ deletions: 50,
+ ci_status: 'success',
+ review_status: 'approved',
+ author: 'developer',
+ },
+ {
+ id: 2,
+ pr_number: 41,
+ title: 'Fix login bug',
+ body: 'Fixes issue with session timeout',
+ status: 'merged',
+ head_branch: 'fix/login-bug',
+ base_branch: 'main',
+ pr_url: 'https://github.com/org/repo/pull/41',
+ issue_id: 5,
+ created_at: '2024-01-14T09:00:00Z',
+ updated_at: '2024-01-14T15:00:00Z',
+ merged_at: '2024-01-14T15:00:00Z',
+ merge_commit_sha: 'abc123',
+ files_changed: 3,
+ additions: 25,
+ deletions: 10,
+ ci_status: 'success',
+ review_status: 'approved',
+ author: 'developer',
+ },
+ {
+ id: 3,
+ pr_number: 40,
+ title: 'Refactor database layer',
+ body: 'Cleanup and optimization',
+ status: 'closed',
+ head_branch: 'refactor/db',
+ base_branch: 'main',
+ pr_url: 'https://github.com/org/repo/pull/40',
+ issue_id: null,
+ created_at: '2024-01-13T08:00:00Z',
+ updated_at: '2024-01-13T16:00:00Z',
+ merged_at: null,
+ merge_commit_sha: null,
+ files_changed: 20,
+ additions: 200,
+ deletions: 300,
+ ci_status: 'failure',
+ review_status: 'changes_requested',
+ author: 'developer',
+ },
+];
+
+// SWR mock state
+let mockSWRData: { prs: PullRequest[]; total: number } | null = { prs: mockPRs, total: 3 };
+let mockSWRError: Error | null = null;
+let mockSWRIsLoading = false;
+
+// Mock SWR with configurable state
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ data: mockSWRData,
+ error: mockSWRError,
+ isLoading: mockSWRIsLoading,
+ mutate: jest.fn(),
+ })),
+}));
+
+// Mock WebSocket client
+const mockOnMessage = jest.fn();
+const mockUnsubscribe = jest.fn();
+jest.mock('@/lib/websocket', () => ({
+ getWebSocketClient: () => ({
+ onMessage: (handler: (msg: unknown) => void) => {
+ mockOnMessage.mockImplementation(handler);
+ return mockUnsubscribe;
+ },
+ }),
+}));
+
+// Mock the API
+jest.mock('@/lib/api', () => ({
+ pullRequestsApi: {
+ list: jest.fn().mockResolvedValue({ data: { prs: [], total: 0 } }),
+ },
+}));
+
+describe('PRList', () => {
+ const defaultProps = {
+ projectId: 1,
+ onCreatePR: jest.fn(),
+ onViewPR: jest.fn(),
+ onMergePR: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset mock state to defaults
+ mockSWRData = { prs: mockPRs, total: 3 };
+ mockSWRError = null;
+ mockSWRIsLoading = false;
+ });
+
+ describe('Rendering', () => {
+ it('should render the PR list with all PRs', () => {
+ render();
+
+ expect(screen.getByText('Add user authentication')).toBeInTheDocument();
+ expect(screen.getByText('Fix login bug')).toBeInTheDocument();
+ expect(screen.getByText('Refactor database layer')).toBeInTheDocument();
+ });
+
+ it('should display PR numbers', () => {
+ render();
+
+ expect(screen.getByText('#42')).toBeInTheDocument();
+ expect(screen.getByText('#41')).toBeInTheDocument();
+ expect(screen.getByText('#40')).toBeInTheDocument();
+ });
+
+ it('should display status badges', () => {
+ render();
+
+ const prCards = screen.getAllByTestId('pr-card');
+ expect(prCards.length).toBe(3);
+
+ // Check status badges exist
+ expect(screen.getByText('open')).toBeInTheDocument();
+ expect(screen.getByText('merged')).toBeInTheDocument();
+ expect(screen.getByText('closed')).toBeInTheDocument();
+ });
+
+ it('should display branch information', () => {
+ render();
+
+ expect(screen.getByText('feature/auth')).toBeInTheDocument();
+ expect(screen.getByText('fix/login-bug')).toBeInTheDocument();
+ });
+
+ it('should display file change counts', () => {
+ render();
+
+ expect(screen.getByText(/15 files/)).toBeInTheDocument();
+ expect(screen.getByText(/\+450/)).toBeInTheDocument();
+ expect(screen.getByText(/-50/)).toBeInTheDocument();
+ });
+
+ it('should display Create PR button', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: /create pr/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('Filtering', () => {
+ it('should display filter buttons', () => {
+ render();
+
+ expect(screen.getByRole('tab', { name: /all/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /open/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /merged/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /closed/i })).toBeInTheDocument();
+ });
+
+ it('should have "All" filter selected by default', () => {
+ render();
+
+ const allButton = screen.getByRole('tab', { name: /all/i });
+ expect(allButton).toHaveAttribute('data-active', 'true');
+ });
+
+ it('should call filter change handler when filter is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const openButton = screen.getByRole('tab', { name: /^open$/i });
+ await user.click(openButton);
+
+ expect(openButton).toHaveAttribute('data-active', 'true');
+ });
+ });
+
+ describe('Actions', () => {
+ it('should call onCreatePR when Create PR button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const createButton = screen.getByRole('button', { name: /create pr/i });
+ await user.click(createButton);
+
+ expect(defaultProps.onCreatePR).toHaveBeenCalled();
+ });
+
+ it('should call onViewPR when View button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByRole('button', { name: /view/i });
+ await user.click(viewButtons[0]);
+
+ expect(defaultProps.onViewPR).toHaveBeenCalledWith(42);
+ });
+
+ it('should show Merge button only for open PRs', () => {
+ render();
+
+ const prCards = screen.getAllByTestId('pr-card');
+
+ // First PR (open) should have Merge button
+ const openPRCard = prCards[0];
+ expect(within(openPRCard).getByRole('button', { name: /merge/i })).toBeInTheDocument();
+
+ // Second PR (merged) should not have Merge button
+ const mergedPRCard = prCards[1];
+ expect(within(mergedPRCard).queryByRole('button', { name: /merge/i })).not.toBeInTheDocument();
+ });
+
+ it('should call onMergePR when Merge button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const mergeButton = screen.getByRole('button', { name: /merge/i });
+ await user.click(mergeButton);
+
+ expect(defaultProps.onMergePR).toHaveBeenCalledWith(mockPRs[0]);
+ });
+ });
+
+ describe('CI/Review Status', () => {
+ it('should display CI status indicator', () => {
+ render();
+
+ // Should show success indicator for first PR
+ const prCards = screen.getAllByTestId('pr-card');
+ expect(within(prCards[0]).getByTestId('ci-status')).toHaveAttribute('data-status', 'success');
+ });
+
+ it('should display review status indicator', () => {
+ render();
+
+ const prCards = screen.getAllByTestId('pr-card');
+ expect(within(prCards[0]).getByTestId('review-status')).toHaveAttribute('data-status', 'approved');
+ });
+ });
+
+ describe('Empty State', () => {
+ it('should display empty state when no PRs exist', () => {
+ mockSWRData = { prs: [], total: 0 };
+ render();
+
+ expect(screen.getByText(/no pull requests/i)).toBeInTheDocument();
+ });
+
+ it('should still show Create PR button in empty state', () => {
+ mockSWRData = { prs: [], total: 0 };
+ render();
+
+ // There should be at least one Create PR button
+ const createButtons = screen.getAllByRole('button', { name: /create pr/i });
+ expect(createButtons.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Loading State', () => {
+ it('should display loading skeleton when loading', () => {
+ mockSWRData = null;
+ mockSWRIsLoading = true;
+ render();
+
+ expect(screen.getByTestId('pr-list-loading')).toBeInTheDocument();
+ });
+ });
+
+ describe('Error State', () => {
+ it('should display error message when fetch fails', () => {
+ mockSWRData = null;
+ mockSWRError = new Error('Failed to load PRs');
+ render();
+
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
+ });
+
+ it('should show retry button on error', () => {
+ mockSWRData = null;
+ mockSWRError = new Error('Failed to load PRs');
+ render();
+
+ expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('External Links', () => {
+ it('should render GitHub link with correct URL', () => {
+ render();
+
+ const githubLinks = screen.getAllByRole('link', { name: /github/i });
+ expect(githubLinks[0]).toHaveAttribute('href', 'https://github.com/org/repo/pull/42');
+ expect(githubLinks[0]).toHaveAttribute('target', '_blank');
+ expect(githubLinks[0]).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+ });
+});
diff --git a/web-ui/__tests__/components/PRMergeDialog.test.tsx b/web-ui/__tests__/components/PRMergeDialog.test.tsx
new file mode 100644
index 00000000..bf4c43a7
--- /dev/null
+++ b/web-ui/__tests__/components/PRMergeDialog.test.tsx
@@ -0,0 +1,235 @@
+/**
+ * Tests for PRMergeDialog Component
+ * TDD: Tests written first to define expected behavior
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import PRMergeDialog from '@/components/pr/PRMergeDialog';
+import type { PullRequest } from '@/types/pullRequest';
+
+// Mock PR data
+const mockPR: PullRequest = {
+ id: 1,
+ pr_number: 42,
+ title: 'Add user authentication',
+ body: 'Implements OAuth 2.0 flow',
+ status: 'open',
+ head_branch: 'feature/auth',
+ base_branch: 'main',
+ pr_url: 'https://github.com/org/repo/pull/42',
+ issue_id: null,
+ created_at: '2024-01-15T10:00:00Z',
+ updated_at: '2024-01-15T12:00:00Z',
+ merged_at: null,
+ merge_commit_sha: null,
+ files_changed: 15,
+ additions: 450,
+ deletions: 50,
+ ci_status: 'success',
+ review_status: 'approved',
+};
+
+// Mock the API
+jest.mock('@/lib/api', () => ({
+ pullRequestsApi: {
+ merge: jest.fn().mockResolvedValue({
+ data: {
+ merged: true,
+ merge_commit_sha: 'def456',
+ },
+ }),
+ },
+}));
+
+describe('PRMergeDialog', () => {
+ const defaultProps = {
+ pr: mockPR,
+ projectId: 1,
+ isOpen: true,
+ onClose: jest.fn(),
+ onSuccess: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render the dialog when open', () => {
+ render();
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText(/merge pull request/i)).toBeInTheDocument();
+ });
+
+ it('should not render when closed', () => {
+ render();
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('should display PR information', () => {
+ render();
+
+ expect(screen.getByText(/#42/)).toBeInTheDocument();
+ expect(screen.getByText('Add user authentication')).toBeInTheDocument();
+ expect(screen.getByText(/feature\/auth/)).toBeInTheDocument();
+ // Use getAllByText since 'main' appears multiple times (in branch info and description)
+ const mainElements = screen.getAllByText(/main/);
+ expect(mainElements.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Merge Method Selection', () => {
+ it('should display all merge method options', () => {
+ render();
+
+ expect(screen.getByLabelText(/squash and merge/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/create merge commit/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/rebase and merge/i)).toBeInTheDocument();
+ });
+
+ it('should have squash selected by default', () => {
+ render();
+
+ const squashRadio = screen.getByLabelText(/squash and merge/i);
+ expect(squashRadio).toBeChecked();
+ });
+
+ it('should allow changing merge method', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const rebaseRadio = screen.getByLabelText(/rebase and merge/i);
+ await user.click(rebaseRadio);
+
+ expect(rebaseRadio).toBeChecked();
+ });
+ });
+
+ describe('Delete Branch Option', () => {
+ it('should have delete branch checkbox checked by default', () => {
+ render();
+
+ const checkbox = screen.getByLabelText(/delete branch after merge/i);
+ expect(checkbox).toBeChecked();
+ });
+
+ it('should allow unchecking delete branch option', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const checkbox = screen.getByLabelText(/delete branch after merge/i);
+ await user.click(checkbox);
+
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ describe('Form Submission', () => {
+ it('should call API with selected options on confirm', async () => {
+ const user = userEvent.setup();
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+
+ render();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm merge/i });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(pullRequestsApi.merge).toHaveBeenCalledWith(1, 42, {
+ method: 'squash',
+ delete_branch: true,
+ });
+ });
+ });
+
+ it('should call onSuccess after successful merge', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm merge/i });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onSuccess).toHaveBeenCalled();
+ });
+ });
+
+ it('should close dialog after successful merge', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm merge/i });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('should show loading state during submission', async () => {
+ // Make the API call take time to show loading state
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+ let resolvePromise: (value: unknown) => void;
+ pullRequestsApi.merge.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ })
+ );
+
+ const user = userEvent.setup();
+ render();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm merge/i });
+ await user.click(confirmButton);
+
+ // Button should show loading state
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /merging/i })).toBeInTheDocument();
+ });
+
+ // Cleanup: resolve the promise
+ resolvePromise!({ data: { merged: true, merge_commit_sha: 'def456' } });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should display error message on API failure', async () => {
+ const user = userEvent.setup();
+ const { pullRequestsApi } = jest.requireMock('@/lib/api');
+ pullRequestsApi.merge.mockRejectedValueOnce(new Error('Merge conflicts detected'));
+
+ render();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm merge/i });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/merge conflicts detected/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Cancel Action', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ await user.click(cancelButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ describe('Warning Message', () => {
+ it('should display warning about irreversibility', () => {
+ render();
+
+ expect(screen.getByText(/this action cannot be undone/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx b/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx
index eeaa38ce..b1c7ef89 100644
--- a/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx
+++ b/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx
@@ -13,34 +13,62 @@ import * as websocket from '@/lib/websocket';
jest.mock('@/lib/api');
jest.mock('@/lib/websocket');
-// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components)
-jest.mock('@hugeicons/react', () => ({
- // PhaseProgress icons
- Search01Icon: ({ className }: { className?: string }) => ,
- TaskEdit01Icon: ({ className }: { className?: string }) => ,
- Wrench01Icon: ({ className }: { className?: string }) => ,
- CheckmarkCircle01Icon: ({ className }: { className?: string }) => ,
- Award01Icon: ({ className }: { className?: string }) => ,
- RocketIcon: ({ className }: { className?: string }) => ,
- HelpCircleIcon: ({ className }: { className?: string }) => ,
- Idea01Icon: ({ className }: { className?: string }) => ,
- // Dashboard section header icons
- UserGroupIcon: ({ className }: { className?: string }) => ,
- Loading03Icon: ({ className }: { className?: string }) => ,
- WorkHistoryIcon: ({ className }: { className?: string }) => ,
- AnalyticsUpIcon: ({ className }: { className?: string }) => ,
- ClipboardIcon: ({ className }: { className?: string }) => ,
- CheckListIcon: ({ className }: { className?: string }) => ,
- Target02Icon: ({ className }: { className?: string }) => ,
- FloppyDiskIcon: ({ className }: { className?: string }) => ,
- // Activity item icons
- TestTube01Icon: ({ className }: { className?: string }) => ,
- Alert02Icon: ({ className }: { className?: string }) => ,
- CheckmarkSquare01Icon: ({ className }: { className?: string }) => ,
- BotIcon: ({ className }: { className?: string }) => ,
- Logout02Icon: ({ className }: { className?: string }) => ,
- GitCommitIcon: ({ className }: { className?: string }) => ,
-}));
+// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components)
+jest.mock('@hugeicons/react', () => {
+ const createMockIcon = (name: string) => {
+ const Icon = ({ className }: { className?: string }) => (
+
+ );
+ Icon.displayName = name;
+ return Icon;
+ };
+
+ return {
+ // PhaseProgress icons
+ Search01Icon: createMockIcon('search-icon'),
+ TaskEdit01Icon: createMockIcon('task-edit-icon'),
+ Wrench01Icon: createMockIcon('wrench-icon'),
+ Award01Icon: createMockIcon('award-icon'),
+ RocketIcon: createMockIcon('rocket-icon'),
+ HelpCircleIcon: createMockIcon('help-icon'),
+ Idea01Icon: createMockIcon('idea-icon'),
+ // Dashboard section header icons
+ UserGroupIcon: createMockIcon('user-group-icon'),
+ Loading03Icon: createMockIcon('loading-icon'),
+ WorkHistoryIcon: createMockIcon('work-history-icon'),
+ AnalyticsUpIcon: createMockIcon('analytics-icon'),
+ ClipboardIcon: createMockIcon('clipboard-icon'),
+ CheckListIcon: createMockIcon('checklist-icon'),
+ Target02Icon: createMockIcon('target-icon'),
+ FloppyDiskIcon: createMockIcon('floppy-icon'),
+ GitPullRequestIcon: createMockIcon('git-pull-request-icon'),
+ // Activity item icons
+ TestTube01Icon: createMockIcon('test-tube-icon'),
+ Alert02Icon: createMockIcon('alert-icon'),
+ CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'),
+ CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'),
+ BotIcon: createMockIcon('bot-icon'),
+ Logout02Icon: createMockIcon('logout-icon'),
+ GitCommitIcon: createMockIcon('git-commit-icon'),
+ // UI component icons (select, checkbox, dialog, radio-group)
+ Tick01Icon: createMockIcon('tick-icon'),
+ ArrowDown01Icon: createMockIcon('arrow-down-icon'),
+ ArrowUp01Icon: createMockIcon('arrow-up-icon'),
+ Cancel01Icon: createMockIcon('cancel-icon'),
+ CircleIcon: createMockIcon('circle-icon'),
+ // PR component icons
+ AlertCircleIcon: createMockIcon('alert-circle-icon'),
+ GitBranchIcon: createMockIcon('git-branch-icon'),
+ ArrowRight01Icon: createMockIcon('arrow-right-icon'),
+ Link01Icon: createMockIcon('link-icon'),
+ Add01Icon: createMockIcon('add-icon'),
+ Cancel02Icon: createMockIcon('cancel02-icon'),
+ Time01Icon: createMockIcon('time-icon'),
+ // Other icons
+ Download01Icon: createMockIcon('download-icon'),
+ AlertDiamondIcon: createMockIcon('alert-diamond-icon'),
+ };
+});
jest.mock('@/components/ChatInterface', () => ({
__esModule: true,
@@ -177,7 +205,7 @@ describe('Dashboard Real-Time Updates Integration', () => {
// Verify Dashboard updates with new agent
await waitFor(() => {
- expect(screen.getByText(/1 agents active/i)).toBeInTheDocument();
+ expect(screen.getByText(/1 agent active/i)).toBeInTheDocument();
expect(screen.getByText(/backend-worker-1/i)).toBeInTheDocument();
});
});
diff --git a/web-ui/__tests__/integration/prd-button-sync.test.tsx b/web-ui/__tests__/integration/prd-button-sync.test.tsx
index d8601055..b26b7ee2 100644
--- a/web-ui/__tests__/integration/prd-button-sync.test.tsx
+++ b/web-ui/__tests__/integration/prd-button-sync.test.tsx
@@ -23,34 +23,61 @@ jest.mock('@/lib/websocket');
jest.mock('@/lib/api-client', () => ({
authFetch: jest.fn().mockRejectedValue(new Error('Not authenticated')),
}));
-jest.mock('@hugeicons/react', () => ({
- Cancel01Icon: () => ×,
- CheckmarkCircle01Icon: ({ className }: { className?: string }) => ,
- Alert02Icon: () => !,
- // PhaseProgress icons
- Search01Icon: ({ className }: { className?: string }) => ,
- TaskEdit01Icon: ({ className }: { className?: string }) => ,
- Wrench01Icon: ({ className }: { className?: string }) => ,
- Award01Icon: ({ className }: { className?: string }) => ,
- RocketIcon: ({ className }: { className?: string }) => ,
- HelpCircleIcon: ({ className }: { className?: string }) => ,
- Idea01Icon: ({ className }: { className?: string }) => ,
- // Dashboard section header icons
- UserGroupIcon: ({ className }: { className?: string }) => ,
- Loading03Icon: ({ className }: { className?: string }) => ,
- WorkHistoryIcon: ({ className }: { className?: string }) => ,
- AnalyticsUpIcon: ({ className }: { className?: string }) => ,
- ClipboardIcon: ({ className }: { className?: string }) => ,
- CheckListIcon: ({ className }: { className?: string }) => ,
- Target02Icon: ({ className }: { className?: string }) => ,
- FloppyDiskIcon: ({ className }: { className?: string }) => ,
- // Activity item icons
- TestTube01Icon: ({ className }: { className?: string }) => ,
- CheckmarkSquare01Icon: ({ className }: { className?: string }) => ,
- BotIcon: ({ className }: { className?: string }) => ,
- Logout02Icon: ({ className }: { className?: string }) => ,
- GitCommitIcon: ({ className }: { className?: string }) => ,
-}));
+jest.mock('@hugeicons/react', () => {
+ const createMockIcon = (name: string) => {
+ const Icon = ({ className }: { className?: string }) => (
+
+ );
+ Icon.displayName = name;
+ return Icon;
+ };
+
+ return {
+ // PhaseProgress icons
+ Search01Icon: createMockIcon('search-icon'),
+ TaskEdit01Icon: createMockIcon('task-edit-icon'),
+ Wrench01Icon: createMockIcon('wrench-icon'),
+ Award01Icon: createMockIcon('award-icon'),
+ RocketIcon: createMockIcon('rocket-icon'),
+ HelpCircleIcon: createMockIcon('help-icon'),
+ Idea01Icon: createMockIcon('idea-icon'),
+ // Dashboard section header icons
+ UserGroupIcon: createMockIcon('user-group-icon'),
+ Loading03Icon: createMockIcon('loading-icon'),
+ WorkHistoryIcon: createMockIcon('work-history-icon'),
+ AnalyticsUpIcon: createMockIcon('analytics-icon'),
+ ClipboardIcon: createMockIcon('clipboard-icon'),
+ CheckListIcon: createMockIcon('checklist-icon'),
+ Target02Icon: createMockIcon('target-icon'),
+ FloppyDiskIcon: createMockIcon('floppy-icon'),
+ GitPullRequestIcon: createMockIcon('git-pull-request-icon'),
+ // Activity item icons
+ TestTube01Icon: createMockIcon('test-tube-icon'),
+ Alert02Icon: createMockIcon('alert-icon'),
+ CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'),
+ CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'),
+ BotIcon: createMockIcon('bot-icon'),
+ Logout02Icon: createMockIcon('logout-icon'),
+ GitCommitIcon: createMockIcon('git-commit-icon'),
+ // UI component icons (select, checkbox, dialog, radio-group)
+ Tick01Icon: createMockIcon('tick-icon'),
+ ArrowDown01Icon: createMockIcon('arrow-down-icon'),
+ ArrowUp01Icon: createMockIcon('arrow-up-icon'),
+ Cancel01Icon: createMockIcon('cancel-icon'),
+ CircleIcon: createMockIcon('circle-icon'),
+ // PR component icons
+ AlertCircleIcon: createMockIcon('alert-circle-icon'),
+ GitBranchIcon: createMockIcon('git-branch-icon'),
+ ArrowRight01Icon: createMockIcon('arrow-right-icon'),
+ Link01Icon: createMockIcon('link-icon'),
+ Add01Icon: createMockIcon('add-icon'),
+ Cancel02Icon: createMockIcon('cancel02-icon'),
+ Time01Icon: createMockIcon('time-icon'),
+ // Other icons
+ Download01Icon: createMockIcon('download-icon'),
+ AlertDiamondIcon: createMockIcon('alert-diamond-icon'),
+ };
+});
jest.mock('@/components/ChatInterface', () => ({
__esModule: true,
default: () =>
ChatInterface Mock
,
diff --git a/web-ui/jest.setup.js b/web-ui/jest.setup.js
index 3185c90d..6fe831b5 100644
--- a/web-ui/jest.setup.js
+++ b/web-ui/jest.setup.js
@@ -35,3 +35,72 @@ jest.mock('react-markdown', () => {
return children
}
})
+
+// Mock @hugeicons/react - create stub components for all icons
+jest.mock('@hugeicons/react', () => {
+ const React = require('react')
+
+ // Generic icon component factory
+ const createIconMock = (name) => {
+ const IconComponent = React.forwardRef(({ className, ...props }, ref) =>
+ React.createElement('svg', {
+ ref,
+ className,
+ 'data-testid': name,
+ 'aria-hidden': 'true',
+ ...props
+ })
+ )
+ IconComponent.displayName = name
+ return IconComponent
+ }
+
+ // List of all icons used in the codebase
+ const iconNames = [
+ 'Add01Icon',
+ 'Alert02Icon',
+ 'AlertCircleIcon',
+ 'AlertDiamondIcon',
+ 'AnalyticsUpIcon',
+ 'ArrowDown01Icon',
+ 'ArrowRight01Icon',
+ 'ArrowUp01Icon',
+ 'Award01Icon',
+ 'BotIcon',
+ 'Cancel01Icon',
+ 'Cancel02Icon',
+ 'CheckListIcon',
+ 'CheckmarkCircle01Icon',
+ 'CheckmarkSquare01Icon',
+ 'CircleIcon',
+ 'ClipboardIcon',
+ 'Download01Icon',
+ 'FloppyDiskIcon',
+ 'GitBranchIcon',
+ 'GitCommitIcon',
+ 'GitPullRequestIcon',
+ 'HelpCircleIcon',
+ 'Idea01Icon',
+ 'Link01Icon',
+ 'Loading03Icon',
+ 'Logout02Icon',
+ 'RocketIcon',
+ 'Search01Icon',
+ 'Target02Icon',
+ 'TaskEdit01Icon',
+ 'TestTube01Icon',
+ 'Tick01Icon',
+ 'Time01Icon',
+ 'UserGroupIcon',
+ 'WorkHistoryIcon',
+ 'Wrench01Icon',
+ ]
+
+ // Create mock object with all icons
+ const mocks = {}
+ iconNames.forEach(name => {
+ mocks[name] = createIconMock(name)
+ })
+
+ return mocks
+})
diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json
index f6a7592f..4805c344 100644
--- a/web-ui/package-lock.json
+++ b/web-ui/package-lock.json
@@ -9,9 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@hugeicons/react": "^0.3.0",
+ "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -2040,6 +2043,36 @@
}
}
},
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
+ "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2297,6 +2330,52 @@
}
}
},
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
+ "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
@@ -2538,6 +2617,38 @@
}
}
},
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
+ "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
diff --git a/web-ui/package.json b/web-ui/package.json
index 331cf5b7..d662fe59 100644
--- a/web-ui/package.json
+++ b/web-ui/package.json
@@ -14,9 +14,12 @@
},
"dependencies": {
"@hugeicons/react": "^0.3.0",
+ "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
diff --git a/web-ui/src/components/Dashboard.tsx b/web-ui/src/components/Dashboard.tsx
index c2aaef17..1f49580b 100644
--- a/web-ui/src/components/Dashboard.tsx
+++ b/web-ui/src/components/Dashboard.tsx
@@ -38,6 +38,8 @@ import PhaseProgress from './PhaseProgress';
import TaskList from './TaskList';
import TaskReview from './TaskReview';
import { GitSection } from './git';
+import { PRList, PRCreationDialog, PRMergeDialog } from './pr';
+import type { PullRequest } from '@/types/pullRequest';
import {
UserGroupIcon,
Loading03Icon,
@@ -55,6 +57,7 @@ import {
CheckListIcon,
Target02Icon,
FloppyDiskIcon,
+ GitPullRequestIcon,
} from '@hugeicons/react';
/**
@@ -110,6 +113,12 @@ export default function Dashboard({ projectId }: DashboardProps) {
const [showQualityGatesPanel, setShowQualityGatesPanel] = useState(true);
const lastRetryTimeRef = useRef(0);
+ // Pull Request management state (Feature: PR Management UI)
+ const [showPRCreationDialog, setShowPRCreationDialog] = useState(false);
+ const [prToMerge, setPrToMerge] = useState(null);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [_selectedPRNumber, setSelectedPRNumber] = useState(null);
+
// Memoize filtered agent lists for performance (T111)
const _activeAgents = useMemo(
() => agents.filter(a => a.status === 'working' || a.status === 'blocked'),
@@ -519,6 +528,21 @@ export default function Dashboard({ projectId }: DashboardProps) {
>
Metrics
+