Files
maestro/design/ui_kits/console/TaskTree.jsx
T
wangjia 7a6c826610 feat(app): 任务生命周期操作 UI(编辑/取消/重投/删除 + 复杂度落库)
补齐前端缺失的任务管理闭环(均接现有后端端点,无需后端改动):

- api.js:patchTask / cancelTask / requeueTask / deleteTask
- app.jsx:taskOps{patch,cancel,requeue,del},统一带刷新 + toast
- TaskDetail 操作工具条:✎编辑 / ⌨接管 / ↻重投 / ⊘取消 / 🗑删除(删除带确认;
  重投/取消按状态显隐)
- 编辑表单:标题 + 优先级 + 依赖(点选其他任务)→ PATCH /tasks/:id
- 修复:行内复杂度选择器(CplxPicker)从「仅本地 state」改为真正 PATCH 落库

验证:build 通过;建临时任务实测——工具条/编辑表单渲染 0 错误、DELETE 成功
(deleted:1)、PATCH 在途守卫正确拒绝;测试残留已清理。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:26:29 +08:00

404 lines
23 KiB
React
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.
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
const KIT_FILTER_GROUPS = [
['todo', ['init', 'ready', 'blocked']],
['doing', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
['gate', ['plan_review', 'spec_review', 'exec_review']],
['bad', ['failed', 'needs_attention']],
['hold', ['paused', 'cancelled']],
['done', ['done']],
];
function kitFlatten(tasks, map = new Map()) {
for (const tk of tasks) { map.set(tk.id, tk); if (tk.children) kitFlatten(tk.children, map); }
return map;
}
// 复杂度徽章 + 点击换档下拉
function CplxPicker({ task, cplx, onChange }) {
const { ComplexityBadge } = window.MaestroDesignSystem_a6a290;
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
return (
<span ref={ref} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }} onClick={(e) => e.stopPropagation()}>
<button onClick={() => setOpen(!open)} title="点击调整复杂度" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex' }}>
<ComplexityBadge complexity={cplx} />
</button>
{open ? (
<span style={{
position: 'absolute', top: 'calc(100% + 5px)', right: 0, zIndex: 60,
display: 'flex', gap: 5, background: 'var(--bg-deep)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-md, 6px)', padding: 6, boxShadow: '0 10px 30px rgba(0,0,0,.65)',
animation: 'maestro-rise .12s ease both',
}}>
{['hard', 'medium', 'easy'].map((v) => (
<button key={v} onClick={() => { onChange(task.id, v); setOpen(false); }} style={{
background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex',
outline: v === cplx ? '1px solid currentColor' : 'none', outlineOffset: 1,
filter: 'none',
}}
onMouseEnter={(e) => { e.currentTarget.style.filter = 'brightness(1.35)'; }}
onMouseLeave={(e) => { e.currentTarget.style.filter = 'none'; }}>
<ComplexityBadge complexity={v} />
</button>
))}
</span>
) : null}
</span>
);
}
function FilterBar({ t, kw, setKw, cplxSet, toggleCplx, statusSet, toggleGroup, matchCount, filtering, onClear }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const chip = (on, color, dim, label, onClick) => (
<button key={label} onClick={onClick} style={{
fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 700, letterSpacing: '.1em',
background: on ? 'rgba(95,221,125,.08)' : 'transparent',
color: on ? color : 'var(--faint)',
border: '1px solid ' + (on ? dim : 'var(--line)'),
borderRadius: 3, padding: '2px 8px', cursor: 'pointer', transition: 'all .1s', whiteSpace: 'nowrap',
}}>{label}</button>
);
return (
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px', marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input value={kw} onChange={(e) => setKw(e.target.value)} placeholder={t.searchPh} autoComplete="off" style={{
fontFamily: 'var(--mono)', fontSize: 12, width: 190, padding: '4px 9px',
background: 'var(--bg-deep)', color: 'var(--ink)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-sm, 4px)', outline: 'none',
}} />
<span style={{ display: 'inline-flex', gap: 6 }}>
{chip(cplxSet.has('hard'), 'var(--red)', 'var(--red-dim)', 'HARD', () => toggleCplx('hard'))}
{chip(cplxSet.has('medium'), 'var(--amber)', 'var(--amber-dim)', 'MED', () => toggleCplx('medium'))}
{chip(cplxSet.has('easy'), 'var(--green)', 'var(--green-dim)', 'EASY', () => toggleCplx('easy'))}
</span>
<span style={{ flex: 1 }}></span>
{filtering ? <span style={{ fontSize: 11, color: 'var(--amber)', letterSpacing: '.08em' }}>{t.matchCount.replace('{n}', matchCount)}</span> : null}
{filtering ? <Button variant="ghost" size="xs" onClick={onClear}>{t.clearFilter}</Button> : null}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{KIT_FILTER_GROUPS.map(([g, sts]) => {
const on = sts.every((s) => statusSet.has(s));
const part = !on && sts.some((s) => statusSet.has(s));
return (
<button key={g} onClick={() => toggleGroup(sts)} style={{
fontFamily: 'var(--mono)', fontSize: 10.5, letterSpacing: '.04em',
background: 'transparent',
color: on ? 'var(--green)' : part ? 'var(--amber)' : 'var(--muted)',
border: '1px dashed ' + (on ? 'var(--green-dim)' : part ? 'var(--amber-dim)' : 'var(--line-soft)'),
borderRadius: 3, padding: '2px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
}}>{t.fgroups[g]}</button>
);
})}
</div>
</div>
);
}
function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet, onTakeover, taskOps }) {
const { StatusChip } = window.MaestroDesignSystem_a6a290;
const kids = (task.children || []).filter((k) => !visibleSet || visibleSet.has(k.id));
const open = openIds.has(task.id) || !!visibleSet; // 筛选时自动展开可见节点
const expanded = expandedId === task.id;
const isGate = ['plan_review', 'spec_review', 'exec_review'].includes(task.status);
const flashing = flashId === task.id;
const [hover, setHover] = React.useState(false);
return (
<div style={depth > 0 ? { borderLeft: '1px solid var(--line-soft)' } : null}>
<div onClick={() => setExpandedId(expanded ? null : task.id)} data-task-id={task.id}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px 8px 6px',
borderBottom: '1px solid var(--line-soft)', cursor: 'pointer', transition: 'background .1s',
background: expanded ? 'var(--panel-2)' : isGate ? 'rgba(184,142,245,.05)' : hover ? 'var(--panel)' : 'transparent',
opacity: ctx ? .55 : 1,
animation: flashing ? 'maestro-locate 1.8s ease-out' : 'none',
}}>
<span onClick={(e) => { e.stopPropagation(); if (kids.length) toggleOpen(task.id); }}
style={{
width: 16, flex: 'none', textAlign: 'center', fontSize: 10, userSelect: 'none',
color: kids.length ? (open ? 'var(--green)' : 'var(--muted)') : 'var(--faint)',
transform: kids.length && open ? 'rotate(90deg)' : 'none', transition: 'transform .12s',
}}>
{kids.length ? '▶' : '·'}
</span>
<span style={{ fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{task.id}</span>
</span>
<span style={{ flex: 1, minWidth: 8 }}></span>
{task.deps ? (
<span style={{ flex: 'none', fontSize: 10.5, letterSpacing: '.06em', color: 'var(--amber)', border: '1px dashed var(--amber-dim)', borderRadius: 3, padding: '1px 7px', lineHeight: 1.5, whiteSpace: 'nowrap' }}>
{t.depsWait} {task.deps.length}
</span>
) : null}
<span style={{ fontSize: 10.5, color: task.prio === 'P0' ? 'var(--amber)' : 'var(--faint)', flex: 'none' }}>{task.prio}</span>
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
<StatusChip status={task.status} label={t.status[task.status]} />
</div>
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} taskOps={taskOps} /> : null}
{kids.length && open ? (
<div style={{ marginLeft: 22 }}>
{kids.map((k) => (
<TaskRow key={k.id} task={k} depth={depth + 1} ctx={visibleSet ? visibleSet.ctx.has(k.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
) : null}
</div>
);
}
function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null;
const text = task.doc || task.ops;
const attachments = (task._raw && task._raw.attachments) || [];
const ops = taskOps || {};
const st = task.status;
const canCancel = !['done', 'cancelled'].includes(st);
const canRequeue = ['failed', 'needs_attention', 'blocked', 'cancelled'].includes(st);
const [editing, setEditing] = React.useState(false);
const [eTitle, setETitle] = React.useState(task.title);
const [ePrio, setEPrio] = React.useState(String((task.prio || 'P1').replace('P', '')));
const [eDeps, setEDeps] = React.useState(task.deps || []);
const btn = (color) => ({ fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: `var(--${color})`, border: `1px solid var(--${color}-dim)`, borderRadius: 'var(--radius-sm,4px)', padding: '3px 10px' });
const candidates = [...byId.values()].filter((x) => x.id !== task.id);
const toggleDep = (id) => setEDeps((d) => (d.includes(id) ? d.filter((x) => x !== id) : [...d, id]));
const saveEdit = () => {
if (ops.patch) ops.patch(task.id, { title: eTitle.trim(), priority: Number(ePrio), deps: eDeps });
setEditing(false);
};
return (
<div style={{ background: 'var(--panel)', borderBottom: '1px solid var(--line)', borderLeft: '2px solid var(--green-dim)', padding: '14px 16px', animation: 'maestro-rise .2s ease both', display: 'grid', gap: 14 }}>
{/* 操作工具条:编辑 / 接管 / 重投 / 取消 / 删除 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }} onClick={(e) => e.stopPropagation()}>
{ops.patch ? <button onClick={() => setEditing(!editing)} style={btn(editing ? 'green' : 'muted')}> 编辑</button> : null}
{onTakeover ? <button onClick={() => onTakeover(task)} style={btn('cyan')}> 人工接管</button> : null}
{ops.requeue && canRequeue ? <button onClick={() => ops.requeue(task.id)} style={btn('green')}> 重投</button> : null}
{ops.cancel && canCancel ? <button onClick={() => ops.cancel(task.id)} style={btn('amber')}> 取消</button> : null}
{ops.del ? <button onClick={() => { if (confirm('删除任务「' + task.title + '」?子任务一并删除,不可恢复。')) ops.del(task.id); }} style={btn('red')}>🗑 删除</button> : null}
{attachments.length ? <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span> : null}
</div>
{/* 编辑表单 */}
{editing ? (
<div style={{ display: 'grid', gap: 12, padding: 12, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg-deep)' }} onClick={(e) => e.stopPropagation()}>
<Input label="标题" value={eTitle} onChange={setETitle} style={{ width: '100%' }} />
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<Select label={t.priority} value={ePrio} onChange={setEPrio} options={[{ value: '0', label: t.p0 }, { value: '1', label: t.p1 }, { value: '2', label: t.p2 }]} />
</div>
{candidates.length ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.12em', marginBottom: 6 }}>依赖点选其他任务</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto' }}>
{candidates.map((c) => {
const on = eDeps.includes(c.id);
return (
<button key={c.id} onClick={() => toggleDep(c.id)}
style={{ fontFamily: 'var(--mono)', fontSize: 11, cursor: 'pointer', padding: '2px 8px', borderRadius: 3, border: '1px solid ' + (on ? 'var(--green-dim)' : 'var(--line)'), background: on ? 'var(--green-dim)' : 'transparent', color: on ? '#fff' : 'var(--muted)', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{on ? '✓ ' : ''}{c.title}
</button>
);
})}
</div>
</div>
) : null}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button onClick={() => setEditing(false)}>{t.cancel}</Button>
<Button variant="solid" disabled={!eTitle.trim()} onClick={saveEdit}>{t.save}</Button>
</div>
</div>
) : null}
{label ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)', borderRadius: 4 }}>{text}</pre>
</div>
) : (
<div style={{ padding: '10px 12px', border: '1px dashed var(--line)', borderRadius: 6, color: 'var(--faint)', fontStyle: 'italic', fontSize: 12 }}>
{t.pendingDoc}
</div>
)}
{task.deps && task.deps.length ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{t.depsLabel}</div>
<div style={{ display: 'grid', gap: 4 }}>
{task.deps.map((d) => {
const dep = byId.get(d);
const ok = dep && dep.status === 'done';
return (
<div key={d} onClick={(e) => { e.stopPropagation(); onJump(d); }} title="→" style={{
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '4px 10px', cursor: 'pointer',
background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4,
borderLeft: '2px solid ' + (ok ? 'var(--green-dim)' : 'var(--amber-dim)'),
transition: 'border-color .12s, background .12s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--bg-deep)'; }}>
<span style={{ color: ok ? 'var(--green)' : 'var(--amber)', flex: 'none' }}>{ok ? '✓' : '◌'}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dep ? dep.title : d}</span>
<span style={{ flex: 'none', fontSize: 10.5, color: ok ? 'var(--green)' : 'var(--amber)' }}>{ok ? t.depDone : t.depWait}</span>
<span style={{ marginLeft: 'auto', color: 'var(--faint)', fontSize: 12 }}></span>
</div>
);
})}
</div>
</div>
) : null}
</div>
);
}
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
const [title, setTitle] = React.useState('');
const [complexity, setComplexity] = React.useState('auto');
const [priority, setPriority] = React.useState('1');
const [parentId, setParentId] = React.useState('');
const [files, setFiles] = React.useState([]);
const submit = (e) => {
e.preventDefault();
if (!title.trim()) return;
onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null, files });
};
// 扁平化任务树供父任务下拉(含子任务)
const flat = [];
const walk = (list, prefix) => (list || []).forEach((tk) => { flat.push({ value: tk.id, label: prefix + tk.title }); walk(tk.children, prefix + '— '); });
walk(tasks, '');
return (
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
onSubmit={submit}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} value={title} onChange={setTitle} style={{ width: '100%' }} /></span>
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.complexity} <span style={{ color: 'var(--red)' }}>*</span></span>
<ComplexitySeg defaultValue="auto" includeAuto autoLabel={t.cplxAuto} onChange={setComplexity} />
</label>
<Select label={t.priority} defaultValue="1" onChange={setPriority} options={[
{ value: '0', label: t.p0 }, { value: '1', label: t.p1 }, { value: '2', label: t.p2 },
]} />
<span style={{ flex: 1, minWidth: 240, display: 'flex' }}>
<Select label={t.parentTask} style={{ width: '100%' }} onChange={setParentId} options={[{ value: '', label: t.topLevel }, ...flat]} />
</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel || '附件 · 图片/文件(可选)'}</span>
<input type="file" multiple onChange={(e) => setFiles([...e.target.files])}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
</label>
{files.length ? (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--faint)' }}>
{files.map((f) => f.name).join(' · ')}
</div>
) : null}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
<Button onClick={onCancel}>{t.cancel}</Button>
<Button variant="solid" type="submit">{t.create}</Button>
</div>
</form>
);
}
function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
const { Button, SectionHead } = window.MaestroDesignSystem_a6a290;
const [expandedId, setExpandedId] = React.useState(null);
const [openIds, setOpenIds] = React.useState(() => new Set(['t1']));
const [showNew, setShowNew] = React.useState(false);
const [kw, setKw] = React.useState('');
const [cplxSet, setCplxSet] = React.useState(() => new Set());
const [statusSet, setStatusSet] = React.useState(() => new Set());
const [cplxOverride, setCplxOverride] = React.useState({});
const [flashId, setFlashId] = React.useState(null);
const byId = React.useMemo(() => kitFlatten(tasks), [tasks]);
const cplxOf = (task) => cplxOverride[task.id] || task.cplx;
const onChangeCplx = (id, v) => {
setCplxOverride((m) => ({ ...m, [id]: v })); // 乐观显示
if (taskOps && taskOps.patch) taskOps.patch(id, { complexity: v }); // 落库
};
const toggleOpen = (id) => setOpenIds((prev) => {
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next;
});
const toggleCplx = (v) => setCplxSet((prev) => {
const next = new Set(prev); next.has(v) ? next.delete(v) : next.add(v); return next;
});
const toggleGroup = (sts) => setStatusSet((prev) => {
const next = new Set(prev);
const allOn = sts.every((s) => next.has(s));
for (const s of sts) allOn ? next.delete(s) : next.add(s);
return next;
});
const filtering = kw.trim() !== '' || cplxSet.size > 0 || statusSet.size > 0;
const clearFilters = () => { setKw(''); setCplxSet(new Set()); setStatusSet(new Set()); };
// 筛选:自身命中 → 显示;祖先链作为上下文淡显;命中节点的父级自动展开
const { visibleSet, matchCount } = React.useMemo(() => {
if (!filtering) return { visibleSet: null, matchCount: 0 };
const matches = (tk) =>
(kw.trim() === '' || tk.title.toLowerCase().includes(kw.trim().toLowerCase())) &&
(cplxSet.size === 0 || cplxSet.has(cplxOverride[tk.id] || tk.cplx)) &&
(statusSet.size === 0 || statusSet.has(tk.status));
const vis = new Set(); const ctx = new Set(); let count = 0;
const walk = (tk) => {
let childHit = false;
for (const k of tk.children || []) if (walk(k)) childHit = true;
const hit = matches(tk);
if (hit) count++;
if (hit || childHit) { vis.add(tk.id); if (!hit) ctx.add(tk.id); return true; }
return false;
};
for (const tk of tasks) walk(tk);
const set = new Set(vis); set.ctx = ctx;
return { visibleSet: set, matchCount: count };
}, [filtering, kw, cplxSet, statusSet, tasks, cplxOverride]);
// 依赖跳转:展开所有祖先 + flash 定位
const onJump = (id) => {
setOpenIds((prev) => {
const next = new Set(prev);
const openAncestors = (list, chain) => {
for (const tk of list) {
if (tk.id === id) { for (const c of chain) next.add(c); return true; }
if (tk.children && openAncestors(tk.children, [...chain, tk.id])) return true;
}
return false;
};
openAncestors(tasks, []);
return next;
});
setFlashId(null);
requestAnimationFrame(() => setFlashId(id));
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1900);
};
const roots = visibleSet ? tasks.filter((tk) => visibleSet.has(tk.id)) : tasks;
return (
<section>
<SectionHead title={t.taskSection} sticky action={<Button variant="ghost" size="xs" onClick={() => setShowNew(!showNew)}>{t.newTask}</Button>} />
{showNew ? <NewTaskPanel tasks={tasks} t={t} onCancel={() => setShowNew(false)}
onCreate={(payload) => { setShowNew(false); onCreate ? onCreate(payload) : onToast('ok', t.toastCreated); }} /> : null}
<FilterBar t={t} kw={kw} setKw={setKw} cplxSet={cplxSet} toggleCplx={toggleCplx}
statusSet={statusSet} toggleGroup={toggleGroup} matchCount={matchCount} filtering={filtering} onClear={clearFilters} />
<div>
{roots.map((tk) => (
<TaskRow key={tk.id} task={tk} depth={0} ctx={visibleSet ? visibleSet.ctx.has(tk.id) : false}
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
</section>
);
}
Object.assign(window, { MaestroKitTaskSection: TaskSection });