diff --git a/frontend/e2e/issues.spec.ts b/frontend/e2e/issues.spec.ts new file mode 100644 index 0000000..ade63c3 --- /dev/null +++ b/frontend/e2e/issues.spec.ts @@ -0,0 +1,265 @@ +/** + * Issue Management E2E Tests + * + * Tests for the issue list and detail pages. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Issue Management', () => { + // Use a test project ID + const projectId = 'test-project-123'; + + test.beforeEach(async ({ page }) => { + // Mock authentication - inject test auth store + await page.addInitScript(() => { + window.__TEST_AUTH_STORE__ = { + getState: () => ({ + isAuthenticated: true, + user: { id: 'test-user', email: 'test@example.com', is_superuser: false }, + accessToken: 'test-token', + refreshToken: 'test-refresh', + }), + }; + }); + }); + + test.describe('Issue List Page', () => { + test('displays issues list', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Wait for the page to load + await expect(page.getByRole('heading', { name: /issues/i })).toBeVisible(); + + // Should show issue count + await expect(page.getByText(/issues found/i)).toBeVisible(); + }); + + test('has search functionality', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + const searchInput = page.getByPlaceholder('Search issues...'); + await expect(searchInput).toBeVisible(); + + // Type in search + await searchInput.fill('authentication'); + + // Wait for debounced search (mock data should filter) + await page.waitForTimeout(500); + }); + + test('has status filter', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Find status filter + const statusFilter = page.getByRole('combobox', { name: /filter by status/i }); + await expect(statusFilter).toBeVisible(); + + // Open and select a status + await statusFilter.click(); + await page.getByRole('option', { name: /in progress/i }).click(); + }); + + test('can toggle extended filters', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Extended filters should not be visible initially + await expect(page.getByLabel('Priority')).not.toBeVisible(); + + // Click filter toggle + await page.getByRole('button', { name: /toggle extended filters/i }).click(); + + // Extended filters should now be visible + await expect(page.getByLabel('Priority')).toBeVisible(); + await expect(page.getByLabel('Sprint')).toBeVisible(); + await expect(page.getByLabel('Assignee')).toBeVisible(); + }); + + test('can select issues for bulk actions', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Wait for issues to load + await page.waitForSelector('[data-testid^="issue-row-"]'); + + // Select first issue checkbox + const firstCheckbox = page.getByRole('checkbox', { name: /select issue/i }).first(); + await firstCheckbox.click(); + + // Bulk actions bar should appear + await expect(page.getByText('1 selected')).toBeVisible(); + await expect(page.getByRole('button', { name: /change status/i })).toBeVisible(); + }); + + test('navigates to issue detail when clicking row', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Wait for issues to load + await page.waitForSelector('[data-testid^="issue-row-"]'); + + // Click on first issue row + await page.locator('[data-testid^="issue-row-"]').first().click(); + + // Should navigate to detail page + await expect(page).toHaveURL(/\/issues\/[^/]+$/); + }); + + test('has new issue button', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + await expect(page.getByRole('button', { name: /new issue/i })).toBeVisible(); + }); + + test('has sync button', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + await expect(page.getByRole('button', { name: /sync/i })).toBeVisible(); + }); + }); + + test.describe('Issue Detail Page', () => { + const issueId = 'ISS-001'; + + test('displays issue details', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Wait for the page to load + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + + // Should show issue number + await expect(page.getByText(/#\d+/)).toBeVisible(); + }); + + test('displays status badge', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show status + await expect( + page.getByText(/open|in progress|in review|blocked|done|closed/i).first() + ).toBeVisible(); + }); + + test('displays priority badge', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show priority + await expect(page.getByText(/high|medium|low/i).first()).toBeVisible(); + }); + + test('has back button', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + const backButton = page.getByRole('link', { name: /back to issues/i }); + await expect(backButton).toBeVisible(); + }); + + test('displays status workflow panel', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show status workflow + await expect(page.getByRole('heading', { name: /status workflow/i })).toBeVisible(); + + // Should show all status options + await expect(page.getByRole('radiogroup', { name: /issue status/i })).toBeVisible(); + }); + + test('displays activity timeline', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show activity section + await expect(page.getByRole('heading', { name: /activity/i })).toBeVisible(); + + // Should have add comment button + await expect(page.getByRole('button', { name: /add comment/i })).toBeVisible(); + }); + + test('displays issue details panel', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show details section + await expect(page.getByRole('heading', { name: /details/i })).toBeVisible(); + + // Should show assignee info + await expect(page.getByText(/assignee/i)).toBeVisible(); + }); + + test('can change status via workflow', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Wait for page to load + await page.waitForSelector('[role="radiogroup"]'); + + // Click on a different status + const inProgressOption = page.getByRole('radio', { name: /in progress/i }); + await inProgressOption.click(); + + // The status should update (optimistic update) + await expect(inProgressOption).toHaveAttribute('aria-checked', 'true'); + }); + + test('displays description', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // Should show description heading + await expect(page.getByRole('heading', { name: /description/i })).toBeVisible(); + }); + + test('shows edit button', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + await expect(page.getByRole('button', { name: /edit/i })).toBeVisible(); + }); + + test('shows external link when available', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/${issueId}`); + + // The mock data includes an external URL + await expect(page.getByRole('link', { name: /view in gitea/i })).toBeVisible(); + }); + }); + + test.describe('Accessibility', () => { + test('issue list has proper heading structure', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Main heading should be h1 + const h1 = page.getByRole('heading', { level: 1 }); + await expect(h1).toBeVisible(); + }); + + test('issue list table has proper ARIA labels', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues`); + + // Wait for table to load + await page.waitForSelector('[data-testid^="issue-row-"]'); + + // Checkboxes should have labels + const checkboxes = page.getByRole('checkbox'); + const count = await checkboxes.count(); + expect(count).toBeGreaterThan(0); + + // First checkbox should have accessible label + await expect(checkboxes.first()).toHaveAccessibleName(); + }); + + test('issue detail has proper radiogroup for status', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/ISS-001`); + + // Status workflow should be a radiogroup + const radiogroup = page.getByRole('radiogroup', { name: /issue status/i }); + await expect(radiogroup).toBeVisible(); + + // Each status should be a radio button + const radios = page.getByRole('radio'); + const count = await radios.count(); + expect(count).toBe(6); // 6 statuses + }); + + test('activity timeline has proper list structure', async ({ page }) => { + await page.goto(`/en/projects/${projectId}/issues/ISS-001`); + + // Activity should be a list + const list = page.getByRole('list', { name: /issue activity/i }); + await expect(list).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/project-dashboard.spec.ts b/frontend/e2e/project-dashboard.spec.ts new file mode 100644 index 0000000..0583474 --- /dev/null +++ b/frontend/e2e/project-dashboard.spec.ts @@ -0,0 +1,297 @@ +/** + * E2E Tests for Project Dashboard Page + * + * Tests the single project view showing: + * - Project header with status badges + * - Agent panel with status indicators + * - Sprint progress and burndown chart + * - Issue summary sidebar + * - Recent activity feed + * - Quick actions (start/pause agents, create sprint) + * + * @module e2e/project-dashboard.spec + * @see Issue #40 + */ + +import { test, expect } from '@playwright/test'; +import { setupAuthenticatedMocks, loginViaUI } from './helpers/auth'; + +test.describe('Project Dashboard Page', () => { + test.beforeEach(async ({ page }) => { + // Set up mock API endpoints + await setupAuthenticatedMocks(page); + }); + + test('should display project header with name and status badges', async ({ page }) => { + await loginViaUI(page); + + // Navigate to a project dashboard + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check project header is present + await expect(page.getByTestId('project-header')).toBeVisible(); + + // Check project name + await expect(page.getByRole('heading', { level: 1 })).toContainText('E-Commerce Platform Redesign'); + + // Check status badges + await expect(page.getByText('In Progress')).toBeVisible(); + await expect(page.getByText('Milestone')).toBeVisible(); + }); + + test('should display agent panel with active agents', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check agent panel is present + await expect(page.getByTestId('agent-panel')).toBeVisible(); + await expect(page.getByText('Active Agents')).toBeVisible(); + + // Check agent count + await expect(page.getByText(/\d+ of \d+ agents working/)).toBeVisible(); + + // Check at least one agent is visible + await expect(page.getByText('Product Owner')).toBeVisible(); + }); + + test('should display sprint progress with stats', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check sprint progress is present + await expect(page.getByTestId('sprint-progress')).toBeVisible(); + await expect(page.getByText('Sprint Overview')).toBeVisible(); + + // Check progress bar exists + await expect(page.getByRole('progressbar')).toBeVisible(); + + // Check issue stats are shown + await expect(page.getByText('Completed')).toBeVisible(); + await expect(page.getByText('In Progress')).toBeVisible(); + await expect(page.getByText('Blocked')).toBeVisible(); + + // Check burndown chart is present + await expect(page.getByText('Burndown Chart')).toBeVisible(); + }); + + test('should display issue summary sidebar', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check issue summary is present + await expect(page.getByTestId('issue-summary')).toBeVisible(); + await expect(page.getByText('Issue Summary')).toBeVisible(); + + // Check issue counts by status + await expect(page.getByText('Open')).toBeVisible(); + await expect(page.getByText('In Review')).toBeVisible(); + }); + + test('should display recent activity feed', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check activity feed is present + await expect(page.getByTestId('recent-activity')).toBeVisible(); + await expect(page.getByText('Recent Activity')).toBeVisible(); + }); + + test('should have quick action buttons', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check quick actions are present + await expect(page.getByRole('button', { name: /run sprint/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /new sprint/i })).toBeVisible(); + }); + + test('should have manage agents button', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check manage agents button + await expect(page.getByRole('button', { name: /manage agents/i })).toBeVisible(); + }); + + test('should have view all issues button', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check view all issues button + const viewAllButton = page.getByRole('button', { name: /view all issues/i }); + await expect(viewAllButton).toBeVisible(); + }); + + test('should have accessible heading hierarchy', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check for h1 (project name) + await expect(page.locator('h1')).toBeVisible(); + + // Check for multiple headings + const headings = page.getByRole('heading'); + const count = await headings.count(); + expect(count).toBeGreaterThan(3); // Project name + Agent Panel + Sprint + Activity + }); + + test('should be keyboard navigable', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Tab through the page elements + await page.keyboard.press('Tab'); + + // Should be able to focus on interactive elements + const focusedElement = page.locator(':focus'); + await expect(focusedElement).toBeVisible(); + }); + + test('should show responsive layout on mobile', async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Page should still be functional on mobile + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.getByTestId('agent-panel')).toBeVisible(); + await expect(page.getByTestId('sprint-progress')).toBeVisible(); + }); + + test('should load within acceptable time', async ({ page }) => { + await loginViaUI(page); + + // Measure navigation timing + const start = Date.now(); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + const duration = Date.now() - start; + + // Dashboard should load within 5 seconds (including mock data) + expect(duration).toBeLessThan(5000); + }); + + test('should show SSE connection status when disconnected', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // When SSE is not connected, connection status should be visible + // (Connected state hides the status indicator) + // Since we don't have SSE mock, it will show as connecting/disconnected + const connectionStatus = page.locator('[role="status"]').first(); + + // Either connection status is visible (not connected) or hidden (connected) + // Both are valid states + const count = await connectionStatus.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); +}); + +test.describe('Project Dashboard Agent Interactions', () => { + test.beforeEach(async ({ page }) => { + await setupAuthenticatedMocks(page); + }); + + test('should display agent action menu when clicking agent options', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Find the first agent's action menu button + const agentActionButton = page + .getByTestId('agent-panel') + .getByRole('button', { name: /actions for/i }) + .first(); + + // Check button is visible and clickable + await expect(agentActionButton).toBeVisible(); + await agentActionButton.click(); + + // Menu should show options + await expect(page.getByText('View Details')).toBeVisible(); + await expect(page.getByText('Terminate Agent')).toBeVisible(); + }); + + test('should show agent status indicators', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Look for status role elements within agent panel + const statusIndicators = page.getByTestId('agent-panel').locator('[role="status"]'); + const count = await statusIndicators.count(); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('Project Dashboard Sprint Interactions', () => { + test.beforeEach(async ({ page }) => { + await setupAuthenticatedMocks(page); + }); + + test('should display sprint selector when multiple sprints exist', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check for sprint selector combobox + const sprintSelector = page.getByRole('combobox', { name: /select sprint/i }); + await expect(sprintSelector).toBeVisible(); + }); + + test('should show burndown chart legend', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check burndown chart has legend items + await expect(page.getByText('Actual')).toBeVisible(); + await expect(page.getByText('Ideal')).toBeVisible(); + }); +}); + +test.describe('Project Dashboard Activity Feed', () => { + test.beforeEach(async ({ page }) => { + await setupAuthenticatedMocks(page); + }); + + test('should display activity items with timestamps', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Check activity feed has content + const activityFeed = page.getByTestId('recent-activity'); + await expect(activityFeed).toBeVisible(); + + // Check for relative timestamps (e.g., "5 minutes ago", "less than a minute ago") + await expect(activityFeed.getByText(/ago|minute|hour|day/i).first()).toBeVisible(); + }); + + test('should highlight action-required activities', async ({ page }) => { + await loginViaUI(page); + await page.goto('/en/projects/proj-001'); + await page.waitForLoadState('networkidle'); + + // Look for action buttons in activity feed (if any require action) + const reviewButton = page.getByTestId('recent-activity').getByRole('button', { name: /review/i }); + const count = await reviewButton.count(); + + // Either there are action items or not - both are valid + expect(count).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/[issueId]/page.tsx b/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/[issueId]/page.tsx new file mode 100644 index 0000000..53dee15 --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/[issueId]/page.tsx @@ -0,0 +1,204 @@ +'use client'; + +/** + * Issue Detail Page + * + * Displays full issue details with status workflow and activity timeline. + * + * @module app/[locale]/(authenticated)/projects/[id]/issues/[issueId]/page + */ + +import { use } from 'react'; +import Link from 'next/link'; +import { + ArrowLeft, + Calendar, + Clock, + ExternalLink, + Edit, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + StatusBadge, + PriorityBadge, + SyncStatusIndicator, + StatusWorkflow, + ActivityTimeline, + IssueDetailPanel, + useIssue, + useUpdateIssueStatus, + getPrimaryTransition, +} from '@/features/issues'; +import type { IssueStatus } from '@/features/issues'; + +interface IssueDetailPageProps { + params: Promise<{ + locale: string; + id: string; + issueId: string; + }>; +} + +export default function IssueDetailPage({ params }: IssueDetailPageProps) { + const { locale, id: projectId, issueId } = use(params); + + const { data: issue, isLoading, error } = useIssue(issueId); + const updateStatus = useUpdateIssueStatus(); + + const handleStatusChange = (status: IssueStatus) => { + updateStatus.mutate({ issueId, status }); + }; + + const primaryTransition = issue ? getPrimaryTransition(issue.status) : undefined; + + if (error) { + return ( +
+
+

Error Loading Issue

+

+ Failed to load issue details. Please try again later. +

+
+ + + + +
+
+
+ ); + } + + if (isLoading || !issue) { + return ( +
+
+
+ +
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + + +
+
+ #{issue.number} + + + +
+

{issue.title}

+
+
+
+
+
+ {issue.external_url && ( + + + )} +
+
+
+ + {primaryTransition && ( + + )} +
+
+ +
+ {/* Main Content */} +
+ {/* Description Card */} + + + Description + + +
+
+                    {issue.description}
+                  
+
+
+
+ + {/* Activity Timeline */} + console.log('Add comment')} + /> +
+ + {/* Sidebar */} +
+ {/* Status Workflow */} + + + {/* Issue Details */} + +
+
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/page.tsx b/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/page.tsx new file mode 100644 index 0000000..65aebfc --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/projects/[id]/issues/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +/** + * Project Issues List Page + * + * Displays filterable, sortable list of issues for a project. + * Supports bulk actions and sync with external trackers. + * + * @module app/[locale]/(authenticated)/projects/[id]/issues/page + */ + +import { useState, use } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plus, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + IssueFilters, + IssueTable, + BulkActions, + useIssues, +} from '@/features/issues'; +import type { IssueFiltersType, IssueSort } from '@/features/issues'; + +interface ProjectIssuesPageProps { + params: Promise<{ + locale: string; + id: string; + }>; +} + +export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) { + const { locale, id: projectId } = use(params); + const router = useRouter(); + + // Filter state + const [filters, setFilters] = useState({ + status: 'all', + priority: 'all', + sprint: 'all', + assignee: 'all', + }); + + // Sort state + const [sort, setSort] = useState({ + field: 'updated_at', + direction: 'desc', + }); + + // Selection state + const [selectedIssues, setSelectedIssues] = useState([]); + + // Fetch issues + const { data, isLoading, error } = useIssues(projectId, filters, sort); + + const handleIssueClick = (issueId: string) => { + router.push(`/${locale}/projects/${projectId}/issues/${issueId}`); + }; + + const handleBulkChangeStatus = () => { + // TODO: Open status change dialog + console.log('Change status for:', selectedIssues); + }; + + const handleBulkAssign = () => { + // TODO: Open assign dialog + console.log('Assign:', selectedIssues); + }; + + const handleBulkAddLabels = () => { + // TODO: Open labels dialog + console.log('Add labels to:', selectedIssues); + }; + + const handleBulkDelete = () => { + // TODO: Confirm and delete + console.log('Delete:', selectedIssues); + }; + + const handleSync = () => { + // TODO: Sync all issues + console.log('Sync issues'); + }; + + const handleNewIssue = () => { + // TODO: Navigate to new issue page or open dialog + console.log('Create new issue'); + }; + + if (error) { + return ( +
+
+

Error Loading Issues

+

+ Failed to load issues. Please try again later. +

+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+

Issues

+

+ {isLoading ? ( + + ) : ( + `${data?.pagination.total || 0} issues found` + )} +

+
+
+ + +
+
+ + {/* Filters */} + + + {/* Bulk Actions */} + + + {/* Issue Table */} + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination info */} + {data && data.pagination.total > 0 && ( +
+ + Showing {(data.pagination.page - 1) * data.pagination.page_size + 1} to{' '} + {Math.min( + data.pagination.page * data.pagination.page_size, + data.pagination.total + )}{' '} + of {data.pagination.total} issues + + {data.pagination.total_pages > 1 && ( +
+ + +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/[locale]/(authenticated)/projects/[id]/page.tsx b/frontend/src/app/[locale]/(authenticated)/projects/[id]/page.tsx new file mode 100644 index 0000000..cd17c4d --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/projects/[id]/page.tsx @@ -0,0 +1,29 @@ +/** + * Project Dashboard Page + * + * Main dashboard for viewing project status, agents, sprints, and activity. + * Provides real-time updates via SSE and quick actions for project management. + * + * @see Issue #40 + */ + +import { Metadata } from 'next'; +import { ProjectDashboard } from '@/components/projects/ProjectDashboard'; + +export const metadata: Metadata = { + title: 'Project Dashboard', + description: 'View project status, agents, sprints, and activity', +}; + +interface ProjectDashboardPageProps { + params: Promise<{ + id: string; + locale: string; + }>; +} + +export default async function ProjectDashboardPage({ params }: ProjectDashboardPageProps) { + const { id } = await params; + + return ; +} diff --git a/frontend/src/app/[locale]/(authenticated)/projects/new/page.tsx b/frontend/src/app/[locale]/(authenticated)/projects/new/page.tsx new file mode 100644 index 0000000..95ae345 --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/projects/new/page.tsx @@ -0,0 +1,30 @@ +/** + * New Project Page + * + * Multi-step wizard for creating new Syndarix projects. + */ + +import type { Metadata } from 'next'; + +import { ProjectWizard } from '@/components/projects'; + +export const metadata: Metadata = { + title: 'New Project', + description: 'Create a new Syndarix project with AI-powered agents', +}; + +interface NewProjectPageProps { + params: Promise<{ locale: string }>; +} + +export default async function NewProjectPage({ params }: NewProjectPageProps) { + const { locale } = await params; + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/projects/AgentPanel.tsx b/frontend/src/components/projects/AgentPanel.tsx new file mode 100644 index 0000000..a165d73 --- /dev/null +++ b/frontend/src/components/projects/AgentPanel.tsx @@ -0,0 +1,242 @@ +/** + * Agent Panel Component + * + * Displays a list of active agents on the project with their status and current task. + */ + +'use client'; + +import { Bot, MoreVertical } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AgentStatusIndicator } from './AgentStatusIndicator'; +import type { AgentInstance } from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface AgentPanelProps { + /** List of agent instances */ + agents: AgentInstance[]; + /** Whether data is loading */ + isLoading?: boolean; + /** Callback when "Manage Agents" is clicked */ + onManageAgents?: () => void; + /** Callback when an agent action is triggered */ + onAgentAction?: (agentId: string, action: 'view' | 'pause' | 'restart' | 'terminate') => void; + /** Additional CSS classes */ + className?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getAgentAvatarText(agent: AgentInstance): string { + if (agent.avatar) return agent.avatar; + // Generate initials from role + const words = agent.role.split(/[\s_-]+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return agent.role.substring(0, 2).toUpperCase(); +} + +function formatLastActivity(lastActivity?: string): string { + if (!lastActivity) return 'No activity'; + try { + return formatDistanceToNow(new Date(lastActivity), { addSuffix: true }); + } catch { + return 'Unknown'; + } +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +function AgentListItem({ + agent, + onAction, +}: { + agent: AgentInstance; + onAction?: (agentId: string, action: 'view' | 'pause' | 'restart' | 'terminate') => void; +}) { + const avatarText = getAgentAvatarText(agent); + const lastActivity = formatLastActivity(agent.last_activity_at); + + return ( +
+
+ {/* Avatar */} + + + {/* Info */} +
+
+ {agent.name} + +
+

+ {agent.current_task || 'No active task'} +

+
+
+ +
+ {lastActivity} + + {onAction && ( + + + + + + onAction(agent.id, 'view')}> + View Details + + {agent.status === 'active' || agent.status === 'working' ? ( + onAction(agent.id, 'pause')}> + Pause Agent + + ) : ( + onAction(agent.id, 'restart')}> + Restart Agent + + )} + + onAction(agent.id, 'terminate')} + className="text-destructive focus:text-destructive" + > + Terminate Agent + + + + )} +
+
+ ); +} + +function AgentPanelSkeleton() { + return ( + + +
+
+ + +
+ +
+
+ +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function AgentPanel({ + agents, + isLoading = false, + onManageAgents, + onAgentAction, + className, +}: AgentPanelProps) { + if (isLoading) { + return ; + } + + const activeAgentCount = agents.filter( + (a) => a.status === 'active' || a.status === 'working' + ).length; + + return ( + + +
+
+ + + + {activeAgentCount} of {agents.length} agents working + +
+ {onManageAgents && ( + + )} +
+
+ + {agents.length === 0 ? ( +
+
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/projects/AgentStatusIndicator.tsx b/frontend/src/components/projects/AgentStatusIndicator.tsx new file mode 100644 index 0000000..8c26029 --- /dev/null +++ b/frontend/src/components/projects/AgentStatusIndicator.tsx @@ -0,0 +1,79 @@ +/** + * Agent Status Indicator + * + * Visual indicator for agent status (idle, working, error, etc.) + */ + +'use client'; + +import { cn } from '@/lib/utils'; +import type { AgentStatus } from './types'; + +const statusConfig: Record = { + idle: { + color: 'bg-yellow-500', + label: 'Idle', + }, + active: { + color: 'bg-green-500', + label: 'Active', + }, + working: { + color: 'bg-green-500 animate-pulse', + label: 'Working', + }, + pending: { + color: 'bg-gray-400', + label: 'Pending', + }, + error: { + color: 'bg-red-500', + label: 'Error', + }, + terminated: { + color: 'bg-gray-600', + label: 'Terminated', + }, +}; + +interface AgentStatusIndicatorProps { + status: AgentStatus; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + className?: string; +} + +export function AgentStatusIndicator({ + status, + size = 'sm', + showLabel = false, + className, +}: AgentStatusIndicatorProps) { + const config = statusConfig[status] || statusConfig.pending; + + const sizeClasses = { + sm: 'h-2 w-2', + md: 'h-3 w-3', + lg: 'h-4 w-4', + }; + + return ( + + + ); +} diff --git a/frontend/src/components/projects/BurndownChart.tsx b/frontend/src/components/projects/BurndownChart.tsx new file mode 100644 index 0000000..67c8cb5 --- /dev/null +++ b/frontend/src/components/projects/BurndownChart.tsx @@ -0,0 +1,146 @@ +/** + * Burndown Chart Component + * + * Simple SVG-based burndown chart showing actual vs ideal progress. + * This is a placeholder that will be enhanced when a charting library is integrated. + */ + +'use client'; + +import { cn } from '@/lib/utils'; +import type { BurndownDataPoint } from './types'; + +interface BurndownChartProps { + /** Burndown data points */ + data: BurndownDataPoint[]; + /** Chart height */ + height?: number; + /** Additional CSS classes */ + className?: string; + /** Show legend */ + showLegend?: boolean; +} + +export function BurndownChart({ + data, + height = 120, + className, + showLegend = true, +}: BurndownChartProps) { + if (data.length === 0) { + return ( +
+ No burndown data available +
+ ); + } + + const chartWidth = 100; + const chartHeight = height; + const padding = { top: 10, right: 10, bottom: 20, left: 10 }; + const innerWidth = chartWidth - padding.left - padding.right; + const innerHeight = chartHeight - padding.top - padding.bottom; + + const maxPoints = Math.max(...data.map((d) => Math.max(d.remaining, d.ideal))); + + // Generate points for polylines + const getPoints = (key: 'remaining' | 'ideal') => { + return data + .map((d, i) => { + const x = padding.left + (i / (data.length - 1)) * innerWidth; + const y = padding.top + innerHeight - (d[key] / maxPoints) * innerHeight; + return `${x},${y}`; + }) + .join(' '); + }; + + return ( +
+
+ + {/* Grid lines */} + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( + + ))} + + + {/* Ideal line (dashed) */} + + + {/* Actual line */} + + + {/* Data points on actual line */} + {data.map((d, i) => { + const x = padding.left + (i / (data.length - 1)) * innerWidth; + const y = padding.top + innerHeight - (d.remaining / maxPoints) * innerHeight; + return ( + + ); + })} + + + {/* X-axis labels */} +
+ Day 1 + Day {data.length} +
+
+ + {/* Legend */} + {showLegend && ( +
+ + + Actual + + + + Ideal + +
+ )} +
+ ); +} diff --git a/frontend/src/components/projects/IssueSummary.tsx b/frontend/src/components/projects/IssueSummary.tsx new file mode 100644 index 0000000..4333965 --- /dev/null +++ b/frontend/src/components/projects/IssueSummary.tsx @@ -0,0 +1,194 @@ +/** + * Issue Summary Component + * + * Sidebar component showing issue counts by status. + */ + +'use client'; + +import { + GitBranch, + CircleDot, + PlayCircle, + Clock, + AlertCircle, + CheckCircle2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { IssueSummary as IssueSummaryType } from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface IssueSummaryProps { + /** Issue summary data */ + summary: IssueSummaryType | null; + /** Whether data is loading */ + isLoading?: boolean; + /** Callback when "View All Issues" is clicked */ + onViewAllIssues?: () => void; + /** Additional CSS classes */ + className?: string; +} + +interface StatusRowProps { + icon: React.ElementType; + iconColor: string; + label: string; + count: number; +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +function StatusRow({ icon: Icon, iconColor, label, count }: StatusRowProps) { + return ( +
+
+
+ {count} +
+ ); +} + +function IssueSummarySkeleton() { + return ( + + + + + +
+ {[1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} + +
+
+ + +
+ +
+
+ +
+
+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function IssueSummary({ + summary, + isLoading = false, + onViewAllIssues, + className, +}: IssueSummaryProps) { + if (isLoading) { + return ; + } + + if (!summary) { + return ( + + + + + + +
+
+
+
+ ); + } + + return ( + + + + + + +
+ + + + + + + + + + {onViewAllIssues && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/projects/ProgressBar.tsx b/frontend/src/components/projects/ProgressBar.tsx new file mode 100644 index 0000000..39a97af --- /dev/null +++ b/frontend/src/components/projects/ProgressBar.tsx @@ -0,0 +1,75 @@ +/** + * Progress Bar Component + * + * Simple progress bar with customizable appearance. + */ + +'use client'; + +import { cn } from '@/lib/utils'; + +interface ProgressBarProps { + /** Progress value (0-100) */ + value: number; + /** Color variant */ + variant?: 'default' | 'success' | 'warning' | 'error'; + /** Height size */ + size?: 'sm' | 'md' | 'lg'; + /** Show percentage label */ + showLabel?: boolean; + /** Additional CSS classes for the container */ + className?: string; + /** Aria label for accessibility */ + 'aria-label'?: string; +} + +const variantClasses = { + default: 'bg-primary', + success: 'bg-green-500', + warning: 'bg-yellow-500', + error: 'bg-red-500', +}; + +const sizeClasses = { + sm: 'h-1', + md: 'h-2', + lg: 'h-3', +}; + +export function ProgressBar({ + value, + variant = 'default', + size = 'md', + showLabel = false, + className, + 'aria-label': ariaLabel, +}: ProgressBarProps) { + const clampedValue = Math.min(100, Math.max(0, value)); + + return ( +
+ {showLabel && ( +
+ Progress + {Math.round(clampedValue)}% +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/projects/ProjectDashboard.tsx b/frontend/src/components/projects/ProjectDashboard.tsx new file mode 100644 index 0000000..e5ce54e --- /dev/null +++ b/frontend/src/components/projects/ProjectDashboard.tsx @@ -0,0 +1,451 @@ +/** + * Project Dashboard Component + * + * Main dashboard view for a project showing agents, sprints, issues, and activity. + * Integrates real-time updates via SSE. + * + * @see Issue #40 + */ + +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { ConnectionStatus } from '@/components/events/ConnectionStatus'; +import { useProjectEvents } from '@/lib/hooks/useProjectEvents'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; +import { ProjectHeader } from './ProjectHeader'; +import { AgentPanel } from './AgentPanel'; +import { SprintProgress } from './SprintProgress'; +import { IssueSummary } from './IssueSummary'; +import { RecentActivity } from './RecentActivity'; +import type { + Project, + AgentInstance, + Sprint, + BurndownDataPoint, + IssueSummary as IssueSummaryType, + ActivityItem, +} from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ProjectDashboardProps { + /** Project ID */ + projectId: string; + /** Additional CSS classes */ + className?: string; +} + +// ============================================================================ +// Mock Data (to be replaced with API calls) +// ============================================================================ + +// Mock data for development - will be replaced with TanStack Query hooks +const mockProject: Project = { + id: 'proj-001', + name: 'E-Commerce Platform Redesign', + description: 'Complete redesign of the e-commerce platform with modern UI/UX', + status: 'in_progress', + autonomy_level: 'milestone', + current_sprint_id: 'sprint-003', + created_at: '2025-01-15T00:00:00Z', + owner_id: 'user-001', +}; + +const mockAgents: AgentInstance[] = [ + { + id: 'agent-001', + agent_type_id: 'type-po', + project_id: 'proj-001', + name: 'Product Owner', + role: 'product_owner', + status: 'active', + current_task: 'Reviewing user story acceptance criteria', + last_activity_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), + spawned_at: '2025-01-15T00:00:00Z', + avatar: 'PO', + }, + { + id: 'agent-002', + agent_type_id: 'type-arch', + project_id: 'proj-001', + name: 'Architect', + role: 'architect', + status: 'working', + current_task: 'Designing API contract for checkout flow', + last_activity_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + spawned_at: '2025-01-15T00:00:00Z', + avatar: 'AR', + }, + { + id: 'agent-003', + agent_type_id: 'type-be', + project_id: 'proj-001', + name: 'Backend Engineer', + role: 'backend_engineer', + status: 'idle', + current_task: 'Waiting for architecture review', + last_activity_at: new Date(Date.now() - 15 * 60 * 1000).toISOString(), + spawned_at: '2025-01-15T00:00:00Z', + avatar: 'BE', + }, + { + id: 'agent-004', + agent_type_id: 'type-fe', + project_id: 'proj-001', + name: 'Frontend Engineer', + role: 'frontend_engineer', + status: 'active', + current_task: 'Implementing product catalog component', + last_activity_at: new Date(Date.now() - 1 * 60 * 1000).toISOString(), + spawned_at: '2025-01-15T00:00:00Z', + avatar: 'FE', + }, + { + id: 'agent-005', + agent_type_id: 'type-qa', + project_id: 'proj-001', + name: 'QA Engineer', + role: 'qa_engineer', + status: 'pending', + current_task: 'Preparing test cases for Sprint 3', + last_activity_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + spawned_at: '2025-01-15T00:00:00Z', + avatar: 'QA', + }, +]; + +const mockSprint: Sprint = { + id: 'sprint-003', + project_id: 'proj-001', + name: 'Sprint 3', + goal: 'Complete checkout flow', + status: 'active', + start_date: '2025-01-27', + end_date: '2025-02-10', + total_issues: 15, + completed_issues: 8, + in_progress_issues: 4, + blocked_issues: 1, + todo_issues: 2, +}; + +const mockBurndownData: BurndownDataPoint[] = [ + { day: 1, remaining: 45, ideal: 45 }, + { day: 2, remaining: 42, ideal: 42 }, + { day: 3, remaining: 38, ideal: 39 }, + { day: 4, remaining: 35, ideal: 36 }, + { day: 5, remaining: 30, ideal: 33 }, + { day: 6, remaining: 28, ideal: 30 }, + { day: 7, remaining: 25, ideal: 27 }, + { day: 8, remaining: 20, ideal: 24 }, +]; + +const mockIssueSummary: IssueSummaryType = { + open: 12, + in_progress: 8, + in_review: 3, + blocked: 2, + done: 45, + total: 70, +}; + +const mockActivity: ActivityItem[] = [ + { + id: 'act-001', + type: 'agent_message', + agent: 'Product Owner', + message: 'Approved user story #42: Cart checkout flow', + timestamp: new Date(Date.now() - 2 * 60 * 1000).toISOString(), + }, + { + id: 'act-002', + type: 'issue_update', + agent: 'Backend Engineer', + message: 'Moved issue #38 to "In Review"', + timestamp: new Date(Date.now() - 8 * 60 * 1000).toISOString(), + }, + { + id: 'act-003', + type: 'agent_status', + agent: 'Frontend Engineer', + message: 'Started working on issue #45', + timestamp: new Date(Date.now() - 15 * 60 * 1000).toISOString(), + }, + { + id: 'act-004', + type: 'approval_request', + agent: 'Architect', + message: 'Requesting approval for API design document', + timestamp: new Date(Date.now() - 25 * 60 * 1000).toISOString(), + requires_action: true, + }, + { + id: 'act-005', + type: 'sprint_event', + agent: 'System', + message: 'Sprint 3 daily standup completed', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }, +]; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Convert SSE events to activity items + */ +function eventToActivity(event: ProjectEvent): ActivityItem { + const getAgentName = (event: ProjectEvent): string | undefined => { + if (event.actor_type === 'agent') { + const payload = event.payload as Record; + return (payload.agent_name as string) || 'Agent'; + } + if (event.actor_type === 'system') { + return 'System'; + } + return undefined; + }; + + const getMessage = (event: ProjectEvent): string => { + const payload = event.payload as Record; + switch (event.type) { + case EventType.AGENT_SPAWNED: + return `spawned as ${payload.role || 'agent'}`; + case EventType.AGENT_MESSAGE: + return String(payload.message || 'sent a message'); + case EventType.AGENT_STATUS_CHANGED: + return `status changed to ${payload.new_status}`; + case EventType.ISSUE_CREATED: + return `created issue: ${payload.title}`; + case EventType.ISSUE_UPDATED: + return `updated issue #${payload.issue_id}`; + case EventType.APPROVAL_REQUESTED: + return String(payload.description || 'requested approval'); + case EventType.SPRINT_STARTED: + return `started sprint: ${payload.sprint_name}`; + default: + return event.type.replace(/_/g, ' '); + } + }; + + const getType = (event: ProjectEvent): ActivityItem['type'] => { + if (event.type.startsWith('agent.message')) return 'agent_message'; + if (event.type.startsWith('agent.')) return 'agent_status'; + if (event.type.startsWith('issue.')) return 'issue_update'; + if (event.type.startsWith('approval.')) return 'approval_request'; + if (event.type.startsWith('sprint.')) return 'sprint_event'; + return 'system'; + }; + + return { + id: event.id, + type: getType(event), + agent: getAgentName(event), + message: getMessage(event), + timestamp: event.timestamp, + requires_action: event.type === EventType.APPROVAL_REQUESTED, + }; +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function ProjectDashboard({ projectId, className }: ProjectDashboardProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // SSE connection for real-time updates + const { + events: sseEvents, + connectionState, + error: sseError, + retryCount, + reconnect, + } = useProjectEvents(projectId, { + autoConnect: true, + onEvent: (event) => { + // Handle specific event types for state updates + console.log('[Dashboard] Received event:', event.type); + }, + }); + + // Convert SSE events to activity items + const sseActivities = useMemo(() => { + return sseEvents.slice(-10).map(eventToActivity); + }, [sseEvents]); + + // Merge mock activities with SSE activities (SSE takes priority) + const allActivities = useMemo(() => { + const merged = [...sseActivities, ...mockActivity]; + // Sort by timestamp, newest first + merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + return merged.slice(0, 10); + }, [sseActivities]); + + // Use mock data for now (will be replaced with TanStack Query) + const project = mockProject; + const agents = mockAgents; + const sprint = mockSprint; + const burndownData = mockBurndownData; + const issueSummary = mockIssueSummary; + + // Event handlers + const handleStartSprint = useCallback(() => { + console.log('Start sprint clicked'); + // TODO: Implement start sprint action + }, []); + + const handlePauseProject = useCallback(() => { + console.log('Pause project clicked'); + // TODO: Implement pause project action + }, []); + + const handleCreateSprint = useCallback(() => { + console.log('Create sprint clicked'); + // TODO: Navigate to create sprint page + }, []); + + const handleSettings = useCallback(() => { + router.push(`/projects/${projectId}/settings`); + }, [router, projectId]); + + const handleManageAgents = useCallback(() => { + router.push(`/projects/${projectId}/agents`); + }, [router, projectId]); + + const handleAgentAction = useCallback( + (agentId: string, action: 'view' | 'pause' | 'restart' | 'terminate') => { + console.log(`Agent action: ${action} on ${agentId}`); + // TODO: Implement agent actions + }, + [] + ); + + const handleViewAllIssues = useCallback(() => { + router.push(`/projects/${projectId}/issues`); + }, [router, projectId]); + + const handleViewAllActivity = useCallback(() => { + router.push(`/projects/${projectId}/activity`); + }, [router, projectId]); + + const handleActionClick = useCallback((activityId: string) => { + console.log(`Action clicked for activity: ${activityId}`); + // TODO: Navigate to approval page + }, []); + + // Error state + if (error) { + return ( +
+ + + Error loading project + + {error} + + + +
+ ); + } + + return ( +
+
+
+ {/* SSE Connection Status - only show if not connected */} + {connectionState !== 'connected' && ( + + )} + + {/* Header Section */} + + + {/* Main Content Grid */} +
+ {/* Left Column - Agent Panel & Sprint Overview */} +
+ {/* Agent Panel */} + + + {/* Sprint Overview */} + +
+ + {/* Right Column - Activity & Issue Summary */} +
+ {/* Issue Summary */} + + + {/* Recent Activity */} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/projects/ProjectHeader.tsx b/frontend/src/components/projects/ProjectHeader.tsx new file mode 100644 index 0000000..d904700 --- /dev/null +++ b/frontend/src/components/projects/ProjectHeader.tsx @@ -0,0 +1,146 @@ +/** + * Project Header Component + * + * Header section for the project dashboard with title, status, and quick actions. + */ + +'use client'; + +import { PlayCircle, PauseCircle, Plus, Settings } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ProjectStatusBadge, AutonomyBadge } from './StatusBadge'; +import type { Project } from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ProjectHeaderProps { + /** Project data */ + project: Project | null; + /** Whether data is loading */ + isLoading?: boolean; + /** Whether the project can be paused */ + canPause?: boolean; + /** Whether the project can be started */ + canStart?: boolean; + /** Callback when "Start/Run Sprint" is clicked */ + onStartSprint?: () => void; + /** Callback when "Pause" is clicked */ + onPauseProject?: () => void; + /** Callback when "Create Sprint" is clicked */ + onCreateSprint?: () => void; + /** Callback when "Settings" is clicked */ + onSettings?: () => void; + /** Additional CSS classes */ + className?: string; +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +function ProjectHeaderSkeleton() { + return ( +
+
+
+ + + +
+ +
+
+ + +
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function ProjectHeader({ + project, + isLoading = false, + canPause = false, + canStart = true, + onStartSprint, + onPauseProject, + onCreateSprint, + onSettings, + className, +}: ProjectHeaderProps) { + if (isLoading) { + return ; + } + + if (!project) { + return null; + } + + const showPauseButton = canPause && project.status === 'in_progress'; + const showStartButton = canStart && project.status !== 'completed' && project.status !== 'archived'; + + return ( +
+ {/* Project Info */} +
+
+

{project.name}

+ + +
+ {project.description && ( +

{project.description}

+ )} +
+ + {/* Quick Actions */} +
+ {onSettings && ( + + )} + + {showPauseButton && onPauseProject && ( + + )} + + {onCreateSprint && ( + + )} + + {showStartButton && onStartSprint && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/projects/RecentActivity.tsx b/frontend/src/components/projects/RecentActivity.tsx new file mode 100644 index 0000000..168cfff --- /dev/null +++ b/frontend/src/components/projects/RecentActivity.tsx @@ -0,0 +1,207 @@ +/** + * Recent Activity Component + * + * Displays recent project activity feed with action items. + */ + +'use client'; + +import { formatDistanceToNow } from 'date-fns'; +import { + Activity, + MessageSquare, + GitPullRequest, + PlayCircle, + AlertCircle, + Users, + Cog, + type LucideIcon, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { ActivityItem } from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface RecentActivityProps { + /** Activity items to display */ + activities: ActivityItem[]; + /** Whether data is loading */ + isLoading?: boolean; + /** Maximum items to show */ + maxItems?: number; + /** Callback when "View All" is clicked */ + onViewAll?: () => void; + /** Callback when an action item is clicked */ + onActionClick?: (activityId: string) => void; + /** Additional CSS classes */ + className?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getActivityIcon(type: ActivityItem['type']): LucideIcon { + switch (type) { + case 'agent_message': + return MessageSquare; + case 'issue_update': + return GitPullRequest; + case 'agent_status': + return PlayCircle; + case 'approval_request': + return AlertCircle; + case 'sprint_event': + return Users; + case 'system': + default: + return Cog; + } +} + +function formatTimestamp(timestamp: string): string { + try { + return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); + } catch { + return 'Unknown time'; + } +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface ActivityItemRowProps { + activity: ActivityItem; + onActionClick?: (activityId: string) => void; +} + +function ActivityItemRow({ activity, onActionClick }: ActivityItemRowProps) { + const Icon = getActivityIcon(activity.type); + const timestamp = formatTimestamp(activity.timestamp); + + return ( +
+
+
+
+

+ {activity.agent && ( + {activity.agent} + )}{' '} + {activity.message} +

+

{timestamp}

+ {activity.requires_action && onActionClick && ( + + )} +
+
+ ); +} + +function RecentActivitySkeleton({ count = 5 }: { count?: number }) { + return ( + + +
+ + +
+
+ +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function RecentActivity({ + activities, + isLoading = false, + maxItems = 5, + onViewAll, + onActionClick, + className, +}: RecentActivityProps) { + if (isLoading) { + return ; + } + + const displayedActivities = activities.slice(0, maxItems); + + return ( + + +
+ + + {onViewAll && activities.length > maxItems && ( + + )} +
+
+ + {displayedActivities.length === 0 ? ( +
+
+ ) : ( +
+ {displayedActivities.map((activity) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/projects/SprintProgress.tsx b/frontend/src/components/projects/SprintProgress.tsx new file mode 100644 index 0000000..8d1c2c8 --- /dev/null +++ b/frontend/src/components/projects/SprintProgress.tsx @@ -0,0 +1,255 @@ +/** + * Sprint Progress Component + * + * Displays sprint overview with progress bar, issue stats, and burndown chart. + */ + +'use client'; + +import { TrendingUp, Calendar } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ProgressBar } from './ProgressBar'; +import { BurndownChart } from './BurndownChart'; +import type { Sprint, BurndownDataPoint } from './types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface SprintProgressProps { + /** Current sprint data */ + sprint: Sprint | null; + /** Burndown chart data */ + burndownData?: BurndownDataPoint[]; + /** List of available sprints for selector */ + availableSprints?: { id: string; name: string }[]; + /** Currently selected sprint ID */ + selectedSprintId?: string; + /** Callback when sprint selection changes */ + onSprintChange?: (sprintId: string) => void; + /** Whether data is loading */ + isLoading?: boolean; + /** Additional CSS classes */ + className?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function formatSprintDates(startDate?: string, endDate?: string): string { + if (!startDate || !endDate) return 'Dates not set'; + try { + const start = format(new Date(startDate), 'MMM d'); + const end = format(new Date(endDate), 'MMM d, yyyy'); + return `${start} - ${end}`; + } catch { + return 'Invalid dates'; + } +} + +function calculateProgress(sprint: Sprint): number { + if (sprint.total_issues === 0) return 0; + return Math.round((sprint.completed_issues / sprint.total_issues) * 100); +} + +// ============================================================================ +// Subcomponents +// ============================================================================ + +interface StatCardProps { + value: number; + label: string; + colorClass: string; +} + +function StatCard({ value, label, colorClass }: StatCardProps) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function SprintProgressSkeleton() { + return ( + + +
+
+ + +
+ +
+
+ +
+ {/* Progress bar skeleton */} +
+
+ + +
+ +
+ + {/* Stats grid skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+ + {/* Burndown skeleton */} +
+ + +
+
+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function SprintProgress({ + sprint, + burndownData = [], + availableSprints = [], + selectedSprintId, + onSprintChange, + isLoading = false, + className, +}: SprintProgressProps) { + if (isLoading) { + return ; + } + + if (!sprint) { + return ( + + + + + No active sprint + + +
+
+
+
+ ); + } + + const progress = calculateProgress(sprint); + const dateRange = formatSprintDates(sprint.start_date, sprint.end_date); + + return ( + + +
+
+ + + + {sprint.name} ({dateRange}) + +
+ + {availableSprints.length > 1 && onSprintChange && ( + + )} +
+
+ +
+ {/* Sprint Progress */} + + + {/* Issue Stats Grid */} +
+ + + + +
+ + {/* Burndown Chart */} +
+

Burndown Chart

+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/projects/StatusBadge.tsx b/frontend/src/components/projects/StatusBadge.tsx new file mode 100644 index 0000000..fb47574 --- /dev/null +++ b/frontend/src/components/projects/StatusBadge.tsx @@ -0,0 +1,97 @@ +/** + * Status Badge Components + * + * Reusable badge components for displaying project and autonomy status. + */ + +'use client'; + +import { CircleDot } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { ProjectStatus, AutonomyLevel } from './types'; + +// ============================================================================ +// Project Status Badge +// ============================================================================ + +const projectStatusConfig: Record = { + draft: { + label: 'Draft', + className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', + }, + in_progress: { + label: 'In Progress', + className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + }, + paused: { + label: 'Paused', + className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + }, + completed: { + label: 'Completed', + className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + }, + blocked: { + label: 'Blocked', + className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + }, + archived: { + label: 'Archived', + className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + }, +}; + +interface ProjectStatusBadgeProps { + status: ProjectStatus; + className?: string; +} + +export function ProjectStatusBadge({ status, className }: ProjectStatusBadgeProps) { + const config = projectStatusConfig[status] || projectStatusConfig.draft; + + return ( + + {config.label} + + ); +} + +// ============================================================================ +// Autonomy Level Badge +// ============================================================================ + +const autonomyLevelConfig: Record = { + full_control: { + label: 'Full Control', + description: 'Approve every action', + }, + milestone: { + label: 'Milestone', + description: 'Approve at sprint boundaries', + }, + autonomous: { + label: 'Autonomous', + description: 'Only major decisions', + }, +}; + +interface AutonomyBadgeProps { + level: AutonomyLevel; + showDescription?: boolean; + className?: string; +} + +export function AutonomyBadge({ level, showDescription = false, className }: AutonomyBadgeProps) { + const config = autonomyLevelConfig[level] || autonomyLevelConfig.milestone; + + return ( + + + ); +} diff --git a/frontend/src/components/projects/index.ts b/frontend/src/components/projects/index.ts new file mode 100644 index 0000000..01ce7f0 --- /dev/null +++ b/frontend/src/components/projects/index.ts @@ -0,0 +1,19 @@ +/** + * Project Components + * + * Export all project-related components for use throughout the application. + * + * @module components/projects + */ + +// Wizard Components +export { ProjectWizard, StepIndicator, SelectableCard } from './wizard'; + +// Re-export wizard types +export type { + WizardState, + WizardStep, + ProjectComplexity, + ClientMode, + AutonomyLevel, +} from './wizard'; diff --git a/frontend/src/components/projects/types.ts b/frontend/src/components/projects/types.ts new file mode 100644 index 0000000..7617ba7 --- /dev/null +++ b/frontend/src/components/projects/types.ts @@ -0,0 +1,120 @@ +/** + * Project Dashboard Types + * + * Type definitions for project-related components. + * These types will be updated when the API endpoints are implemented. + * + * @module components/projects/types + */ + +// ============================================================================ +// Project Types +// ============================================================================ + +export type ProjectStatus = 'draft' | 'in_progress' | 'paused' | 'completed' | 'blocked' | 'archived'; + +export type AutonomyLevel = 'full_control' | 'milestone' | 'autonomous'; + +export interface Project { + id: string; + name: string; + description?: string; + status: ProjectStatus; + autonomy_level: AutonomyLevel; + current_sprint_id?: string; + created_at: string; + updated_at?: string; + owner_id: string; +} + +// ============================================================================ +// Agent Types +// ============================================================================ + +export type AgentStatus = 'idle' | 'active' | 'working' | 'pending' | 'error' | 'terminated'; + +export interface AgentInstance { + id: string; + agent_type_id: string; + project_id: string; + name: string; + role: string; + status: AgentStatus; + current_task?: string; + last_activity_at?: string; + spawned_at: string; + avatar?: string; +} + +// ============================================================================ +// Sprint Types +// ============================================================================ + +export type SprintStatus = 'planning' | 'active' | 'review' | 'completed'; + +export interface Sprint { + id: string; + project_id: string; + name: string; + goal?: string; + status: SprintStatus; + start_date?: string; + end_date?: string; + total_issues: number; + completed_issues: number; + in_progress_issues: number; + blocked_issues: number; + todo_issues: number; +} + +export interface BurndownDataPoint { + day: number; + date?: string; + remaining: number; + ideal: number; +} + +// ============================================================================ +// Issue Types +// ============================================================================ + +export type IssueStatus = 'open' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'closed'; + +export type IssuePriority = 'low' | 'medium' | 'high' | 'critical'; + +export interface Issue { + id: string; + project_id: string; + sprint_id?: string; + title: string; + description?: string; + status: IssueStatus; + priority: IssuePriority; + assignee_id?: string; + created_at: string; + updated_at?: string; + labels?: string[]; +} + +export interface IssueSummary { + open: number; + in_progress: number; + in_review: number; + blocked: number; + done: number; + total: number; +} + +// ============================================================================ +// Activity Types +// ============================================================================ + +export interface ActivityItem { + id: string; + type: 'agent_message' | 'issue_update' | 'agent_status' | 'approval_request' | 'sprint_event' | 'system'; + agent?: string; + message: string; + timestamp: string; + requires_action?: boolean; + metadata?: Record; +} diff --git a/frontend/src/components/projects/wizard/ProjectWizard.tsx b/frontend/src/components/projects/wizard/ProjectWizard.tsx new file mode 100644 index 0000000..e969dfd --- /dev/null +++ b/frontend/src/components/projects/wizard/ProjectWizard.tsx @@ -0,0 +1,227 @@ +'use client'; + +/** + * Project Creation Wizard + * + * Multi-step wizard for creating new Syndarix projects. + * Adapts based on project complexity - scripts use a simplified 4-step flow. + */ + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, ArrowRight, Check, CheckCircle2, Loader2 } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { apiClient } from '@/lib/api/client'; + +import { StepIndicator } from './StepIndicator'; +import { useWizardState, type ProjectCreateData } from './useWizardState'; +import { WIZARD_STEPS } from './constants'; +import { + BasicInfoStep, + ComplexityStep, + ClientModeStep, + AutonomyStep, + AgentChatStep, + ReviewStep, +} from './steps'; + +/** + * Project response from API + */ +interface ProjectResponse { + id: string; + name: string; + slug: string; + description: string | null; + autonomy_level: string; + status: string; + settings: Record; + owner_id: string | null; + created_at: string; + updated_at: string; + agent_count: number; + issue_count: number; + active_sprint_name: string | null; +} + +interface ProjectWizardProps { + locale: string; + className?: string; +} + +export function ProjectWizard({ locale, className }: ProjectWizardProps) { + const router = useRouter(); + const [isCreated, setIsCreated] = useState(false); + + const { + state, + updateState, + resetState, + isScriptMode, + canProceed, + goNext, + goBack, + getProjectData, + } = useWizardState(); + + // Project creation mutation using the configured API client + const createProjectMutation = useMutation({ + mutationFn: async (projectData: ProjectCreateData): Promise => { + // Call the projects API endpoint + // Note: The API client already handles authentication via interceptors + const response = await apiClient.instance.post( + '/api/v1/projects', + { + name: projectData.name, + slug: projectData.slug, + description: projectData.description, + autonomy_level: projectData.autonomy_level, + settings: projectData.settings, + } + ); + + return response.data; + }, + onSuccess: () => { + setIsCreated(true); + }, + onError: (error) => { + // Error handling - in production, show toast notification + console.error('Failed to create project:', error); + }, + }); + + const handleCreate = () => { + const projectData = getProjectData(); + createProjectMutation.mutate(projectData); + }; + + const handleReset = () => { + resetState(); + setIsCreated(false); + createProjectMutation.reset(); + }; + + const handleGoToProject = () => { + // Navigate to project dashboard - using slug from successful creation + if (createProjectMutation.data) { + router.push(`/${locale}/projects/${createProjectMutation.data.slug}`); + } else { + router.push(`/${locale}/projects`); + } + }; + + // Success screen + if (isCreated && createProjectMutation.data) { + return ( +
+
+ + +
+
+
+

Project Created Successfully!

+

+ "{createProjectMutation.data.name}" has been created. The Product Owner + agent will begin the requirements discovery process shortly. +

+
+
+ + +
+
+
+
+
+ ); + } + + return ( +
+
+ {/* Step Indicator */} +
+ +
+ + {/* Step Content */} + + + {state.step === WIZARD_STEPS.BASIC_INFO && ( + + )} + {state.step === WIZARD_STEPS.COMPLEXITY && ( + + )} + {state.step === WIZARD_STEPS.CLIENT_MODE && !isScriptMode && ( + + )} + {state.step === WIZARD_STEPS.AUTONOMY && !isScriptMode && ( + + )} + {state.step === WIZARD_STEPS.AGENT_CHAT && } + {state.step === WIZARD_STEPS.REVIEW && } + + + {/* Navigation Footer */} + +
+ + +
+ {state.step < WIZARD_STEPS.REVIEW ? ( + + ) : ( + + )} +
+
+ + {/* Error display */} + {createProjectMutation.isError && ( +
+

+ Failed to create project. Please try again. +

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/projects/wizard/SelectableCard.tsx b/frontend/src/components/projects/wizard/SelectableCard.tsx new file mode 100644 index 0000000..cf58fb7 --- /dev/null +++ b/frontend/src/components/projects/wizard/SelectableCard.tsx @@ -0,0 +1,44 @@ +'use client'; + +/** + * Selectable Card Component + * + * A button-based card that can be selected/deselected. + * Used for complexity, client mode, and autonomy selection. + */ + +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface SelectableCardProps { + selected: boolean; + onClick: () => void; + children: ReactNode; + className?: string; + 'aria-label'?: string; +} + +export function SelectableCard({ + selected, + onClick, + children, + className, + 'aria-label': ariaLabel, +}: SelectableCardProps) { + return ( + + ); +} diff --git a/frontend/src/components/projects/wizard/StepIndicator.tsx b/frontend/src/components/projects/wizard/StepIndicator.tsx new file mode 100644 index 0000000..c5a3f64 --- /dev/null +++ b/frontend/src/components/projects/wizard/StepIndicator.tsx @@ -0,0 +1,50 @@ +'use client'; + +/** + * Step Indicator Component + * + * Shows progress through the wizard steps with visual feedback. + * Dynamically adjusts based on whether script mode is active. + */ + +import { cn } from '@/lib/utils'; +import { getStepLabels, getDisplayStep, getTotalSteps } from './constants'; + +interface StepIndicatorProps { + currentStep: number; + isScriptMode: boolean; + className?: string; +} + +export function StepIndicator({ currentStep, isScriptMode, className }: StepIndicatorProps) { + const steps = getStepLabels(isScriptMode); + const totalSteps = getTotalSteps(isScriptMode); + const displayStep = getDisplayStep(currentStep, isScriptMode); + + return ( +
+
+ + Step {displayStep} of {totalSteps} + + {steps[displayStep - 1]} +
+
+ {Array.from({ length: totalSteps }, (_, i) => ( + +
+ ); +} diff --git a/frontend/src/components/projects/wizard/constants.ts b/frontend/src/components/projects/wizard/constants.ts new file mode 100644 index 0000000..d821b12 --- /dev/null +++ b/frontend/src/components/projects/wizard/constants.ts @@ -0,0 +1,188 @@ +/** + * Constants for the Project Creation Wizard + */ + +import { + FileCode, + Folder, + Layers, + Building2, + Zap, + HelpCircle, + Shield, + Milestone, + Bot, +} from 'lucide-react'; + +import type { ComplexityOption, ClientModeOption, AutonomyOption } from './types'; + +/** + * Complexity options with descriptions + * Note: Timelines match the exact requirements from Issue #50 + */ +export const complexityOptions: ComplexityOption[] = [ + { + id: 'script', + label: 'Script', + icon: FileCode, + description: 'Single-file utilities, automation scripts, CLI tools', + scope: 'Minutes to 1-2 hours, single file or small module', + examples: 'Data migration script, API integration helper, Build tool plugin', + skipConfig: true, + }, + { + id: 'simple', + label: 'Simple', + icon: Folder, + description: 'Small applications with clear requirements', + scope: '2-3 days, handful of files/components', + examples: 'Landing page, REST API endpoint, Browser extension', + skipConfig: false, + }, + { + id: 'medium', + label: 'Medium', + icon: Layers, + description: 'Full applications with multiple features', + scope: '2-3 weeks, multiple modules/services', + examples: 'Admin dashboard, E-commerce store, Mobile app', + skipConfig: false, + }, + { + id: 'complex', + label: 'Complex', + icon: Building2, + description: 'Enterprise systems with many moving parts', + scope: '2-3 months, distributed architecture', + examples: 'SaaS platform, Microservices ecosystem, Data pipeline', + skipConfig: false, + }, +]; + +/** + * Client mode options + */ +export const clientModeOptions: ClientModeOption[] = [ + { + id: 'technical', + label: 'Technical Mode', + icon: Zap, + description: "I'll provide detailed technical specifications", + details: [ + 'Upload existing specs or PRDs', + 'Define API contracts and schemas', + 'Specify architecture patterns', + 'Direct sprint planning input', + ], + }, + { + id: 'auto', + label: 'Auto Mode', + icon: HelpCircle, + description: 'Help me figure out what I need', + details: [ + 'Guided requirements discovery', + 'AI suggests best practices', + 'Interactive brainstorming sessions', + 'Progressive refinement of scope', + ], + }, +]; + +/** + * Autonomy level options with approval matrix + */ +export const autonomyOptions: AutonomyOption[] = [ + { + id: 'full_control', + label: 'Full Control', + icon: Shield, + description: 'Review every action before it happens', + approvals: { + codeChanges: true, + issueUpdates: true, + architectureDecisions: true, + sprintPlanning: true, + deployments: true, + }, + recommended: 'New users or critical projects', + }, + { + id: 'milestone', + label: 'Milestone', + icon: Milestone, + description: 'Review at sprint boundaries', + approvals: { + codeChanges: false, + issueUpdates: false, + architectureDecisions: true, + sprintPlanning: true, + deployments: true, + }, + recommended: 'Balanced control (recommended)', + }, + { + id: 'autonomous', + label: 'Autonomous', + icon: Bot, + description: 'Only major decisions require approval', + approvals: { + codeChanges: false, + issueUpdates: false, + architectureDecisions: true, + sprintPlanning: false, + deployments: true, + }, + recommended: 'Experienced users or low-risk projects', + }, +]; + +/** + * Step configuration for wizard navigation + */ +export const WIZARD_STEPS = { + BASIC_INFO: 1, + COMPLEXITY: 2, + CLIENT_MODE: 3, + AUTONOMY: 4, + AGENT_CHAT: 5, + REVIEW: 6, +} as const; + +/** + * Total steps based on complexity mode + */ +export const getTotalSteps = (isScriptMode: boolean): number => { + return isScriptMode ? 4 : 6; +}; + +/** + * Get step labels based on complexity mode + */ +export const getStepLabels = (isScriptMode: boolean): string[] => { + if (isScriptMode) { + return ['Basic Info', 'Complexity', 'Agent Chat', 'Review']; + } + return ['Basic Info', 'Complexity', 'Client Mode', 'Autonomy', 'Agent Chat', 'Review']; +}; + +/** + * Map actual step to display step for scripts + */ +export const getDisplayStep = (actualStep: number, isScriptMode: boolean): number => { + if (!isScriptMode) return actualStep; + + // For scripts: 1->1, 2->2, 5->3, 6->4 + switch (actualStep) { + case 1: + return 1; + case 2: + return 2; + case 5: + return 3; + case 6: + return 4; + default: + return actualStep; + } +}; diff --git a/frontend/src/components/projects/wizard/index.ts b/frontend/src/components/projects/wizard/index.ts new file mode 100644 index 0000000..1d4ee44 --- /dev/null +++ b/frontend/src/components/projects/wizard/index.ts @@ -0,0 +1,26 @@ +/** + * Project Creation Wizard + * + * Multi-step wizard for creating new Syndarix projects. + */ + +export { ProjectWizard } from './ProjectWizard'; +export { StepIndicator } from './StepIndicator'; +export { SelectableCard } from './SelectableCard'; + +// Re-export types +export type { + WizardState, + WizardStep, + ProjectComplexity, + ClientMode, + AutonomyLevel, +} from './types'; + +// Re-export constants +export { + complexityOptions, + clientModeOptions, + autonomyOptions, + WIZARD_STEPS, +} from './constants'; diff --git a/frontend/src/components/projects/wizard/steps/AgentChatStep.tsx b/frontend/src/components/projects/wizard/steps/AgentChatStep.tsx new file mode 100644 index 0000000..5ba1216 --- /dev/null +++ b/frontend/src/components/projects/wizard/steps/AgentChatStep.tsx @@ -0,0 +1,171 @@ +'use client'; + +/** + * Step 5: Agent Chat Placeholder + * + * Preview of the requirements discovery chat interface. + * This will be fully implemented in Phase 4. + */ + +import { Bot, User, MessageSquare, Sparkles } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +interface MockMessage { + id: number; + role: 'agent' | 'user'; + name: string; + message: string; + timestamp: string; +} + +const mockMessages: MockMessage[] = [ + { + id: 1, + role: 'agent', + name: 'Product Owner Agent', + message: + "Hello! I'm your Product Owner agent. I'll help you define what we're building. Can you tell me more about your project goals?", + timestamp: '10:00 AM', + }, + { + id: 2, + role: 'user', + name: 'You', + message: + 'I want to build an e-commerce platform for selling handmade crafts. It should have user accounts, a product catalog, and checkout.', + timestamp: '10:02 AM', + }, + { + id: 3, + role: 'agent', + name: 'Product Owner Agent', + message: + "Great! Let me break this down into user stories. For the MVP, I'd suggest focusing on: user registration/login, product browsing with categories, and a simple cart checkout. Should we also include seller accounts or just a single store?", + timestamp: '10:03 AM', + }, +]; + +export function AgentChatStep() { + return ( +
+
+
+

Requirements Discovery

+ Coming in Phase 4 +
+

+ In the full version, you'll chat with our Product Owner agent here to define + requirements. +

+
+ + + +
+
+
+
+ Product Owner Agent + Requirements discovery and sprint planning +
+ + Preview Only + +
+
+ + {/* Chat Messages Area */} +
+ {mockMessages.map((msg) => ( +
+ +
+

{msg.message}

+

+ {msg.timestamp} +

+
+
+ ))} +
+ + {/* Chat Input Area (disabled preview) */} +
+
+ + +
+

+ This chat interface is a preview. Full agent interaction will be available in Phase 4. +

+
+
+
+ + + +
+
+
+

What to Expect in the Full Version

+
    +
  • - Interactive requirements gathering with AI Product Owner
  • +
  • - Architecture spike with BA and Architect agents
  • +
  • - Collaborative backlog creation and prioritization
  • +
  • - Real-time refinement of user stories
  • +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/projects/wizard/steps/AutonomyStep.tsx b/frontend/src/components/projects/wizard/steps/AutonomyStep.tsx new file mode 100644 index 0000000..d30a647 --- /dev/null +++ b/frontend/src/components/projects/wizard/steps/AutonomyStep.tsx @@ -0,0 +1,153 @@ +'use client'; + +/** + * Step 4: Autonomy Level Selection + * + * Allows users to choose how much control they want over agent actions. + * Includes a detailed approval matrix comparison. + * Skipped for script complexity projects. + */ + +import { Check, AlertCircle } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { SelectableCard } from '../SelectableCard'; +import { autonomyOptions } from '../constants'; +import type { WizardState, AutonomyLevel, ApprovalMatrix } from '../types'; +import { approvalLabels } from '../types'; + +interface AutonomyStepProps { + state: WizardState; + updateState: (updates: Partial) => void; +} + +export function AutonomyStep({ state, updateState }: AutonomyStepProps) { + const handleSelect = (autonomyLevel: AutonomyLevel) => { + updateState({ autonomyLevel }); + }; + + return ( +
+
+

Autonomy Level

+

+ How much control do you want over the AI agents' actions? +

+
+ +
+ {autonomyOptions.map((option) => { + const Icon = option.icon; + const isSelected = state.autonomyLevel === option.id; + + return ( + handleSelect(option.id)} + aria-label={`${option.label}: ${option.description}`} + > +
+
+
+
+
+
+

{option.label}

+ {isSelected && ( +
+
+ )} +
+

{option.description}

+

+ Best for: {option.recommended} +

+
+
+ +
+ {Object.entries(option.approvals).map(([key, requiresApproval]) => ( + + {requiresApproval ? 'Approve' : 'Auto'}:{' '} + {approvalLabels[key as keyof ApprovalMatrix]} + + ))} +
+
+
+ ); + })} +
+ + + + + + + +
+ + + + + + + + + + + {Object.keys(autonomyOptions[0].approvals).map((key) => ( + + + {autonomyOptions.map((option) => ( + + ))} + + ))} + +
+ Action Type + + Full Control + + Milestone + + Autonomous +
+ {approvalLabels[key as keyof ApprovalMatrix]} + + {option.approvals[key as keyof ApprovalMatrix] ? ( + + Required + + ) : ( + Automatic + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/projects/wizard/steps/BasicInfoStep.tsx b/frontend/src/components/projects/wizard/steps/BasicInfoStep.tsx new file mode 100644 index 0000000..512320b --- /dev/null +++ b/frontend/src/components/projects/wizard/steps/BasicInfoStep.tsx @@ -0,0 +1,142 @@ +'use client'; + +/** + * Step 1: Basic Information + * + * Collects project name, description, and optional repository URL. + */ + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { GitBranch } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import type { WizardState } from '../types'; + +const basicInfoSchema = z.object({ + projectName: z + .string() + .min(3, 'Project name must be at least 3 characters') + .max(255, 'Project name must be less than 255 characters'), + description: z.string().max(2000, 'Description must be less than 2000 characters').optional(), + repoUrl: z.string().url('Please enter a valid URL').or(z.literal('')).optional(), +}); + +type BasicInfoFormData = z.infer; + +interface BasicInfoStepProps { + state: WizardState; + updateState: (updates: Partial) => void; +} + +export function BasicInfoStep({ state, updateState }: BasicInfoStepProps) { + const { + register, + formState: { errors }, + trigger, + } = useForm({ + resolver: zodResolver(basicInfoSchema), + defaultValues: { + projectName: state.projectName, + description: state.description, + repoUrl: state.repoUrl, + }, + mode: 'onBlur', + }); + + const handleChange = (field: keyof BasicInfoFormData, value: string) => { + updateState({ [field]: value }); + }; + + return ( +
+
+

Create New Project

+

+ Let's start with the basics. Give your project a name and description. +

+
+ +
+
+ + { + handleChange('projectName', e.target.value); + if (errors.projectName) { + trigger('projectName'); + } + }} + onBlur={() => trigger('projectName')} + aria-invalid={!!errors.projectName} + aria-describedby={errors.projectName ? 'project-name-error' : undefined} + /> + {errors.projectName && ( +

+ {errors.projectName.message} +

+ )} +
+ +
+ +