mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
feat(web): migrate task execution into Run mode (#1346)
This commit is contained in:
parent
1be8b9de38
commit
035c6a1fcd
13 changed files with 305 additions and 39 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
36
web/src/components/task/RunAccessSection.tsx
Normal file
36
web/src/components/task/RunAccessSection.tsx
Normal 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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
82
web/src/components/task/RunWorkflowPanel.tsx
Normal file
82
web/src/components/task/RunWorkflowPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'])} />
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue