From 90f65e4760b02ce07c9b89e4bb93dc5bbb4a3e47 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Feb 2026 10:07:00 -0700 Subject: [PATCH 1/3] feat: Blocker Resolution View for answering agent questions (#333) Add dedicated /blockers page with inline answer forms, real-time polling, and sidebar notification badge for open blocker count. --- web-ui/__mocks__/@hugeicons/react.js | 1 + .../blockers/ResolvedBlockersSection.test.tsx | 187 ++++++++++++++++++ .../components/layout/AppSidebar.test.tsx | 11 +- web-ui/next-env.d.ts | 2 +- web-ui/src/app/blockers/page.tsx | 48 +++++ .../src/components/blockers/BlockerCard.tsx | 161 +++++++++++++++ .../blockers/BlockerResolutionView.tsx | 104 ++++++++++ .../blockers/ResolvedBlockersSection.tsx | 109 ++++++++++ web-ui/src/components/layout/AppSidebar.tsx | 27 ++- web-ui/src/lib/api.ts | 18 ++ 10 files changed, 662 insertions(+), 6 deletions(-) create mode 100644 web-ui/__tests__/components/blockers/ResolvedBlockersSection.test.tsx create mode 100644 web-ui/src/app/blockers/page.tsx create mode 100644 web-ui/src/components/blockers/BlockerCard.tsx create mode 100644 web-ui/src/components/blockers/BlockerResolutionView.tsx create mode 100644 web-ui/src/components/blockers/ResolvedBlockersSection.tsx 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/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..ddff76e3 --- /dev/null +++ b/web-ui/src/components/blockers/BlockerCard.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState } 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 type { Blocker, ApiError } from '@/types'; + +interface BlockerCardProps { + blocker: Blocker; + workspacePath: string; + onAnswered: () => void; +} + +function formatRelativeTime(isoDate: string): string { + const now = new Date(); + const date = new Date(isoDate); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +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 isOpen = blocker.status === 'OPEN'; + + const handleSubmit = async () => { + if (!answer.trim() || isSubmitting) return; + setIsSubmitting(true); + setError(null); + try { + await blockersApi.answer(workspacePath, blocker.id, answer.trim()); + setSubmitted(true); + 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 && ( +
+