Files
metona-ai-desktop/src/components/tasks/TaskList.tsx
T

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TaskList — 任务列表
*
* 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box, Typography, Stack, List, ListItem, ListItemIcon,
Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse,
} from '@mui/material';
import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { formatTime } from '@renderer/lib/formatters';
// ===== 类型 =====
type TaskStatus = MetonaTask['status'];
type TaskPriority = MetonaTask['priority'];
// ===== 常量 =====
const STATUS_LABELS: Record<TaskStatus, string> = {
pending: '待处理',
in_progress: '进行中',
completed: '已完成',
blocked: '阻塞',
cancelled: '已取消',
};
const STATUS_COLORS: Record<TaskStatus, string> = {
pending: '#f59e0b',
in_progress: '#a855f7',
completed: '#22c55e',
blocked: '#ef4444',
cancelled: '#64748b',
};
const PRIORITY_LABELS: Record<TaskPriority, string> = {
low: '低',
medium: '中',
high: '高',
critical: '紧急',
};
const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: '#64748b',
medium: '#3b82f6',
high: '#f97316',
critical: '#ef4444',
};
const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'critical'];
// ===== 主组件 =====
export function TaskList(): React.JSX.Element {
const sessionId = useAgentStore((s) => s.currentSessionId);
const [tasks, setTasks] = useState<MetonaTask[]>([]);
const [error, setError] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
// M-51 修复: 竞态保护 ref,防止快速切换会话时旧请求覆盖新数据
const loadReqIdRef = useRef(0);
const loadTasks = useCallback(async (sid?: string) => {
if (!window.metona?.tasks?.list) return;
const reqId = ++loadReqIdRef.current;
try {
setError(null);
const res = await window.metona.tasks.list(sid);
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
if (loadReqIdRef.current !== reqId) return;
if (!res.success) {
setError('加载任务失败');
return;
}
const list = (res.data ?? []).slice().sort((a, b) => {
if (a.order_idx !== b.order_idx) return a.order_idx - b.order_idx;
return a.created_at - b.created_at;
});
setTasks(list);
} catch (err) {
if (loadReqIdRef.current !== reqId) return;
setError((err as Error).message ?? '加载任务失败');
}
}, []);
useEffect(() => {
loadTasks(sessionId ?? undefined);
// cleanup: 使当前请求失效(防止卸载后 setState + 竞态)
return () => { loadReqIdRef.current++; };
}, [sessionId, loadTasks]);
// P2v0.3.13: 订阅任务变更事件(Agent 通过 task_manager 工具写入后自动刷新)
useEffect(() => {
if (!window.metona?.tasks?.onTaskChanged) return;
const unsubscribe = window.metona.tasks.onTaskChanged((changedSessionId) => {
// 只刷新当前会话的任务
if (changedSessionId === sessionId) {
loadTasks(sessionId ?? undefined);
}
});
return unsubscribe;
}, [sessionId, loadTasks]);
const handleCreate = async () => {
if (!sessionId) {
// 用户主动操作失败应用 toast(与项目惯例一致)
import('metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {});
return;
}
if (!newTitle.trim()) {
// 表单校验保留 inline error(让用户看到具体哪个字段有问题)
setError('任务标题不能为空');
return;
}
if (!window.metona?.tasks?.create) return;
setCreating(true);
setError(null);
try {
const res = await window.metona.tasks.create({
sessionId,
title: newTitle.trim(),
priority: newPriority,
});
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {});
return;
}
setNewTitle('');
setNewPriority('medium');
setShowAddForm(false);
await loadTasks(sessionId);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {});
} finally {
setCreating(false);
}
};
const handleToggleComplete = async (task: MetonaTask) => {
if (!window.metona?.tasks?.update) return;
if (!sessionId) return;
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
try {
const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId);
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
return;
}
await loadTasks(sessionId ?? undefined);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {});
}
};
const handleDelete = async (id: string) => {
if (!window.metona?.tasks?.delete) return;
if (!sessionId) return;
try {
const res = await window.metona.tasks.delete(id, sessionId);
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
return;
}
if (expandedId === id) setExpandedId(null);
await loadTasks(sessionId ?? undefined);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {});
}
};
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
<ListChecks size={14} style={{ color: '#22d3ee' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
任务列表
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{tasks.length}
</Typography>
<IconButton
size="small"
onClick={() => setShowAddForm(!showAddForm)}
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
title="新增任务"
>
<Plus size={14} />
</IconButton>
</Stack>
{error && (
<Typography
variant="caption"
sx={{
mb: 1, px: 1, py: 0.5, fontSize: 10,
color: 'error.main', bgcolor: 'error.main' + '14',
borderRadius: 1, flexShrink: 0,
}}
>
{error}
</Typography>
)}
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<TextField
size="small"
fullWidth
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="任务标题..."
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
/>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Select
size="small"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as TaskPriority)}
sx={{ flex: 1, height: 28, fontSize: 11, '& .MuiSelect-select': { py: 0.5, px: 1 } }}
>
{PRIORITY_OPTIONS.map((p) => (
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
</Stack>
</MenuItem>
))}
</Select>
<Button
variant="contained"
size="small"
onClick={handleCreate}
disabled={creating || !newTitle.trim()}
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
>
{creating ? '...' : '创建'}
</Button>
<IconButton
size="small"
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
sx={{ p: 0.5, color: 'text.secondary' }}
>
<Trash2 size={12} />
</IconButton>
</Stack>
</Box>
</Collapse>
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{!sessionId ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
请先选择会话
</Typography>
) : tasks.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
暂无任务,点击 + 创建
</Typography>
) : (
<List disablePadding dense>
{tasks.map((task) => (
<TaskItemRow
key={task.id}
task={task}
expanded={expandedId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onToggleExpand={() => toggleExpand(task.id)}
onDelete={() => handleDelete(task.id)}
/>
))}
</List>
)}
</Box>
</Box>
);
}
// ===== 子组件 =====
function TaskItemRow({
task,
expanded,
onToggleComplete,
onToggleExpand,
onDelete,
}: {
task: MetonaTask;
expanded: boolean;
onToggleComplete: () => void;
onToggleExpand: () => void;
onDelete: () => void;
}): React.JSX.Element {
const isCompleted = task.status === 'completed';
const statusColor = STATUS_COLORS[task.status];
const priorityColor = PRIORITY_COLORS[task.priority];
return (
<ListItem
disableGutters
disablePadding
sx={{
display: 'block',
px: 0.5, py: 0.25,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
'&:hover .delete-btn': { opacity: 1 },
}}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'flex-start' }}>
<ListItemIcon sx={{ minWidth: 20, mt: 0.25 }}>
<Checkbox
size="small"
checked={isCompleted}
onChange={onToggleComplete}
sx={{ p: 0.25, '& .MuiSvgIcon-root': { fontSize: 14 } }}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
onClick={onToggleExpand}
sx={{
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
color: isCompleted ? 'text.disabled' : 'text.primary',
textDecoration: isCompleted ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{task.title}
</Box>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
<Chip
label={STATUS_LABELS[task.status]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: statusColor + '22', color: statusColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Chip
label={PRIORITY_LABELS[task.priority]}
size="small"
sx={{
height: 14, fontSize: 9,
bgcolor: priorityColor + '22', color: priorityColor,
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography
variant="caption"
component="span"
sx={{ fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
>
{formatTime(task.created_at)}
</Typography>
</Stack>
</Box>
<ChevronRight
size={12}
style={{
color: 'text.disabled',
transition: 'transform 150ms',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer',
marginTop: 4,
}}
onClick={onToggleExpand}
/>
<IconButton
className="delete-btn"
size="small"
onClick={onDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
>
<Trash2 size={11} />
</IconButton>
</Stack>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
{task.description ? task.description : (
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
无描述
</Typography>
)}
</Box>
</Collapse>
</ListItem>
);
}