diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 10c8255e..13e2748a 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -51,5 +51,6 @@ module.exports = { WifiDisconnected01Icon: createIconMock('WifiDisconnected01Icon'), SidebarLeftIcon: createIconMock('SidebarLeftIcon'), ArrowDown01Icon: createIconMock('ArrowDown01Icon'), + ArrowUp01Icon: createIconMock('ArrowUp01Icon'), StopIcon: createIconMock('StopIcon'), }; diff --git a/web-ui/__tests__/components/blockers/BlockerCard.test.tsx b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx new file mode 100644 index 00000000..6f541d39 --- /dev/null +++ b/web-ui/__tests__/components/blockers/BlockerCard.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BlockerCard } from '@/components/blockers/BlockerCard'; +import { blockersApi } from '@/lib/api'; +import type { Blocker } from '@/types'; + +// Mock the API +jest.mock('@/lib/api', () => ({ + blockersApi: { + answer: jest.fn(), + }, +})); + +const mockAnswer = blockersApi.answer as jest.MockedFunction; + +function makeBlocker(overrides: Partial = {}): Blocker { + return { + id: 'blocker-1', + workspace_id: 'ws-1', + task_id: 'task-42', + question: 'Which database should we use?', + answer: null, + status: 'OPEN', + created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30m ago + answered_at: null, + ...overrides, + }; +} + +describe('BlockerCard', () => { + const workspacePath = '/home/user/project'; + const onAnswered = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders the blocker question prominently', () => { + render( + + ); + + expect(screen.getByText('Which database should we use?')).toBeInTheDocument(); + }); + + it('displays the task ID', () => { + render( + + ); + + expect(screen.getByText('Task task-42')).toBeInTheDocument(); + }); + + it('shows OPEN badge', () => { + render( + + ); + + expect(screen.getByText('OPEN')).toBeInTheDocument(); + }); + + it('shows relative timestamp', () => { + render( + + ); + + expect(screen.getByText('30m ago')).toBeInTheDocument(); + }); + + it('shows the answer form for OPEN blockers', () => { + render( + + ); + + expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /answer blocker/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /skip/i })).toBeInTheDocument(); + }); + + it('disables submit button when answer is empty', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /answer blocker/i })).toBeDisabled(); + }); + + it('enables submit button when answer has text', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Use PostgreSQL'); + + expect(screen.getByRole('button', { name: /answer blocker/i })).toBeEnabled(); + }); + + it('shows success state after successful submission', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + mockAnswer.mockResolvedValueOnce(makeBlocker({ status: 'ANSWERED', answer: 'Use PostgreSQL' })); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Use PostgreSQL'); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + await waitFor(() => { + expect(screen.getByText(/blocker answered/i)).toBeInTheDocument(); + }); + }); + + it('calls blockersApi.answer with correct arguments', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + mockAnswer.mockResolvedValueOnce(makeBlocker({ status: 'ANSWERED', answer: 'Use PostgreSQL' })); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Use PostgreSQL'); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + expect(mockAnswer).toHaveBeenCalledWith(workspacePath, 'blocker-1', 'Use PostgreSQL'); + }); + + it('displays error when API call fails', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + mockAnswer.mockRejectedValueOnce({ detail: 'Blocker already resolved' }); + + render( + + ); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Some answer'); + await user.click(screen.getByRole('button', { name: /answer blocker/i })); + + await waitFor(() => { + expect(screen.getByText('Blocker already resolved')).toBeInTheDocument(); + }); + }); + + it('hides form when Skip is clicked and shows "Show answer form" button', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /skip/i })); + + expect(screen.queryByTestId('blocker-answer-form')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /show answer form/i })).toBeInTheDocument(); + }); + + it('re-expands form when "Show answer form" is clicked after Skip', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /skip/i })); + await user.click(screen.getByRole('button', { name: /show answer form/i })); + + expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument(); + }); + + it('shows character count', async () => { + jest.useRealTimers(); + const user = userEvent.setup(); + + render( + + ); + + expect(screen.getByText('0 characters')).toBeInTheDocument(); + + const textarea = screen.getByPlaceholderText('Type your answer...'); + await user.type(textarea, 'Hello'); + + expect(screen.getByText('5 characters')).toBeInTheDocument(); + }); + + it('has correct data-testid attributes', () => { + render( + + ); + + expect(screen.getByTestId('blocker-card')).toBeInTheDocument(); + expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument(); + }); + + it('has correct aria-label on textarea', () => { + render( + + ); + + expect(screen.getByLabelText('Your answer to the blocker question')).toBeInTheDocument(); + }); +}); diff --git a/web-ui/__tests__/components/blockers/ResolvedBlockersSection.test.tsx b/web-ui/__tests__/components/blockers/ResolvedBlockersSection.test.tsx new file mode 100644 index 00000000..8995b68f --- /dev/null +++ b/web-ui/__tests__/components/blockers/ResolvedBlockersSection.test.tsx @@ -0,0 +1,187 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ResolvedBlockersSection } from '@/components/blockers/ResolvedBlockersSection'; +import type { Blocker } from '@/types'; + +// ── Fixtures ────────────────────────────────────────────────────────── + +function makeBlocker(overrides: Partial = {}): Blocker { + return { + id: 'blocker-1', + workspace_id: 'ws-1', + task_id: 'task-1', + question: 'Which database should we use?', + answer: 'Use PostgreSQL for persistence.', + status: 'RESOLVED', + created_at: '2026-02-19T10:00:00Z', + answered_at: '2026-02-19T10:05:00Z', + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('ResolvedBlockersSection', () => { + it('renders nothing when blockers array is empty', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders the toggle button with correct count', () => { + const blockers = [ + makeBlocker({ id: 'b-1' }), + makeBlocker({ id: 'b-2', question: 'What auth provider?' }), + ]; + + render(); + + const button = screen.getByRole('button', { name: /resolved blockers \(2\)/i }); + expect(button).toBeInTheDocument(); + }); + + it('is collapsed by default', () => { + render( + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + // The list region should be hidden (JSDOM doesn't compute CSS from class names) + const list = screen.getByTestId('resolved-blockers-list'); + expect(list).toHaveClass('hidden'); + }); + + it('expands when toggle button is clicked', async () => { + const user = userEvent.setup(); + const blocker = makeBlocker(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + + const list = screen.getByTestId('resolved-blockers-list'); + expect(list).not.toHaveClass('hidden'); + expect(screen.getByText('Which database should we use?')).toBeInTheDocument(); + expect(screen.getByText('Use PostgreSQL for persistence.')).toBeInTheDocument(); + }); + + it('collapses when toggle button is clicked again', async () => { + const user = userEvent.setup(); + + render(); + + const button = screen.getByRole('button'); + await user.click(button); // expand + expect(button).toHaveAttribute('aria-expanded', 'true'); + + await user.click(button); // collapse + expect(button).toHaveAttribute('aria-expanded', 'false'); + + const list = screen.getByTestId('resolved-blockers-list'); + expect(list).toHaveClass('hidden'); + }); + + it('shows RESOLVED badge for RESOLVED blockers', async () => { + const user = userEvent.setup(); + const blocker = makeBlocker({ status: 'RESOLVED' }); + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('RESOLVED')).toBeInTheDocument(); + }); + + it('shows ANSWERED badge for ANSWERED blockers', async () => { + const user = userEvent.setup(); + const blocker = makeBlocker({ status: 'ANSWERED' }); + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('ANSWERED')).toBeInTheDocument(); + }); + + it('displays task ID for each blocker', async () => { + const user = userEvent.setup(); + const blocker = makeBlocker({ task_id: 'task-42' }); + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('task-42')).toBeInTheDocument(); + }); + + it('displays relative time from answered_at when available', async () => { + const user = userEvent.setup(); + // Set answered_at to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const blocker = makeBlocker({ answered_at: twoHoursAgo }); + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('2h ago')).toBeInTheDocument(); + }); + + it('falls back to created_at when answered_at is null', async () => { + const user = userEvent.setup(); + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const blocker = makeBlocker({ answered_at: null, created_at: fiveMinAgo }); + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('5m ago')).toBeInTheDocument(); + }); + + it('renders multiple blockers when expanded', async () => { + const user = userEvent.setup(); + const blockers = [ + makeBlocker({ id: 'b-1', question: 'First question?', answer: 'First answer.' }), + makeBlocker({ id: 'b-2', question: 'Second question?', answer: 'Second answer.' }), + makeBlocker({ id: 'b-3', question: 'Third question?', answer: 'Third answer.' }), + ]; + + render(); + await user.click(screen.getByRole('button')); + + expect(screen.getByText('First question?')).toBeInTheDocument(); + expect(screen.getByText('Second question?')).toBeInTheDocument(); + expect(screen.getByText('Third question?')).toBeInTheDocument(); + expect(screen.getByText('First answer.')).toBeInTheDocument(); + expect(screen.getByText('Second answer.')).toBeInTheDocument(); + expect(screen.getByText('Third answer.')).toBeInTheDocument(); + }); + + it('has the correct data-testid on the section', () => { + render(); + + expect(screen.getByTestId('resolved-blockers-section')).toBeInTheDocument(); + }); + + it('has aria-controls on the toggle button pointing to list', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-controls', 'resolved-blockers-list'); + }); + + it('renders the answer in a quoted style container', async () => { + const user = userEvent.setup(); + const blocker = makeBlocker({ answer: 'Use PostgreSQL.' }); + + render(); + await user.click(screen.getByRole('button')); + + const answerEl = screen.getByText('Use PostgreSQL.'); + // The answer should be in a container with left border styling + expect(answerEl.closest('[class*="border-l-2"]')).toBeInTheDocument(); + }); +}); diff --git a/web-ui/__tests__/components/layout/AppSidebar.test.tsx b/web-ui/__tests__/components/layout/AppSidebar.test.tsx index f010f3fa..f61112c9 100644 --- a/web-ui/__tests__/components/layout/AppSidebar.test.tsx +++ b/web-ui/__tests__/components/layout/AppSidebar.test.tsx @@ -25,6 +25,12 @@ jest.mock('@/lib/workspace-storage', () => ({ getSelectedWorkspacePath: jest.fn(), })); +// Mock SWR (used for blocker badge count) +jest.mock('swr', () => ({ + __esModule: true, + default: () => ({ data: undefined, isLoading: false, error: undefined }), +})); + import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; const mockGetWorkspacePath = getSelectedWorkspacePath as jest.MockedFunction< typeof getSelectedWorkspacePath @@ -81,10 +87,11 @@ describe('AppSidebar', () => { mockGetWorkspacePath.mockReturnValue('/home/user/projects/test'); render(); - // Tasks and Execution are now enabled; Blockers, Review are still disabled + // Tasks, Execution, and Blockers are enabled; Review is still disabled expect(screen.getByRole('link', { name: /^tasks$/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /^execution$/i })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /^blockers$/i })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^blockers$/i })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^review$/i })).not.toBeInTheDocument(); }); it('highlights the active route', () => { diff --git a/web-ui/next-env.d.ts b/web-ui/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/web-ui/next-env.d.ts +++ b/web-ui/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web-ui/src/app/blockers/page.tsx b/web-ui/src/app/blockers/page.tsx new file mode 100644 index 00000000..3c5a289e --- /dev/null +++ b/web-ui/src/app/blockers/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { BlockerResolutionView } from '@/components/blockers/BlockerResolutionView'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; + +export default function BlockersPage() { + const [workspacePath, setWorkspacePath] = useState(null); + const [workspaceReady, setWorkspaceReady] = useState(false); + + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + setWorkspaceReady(true); + }, []); + + // Still hydrating — avoid flashing "No workspace selected" + if (!workspaceReady) { + return null; + } + + // No workspace selected + if (!workspacePath) { + return ( +
+
+
+

