From d90f67579e1598a2c17b135d8cf6554adabee769 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:22:32 +0000 Subject: [PATCH 01/23] feat(studio): blocked tasks tell their story and offer a next step A blocked card rendered only task.error, the last line of a longer story. The expanded card now shows the attempts timeline (attempt_log), the review verdict, the structured criteria checklist, and a next-step row: Send back to Ready, Retry (POST /api/tasks/{id}/retry, falling back to the Ready move on a server without the endpoint), Reassign team (opens the edit form on the team control) and Steer (prefills LiveFeedback with @task:ID, parking the request across navigation when the composer is not mounted). LiveTaskPanel's Blocked/Failed counters are now filters over the list, every attention row carries Retry, and a focusTaskId prop expands, scrolls to and flashes one task. Also: additive Task/QuerySession/PendingChange fields, a shared window-event contract (ui/events.ts), an ErrorState primitive, a roving tabindex helper, a confetti layer, and Modal's focus trap extracted into useFocusTrap so other dialogs can reuse it. --- web/src/api/client.ts | 7 + web/src/components/Board/TaskCard.test.tsx | 133 +++++++++ web/src/components/Board/TaskCard.tsx | 75 ++++- web/src/components/Board/TaskStory.tsx | 263 ++++++++++++++++++ web/src/components/Live/LiveFeedback.tsx | 28 +- .../components/Live/LiveTaskPanel.test.tsx | 118 ++++++++ web/src/components/Live/LiveTaskPanel.tsx | 146 +++++++++- web/src/components/ui/Confetti.tsx | 73 +++++ web/src/components/ui/ErrorState.tsx | 50 ++++ web/src/components/ui/Modal.tsx | 88 ++++-- web/src/components/ui/events.ts | 92 ++++++ web/src/components/ui/useRovingTabs.ts | 83 ++++++ web/src/styles/globals.css | 25 ++ web/src/types/index.ts | 35 +++ 14 files changed, 1166 insertions(+), 50 deletions(-) create mode 100644 web/src/components/Board/TaskCard.test.tsx create mode 100644 web/src/components/Board/TaskStory.tsx create mode 100644 web/src/components/Live/LiveTaskPanel.test.tsx create mode 100644 web/src/components/ui/Confetti.tsx create mode 100644 web/src/components/ui/ErrorState.tsx create mode 100644 web/src/components/ui/events.ts create mode 100644 web/src/components/ui/useRovingTabs.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d97d1b3..1fddee0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -864,3 +864,10 @@ export async function createTeamManager( body: JSON.stringify(opts ?? {}), }); } + +// ── Task retry ── +// POST /api/tasks/{id}/retry → the task goes back to the queue with its +// attempt log intact. 409 when no board is loaded, 404 for an unknown id. +export async function retryTask(id: string): Promise { + return request(`/tasks/${encodeURIComponent(id)}/retry`, { method: 'POST' }); +} diff --git a/web/src/components/Board/TaskCard.test.tsx b/web/src/components/Board/TaskCard.test.tsx new file mode 100644 index 0000000..911ccec --- /dev/null +++ b/web/src/components/Board/TaskCard.test.tsx @@ -0,0 +1,133 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import TaskCard from './TaskCard'; +import type { Task } from '@/types'; + +const retryTask = vi.fn(async (id: string) => ({ id }) as unknown as Task); +const patchTask = vi.fn(async (id: string, patch: Partial) => ({ id, ...patch }) as Task); + +vi.mock('@/api/client', () => { + class ApiError extends Error { + status: number; + constructor(status: number, body: string) { + super(body); + this.status = status; + } + get isConflict() { + return this.status === 409; + } + } + return { + ApiError, + errorText: (e: unknown) => (e instanceof Error ? e.message : String(e)), + retryTask: (...a: unknown[]) => retryTask(...(a as [string])), + patchTask: (...a: unknown[]) => patchTask(...(a as [string, Partial])), + deleteTask: vi.fn(async () => ({ ok: 'true' })), + }; +}); + +const stuck: Task = { + id: 'T3', + title: 'Wire the retry endpoint', + description: '', + role: 'worker', + assignee: '', + column: 'blocked', + status: 'blocked', + priority: 0, + depends_on: [], + files: ['pkg/server/tasks.go'], + acceptance: '', + checklist: [], + output: '', + review: 'Reviewer: the handler returns 200 on an unknown id.', + retries: 2, + error: 'attempt 2 failed because go test ./pkg/server timed out', + updated_at: '', + notes: '', + attempt_log: [ + 'attempt 1 failed because handler not registered on the mux', + 'attempt 2 failed because go test ./pkg/server timed out', + ], + gate_retries: 1, + criteria: [ + { id: 'AC1', text: 'POST /api/tasks/{id}/retry returns 404 for an unknown id', met: false }, + { id: 'AC2', text: 'Attempt log survives the retry', priority: 'should' }, + ], +}; + +function renderCard(task: Task, onUpdate = vi.fn()) { + render( + + + , + ); + return onUpdate; +} + +describe('TaskCard — the story of a stuck task', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the attempts timeline, the verdict and the criteria once expanded', async () => { + renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + + const timeline = screen.getByTestId('attempts-timeline'); + expect(timeline).toHaveTextContent('Attempts'); + expect(timeline).toHaveTextContent('2 failed'); + expect(timeline).toHaveTextContent('1 gate retry'); + // Each attempt is numbered and its reason is shown without the boilerplate. + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('handler not registered on the mux')).toBeInTheDocument(); + expect(screen.getByText('go test ./pkg/server timed out')).toBeInTheDocument(); + + expect(screen.getByText('Review verdict')).toBeInTheDocument(); + expect(screen.getByText(/returns 200 on an unknown id/)).toBeInTheDocument(); + + expect(screen.getByText('Criteria')).toBeInTheDocument(); + expect(screen.getByText(/returns 404 for an unknown id/)).toBeInTheDocument(); + expect(screen.getByText('not met:')).toBeInTheDocument(); + expect(screen.getByText('not yet checked:')).toBeInTheDocument(); + }); + + it('offers the next step: Retry calls the endpoint and refreshes the board', async () => { + const onUpdate = renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + + const group = screen.getByRole('group', { name: 'Next steps for T3' }); + expect(group).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Retry$/ })); + + await waitFor(() => expect(retryTask).toHaveBeenCalledWith('T3')); + expect(onUpdate).toHaveBeenCalled(); + }); + + it('sends the task back to Ready through the board patch', async () => { + const onUpdate = renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + await userEvent.click(screen.getByRole('button', { name: /Send back to Ready/ })); + await waitFor(() => + expect(patchTask).toHaveBeenCalledWith('T3', expect.objectContaining({ column: 'ready_to_dev' })), + ); + expect(onUpdate).toHaveBeenCalled(); + }); + + it('opens the edit form on the team control for Reassign', async () => { + renderCard(stuck); + await userEvent.click(screen.getByTitle('Show full task details')); + await userEvent.click(screen.getByRole('button', { name: /Reassign team/ })); + const select = await screen.findByLabelText('Team of T3'); + await waitFor(() => expect(select).toHaveFocus()); + }); + + it('keeps the action row off a healthy task', async () => { + renderCard({ ...stuck, column: 'done', status: 'done', attempt_log: [], error: '' }); + await userEvent.click(screen.getByTitle('Show full task details')); + expect(screen.queryByRole('group', { name: /Next steps/ })).not.toBeInTheDocument(); + expect(screen.queryByTestId('attempts-timeline')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Board/TaskCard.tsx b/web/src/components/Board/TaskCard.tsx index 82ec3d7..fdd9592 100644 --- a/web/src/components/Board/TaskCard.tsx +++ b/web/src/components/Board/TaskCard.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { patchTask, deleteTask } from '@/api/client'; import { teamColor } from './teamColor'; +import { AttemptsTimeline, CriteriaChecklist, ReviewVerdict, TaskActionRow, isStuck } from './TaskStory'; import type { Task } from '@/types'; import clsx from 'clsx'; import { useConfirm } from '@/components/ui/Modal'; @@ -49,9 +50,28 @@ interface TaskDraft { notes: string; } +// Colour plus a word: the ring around a failed card and a lone red triangle +// read as decoration to anyone who cannot see red, so each state also says +// its name. const STATUS_ICON: Record = { - done: , - failed: , + done: ( + + + ), + failed: ( + + + ), + blocked: ( + + + ), }; const STATUS_OPTIONS = ['todo', 'scoped', 'ready', 'running', 'review', 'correcting', 'blocked', 'failed', 'done']; @@ -64,6 +84,10 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp const [saving, setSaving] = useState(false); const [draft, setDraft] = useState(() => taskToDraft(task)); const titleFieldRef = useRef(null); + const teamFieldRef = useRef(null); + // "Reassign team" opens the edit form on the team control rather than the + // title. A ref, not state: the flag must not re-run the focus effect. + const focusTeamRef = useRef(false); const ticket = ticketInfo(task); useEffect(() => { @@ -71,11 +95,22 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp }, [task]); useEffect(() => { - if (editing) { - titleFieldRef.current?.focus(); + if (!editing) return; + const wantTeam = focusTeamRef.current; + focusTeamRef.current = false; + if (wantTeam && teamFieldRef.current) { + teamFieldRef.current.focus(); + return; } + titleFieldRef.current?.focus(); }, [editing]); + const handleReassign = () => { + focusTeamRef.current = true; + setEditing(true); + setExpanded(true); + }; + const { attributes, listeners, @@ -354,6 +389,7 @@ export default function TaskCard({ task, columns, columnLabels, teams = [], onUp