From 37fa85161af4494594ebe58002d5de7bcc0b57e9 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 23 Mar 2026 16:16:32 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(web-ui):=20add=20pipeline=20progress?= =?UTF-8?q?=20indicator=20(Think=E2=86=92Build=E2=86=92Prove=E2=86=92Ship)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent PipelineProgressBar to AppLayout showing the four CodeFRAME phases with live completion status from existing API endpoints. - usePipelineStatus hook: aggregates prd/tasks/proof/review APIs via SWR, derives completion per phase (think=PRD exists, build=DONE+MERGED>0, prove=no open requirements, ship=no uncommitted diff) - PipelineProgressBar: horizontal stepper with checkmark (Tick01Icon) for complete phases, numbered circle for incomplete, current phase highlighted with bg-accent, responsive (abbreviated labels on mobile, full on lg:) - AppLayout: injects PipelineProgressBar above children in flex-col column - Bar hidden on root path (/), workspace-aware via localStorage events Tests: 24 new unit tests (hook + component), 371 total passing Closes #466 --- web-ui/__mocks__/@hugeicons/react.js | 2 + .../layout/PipelineProgressBar.test.tsx | 149 +++++++++++ .../__tests__/hooks/usePipelineStatus.test.ts | 232 ++++++++++++++++++ web-ui/src/components/layout/AppLayout.tsx | 6 +- .../components/layout/PipelineProgressBar.tsx | 92 +++++++ web-ui/src/hooks/usePipelineStatus.ts | 85 +++++++ 6 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx create mode 100644 web-ui/src/__tests__/hooks/usePipelineStatus.test.ts create mode 100644 web-ui/src/components/layout/PipelineProgressBar.tsx create mode 100644 web-ui/src/hooks/usePipelineStatus.ts diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 13e2748a..d3fa2e15 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -37,6 +37,8 @@ module.exports = { SentIcon: createIconMock('SentIcon'), // AppSidebar Home01Icon: createIconMock('Home01Icon'), + // PipelineProgressBar + Tick01Icon: createIconMock('Tick01Icon'), // Task Board components PlayCircleIcon: createIconMock('PlayCircleIcon'), LinkCircleIcon: createIconMock('LinkCircleIcon'), diff --git a/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx new file mode 100644 index 00000000..9eea1395 --- /dev/null +++ b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { usePathname } from 'next/navigation'; +import { PipelineProgressBar } from '@/components/layout/PipelineProgressBar'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; + +jest.mock('@/hooks/usePipelineStatus'); +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), + useRouter: jest.fn(() => ({ push: jest.fn() })), +})); + +const mockUsePipelineStatus = usePipelineStatus as jest.MockedFunction; +const mockUsePathname = usePathname as jest.MockedFunction; + +const allIncomplete = { + think: { isComplete: false, isLoading: false }, + build: { isComplete: false, isLoading: false }, + prove: { isComplete: false, isLoading: false }, + ship: { isComplete: false, isLoading: false }, +}; + +const allComplete = { + think: { isComplete: true, isLoading: false }, + build: { isComplete: true, isLoading: false }, + prove: { isComplete: true, isLoading: false }, + ship: { isComplete: true, isLoading: false }, +}; + +describe('PipelineProgressBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all four phase labels on desktop', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + expect(screen.getByText('Think')).toBeInTheDocument(); + expect(screen.getByText('Build')).toBeInTheDocument(); + expect(screen.getByText('Prove')).toBeInTheDocument(); + expect(screen.getByText('Ship')).toBeInTheDocument(); + }); + + it('returns null on root path /', () => { + mockUsePathname.mockReturnValue('/'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('highlights the Think phase when on /prd', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + // Think link should have active styling indicator + const thinkLink = screen.getByRole('link', { name: /think/i }); + expect(thinkLink).toHaveAttribute('href', '/prd'); + }); + + it('highlights the Build phase when on /tasks', () => { + mockUsePathname.mockReturnValue('/tasks'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + }); + + it('highlights the Build phase when on /execution', () => { + mockUsePathname.mockReturnValue('/execution'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + }); + + it('highlights the Build phase when on /blockers', () => { + mockUsePathname.mockReturnValue('/blockers'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const buildLink = screen.getByRole('link', { name: /build/i }); + expect(buildLink).toHaveAttribute('href', '/tasks'); + }); + + it('highlights the Prove phase when on /proof', () => { + mockUsePathname.mockReturnValue('/proof'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const proveLink = screen.getByRole('link', { name: /prove/i }); + expect(proveLink).toHaveAttribute('href', '/proof'); + }); + + it('highlights the Ship phase when on /review', () => { + mockUsePathname.mockReturnValue('/review'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + const shipLink = screen.getByRole('link', { name: /ship/i }); + expect(shipLink).toHaveAttribute('href', '/review'); + }); + + it('shows checkmark icon for completed phases', () => { + mockUsePathname.mockReturnValue('/tasks'); + mockUsePipelineStatus.mockReturnValue({ + ...allIncomplete, + think: { isComplete: true, isLoading: false }, + }); + + render(); + + // Completed phase should show check icon + expect(screen.getByTestId('icon-Tick01Icon')).toBeInTheDocument(); + }); + + it('shows all checkmarks when all phases complete', () => { + mockUsePathname.mockReturnValue('/review'); + mockUsePipelineStatus.mockReturnValue(allComplete); + + render(); + + expect(screen.getAllByTestId('icon-Tick01Icon')).toHaveLength(4); + }); + + it('each phase link navigates to correct route', () => { + mockUsePathname.mockReturnValue('/prd'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + render(); + + expect(screen.getByRole('link', { name: /think/i })).toHaveAttribute('href', '/prd'); + expect(screen.getByRole('link', { name: /build/i })).toHaveAttribute('href', '/tasks'); + expect(screen.getByRole('link', { name: /prove/i })).toHaveAttribute('href', '/proof'); + expect(screen.getByRole('link', { name: /ship/i })).toHaveAttribute('href', '/review'); + }); +}); diff --git a/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts new file mode 100644 index 00000000..928e2e68 --- /dev/null +++ b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts @@ -0,0 +1,232 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useSWR from 'swr'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; + +jest.mock('swr'); +jest.mock('@/lib/workspace-storage'); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockGetWorkspacePath = getSelectedWorkspacePath as jest.MockedFunction< + typeof getSelectedWorkspacePath +>; + +// Helper to set up SWR mocks for a given scenario, keyed by URL prefix +function mockSWRCalls({ + prd, + tasks, + proof, + review, +}: { + prd?: { data?: unknown; isLoading?: boolean }; + tasks?: { data?: unknown; isLoading?: boolean }; + proof?: { data?: unknown; isLoading?: boolean }; + review?: { data?: unknown; isLoading?: boolean }; +}) { + mockUseSWR.mockImplementation((key: unknown, ..._rest: unknown[]) => { + const keyStr = typeof key === 'string' ? key : ''; + let scenario: { data?: unknown; isLoading?: boolean } = {}; + if (keyStr.includes('/pipeline/prd')) scenario = prd ?? {}; + else if (keyStr.includes('/pipeline/tasks')) scenario = tasks ?? {}; + else if (keyStr.includes('/pipeline/proof')) scenario = proof ?? {}; + else if (keyStr.includes('/pipeline/review')) scenario = review ?? {}; + + return { + data: scenario.data ?? undefined, + isLoading: scenario.isLoading ?? false, + error: undefined, + isValidating: false, + mutate: jest.fn(), + } as ReturnType; + }); +} + +describe('usePipelineStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetWorkspacePath.mockReturnValue('/test/workspace'); + }); + + it('returns all phases incomplete when no data yet', () => { + mockSWRCalls({ + prd: { isLoading: true }, + tasks: { isLoading: true }, + proof: { isLoading: true }, + review: { isLoading: true }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(false); + expect(result.current.build.isComplete).toBe(false); + expect(result.current.prove.isComplete).toBe(false); + expect(result.current.ship.isComplete).toBe(false); + }); + + it('think phase complete when PRD data returned', () => { + mockSWRCalls({ + prd: { data: { id: 'prd-1', content: 'some content' } }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(true); + }); + + it('think phase incomplete when PRD endpoint returns no data (404)', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isComplete).toBe(false); + }); + + it('build phase complete when at least one task is DONE', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 3, + by_status: { BACKLOG: 1, READY: 1, IN_PROGRESS: 0, DONE: 1, BLOCKED: 0, FAILED: 0, MERGED: 0 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(true); + }); + + it('build phase complete when at least one task is MERGED', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 2, + by_status: { BACKLOG: 0, READY: 0, IN_PROGRESS: 0, DONE: 0, BLOCKED: 0, FAILED: 0, MERGED: 1 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(true); + }); + + it('build phase incomplete when no tasks are done or merged', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { + data: { + tasks: [], + total: 2, + by_status: { BACKLOG: 2, READY: 0, IN_PROGRESS: 0, DONE: 0, BLOCKED: 0, FAILED: 0, MERGED: 0 }, + }, + }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.build.isComplete).toBe(false); + }); + + it('prove phase complete when proof has requirements and none open', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 3, open: 0, satisfied: 3, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(true); + }); + + it('prove phase incomplete when open requirements remain', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 3, open: 1, satisfied: 2, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(false); + }); + + it('prove phase incomplete when total is 0 (no proof requirements)', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.prove.isComplete).toBe(false); + }); + + it('ship phase complete when files_changed is 0', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 0 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.ship.isComplete).toBe(true); + }); + + it('ship phase incomplete when there are uncommitted changes', () => { + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 3 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.ship.isComplete).toBe(false); + }); + + it('returns isLoading true while any phase is loading', () => { + mockSWRCalls({ + prd: { isLoading: true }, + tasks: { isLoading: false }, + proof: { isLoading: false }, + review: { isLoading: false }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isLoading).toBe(true); + expect(result.current.build.isLoading).toBe(false); + }); + + it('returns null SWR keys when no workspace selected', () => { + mockGetWorkspacePath.mockReturnValue(null); + mockSWRCalls({ + prd: { data: undefined }, + tasks: { data: undefined }, + proof: { data: undefined }, + review: { data: undefined }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + // All incomplete when no workspace + expect(result.current.think.isComplete).toBe(false); + expect(result.current.build.isComplete).toBe(false); + expect(result.current.prove.isComplete).toBe(false); + expect(result.current.ship.isComplete).toBe(false); + }); +}); diff --git a/web-ui/src/components/layout/AppLayout.tsx b/web-ui/src/components/layout/AppLayout.tsx index 05a46ce3..85e1bcf8 100644 --- a/web-ui/src/components/layout/AppLayout.tsx +++ b/web-ui/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { AppSidebar } from './AppSidebar'; +import { PipelineProgressBar } from './PipelineProgressBar'; interface AppLayoutProps { children: ReactNode; @@ -16,7 +17,10 @@ export function AppLayout({ children }: AppLayoutProps) { return (
-
{children}
+
+ + {children} +
); } diff --git a/web-ui/src/components/layout/PipelineProgressBar.tsx b/web-ui/src/components/layout/PipelineProgressBar.tsx new file mode 100644 index 00000000..86adc326 --- /dev/null +++ b/web-ui/src/components/layout/PipelineProgressBar.tsx @@ -0,0 +1,92 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Tick01Icon } from '@hugeicons/react'; +import { cn } from '@/lib/utils'; +import { usePipelineStatus } from '@/hooks/usePipelineStatus'; + +interface Phase { + key: 'think' | 'build' | 'prove' | 'ship'; + label: string; + href: string; + paths: string[]; +} + +const PHASES: Phase[] = [ + { key: 'think', label: 'Think', href: '/prd', paths: ['/prd'] }, + { key: 'build', label: 'Build', href: '/tasks', paths: ['/tasks', '/execution', '/blockers'] }, + { key: 'prove', label: 'Prove', href: '/proof', paths: ['/proof'] }, + { key: 'ship', label: 'Ship', href: '/review', paths: ['/review'] }, +]; + +function getActivePhase(pathname: string): string | null { + for (const phase of PHASES) { + if (phase.paths.some((p) => pathname === p || pathname.startsWith(p + '/'))) { + return phase.key; + } + } + return null; +} + +export function PipelineProgressBar() { + const pathname = usePathname(); + const status = usePipelineStatus(); + + // Hide on root (workspace selector) and any path with no matching phase + if (pathname === '/') return null; + + const activePhase = getActivePhase(pathname); + + return ( + + ); +} diff --git a/web-ui/src/hooks/usePipelineStatus.ts b/web-ui/src/hooks/usePipelineStatus.ts new file mode 100644 index 00000000..b52ab778 --- /dev/null +++ b/web-ui/src/hooks/usePipelineStatus.ts @@ -0,0 +1,85 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; +import { prdApi, tasksApi, proofApi, reviewApi } from '@/lib/api'; + +export interface PhaseStatus { + isComplete: boolean; + isLoading: boolean; +} + +export interface PipelineStatus { + think: PhaseStatus; + build: PhaseStatus; + prove: PhaseStatus; + ship: PhaseStatus; +} + +/** + * Aggregates pipeline phase completion from four existing API endpoints. + * Returns null SWR keys when no workspace is selected, preventing fetches. + */ +export function usePipelineStatus(): PipelineStatus { + const [workspacePath, setWorkspacePath] = useState(null); + + useEffect(() => { + setWorkspacePath(getSelectedWorkspacePath()); + + const handleChange = () => setWorkspacePath(getSelectedWorkspacePath()); + window.addEventListener('storage', handleChange); + window.addEventListener('workspaceChanged', handleChange); + return () => { + window.removeEventListener('storage', handleChange); + window.removeEventListener('workspaceChanged', handleChange); + }; + }, []); + + const { data: prdData, isLoading: prdLoading } = useSWR( + workspacePath ? `/pipeline/prd?path=${workspacePath}` : null, + () => prdApi.getLatest(workspacePath!), + { revalidateOnFocus: false } + ); + + const { data: tasksData, isLoading: tasksLoading } = useSWR( + workspacePath ? `/pipeline/tasks?path=${workspacePath}` : null, + () => tasksApi.getAll(workspacePath!), + { revalidateOnFocus: false } + ); + + const { data: proofData, isLoading: proofLoading } = useSWR( + workspacePath ? `/pipeline/proof?path=${workspacePath}` : null, + () => proofApi.getStatus(workspacePath!), + { revalidateOnFocus: false } + ); + + const { data: reviewData, isLoading: reviewLoading } = useSWR( + workspacePath ? `/pipeline/review?path=${workspacePath}` : null, + () => reviewApi.getDiff(workspacePath!), + { revalidateOnFocus: false } + ); + + const doneMerged = tasksData + ? (tasksData.by_status.DONE ?? 0) + (tasksData.by_status.MERGED ?? 0) + : 0; + + return { + think: { + isComplete: !!prdData, + isLoading: prdLoading, + }, + build: { + isComplete: doneMerged > 0, + isLoading: tasksLoading, + }, + prove: { + isComplete: !!proofData && proofData.total > 0 && proofData.open === 0, + isLoading: proofLoading, + }, + ship: { + isComplete: !!reviewData && reviewData.files_changed === 0, + isLoading: reviewLoading, + }, + }; +} From 9f50ccb0696515fede957a354926b5c82e7fff78 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 23 Mar 2026 16:26:05 -0700 Subject: [PATCH 2/3] fix: address CodeRabbit feedback on PR #488 - Fix comment/behavior mismatch: bar now returns null for non-pipeline paths (e.g. /settings) matching the stated intent (Option B) - Move PhaseStatus/PipelineStatus types to src/types/index.ts per guidelines - Add isError field to PhaseStatus to surface API call failures - Remove unused waitFor import from hook test - Expand isLoading test to assert all 4 phases independently - Remove unnecessary React import from component test (modern JSX transform) - Add aria-current="step" assertion to all active-phase tests - Add test for null return on non-pipeline paths --- .../layout/PipelineProgressBar.test.tsx | 37 ++++++++++++------- .../__tests__/hooks/usePipelineStatus.test.ts | 35 +++++++++++++----- .../components/layout/PipelineProgressBar.tsx | 3 +- web-ui/src/hooks/usePipelineStatus.ts | 25 +++++-------- web-ui/src/types/index.ts | 15 ++++++++ 5 files changed, 76 insertions(+), 39 deletions(-) diff --git a/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx index 9eea1395..8b62c9a7 100644 --- a/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx +++ b/web-ui/src/__tests__/components/layout/PipelineProgressBar.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { render, screen } from '@testing-library/react'; import { usePathname } from 'next/navigation'; import { PipelineProgressBar } from '@/components/layout/PipelineProgressBar'; @@ -14,17 +13,17 @@ const mockUsePipelineStatus = usePipelineStatus as jest.MockedFunction; const allIncomplete = { - think: { isComplete: false, isLoading: false }, - build: { isComplete: false, isLoading: false }, - prove: { isComplete: false, isLoading: false }, - ship: { isComplete: false, isLoading: false }, + think: { isComplete: false, isLoading: false, isError: false }, + build: { isComplete: false, isLoading: false, isError: false }, + prove: { isComplete: false, isLoading: false, isError: false }, + ship: { isComplete: false, isLoading: false, isError: false }, }; const allComplete = { - think: { isComplete: true, isLoading: false }, - build: { isComplete: true, isLoading: false }, - prove: { isComplete: true, isLoading: false }, - ship: { isComplete: true, isLoading: false }, + think: { isComplete: true, isLoading: false, isError: false }, + build: { isComplete: true, isLoading: false, isError: false }, + prove: { isComplete: true, isLoading: false, isError: false }, + ship: { isComplete: true, isLoading: false, isError: false }, }; describe('PipelineProgressBar', () => { @@ -52,15 +51,23 @@ describe('PipelineProgressBar', () => { expect(container.firstChild).toBeNull(); }); - it('highlights the Think phase when on /prd', () => { + it('returns null on non-pipeline path (e.g. /settings)', () => { + mockUsePathname.mockReturnValue('/settings'); + mockUsePipelineStatus.mockReturnValue(allIncomplete); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('highlights the Think phase when on /prd with aria-current', () => { mockUsePathname.mockReturnValue('/prd'); mockUsePipelineStatus.mockReturnValue(allIncomplete); render(); - // Think link should have active styling indicator const thinkLink = screen.getByRole('link', { name: /think/i }); expect(thinkLink).toHaveAttribute('href', '/prd'); + expect(thinkLink).toHaveAttribute('aria-current', 'step'); }); it('highlights the Build phase when on /tasks', () => { @@ -71,6 +78,7 @@ describe('PipelineProgressBar', () => { const buildLink = screen.getByRole('link', { name: /build/i }); expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); }); it('highlights the Build phase when on /execution', () => { @@ -81,6 +89,7 @@ describe('PipelineProgressBar', () => { const buildLink = screen.getByRole('link', { name: /build/i }); expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); }); it('highlights the Build phase when on /blockers', () => { @@ -91,6 +100,7 @@ describe('PipelineProgressBar', () => { const buildLink = screen.getByRole('link', { name: /build/i }); expect(buildLink).toHaveAttribute('href', '/tasks'); + expect(buildLink).toHaveAttribute('aria-current', 'step'); }); it('highlights the Prove phase when on /proof', () => { @@ -101,6 +111,7 @@ describe('PipelineProgressBar', () => { const proveLink = screen.getByRole('link', { name: /prove/i }); expect(proveLink).toHaveAttribute('href', '/proof'); + expect(proveLink).toHaveAttribute('aria-current', 'step'); }); it('highlights the Ship phase when on /review', () => { @@ -111,18 +122,18 @@ describe('PipelineProgressBar', () => { const shipLink = screen.getByRole('link', { name: /ship/i }); expect(shipLink).toHaveAttribute('href', '/review'); + expect(shipLink).toHaveAttribute('aria-current', 'step'); }); it('shows checkmark icon for completed phases', () => { mockUsePathname.mockReturnValue('/tasks'); mockUsePipelineStatus.mockReturnValue({ ...allIncomplete, - think: { isComplete: true, isLoading: false }, + think: { isComplete: true, isLoading: false, isError: false }, }); render(); - // Completed phase should show check icon expect(screen.getByTestId('icon-Tick01Icon')).toBeInTheDocument(); }); diff --git a/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts index 928e2e68..a4b2aaee 100644 --- a/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts +++ b/web-ui/src/__tests__/hooks/usePipelineStatus.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import useSWR from 'swr'; import { usePipelineStatus } from '@/hooks/usePipelineStatus'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; @@ -18,14 +18,14 @@ function mockSWRCalls({ proof, review, }: { - prd?: { data?: unknown; isLoading?: boolean }; - tasks?: { data?: unknown; isLoading?: boolean }; - proof?: { data?: unknown; isLoading?: boolean }; - review?: { data?: unknown; isLoading?: boolean }; + prd?: { data?: unknown; isLoading?: boolean; error?: unknown }; + tasks?: { data?: unknown; isLoading?: boolean; error?: unknown }; + proof?: { data?: unknown; isLoading?: boolean; error?: unknown }; + review?: { data?: unknown; isLoading?: boolean; error?: unknown }; }) { mockUseSWR.mockImplementation((key: unknown, ..._rest: unknown[]) => { const keyStr = typeof key === 'string' ? key : ''; - let scenario: { data?: unknown; isLoading?: boolean } = {}; + let scenario: { data?: unknown; isLoading?: boolean; error?: unknown } = {}; if (keyStr.includes('/pipeline/prd')) scenario = prd ?? {}; else if (keyStr.includes('/pipeline/tasks')) scenario = tasks ?? {}; else if (keyStr.includes('/pipeline/proof')) scenario = proof ?? {}; @@ -34,7 +34,7 @@ function mockSWRCalls({ return { data: scenario.data ?? undefined, isLoading: scenario.isLoading ?? false, - error: undefined, + error: scenario.error ?? undefined, isValidating: false, mutate: jest.fn(), } as ReturnType; @@ -200,17 +200,34 @@ describe('usePipelineStatus', () => { expect(result.current.ship.isComplete).toBe(false); }); - it('returns isLoading true while any phase is loading', () => { + it('reflects individual phase loading states accurately', () => { mockSWRCalls({ prd: { isLoading: true }, tasks: { isLoading: false }, - proof: { isLoading: false }, + proof: { isLoading: true }, review: { isLoading: false }, }); const { result } = renderHook(() => usePipelineStatus()); expect(result.current.think.isLoading).toBe(true); expect(result.current.build.isLoading).toBe(false); + expect(result.current.prove.isLoading).toBe(true); + expect(result.current.ship.isLoading).toBe(false); + }); + + it('surfaces isError when an API call fails', () => { + mockSWRCalls({ + prd: { error: new Error('404') }, + tasks: { data: { tasks: [], total: 0, by_status: { DONE: 0, MERGED: 0 } } }, + proof: { data: { total: 0, open: 0, satisfied: 0, waived: 0 } }, + review: { data: { files_changed: 5 } }, + }); + + const { result } = renderHook(() => usePipelineStatus()); + expect(result.current.think.isError).toBe(true); + expect(result.current.build.isError).toBe(false); + expect(result.current.prove.isError).toBe(false); + expect(result.current.ship.isError).toBe(false); }); it('returns null SWR keys when no workspace selected', () => { diff --git a/web-ui/src/components/layout/PipelineProgressBar.tsx b/web-ui/src/components/layout/PipelineProgressBar.tsx index 86adc326..793a9484 100644 --- a/web-ui/src/components/layout/PipelineProgressBar.tsx +++ b/web-ui/src/components/layout/PipelineProgressBar.tsx @@ -33,10 +33,11 @@ export function PipelineProgressBar() { const pathname = usePathname(); const status = usePipelineStatus(); - // Hide on root (workspace selector) and any path with no matching phase + // Hide on root (workspace selector) and any path not belonging to a pipeline phase if (pathname === '/') return null; const activePhase = getActivePhase(pathname); + if (!activePhase) return null; return (