diff --git a/web/src/__tests__/run-access-panel.test.tsx b/web/src/__tests__/run-access-panel.test.tsx
index af90c2e6..95a9b59e 100644
--- a/web/src/__tests__/run-access-panel.test.tsx
+++ b/web/src/__tests__/run-access-panel.test.tsx
@@ -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();
+
+ 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();
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();
});
diff --git a/web/src/__tests__/task-detail-agent-template-metrics-mantine.test.tsx b/web/src/__tests__/task-detail-agent-template-metrics-mantine.test.tsx
index b04cf8de..754a9182 100644
--- a/web/src/__tests__/task-detail-agent-template-metrics-mantine.test.tsx
+++ b/web/src/__tests__/task-detail-agent-template-metrics-mantine.test.tsx
@@ -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 () => {
diff --git a/web/src/__tests__/task-detail-git-workflow-mantine.test.tsx b/web/src/__tests__/task-detail-git-workflow-mantine.test.tsx
index 05e47adc..93917705 100644
--- a/web/src/__tests__/task-detail-git-workflow-mantine.test.tsx
+++ b/web/src/__tests__/task-detail-git-workflow-mantine.test.tsx
@@ -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 ?
Conflict resolver
: 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(
+
+ );
+
+ 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();
diff --git a/web/src/__tests__/task-detail-mantine.test.tsx b/web/src/__tests__/task-detail-mantine.test.tsx
index ebf7502d..ea0638d1 100644
--- a/web/src/__tests__/task-detail-mantine.test.tsx
+++ b/web/src/__tests__/task-detail-mantine.test.tsx
@@ -160,6 +160,18 @@ vi.mock('@/components/task/WorkflowSection', () => ({
open ? : null,
}));
+vi.mock('@/components/task/RunWorkflowPanel', () => ({
+ RunWorkflowPanel: ({ onOpenWorkflow }: { onOpenWorkflow: () => void }) => (
+
+ ),
+}));
+
+vi.mock('@/components/task/RunAccessSection', () => ({
+ RunAccessSection: () => Run access section
,
+}));
+
vi.mock('@/components/evidence/EvidenceTimelinePanel', () => ({
EvidenceTimelinePanel: () => (
@@ -342,7 +354,8 @@ describe('task detail Mantine migration', () => {
renderWithProviders(
);
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(
);
+
+ 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' });
diff --git a/web/src/components/task/AgentPanel.tsx b/web/src/components/task/AgentPanel.tsx
index 60ae85d8..18dc3f6e 100644
--- a/web/src/components/task/AgentPanel.tsx
+++ b/web/src/components/task/AgentPanel.tsx
@@ -65,6 +65,14 @@ const attemptStatusIcons: Record
= {
failed: ,
};
+const attemptStatusColors: Record = {
+ 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) {
-
- AI Agent
+
+ Agent activity
-
- {isConnected ? (
-
- ) : (
-
- )}
- {isAgentRunning && (
-
-
- Running
-
- )}
+
+ :
+ }
+ >
+ Transport: {isConnected ? 'connected' : 'disconnected'}
+
+
+ Attempt: {currentAttemptStatus}
+
diff --git a/web/src/components/task/RunAccessPanel.tsx b/web/src/components/task/RunAccessPanel.tsx
index 24318099..9c87c907 100644
--- a/web/src/components/task/RunAccessPanel.tsx
+++ b/web/src/components/task/RunAccessPanel.tsx
@@ -126,9 +126,9 @@ export function RunAccessPanel({ taskId, attemptId, live = false }: RunAccessPan
Run Access
- {summary.status}
+ Access: {summary.status}
- {phaseLabel}
+ Phase: {phaseLabel}
Attempt {summary.identity.attemptId} · sequence {summary.version.sequence} ·{' '}
@@ -253,7 +253,7 @@ export function RunAccessPanel({ taskId, attemptId, live = false }: RunAccessPan
- Evidence sources
+ Diagnostics: evidence sources
{summary.sources.map((source) => (
diff --git a/web/src/components/task/RunAccessSection.tsx b/web/src/components/task/RunAccessSection.tsx
new file mode 100644
index 00000000..f96733da
--- /dev/null
+++ b/web/src/components/task/RunAccessSection.tsx
@@ -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 (
+
+
+
+
+ Run access
+
+
+
+
+ Access evidence becomes available after an execution attempt starts.
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/components/task/RunWorkflowPanel.tsx b/web/src/components/task/RunWorkflowPanel.tsx
new file mode 100644
index 00000000..1d9a07aa
--- /dev/null
+++ b/web/src/components/task/RunWorkflowPanel.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+ Workflow execution
+
+
+
+ Launch a reviewed workflow with this task as its execution context.
+
+
+ {!readOnly && (
+ }
+ >
+ Choose workflow
+
+ )}
+
+
+
+ {currentRun ? (
+
+
+
+ Workflow: {currentRun.status}
+
+
+ {currentRun.id}
+
+
+
+ {currentRun.workflowId} v{currentRun.workflowVersion}
+
+ {currentRun.currentStep && (
+
+ Current step: {currentRun.currentStep}
+
+ )}
+
+ ) : (
+
+ No workflow run is associated with this task.
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx
index 36240c86..9b7641e4 100644
--- a/web/src/components/task/TaskDetailPanel.tsx
+++ b/web/src/components/task/TaskDetailPanel.tsx
@@ -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({
+ {activeMode === 'run' && (
+
+ Task: {localTask.status.replaceAll('-', ' ')}
+
+ )}
{!readOnly && activeMode === 'plan' && (
{run.currentStep && (
diff --git a/web/src/components/task/task-detail-tabs.tsx b/web/src/components/task/task-detail-tabs.tsx
index d741a099..3522ee67 100644
--- a/web/src/components/task/task-detail-tabs.tsx
+++ b/web/src/components/task/task-detail-tabs.tsx
@@ -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 = {
Network,
NotebookPen,
Paperclip,
+ ShieldCheck,
+ Workflow,
};
const TAB_RENDERERS: Record ReactNode> = {
@@ -169,6 +179,10 @@ const TAB_RENDERERS: Record
),
attachments: ({ task }) => ,
+ workflow: ({ task, readOnly, openWorkflow }) => (
+
+ ),
+ access: ({ task }) => ,
git: ({ task, updateField }) => (
updateField('git', git as Task['git'])} />
),
diff --git a/web/src/lib/__tests__/task-detail-workspace.test.ts b/web/src/lib/__tests__/task-detail-workspace.test.ts
index 832fda6f..3048f227 100644
--- a/web/src/lib/__tests__/task-detail-workspace.test.ts
+++ b/web/src/lib/__tests__/task-detail-workspace.test.ts
@@ -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',
diff --git a/web/src/lib/task-detail-tabs.ts b/web/src/lib/task-detail-tabs.ts
index 1ee53064..44e48c58 100644
--- a/web/src/lib/task-detail-tabs.ts
+++ b/web/src/lib/task-detail-tabs.ts
@@ -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',