feat(web): migrate task execution into Run mode (#1346)

This commit is contained in:
Brad Groux 2026-09-02 22:35:17 -05:00 • committed by GitHub
parent 1be8b9de38
commit 035c6a1fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 305 additions and 39 deletions

View file

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { RunAccessSummary } from '@veritas-kanban/shared';
import { renderWithProviders } from './test-utils';
import { createMockTask, renderWithProviders } from './test-utils';
const { mockApplyMutate, mockPreviewMutate, mockUseAgentAccess } = vi.hoisted(() => ({
mockApplyMutate: vi.fn(),
@ -17,6 +17,7 @@ vi.mock('@/hooks/useAgent', () => ({
}));
import { RunAccessPanel } from '@/components/task/RunAccessPanel';
import { RunAccessSection } from '@/components/task/RunAccessSection';
afterEach(cleanup);
@ -26,6 +27,15 @@ beforeEach(() => {
});
describe('RunAccessPanel', () => {
it('keeps access reachable before an attempt creates evidence', () => {
renderWithProviders(<RunAccessSection task={createMockTask({ attempt: undefined })} />);
expect(
screen.getByText('Access evidence becomes available after an execution attempt starts.')
).toBeDefined();
expect(mockUseAgentAccess).not.toHaveBeenCalled();
});
it('renders the shared current contract with blockers and source evidence', async () => {
const user = userEvent.setup();
const current = summaryFixture({
@ -41,7 +51,8 @@ describe('RunAccessPanel', () => {
renderWithProviders(<RunAccessPanel taskId="task-access" attemptId="attempt-access" live />);
const panel = screen.getByLabelText('Run Access');
expect(within(panel).getByText('incomplete')).toBeDefined();
expect(within(panel).getByText('Access: incomplete')).toBeDefined();
expect(within(panel).getByText('Phase: plan')).toBeDefined();
expect(within(panel).getByText('workspace-write · 1 scope')).toBeDefined();
expect(within(panel).getByText('disabled · supported')).toBeDefined();
expect(within(panel).getByLabelText('Run access blockers').textContent).toContain(
@ -49,7 +60,7 @@ describe('RunAccessPanel', () => {
);
expect(mockUseAgentAccess).toHaveBeenCalledWith('task-access', 'attempt-access', true);
await user.click(within(panel).getByRole('button', { name: 'Evidence sources' }));
await user.click(within(panel).getByRole('button', { name: 'Diagnostics: evidence sources' }));
expect(within(panel).getByText(/run-launch-manifest/)).toBeDefined();
expect(within(panel).getByText('verified')).toBeDefined();
});

View file

@ -458,7 +458,8 @@ describe('task detail agent, template, and metrics Mantine migration', () => {
/>
);
expect(screen.queryByText('Running')).toBeNull();
expect(screen.getByText('Attempt: idle')).toBeDefined();
expect(screen.getByText('Transport: connected')).toBeDefined();
expect(screen.queryByRole('button', { name: 'Stop agent' })).toBeNull();
expect(screen.getByText('Agent output will appear here')).toBeDefined();
});
@ -485,7 +486,8 @@ describe('task detail agent, template, and metrics Mantine migration', () => {
/>
);
expect(screen.getByText('Running')).toBeDefined();
expect(screen.getByText('Attempt: running')).toBeDefined();
expect(screen.getByText('Transport: connected')).toBeDefined();
});
it('disables an open stop confirmation when refreshed capability evidence fails', async () => {

View file

@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { GitSection } from '@/components/task/GitSection';
import { RunModeGateSection } from '@/components/task/RunModeGateSection';
import { RunWorkflowPanel } from '@/components/task/RunWorkflowPanel';
import { WorkflowSection } from '@/components/task/WorkflowSection';
import { WorktreeStatus } from '@/components/task/git/WorktreeStatus';
import { createMockTask, renderWithProviders } from './test-utils';
@ -22,6 +23,8 @@ const mocks = vi.hoisted(() => ({
mergeWorktreeMutate: vi.fn(),
createPRMutateAsync: vi.fn(),
toast: vi.fn(),
useActiveRuns: vi.fn(),
useRecentRuns: vi.fn(),
}));
vi.mock('@/hooks/useConfig', () => ({
@ -78,6 +81,11 @@ vi.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock('@/hooks/useWorkflowStats', () => ({
useActiveRuns: mocks.useActiveRuns,
useRecentRuns: mocks.useRecentRuns,
}));
vi.mock('@/components/task/ConflictResolver', () => ({
ConflictResolver: ({ open }: { open: boolean }) => (open ? <div>Conflict resolver</div> : null),
}));
@ -142,6 +150,8 @@ describe('task detail Git and workflow Mantine migration', () => {
data: { hasConflicts: false, conflictingFiles: [], rebaseInProgress: false },
});
mocks.createPRMutateAsync.mockResolvedValue({ url: 'https://github.com/example/pr/1' });
mocks.useActiveRuns.mockReturnValue({ data: [] });
mocks.useRecentRuns.mockReturnValue({ data: [] });
vi.stubGlobal('open', vi.fn());
});
@ -469,6 +479,37 @@ describe('task detail Git and workflow Mantine migration', () => {
expect(window.history.state.veritasTaskDetail).toBe(task.id);
});
it('shows task-owned workflow state in Run and preserves the workflow overlay action', async () => {
const user = userEvent.setup();
const onOpenWorkflow = vi.fn();
const task = createMockTask({ id: 'task-run-workflow', type: 'code' });
mocks.useActiveRuns.mockReturnValue({
data: [
{
id: 'run-blocked',
workflowId: 'release',
workflowVersion: 3,
taskId: task.id,
status: 'blocked',
currentStep: 'Maintainer approval',
startedAt: '2026-09-02T20:00:00.000Z',
steps: [],
},
],
});
renderWithProviders(
<RunWorkflowPanel task={task} readOnly={false} onOpenWorkflow={onOpenWorkflow} />
);
expect(screen.getByText('Workflow: blocked')).toBeDefined();
expect(screen.getByText('release v3')).toBeDefined();
expect(screen.getByText('Current step: Maintainer approval')).toBeDefined();
await user.click(screen.getByRole('button', { name: 'Choose workflow' }));
expect(onOpenWorkflow).toHaveBeenCalledOnce();
});
it('renders run mode and QA gate controls through direct Mantine primitives', async () => {
const user = userEvent.setup();
const onUpdate = vi.fn();

View file

@ -160,6 +160,18 @@ vi.mock('@/components/task/WorkflowSection', () => ({
open ? <div role="dialog" aria-label="Run Workflow" /> : null,
}));
vi.mock('@/components/task/RunWorkflowPanel', () => ({
RunWorkflowPanel: ({ onOpenWorkflow }: { onOpenWorkflow: () => void }) => (
<button type="button" onClick={onOpenWorkflow}>
Choose workflow
</button>
),
}));
vi.mock('@/components/task/RunAccessSection', () => ({
RunAccessSection: () => <div>Run access section</div>,
}));
vi.mock('@/components/evidence/EvidenceTimelinePanel', () => ({
EvidenceTimelinePanel: () => (
<div data-testid="long-evidence-content">
@ -342,7 +354,8 @@ describe('task detail Mantine migration', () => {
renderWithProviders(<TaskDetailPanel task={task} open onOpenChange={mocks.onOpenChange} />);
await user.click(screen.getByRole('button', { name: 'Run' }));
await user.click(screen.getByRole('button', { name: 'Workflow' }));
await user.click(screen.getByRole('tab', { name: 'Workflow' }));
await user.click(screen.getByRole('button', { name: 'Choose workflow' }));
expect(screen.getByRole('dialog', { name: 'Run Workflow' })).toBeDefined();
fireEvent.keyDown(screen.getByTestId('task-detail-panel'), { key: 'Escape' });
@ -351,6 +364,37 @@ describe('task detail Mantine migration', () => {
expect(screen.getByTestId('task-detail-panel')).toBeDefined();
});
it('groups execution destinations under Run without a duplicate global workflow action', async () => {
const user = userEvent.setup();
const task = createMockTask({
id: 'task-run-navigation',
title: 'Monitor a task run',
type: 'code',
status: 'in-progress',
git: {
repo: 'BradGroux/veritas-kanban',
branch: 'task-workspace-run',
baseBranch: 'main',
worktreePath: '/tmp/task-workspace-run',
},
});
renderWithProviders(<TaskDetailPanel task={task} open onOpenChange={mocks.onOpenChange} />);
await user.click(screen.getByRole('button', { name: 'Run' }));
const runSections = screen.getByRole('tablist', { name: 'Run sections' });
expect(
within(runSections)
.getAllByRole('tab')
.map((tab) => tab.textContent)
).toEqual(['Agent', 'Workflow', 'Access', 'Git']);
expect(screen.getByText('Task: in progress')).toBeDefined();
expect(screen.queryByRole('button', { name: 'Workflow' })).toBeNull();
await user.click(within(runSections).getByRole('tab', { name: 'Access' }));
expect(await screen.findByText('Run access section')).toBeDefined();
});
it('keeps title editing and progress tab behavior wired after the migration', async () => {
const { baseElement } = renderTaskDetail();
const detailsTab = screen.getByRole('tab', { name: 'Details' });

View file

@ -65,6 +65,14 @@ const attemptStatusIcons: Record<AttemptStatus, React.ReactNode> = {
failed: <XCircle className="h-3 w-3 text-red-500" />,
};
const attemptStatusColors: Record<AttemptStatus | 'idle', string> = {
idle: 'gray',
pending: 'gray',
running: 'blue',
complete: 'green',
failed: 'red',
};
export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) {
const { data: config } = useConfig();
const {
@ -220,6 +228,9 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) {
// The polled status is authoritative once it settles. While a realtime start
// signal refreshes a stale idle snapshot, preserve the stream's running state.
const isAgentRunning = agentStatus?.running === true || (isAgentStatusFetching && isRunning);
const currentAttemptStatus: AttemptStatus | 'idle' = isAgentRunning
? 'running'
: (task.attempt?.status ?? 'idle');
const canStart = canControlAgent && task.git?.worktreePath && !isAgentRunning;
const stopControl = agentStatus?.controls?.controls.find((control) => control.action === 'stop');
const messageControl = agentStatus?.controls?.controls.find(
@ -271,22 +282,23 @@ export function AgentPanel({ task, onOpenTimeline }: AgentPanelProps) {
<Group justify="space-between">
<Group gap="xs">
<Bot className="h-4 w-4 text-muted-foreground" />
<Text size="sm" c="dimmed">
AI Agent
<Text size="sm" fw={600}>
Agent activity
</Text>
</Group>
<Group gap="xs">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-muted-foreground" />
)}
{isAgentRunning && (
<Text component="span" size="xs" c="green" className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500" />
Running
</Text>
)}
<Group gap="xs" wrap="wrap">
<Badge
color={isConnected ? 'green' : 'gray'}
variant="light"
leftSection={
isConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />
}
>
Transport: {isConnected ? 'connected' : 'disconnected'}
</Badge>
<Badge color={attemptStatusColors[currentAttemptStatus]} variant="light">
Attempt: {currentAttemptStatus}
</Badge>
</Group>
</Group>

View file

@ -126,9 +126,9 @@ export function RunAccessPanel({ taskId, attemptId, live = false }: RunAccessPan
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
<Text fw={700}>Run Access</Text>
<Badge color={STATUS_COLOR[summary.status]} variant="light">
{summary.status}
Access: {summary.status}
</Badge>
<Badge variant="outline">{phaseLabel}</Badge>
<Badge variant="outline">Phase: {phaseLabel}</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Attempt {summary.identity.attemptId} · sequence {summary.version.sequence} ·{' '}
@ -253,7 +253,7 @@ export function RunAccessPanel({ taskId, attemptId, live = false }: RunAccessPan
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="sources">
<Accordion.Control>Evidence sources</Accordion.Control>
<Accordion.Control>Diagnostics: evidence sources</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{summary.sources.map((source) => (

View file

@ -0,0 +1,36 @@
import { Group, Paper, Stack, Text } from '@mantine/core';
import { ShieldCheck } from 'lucide-react';
import type { Task } from '@veritas-kanban/shared';
import { RunAccessPanel } from './RunAccessPanel';
interface RunAccessSectionProps {
task: Task;
}
export function RunAccessSection({ task }: RunAccessSectionProps) {
if (!task.attempt?.id) {
return (
<Stack gap="sm">
<Group gap="xs">
<ShieldCheck className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Text size="sm" fw={600}>
Run access
</Text>
</Group>
<Paper withBorder p="md" radius="md">
<Text size="sm" c="dimmed">
Access evidence becomes available after an execution attempt starts.
</Text>
</Paper>
</Stack>
);
}
return (
<RunAccessPanel
taskId={task.id}
attemptId={task.attempt.id}
live={task.attempt.status === 'running' || task.attempt.status === 'pending'}
/>
);
}

View file

@ -0,0 +1,82 @@
import { Badge, Button, Group, Paper, Stack, Text } from '@mantine/core';
import { Play, Workflow } from 'lucide-react';
import type { Task } from '@veritas-kanban/shared';
import { useActiveRuns, useRecentRuns, type WorkflowRun } from '@/hooks/useWorkflowStats';
interface RunWorkflowPanelProps {
task: Task;
readOnly: boolean;
onOpenWorkflow: () => void;
}
const STATUS_COLOR: Record<WorkflowRun['status'], string> = {
pending: 'gray',
running: 'blue',
blocked: 'yellow',
completed: 'green',
failed: 'red',
};
export function RunWorkflowPanel({ task, readOnly, onOpenWorkflow }: RunWorkflowPanelProps) {
const { data: activeRuns = [] } = useActiveRuns();
const { data: recentRuns = [] } = useRecentRuns();
const taskRuns = [...activeRuns, ...recentRuns]
.filter((run) => run.taskId === task.id)
.filter((run, index, runs) => runs.findIndex((candidate) => candidate.id === run.id) === index)
.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt));
const currentRun = taskRuns[0];
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start" gap="sm" wrap="wrap">
<div>
<Group gap="xs">
<Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Text size="sm" fw={600}>
Workflow execution
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Launch a reviewed workflow with this task as its execution context.
</Text>
</div>
{!readOnly && (
<Button
size="compact-sm"
onClick={onOpenWorkflow}
leftSection={<Play className="h-3.5 w-3.5" aria-hidden="true" />}
>
Choose workflow
</Button>
)}
</Group>
<Paper withBorder p="md" radius="md">
{currentRun ? (
<Stack gap={6}>
<Group gap="xs" wrap="wrap">
<Badge color={STATUS_COLOR[currentRun.status]} variant="light">
Workflow: {currentRun.status}
</Badge>
<Badge variant="outline" className="font-mono">
{currentRun.id}
</Badge>
</Group>
<Text size="sm" fw={500}>
{currentRun.workflowId} v{currentRun.workflowVersion}
</Text>
{currentRun.currentStep && (
<Text size="xs" c="dimmed">
Current step: {currentRun.currentStep}
</Text>
)}
</Stack>
) : (
<Text size="sm" c="dimmed">
No workflow run is associated with this task.
</Text>
)}
</Paper>
</Stack>
);
}

View file

@ -33,7 +33,6 @@ import {
MessageSquare,
Monitor,
PlayCircle,
Workflow,
X,
type LucideIcon,
} from 'lucide-react';
@ -149,8 +148,12 @@ export function TaskDetailPanel({
const activeMode = getTaskWorkspaceDestination(activeTab).mode;
const activeModeMetadata = workspaceModes.find((mode) => mode.id === activeMode);
const activeModeTabs = useMemo(
() => visibleTabs.filter((tab) => getTaskWorkspaceDestination(tab.id).mode === activeMode),
[activeMode, visibleTabs]
() =>
activeModeMetadata?.sections.flatMap((section) => {
const tab = visibleTabs.find((candidate) => candidate.id === section);
return tab ? [tab] : [];
}) ?? [],
[activeModeMetadata, visibleTabs]
);
const activeTabMetadata = visibleTabs.find((tab) => tab.id === activeTab);
@ -411,6 +414,11 @@ export function TaskDetailPanel({
</Text>
</div>
<Group gap="xs" wrap="wrap">
{activeMode === 'run' && (
<Badge variant="light" color="gray" tt="capitalize">
Task: {localTask.status.replaceAll('-', ' ')}
</Badge>
)}
{!readOnly && activeMode === 'plan' && (
<Button
variant="outline"
@ -421,16 +429,6 @@ export function TaskDetailPanel({
Template
</Button>
)}
{!readOnly && activeMode === 'run' && (
<Button
variant="outline"
size="compact-sm"
onClick={() => setWorkflowOpen(true)}
leftSection={<Workflow className="h-3 w-3" />}
>
Workflow
</Button>
)}
{!readOnly &&
activeMode === 'run' &&
activeTab === 'git' &&

View file

@ -317,7 +317,7 @@ export function WorkflowSection({ task, open, onOpenChange }: WorkflowSectionPro
{run.id}
</Badge>
<Badge variant="light" color={getRunStatusColor(run.status)}>
{run.status}
Workflow: {run.status}
</Badge>
</Group>
{run.currentStep && (

View file

@ -12,6 +12,8 @@ import {
Network,
NotebookPen,
Paperclip,
ShieldCheck,
Workflow,
type LucideIcon,
} from 'lucide-react';
import type { ObservationType, ReviewComment, ReviewState, Task } from '@veritas-kanban/shared';
@ -62,6 +64,12 @@ const ProgressTab = lazy(() =>
const ReviewPanel = lazy(() =>
import('./ReviewPanel').then((mod) => ({ default: mod.ReviewPanel }))
);
const RunAccessSection = lazy(() =>
import('./RunAccessSection').then((mod) => ({ default: mod.RunAccessSection }))
);
const RunWorkflowPanel = lazy(() =>
import('./RunWorkflowPanel').then((mod) => ({ default: mod.RunWorkflowPanel }))
);
const TaskMetricsPanel = lazy(() =>
import('./TaskMetricsPanel').then((mod) => ({ default: mod.TaskMetricsPanel }))
);
@ -131,6 +139,8 @@ const TAB_ICONS: Record<TaskDetailTabIcon, LucideIcon> = {
Network,
NotebookPen,
Paperclip,
ShieldCheck,
Workflow,
};
const TAB_RENDERERS: Record<TaskDetailTabId, (context: TaskDetailRenderContext) => ReactNode> = {
@ -169,6 +179,10 @@ const TAB_RENDERERS: Record<TaskDetailTabId, (context: TaskDetailRenderContext)
/>
),
attachments: ({ task }) => <AttachmentsSection task={task} />,
workflow: ({ task, readOnly, openWorkflow }) => (
<RunWorkflowPanel task={task} readOnly={readOnly} onOpenWorkflow={openWorkflow} />
),
access: ({ task }) => <RunAccessSection task={task} />,
git: ({ task, updateField }) => (
<GitSection task={task} onGitChange={(git) => updateField('git', git as Task['git'])} />
),

View file

@ -20,6 +20,8 @@ describe('task workspace navigation', () => {
'work-products': 'results',
observations: 'plan',
attachments: 'plan',
workflow: 'run',
access: 'run',
git: 'run',
agent: 'run',
timeline: 'history',
@ -74,6 +76,12 @@ describe('task workspace navigation', () => {
});
expect(resolveTaskDetailNavigationTab({ tab: 'timeline' }, tabs)).toBe('timeline');
for (const section of ['agent', 'workflow', 'access', 'git'] as const) {
expect(resolveTaskDetailNavigationTab({ tab: section }, tabs)).toBe(section);
expect(
resolveTaskDetailNavigationTab({ workspace: { version: 1, mode: 'run', section } }, tabs)
).toBe(section);
}
for (const section of [
'details',
'progress',

View file

@ -6,6 +6,8 @@ export type TaskDetailTabId =
| 'work-products'
| 'observations'
| 'attachments'
| 'workflow'
| 'access'
| 'git'
| 'agent'
| 'timeline'
@ -48,7 +50,9 @@ export type TaskDetailTabIcon =
| 'History'
| 'Network'
| 'NotebookPen'
| 'Paperclip';
| 'Paperclip'
| 'ShieldCheck'
| 'Workflow';
export interface TaskDetailTabMetadata {
id: TaskDetailTabId;
@ -94,7 +98,7 @@ export const TASK_WORKSPACE_MODE_METADATA: readonly TaskWorkspaceModeMetadata[]
id: 'run',
label: 'Run',
description: 'Agent session, workflow controls, and source context.',
sections: ['git', 'agent'],
sections: ['agent', 'workflow', 'access', 'git'],
},
{
id: 'results',
@ -160,6 +164,20 @@ export const TASK_DETAIL_TAB_METADATA: readonly TaskDetailTabMetadata[] = [
fallbackTitle: 'Attachments section failed to load',
isVisible: ({ attachmentsEnabled }) => attachmentsEnabled,
},
{
id: 'workflow',
label: 'Workflow',
icon: 'Workflow',
fallbackTitle: 'Workflow section failed to load',
isVisible: ({ isCodeTask }) => isCodeTask,
},
{
id: 'access',
label: 'Access',
icon: 'ShieldCheck',
fallbackTitle: 'Run access failed to load',
isVisible: ({ isCodeTask }) => isCodeTask,
},
{
id: 'git',
label: 'Git',