diff --git a/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx
new file mode 100644
index 00000000..9e6dde65
--- /dev/null
+++ b/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { TaskCard } from '@/components/tasks/TaskCard';
+import { STATUS_INFO } from '@/lib/taskStatusInfo';
+import type { Task } from '@/types';
+
+jest.mock('next/link', () => {
+ const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
+ {children}
+ );
+ MockLink.displayName = 'MockLink';
+ return MockLink;
+});
+
+// Radix UI tooltips use portals and pointer events that don't work in jsdom.
+// Replace with a simple always-visible version to test content.
+jest.mock('@/components/ui/tooltip', () => ({
+ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+}));
+
+const baseTask: Task = {
+ id: 'task-1',
+ title: 'Test Task',
+ description: 'A test task description',
+ status: 'BACKLOG',
+ priority: 0,
+ depends_on: [],
+};
+
+const defaultProps = {
+ task: baseTask,
+ selectionMode: false,
+ selected: false,
+ onToggleSelect: jest.fn(),
+ onClick: jest.fn(),
+ onExecute: jest.fn(),
+ onMarkReady: jest.fn(),
+};
+
+describe('TaskCard status badge tooltip', () => {
+ it('renders the status badge label', () => {
+ render( );
+ expect(screen.getByText('Backlog')).toBeInTheDocument();
+ });
+
+ it('renders tooltip with BACKLOG meaning', () => {
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning);
+ });
+
+ it('renders tooltip with BACKLOG next steps', () => {
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.nextSteps);
+ });
+
+ it('renders tooltip with READY meaning', () => {
+ const task = { ...baseTask, status: 'READY' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.READY.meaning);
+ });
+
+ it('renders tooltip with FAILED meaning', () => {
+ const task = { ...baseTask, status: 'FAILED' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning);
+ });
+
+ it('renders tooltip with IN_PROGRESS meaning', () => {
+ const task = { ...baseTask, status: 'IN_PROGRESS' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.IN_PROGRESS.meaning);
+ });
+
+ it('renders tooltip with DONE meaning', () => {
+ const task = { ...baseTask, status: 'DONE' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning);
+ });
+
+ it('renders tooltip with BLOCKED meaning', () => {
+ const task = { ...baseTask, status: 'BLOCKED' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BLOCKED.meaning);
+ });
+
+ it('renders tooltip with MERGED meaning', () => {
+ const task = { ...baseTask, status: 'MERGED' as const };
+ render( );
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.MERGED.meaning);
+ });
+});
diff --git a/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx b/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx
new file mode 100644
index 00000000..bcf60518
--- /dev/null
+++ b/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { TaskDetailModal } from '@/components/tasks/TaskDetailModal';
+import { STATUS_INFO } from '@/lib/taskStatusInfo';
+import type { Task } from '@/types';
+
+// Radix UI tooltips use portals and pointer events that don't work in jsdom.
+jest.mock('@/components/ui/tooltip', () => ({
+ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+// ── Mocks ────────────────────────────────────────────────────────────────
+
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ data: { tasks: [] }, isLoading: false, error: null })),
+}));
+
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn() }),
+}));
+
+jest.mock('next/link', () => {
+ const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
+ {children}
+ );
+ MockLink.displayName = 'MockLink';
+ return MockLink;
+});
+
+jest.mock('@/lib/api', () => ({
+ tasksApi: {
+ getOne: jest.fn(),
+ getAll: jest.fn(),
+ updateStatus: jest.fn(),
+ },
+}));
+
+jest.mock('@/hooks/useRequirementsLookup', () => ({
+ useRequirementsLookup: () => ({ requirementsMap: new Map(), isLoading: false }),
+}));
+
+import { tasksApi } from '@/lib/api';
+
+const makeTask = (overrides: Partial = {}): Task => ({
+ id: 'task-1',
+ title: 'Test Task',
+ description: 'A description',
+ status: 'BACKLOG',
+ priority: 0,
+ depends_on: [],
+ ...overrides,
+});
+
+const defaultProps = {
+ taskId: 'task-1',
+ workspacePath: '/ws',
+ open: true,
+ onClose: jest.fn(),
+ onExecute: jest.fn(),
+ onStatusChange: jest.fn(),
+};
+
+function renderModal(taskOverrides: Partial = {}) {
+ const task = makeTask(taskOverrides);
+ (tasksApi.getOne as jest.Mock).mockResolvedValue(task);
+ return render( );
+}
+
+describe('TaskDetailModal status badge tooltip', () => {
+ it('renders tooltip with BACKLOG meaning', async () => {
+ renderModal({ status: 'BACKLOG' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning);
+ });
+
+ it('renders tooltip with DONE meaning', async () => {
+ renderModal({ status: 'DONE' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning);
+ });
+
+ it('renders tooltip with FAILED meaning', async () => {
+ renderModal({ status: 'FAILED' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning);
+ });
+});
+
+describe('TaskDetailModal valid transition guidance', () => {
+ it('shows "Mark Ready" button for BACKLOG status', async () => {
+ renderModal({ status: 'BACKLOG' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByRole('button', { name: /mark ready/i })).toBeInTheDocument();
+ });
+
+ it('shows "Execute" button for READY status', async () => {
+ renderModal({ status: 'READY' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByRole('button', { name: /execute/i })).toBeInTheDocument();
+ });
+
+ it('shows next-step guidance for DONE status (no action button but guidance visible)', async () => {
+ renderModal({ status: 'DONE' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
+ expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.DONE.nextSteps);
+ });
+
+ it('shows next-step guidance for BLOCKED status', async () => {
+ renderModal({ status: 'BLOCKED' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
+ expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.BLOCKED.nextSteps);
+ });
+
+ it('shows next-step guidance for MERGED status', async () => {
+ renderModal({ status: 'MERGED' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
+ expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.MERGED.nextSteps);
+ });
+
+ it('shows next-step guidance for FAILED status via the alert panel', async () => {
+ renderModal({ status: 'FAILED' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
+ expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.FAILED.nextSteps);
+ });
+
+ it('does not show next-step guidance for BACKLOG (has action button)', async () => {
+ renderModal({ status: 'BACKLOG' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument();
+ });
+
+ it('does not show next-step guidance for READY (has action button)', async () => {
+ renderModal({ status: 'READY' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument();
+ });
+});
+
+describe('TaskDetailModal last changed timestamp', () => {
+ it('shows last changed date when updated_at is present', async () => {
+ renderModal({ status: 'DONE', updated_at: '2026-01-15T10:30:00Z' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.getByText(/last changed/i)).toBeInTheDocument();
+ });
+
+ it('does not show last changed when updated_at is absent', async () => {
+ renderModal({ status: 'BACKLOG' });
+ await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
+ expect(screen.queryByText(/last changed/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx
index 70b6075a..01d72791 100644
--- a/web-ui/src/components/tasks/TaskCard.tsx
+++ b/web-ui/src/components/tasks/TaskCard.tsx
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
+import { STATUS_INFO } from '@/lib/taskStatusInfo';
import type { Task, TaskStatus, ProofRequirement } from '@/types';
/** Map backend TaskStatus to badge variant name. */
@@ -80,6 +81,8 @@ export function TaskCard({
aria-label={`View details for ${task.title}`}
>
+ {/* Single TooltipProvider for the entire card to avoid per-tooltip provider overhead */}
+
{/* Top row: checkbox (if selection mode) + status badge */}
@@ -91,26 +94,30 @@ export function TaskCard({
aria-label={`Select ${task.title}`}
/>
)}
-
- {STATUS_LABEL[task.status]}
-
+
+
+
+ {STATUS_LABEL[task.status]}
+
+
+
+ {STATUS_INFO[task.status].meaning}
+ {STATUS_INFO[task.status].nextSteps}
+
+
{task.depends_on.length > 0 && (
-
-
-
-
-
- {task.depends_on.length}
-
-
-
- Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
-
-
-
+
+
+
+
+ {task.depends_on.length}
+
+
+
+ Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
+
+
)}
@@ -215,6 +222,7 @@ export function TaskCard({
)}
)}
+
);
diff --git a/web-ui/src/components/tasks/TaskDetailModal.tsx b/web-ui/src/components/tasks/TaskDetailModal.tsx
index c64ef7a5..7a6bca62 100644
--- a/web-ui/src/components/tasks/TaskDetailModal.tsx
+++ b/web-ui/src/components/tasks/TaskDetailModal.tsx
@@ -13,6 +13,7 @@ import {
BookOpen01Icon,
Alert02Icon,
CheckListIcon,
+ InformationCircleIcon,
} from '@hugeicons/react';
import {
Dialog,
@@ -22,6 +23,8 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { STATUS_INFO } from '@/lib/taskStatusInfo';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import useSWR from 'swr';
@@ -152,9 +155,19 @@ export function TaskDetailModal({
<>
-
- {STATUS_LABEL[task.status]}
-
+
+
+
+
+ {STATUS_LABEL[task.status]}
+
+
+
+ {STATUS_INFO[task.status].meaning}
+ {STATUS_INFO[task.status].nextSteps}
+
+
+
{task.priority > 0 && (
Priority {task.priority}
@@ -188,6 +201,12 @@ export function TaskDetailModal({
{task.estimated_hours}h estimated
)}
+ {task.updated_at && (
+
+
+ Last changed: {new Date(task.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+
+ )}
{/* Dependencies — full list with status highlights and navigation */}
@@ -263,24 +282,34 @@ export function TaskDetailModal({
)}
- {/* FAILED-state guidance panel */}
+ {/* FAILED-state guidance panel — testid enables status-next-step checks */}
{task.status === 'FAILED' && (
-
+
Task failed during execution
- Check PROOF9 gates to identify which quality requirements need attention.
- {(task.requirement_ids ?? []).length === 0 && (
- <> Use the button below to view all gates.>
- )}
+ {STATUS_INFO.FAILED.nextSteps}
)}
+ {/* Next-step guidance for statuses with no action buttons */}
+ {(task.status === 'DONE' || task.status === 'BLOCKED' || task.status === 'MERGED') && (
+
+
+
+
+
What's next?
+
{STATUS_INFO[task.status].nextSteps}
+
+
+
+ )}
+
{task.status === 'BACKLOG' && (
= {
+ BACKLOG: {
+ meaning: 'Task identified but not yet ready to work on.',
+ enteredWhen: 'Created, or moved back from Ready.',
+ nextSteps: 'Mark Ready when prerequisites are met to enable execution.',
+ },
+ READY: {
+ meaning: 'Task is queued and ready for AI agent execution.',
+ enteredWhen: 'Marked Ready manually or promoted from Backlog.',
+ nextSteps: 'Click Execute to start the AI agent on this task.',
+ },
+ IN_PROGRESS: {
+ meaning: 'AI agent is actively executing this task.',
+ enteredWhen: 'Execution was started.',
+ nextSteps: 'Watch execution output — or Stop to cancel.',
+ },
+ DONE: {
+ meaning: 'Task completed — all verification gates passed.',
+ enteredWhen: 'Agent finished with all quality gates passing.',
+ nextSteps: 'Review changes and create a PR to merge.',
+ },
+ BLOCKED: {
+ meaning: 'Agent needs human input before it can continue.',
+ enteredWhen: 'Agent detected it cannot proceed without an answer.',
+ nextSteps: 'Answer the blocker to resume execution.',
+ },
+ FAILED: {
+ meaning: 'Execution failed due to a technical error.',
+ enteredWhen: 'Agent exceeded retry limit or hit an unrecoverable error.',
+ nextSteps: 'Check PROOF9 gates for details, then Reset to retry.',
+ },
+ MERGED: {
+ meaning: 'Task changes have been merged. No further actions.',
+ enteredWhen: 'PR was merged.',
+ nextSteps: 'This task is complete.',
+ },
+};