Skip to content

Commit 3821731

Browse files
authored
feat: Blocker Resolution View - Answer Agent Questions (#333)
## Summary Implements #333: Blocker Resolution View — a dedicated page for responding to agent questions and unblocking stuck tasks. - New `/blockers` route with inline answer forms and SWR polling (5s) - Sidebar notification badge showing open blocker count (10s polling) - Collapsible resolved blockers section with answer history - Shared `formatRelativeTime` utility - Full accessibility support (ARIA, keyboard nav, Cmd+Enter) - 29 new tests (15 BlockerCard + 14 ResolvedBlockersSection) ## Acceptance Criteria - [x] List all open blockers with task context - [x] Blocker question prominently displayed - [x] Shows timestamp metadata - [x] Inline answer form (text area + submit) - [x] Submit answer auto-resumes task execution - [x] Option to skip/cancel blocker (with re-expand) - [x] Collapsed section for resolved blockers - [x] Notification badge count on sidebar icon - [x] Full context view (task ID + question) ## Validation - Tests: 332 passing (29 suites) - Linting: Clean - Code review: 3 rounds, all feedback addressed - Build: Compiles successfully Closes #333
1 parent cf095ad commit 3821731

12 files changed

Lines changed: 897 additions & 6 deletions

File tree

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ module.exports = {
5151
WifiDisconnected01Icon: createIconMock('WifiDisconnected01Icon'),
5252
SidebarLeftIcon: createIconMock('SidebarLeftIcon'),
5353
ArrowDown01Icon: createIconMock('ArrowDown01Icon'),
54+
ArrowUp01Icon: createIconMock('ArrowUp01Icon'),
5455
StopIcon: createIconMock('StopIcon'),
5556
};
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { BlockerCard } from '@/components/blockers/BlockerCard';
4+
import { blockersApi } from '@/lib/api';
5+
import type { Blocker } from '@/types';
6+
7+
// Mock the API
8+
jest.mock('@/lib/api', () => ({
9+
blockersApi: {
10+
answer: jest.fn(),
11+
},
12+
}));
13+
14+
const mockAnswer = blockersApi.answer as jest.MockedFunction<typeof blockersApi.answer>;
15+
16+
function makeBlocker(overrides: Partial<Blocker> = {}): Blocker {
17+
return {
18+
id: 'blocker-1',
19+
workspace_id: 'ws-1',
20+
task_id: 'task-42',
21+
question: 'Which database should we use?',
22+
answer: null,
23+
status: 'OPEN',
24+
created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30m ago
25+
answered_at: null,
26+
...overrides,
27+
};
28+
}
29+
30+
describe('BlockerCard', () => {
31+
const workspacePath = '/home/user/project';
32+
const onAnswered = jest.fn();
33+
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
jest.useFakeTimers();
37+
});
38+
39+
afterEach(() => {
40+
jest.useRealTimers();
41+
});
42+
43+
it('renders the blocker question prominently', () => {
44+
render(
45+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
46+
);
47+
48+
expect(screen.getByText('Which database should we use?')).toBeInTheDocument();
49+
});
50+
51+
it('displays the task ID', () => {
52+
render(
53+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
54+
);
55+
56+
expect(screen.getByText('Task task-42')).toBeInTheDocument();
57+
});
58+
59+
it('shows OPEN badge', () => {
60+
render(
61+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
62+
);
63+
64+
expect(screen.getByText('OPEN')).toBeInTheDocument();
65+
});
66+
67+
it('shows relative timestamp', () => {
68+
render(
69+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
70+
);
71+
72+
expect(screen.getByText('30m ago')).toBeInTheDocument();
73+
});
74+
75+
it('shows the answer form for OPEN blockers', () => {
76+
render(
77+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
78+
);
79+
80+
expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument();
81+
expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument();
82+
expect(screen.getByRole('button', { name: /answer blocker/i })).toBeInTheDocument();
83+
expect(screen.getByRole('button', { name: /skip/i })).toBeInTheDocument();
84+
});
85+
86+
it('disables submit button when answer is empty', () => {
87+
render(
88+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
89+
);
90+
91+
expect(screen.getByRole('button', { name: /answer blocker/i })).toBeDisabled();
92+
});
93+
94+
it('enables submit button when answer has text', async () => {
95+
jest.useRealTimers();
96+
const user = userEvent.setup();
97+
98+
render(
99+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
100+
);
101+
102+
const textarea = screen.getByPlaceholderText('Type your answer...');
103+
await user.type(textarea, 'Use PostgreSQL');
104+
105+
expect(screen.getByRole('button', { name: /answer blocker/i })).toBeEnabled();
106+
});
107+
108+
it('shows success state after successful submission', async () => {
109+
jest.useRealTimers();
110+
const user = userEvent.setup();
111+
mockAnswer.mockResolvedValueOnce(makeBlocker({ status: 'ANSWERED', answer: 'Use PostgreSQL' }));
112+
113+
render(
114+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
115+
);
116+
117+
const textarea = screen.getByPlaceholderText('Type your answer...');
118+
await user.type(textarea, 'Use PostgreSQL');
119+
await user.click(screen.getByRole('button', { name: /answer blocker/i }));
120+
121+
await waitFor(() => {
122+
expect(screen.getByText(/blocker answered/i)).toBeInTheDocument();
123+
});
124+
});
125+
126+
it('calls blockersApi.answer with correct arguments', async () => {
127+
jest.useRealTimers();
128+
const user = userEvent.setup();
129+
mockAnswer.mockResolvedValueOnce(makeBlocker({ status: 'ANSWERED', answer: 'Use PostgreSQL' }));
130+
131+
render(
132+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
133+
);
134+
135+
const textarea = screen.getByPlaceholderText('Type your answer...');
136+
await user.type(textarea, 'Use PostgreSQL');
137+
await user.click(screen.getByRole('button', { name: /answer blocker/i }));
138+
139+
expect(mockAnswer).toHaveBeenCalledWith(workspacePath, 'blocker-1', 'Use PostgreSQL');
140+
});
141+
142+
it('displays error when API call fails', async () => {
143+
jest.useRealTimers();
144+
const user = userEvent.setup();
145+
mockAnswer.mockRejectedValueOnce({ detail: 'Blocker already resolved' });
146+
147+
render(
148+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
149+
);
150+
151+
const textarea = screen.getByPlaceholderText('Type your answer...');
152+
await user.type(textarea, 'Some answer');
153+
await user.click(screen.getByRole('button', { name: /answer blocker/i }));
154+
155+
await waitFor(() => {
156+
expect(screen.getByText('Blocker already resolved')).toBeInTheDocument();
157+
});
158+
});
159+
160+
it('hides form when Skip is clicked and shows "Show answer form" button', async () => {
161+
jest.useRealTimers();
162+
const user = userEvent.setup();
163+
164+
render(
165+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
166+
);
167+
168+
await user.click(screen.getByRole('button', { name: /skip/i }));
169+
170+
expect(screen.queryByTestId('blocker-answer-form')).not.toBeInTheDocument();
171+
expect(screen.getByRole('button', { name: /show answer form/i })).toBeInTheDocument();
172+
});
173+
174+
it('re-expands form when "Show answer form" is clicked after Skip', async () => {
175+
jest.useRealTimers();
176+
const user = userEvent.setup();
177+
178+
render(
179+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
180+
);
181+
182+
await user.click(screen.getByRole('button', { name: /skip/i }));
183+
await user.click(screen.getByRole('button', { name: /show answer form/i }));
184+
185+
expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument();
186+
});
187+
188+
it('shows character count', async () => {
189+
jest.useRealTimers();
190+
const user = userEvent.setup();
191+
192+
render(
193+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
194+
);
195+
196+
expect(screen.getByText('0 characters')).toBeInTheDocument();
197+
198+
const textarea = screen.getByPlaceholderText('Type your answer...');
199+
await user.type(textarea, 'Hello');
200+
201+
expect(screen.getByText('5 characters')).toBeInTheDocument();
202+
});
203+
204+
it('has correct data-testid attributes', () => {
205+
render(
206+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
207+
);
208+
209+
expect(screen.getByTestId('blocker-card')).toBeInTheDocument();
210+
expect(screen.getByTestId('blocker-answer-form')).toBeInTheDocument();
211+
});
212+
213+
it('has correct aria-label on textarea', () => {
214+
render(
215+
<BlockerCard blocker={makeBlocker()} workspacePath={workspacePath} onAnswered={onAnswered} />
216+
);
217+
218+
expect(screen.getByLabelText('Your answer to the blocker question')).toBeInTheDocument();
219+
});
220+
});

0 commit comments

Comments
 (0)