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 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 ( + + + + + + 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 ( + + +
+ + + + Launch a reviewed workflow with this task as its execution context. + +
+ {!readOnly && ( + + )} +
+ + + {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' && ( - )} {!readOnly && activeMode === 'run' && activeTab === 'git' && diff --git a/web/src/components/task/WorkflowSection.tsx b/web/src/components/task/WorkflowSection.tsx index cf24fce2..3a0f94f1 100644 --- a/web/src/components/task/WorkflowSection.tsx +++ b/web/src/components/task/WorkflowSection.tsx @@ -317,7 +317,7 @@ export function WorkflowSection({ task, open, onOpenChange }: WorkflowSectionPro {run.id} - {run.status} + Workflow: {run.status} {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',