+ No workspace selected. Use the sidebar to return to{' '} + + Workspace + {' '} + and select a project. +

+
+
+
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/web-ui/src/components/blockers/BlockerCard.tsx b/web-ui/src/components/blockers/BlockerCard.tsx new file mode 100644 index 00000000..83f04efa --- /dev/null +++ b/web-ui/src/components/blockers/BlockerCard.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { Alert02Icon, Loading03Icon, CheckmarkCircle01Icon } from '@hugeicons/react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { blockersApi } from '@/lib/api'; +import { formatRelativeTime } from '@/lib/format'; +import type { Blocker, ApiError } from '@/types'; + +interface BlockerCardProps { + blocker: Blocker; + workspacePath: string; + onAnswered: () => void; +} + +export function BlockerCard({ blocker, workspacePath, onAnswered }: BlockerCardProps) { + const [answer, setAnswer] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const [collapsed, setCollapsed] = useState(false); + const timerRef = useRef | null>(null); + + const isOpen = blocker.status === 'OPEN'; + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleSubmit = async () => { + if (!answer.trim() || isSubmitting) return; + setIsSubmitting(true); + setError(null); + try { + await blockersApi.answer(workspacePath, blocker.id, answer.trim()); + setSubmitted(true); + timerRef.current = setTimeout(() => onAnswered(), 1500); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr.detail || 'Failed to submit answer'); + } finally { + setIsSubmitting(false); + } + }; + + if (submitted) { + return ( + + + +

+ Blocker answered. Task will resume execution. +

+
+
+ ); + } + + return ( + + + {/* Task context */} + {blocker.task_id && ( +

+ Task {blocker.task_id} +

+ )} + + {/* Question */} +
+
+ +

{blocker.question}

+
+
+ + {/* Metadata row */} +
+ OPEN + + {formatRelativeTime(blocker.created_at)} + +
+
+ + + {/* Read-only answer display for already-answered blockers */} + {!isOpen && blocker.answer && ( +
+

Answer

+

{blocker.answer}

+
+ )} + + {/* Answer form for OPEN blockers */} + {isOpen && !collapsed && ( +
+