feat(F.14): Unified Task System — F-TASK-MODEL/API/AGENT/WORK/UI/MIG/GOAL/TEST
Check Cross-Plugin Imports / check (push) Has been cancelled

- F-TASK-MODEL: Extended Task model with polymorphic assignee/entity/creator, subtasks, dependencies, task_type, success_criteria, progress
- F-TASK-API: Extended task routes with polymorphic filters, subtasks, dependencies, new lifecycle
- F-TASK-AGENT: ai_tools.py (191 lines) — create_task, assign_task, update_task_status, decompose_goal tools
- F-TASK-WORK: workstream.py — task_card and goal_card blocks in communication system
- F-TASK-UI: TaskBoard.tsx, TaskDetail.tsx, GoalView.tsx frontend components
- F-TASK-MIG: Migration 0124 — new columns, data migration for contact_id/assigned_to
- F-TASK-GOAL: Progress aggregation, success criteria evaluation, parent status propagation
- F-TASK-TEST: test_unified_tasks.py (414 lines)
- i18n updates for task system
This commit is contained in:
Agent Zero
2026-08-17 18:51:22 +02:00
parent 06b281ba74
commit a53dcc38d5
9 changed files with 1397 additions and 12 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* GoalView — goal overview with progress bar and milestone hierarchy.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTask, useListSubtasks, type Task, type TaskStatus } from '@/api/tasks';
import { Loader2, Target, Flag } from 'lucide-react';
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
interface GoalViewProps {
goalId: string;
onSelectTask?: (task: Task) => void;
}
export function GoalView({ goalId, onSelectTask }: GoalViewProps) {
const { t } = useTranslation();
const { data: goal, isLoading } = useTask(goalId);
const { data: children } = useListSubtasks(goalId);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
if (!goal) {
return (
<Card title={t('tasks.title')}>
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
</Card>
);
}
const progress = goal.progress ?? 0;
const milestones = (children ?? []).filter((c) => c.task_type === 'milestone');
const todos = (children ?? []).filter((c) => c.task_type !== 'milestone');
return (
<Card title={goal.title}>
<div className="space-y-4">
{goal.description ? <p className="text-sm text-gray-700">{goal.description}</p> : null}
<div className="flex items-center gap-2">
<Badge variant={STATUS_VARIANTS[goal.status]}>{statusLabel(t, goal.status)}</Badge>
<Badge variant="primary">{progress}%</Badge>
{goal.target_date ? (
<Badge variant="info">
{t('tasks.targetDate')}: {new Date(goal.target_date).toLocaleDateString()}
</Badge>
) : null}
</div>
{/* Progress bar */}
<div>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span className="inline-flex items-center gap-1">
<Target className="h-3 w-3" aria-hidden="true" />
{t('tasks.progress')}
</span>
<span>{progress}%</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-gray-200"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Milestones */}
{milestones.length > 0 ? (
<div>
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
<Flag className="h-4 w-4" aria-hidden="true" />
{t('tasks.milestones')}
</h4>
<div className="space-y-1">
{milestones.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelectTask?.(m)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{m.title}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-500">{m.progress ?? 0}%</span>
<Badge variant={STATUS_VARIANTS[m.status]}>{statusLabel(t, m.status)}</Badge>
</span>
</button>
))}
</div>
</div>
) : null}
{/* Todos */}
{todos.length > 0 ? (
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
<div className="space-y-1">
{todos.map((todo) => (
<button
key={todo.id}
type="button"
onClick={() => onSelectTask?.(todo)}
className="flex w-full items-center justify-between rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50"
>
<span className="text-sm text-gray-800">{todo.title}</span>
<Badge variant={STATUS_VARIANTS[todo.status]}>{statusLabel(t, todo.status)}</Badge>
</button>
))}
</div>
</div>
) : null}
</div>
</Card>
);
}
export default GoalView;
+147
View File
@@ -0,0 +1,147 @@
/**
* TaskBoard — Kanban view with columns by lifecycle status.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { useTasks, type Task, type TaskStatus } from '@/api/tasks';
import { Clock, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
const STATUS_COLUMNS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
const PRIORITY_VARIANTS: Record<string, 'secondary' | 'info' | 'warning' | 'danger'> = {
low: 'secondary',
medium: 'info',
high: 'warning',
urgent: 'danger',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
function isOverdue(dateStr: string | null, status: string): boolean {
if (!dateStr || status === 'done' || status === 'cancelled') return false;
try {
return new Date(dateStr) < new Date();
} catch {
return false;
}
}
interface TaskCardProps {
task: Task;
onSelect: (task: Task) => void;
}
function TaskCard({ task, onSelect }: TaskCardProps) {
const { t } = useTranslation();
const overdue = isOverdue(task.due_date, task.status);
return (
<button
type="button"
onClick={() => onSelect(task)}
className="w-full text-left rounded-lg border border-gray-200 bg-white p-3 shadow-sm hover:shadow-md transition-shadow focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label={task.title}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-gray-900 line-clamp-2">{task.title}</span>
<Badge variant={PRIORITY_VARIANTS[task.priority] ?? 'secondary'}>{t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)}</Badge>
</div>
{task.description ? (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{task.description}</p>
) : null}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
{task.due_date ? (
<span className={`inline-flex items-center gap-1 ${overdue ? 'text-danger-600' : ''}`}>
<Clock className="h-3 w-3" aria-hidden="true" />
{new Date(task.due_date).toLocaleDateString()}
</span>
) : null}
{task.task_type !== 'todo' ? (
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
) : null}
{task.progress > 0 ? (
<span className="inline-flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{task.progress}%
</span>
) : null}
</div>
</button>
);
}
interface TaskBoardProps {
filter?: {
entity_type?: string;
entity_id?: string;
assignee_type?: string;
assignee_id?: string;
parent_task_id?: string;
task_type?: string;
};
onSelectTask?: (task: Task) => void;
}
export function TaskBoard({ filter, onSelectTask }: TaskBoardProps) {
const { t } = useTranslation();
const { data, isLoading } = useTasks(1, 200, filter);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
const tasks = data?.items ?? [];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{STATUS_COLUMNS.map((status) => {
const columnTasks = tasks.filter((task) => task.status === status);
return (
<div key={status} className="flex flex-col rounded-lg bg-gray-50 p-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">{statusLabel(t, status)}</h3>
<Badge variant={STATUS_VARIANTS[status]}>{columnTasks.length}</Badge>
</div>
<div className="flex flex-col gap-2">
{columnTasks.length === 0 ? (
<p className="text-xs text-gray-400">{t('tasks.noTasks')}</p>
) : (
columnTasks.map((task) => (
<TaskCard key={task.id} task={task} onSelect={onSelectTask ?? (() => {})} />
))
)}
</div>
</div>
);
})}
</div>
);
}
export default TaskBoard;
@@ -0,0 +1,287 @@
/**
* TaskDetail — detail view with subtasks, dependencies, assignee dropdown.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Card } from '@/components/ui/Card';
import { useToast } from '@/components/ui/Toast';
import {
useTask,
useListSubtasks,
useCreateSubtask,
useUpdateTaskStatus,
useAssignTask,
useAddDependency,
useRemoveDependency,
type Task,
type TaskStatus,
type AssigneeType,
} from '@/api/tasks';
import { Loader2, Plus, X, Link2, CheckCircle2 } from 'lucide-react';
const STATUS_OPTIONS: TaskStatus[] = ['open', 'in_progress', 'review', 'blocked', 'done', 'cancelled'];
const ASSIGNEE_TYPES: AssigneeType[] = ['user', 'agent', 'group'];
const STATUS_VARIANTS: Record<TaskStatus, 'secondary' | 'info' | 'warning' | 'success' | 'danger'> = {
open: 'secondary',
in_progress: 'info',
review: 'warning',
blocked: 'danger',
done: 'success',
cancelled: 'secondary',
};
function statusLabel(t: (k: string) => string, status: TaskStatus): string {
const map: Record<TaskStatus, string> = {
open: t('tasks.statusOpen'),
in_progress: t('tasks.statusInProgress'),
review: t('tasks.statusReview'),
blocked: t('tasks.statusBlocked'),
done: t('tasks.statusDone'),
cancelled: t('tasks.statusCancelled'),
};
return map[status];
}
interface TaskDetailProps {
taskId: string;
onClose?: () => void;
}
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
const { t } = useTranslation();
const toast = useToast();
const { data: task, isLoading } = useTask(taskId);
const { data: subtasks } = useListSubtasks(taskId);
const [subtaskTitle, setSubtaskTitle] = useState('');
const [depId, setDepId] = useState('');
const [assigneeType, setAssigneeType] = useState<AssigneeType>('user');
const [assigneeId, setAssigneeId] = useState('');
const statusMutation = useUpdateTaskStatus();
const assignMutation = useAssignTask();
const createSubtaskMutation = useCreateSubtask();
const addDepMutation = useAddDependency();
const removeDepMutation = useRemoveDependency();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
}
if (!task) {
return (
<Card title={t('tasks.title')}>
<p className="text-sm text-gray-500">{t('tasks.noTasks')}</p>
</Card>
);
}
const handleStatusChange = (status: TaskStatus) => {
statusMutation.mutate(
{ id: task.id, status },
{
onSuccess: () => toast.success(t('tasks.statusUpdated')),
onError: () => toast.error(t('common.error')),
},
);
};
const handleAssign = () => {
if (!assigneeId) return;
assignMutation.mutate(
{ id: task.id, assigneeType, assigneeId },
{
onSuccess: () => {
toast.success(t('tasks.updated'));
setAssigneeId('');
},
onError: () => toast.error(t('common.error')),
},
);
};
const handleCreateSubtask = () => {
if (!subtaskTitle.trim()) return;
createSubtaskMutation.mutate(
{ parentId: task.id, data: { title: subtaskTitle.trim() } },
{
onSuccess: () => {
toast.success(t('tasks.created'));
setSubtaskTitle('');
},
onError: () => toast.error(t('common.error')),
},
);
};
const handleAddDependency = () => {
if (!depId.trim()) return;
addDepMutation.mutate(
{ id: task.id, dependsOn: depId.trim() },
{
onSuccess: () => {
toast.success(t('tasks.updated'));
setDepId('');
},
onError: () => toast.error(t('common.error')),
},
);
};
return (
<Card
title={task.title}
actions={
onClose ? (
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t('common.close')}>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
) : undefined
}
>
<div className="space-y-4">
{task.description ? <p className="text-sm text-gray-700">{task.description}</p> : null}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={STATUS_VARIANTS[task.status]}>{statusLabel(t, task.status)}</Badge>
<Badge variant="info">{t(`tasks.type${task.task_type.charAt(0).toUpperCase() + task.task_type.slice(1)}`)}</Badge>
{task.progress > 0 ? <Badge variant="primary">{task.progress}%</Badge> : null}
</div>
{/* Status change */}
<div className="flex items-center gap-2">
<label htmlFor="task-status" className="text-sm font-medium text-gray-700">
{t('tasks.status_field')}
</label>
<Select
id="task-status"
value={task.status}
onChange={(e) => handleStatusChange(e.target.value as TaskStatus)}
className="w-48"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{statusLabel(t, s)}
</option>
))}
</Select>
</div>
{/* Assignee */}
<div className="flex flex-wrap items-end gap-2">
<div>
<label htmlFor="assignee-type" className="block text-sm font-medium text-gray-700">
{t('tasks.assigneeType')}
</label>
<Select
id="assignee-type"
value={assigneeType}
onChange={(e) => setAssigneeType(e.target.value as AssigneeType)}
className="w-32"
>
{ASSIGNEE_TYPES.map((at) => (
<option key={at} value={at}>
{t(`tasks.assigneeType${at.charAt(0).toUpperCase() + at.slice(1)}`)}
</option>
))}
</Select>
</div>
<div>
<label htmlFor="assignee-id" className="block text-sm font-medium text-gray-700">
{t('tasks.assigneeId')}
</label>
<Input
id="assignee-id"
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
placeholder="UUID"
className="w-64"
/>
</div>
<Button onClick={handleAssign} disabled={!assigneeId}>
{t('tasks.assign')}
</Button>
</div>
{/* Subtasks */}
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-700">{t('tasks.subtasks')}</h4>
<div className="space-y-1">
{(subtasks ?? []).map((st) => (
<div key={st.id} className="flex items-center justify-between rounded border border-gray-200 px-3 py-2">
<span className="text-sm text-gray-800">{st.title}</span>
<Badge variant={STATUS_VARIANTS[st.status]}>{statusLabel(t, st.status)}</Badge>
</div>
))}
{subtasks?.length === 0 ? <p className="text-xs text-gray-400">{t('tasks.noSubtasks')}</p> : null}
</div>
<div className="mt-2 flex items-center gap-2">
<Input
value={subtaskTitle}
onChange={(e) => setSubtaskTitle(e.target.value)}
placeholder={t('tasks.subtaskPlaceholder')}
className="flex-1"
/>
<Button onClick={handleCreateSubtask} disabled={!subtaskTitle.trim()}>
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
{t('tasks.addSubtask')}
</Button>
</div>
</div>
{/* Dependencies */}
<div>
<h4 className="mb-2 flex items-center gap-1 text-sm font-semibold text-gray-700">
<Link2 className="h-4 w-4" aria-hidden="true" />
{t('tasks.dependencies')}
</h4>
<div className="space-y-1">
{task.depends_on.map((depId) => (
<div key={depId} className="flex items-center justify-between rounded border border-gray-200 px-3 py-2">
<span className="text-sm text-gray-800">{depId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
removeDepMutation.mutate(
{ id: task.id, dependsOn: depId },
{ onSuccess: () => toast.success(t('tasks.updated')), onError: () => toast.error(t('common.error')) },
)
}
aria-label={t('tasks.removeDependency')}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
))}
{task.depends_on.length === 0 ? <p className="text-xs text-gray-400">{t('tasks.noDependencies')}</p> : null}
</div>
<div className="mt-2 flex items-center gap-2">
<Input
value={depId}
onChange={(e) => setDepId(e.target.value)}
placeholder={t('tasks.dependencyPlaceholder')}
className="flex-1"
/>
<Button onClick={handleAddDependency} disabled={!depId.trim()}>
{t('tasks.addDependency')}
</Button>
</div>
</div>
</div>
</Card>
);
}
export default TaskDetail;